低代码到底是不是行业毒瘤?一线大厂怎么做的?戳此了解>>> 了解详情
写点什么

漫画详解:WebAssembly 与所有语言的互操作!

2019 年 9 月 04 日

漫画详解:WebAssembly与所有语言的互操作!

WebAssembly 能脱离浏览器运行是一件大好事。


WebAssembly 跑在独立的运行时中就非常吸引人了,此外使用 Python、Ruby 和 Rust 等语言运行 WebAssembly 也让人激动不已。


这样做起码有以下好处:


  • 简化“原生”模块


像 Node 或 Python 的 CPython 这样的运行时往往可以用 C++等底层语言编写模块,因为这些底层语言速度一般很快。所以你就可以使用 Node 中的原生模块,或是 Python 中的扩展模块。但这些模块大都是很难用的,因为它们需要在用户的设备上编译。使用 WebAssembly“原生”模块时就可以兼顾速度并避免复杂性。


  • 更容易沙箱化原生代码


另一方面,像 Rust 这样的底层语言使用 WebAssembly 不是为了速度,而是为了安全性。一如WASI公告所述,WebAssembly 默认提供轻量级沙盒。因此像 Rust 这样的语言可以使用 WebAssembly 来沙箱化原生代码模块。


  • 跨平台共享原生代码


跨不同平台(例如 Web 和桌面应用程序)共享相同的代码库可以节省时间并降低维护成本,不管是脚本语言还是底层语言都是如此。WebAssembly 提供了一种不需要付出很多性能代价的跨平台方法。



因此 WebAssembly 可以真正帮助其他语言解决很多重要问题。


但今天人们对 WebAssembly 提出了更高的要求。WebAssembly 能运行的平台很多,但这还不够。


现在,WebAssembly 只能用数字来通信,这样两种语言就可以相互调用彼此的函数。


但是如果一个函数接受或返回数字以外的类型就很麻烦了,你的选择有:


  • 给模块加上只能用数字通信的,非常难用的 API,给用户带来不便。

  • 为模块运行的所有可能环境添加胶水代码…给模块开发人员带来不便。


事情本来不该这么麻烦的。


我们应该能让 WebAssembly 模块在任何地方运行,同时无需给模块用户或开发者带来不便。



这样这个 WebAssembly 模块就可以使用丰富的 API 和众多类型与下列事物通信了:


  • 在自己的原生运行时运行的模块(例如,在 Python 运行时中运行的 Python 模块)。

  • 用另一种源语言编写的 WebAssembly 模块(例如在浏览器中共同运行的 Rust 模块和 Go 模块)。

  • 主机系统本身(例如,为操作系统或浏览器的 API 提供系统接口的 WASI 模块)。



所以我们引入了一项新的提案,目前它正处于早期阶段;这项提案足以实现我们的愿望,具体可以参考这个视频:


https://youtu.be/Qn_4F3foB3Q


下面就来看看它的具体原理。首先我们总结一下现在面临的状况以及我们试图解决的问题。


WebAssembly 与 JS 通信

WebAssembly 支持的不只是 Web 平台。但时至今日,围绕 WebAssembly 的大部分开发工作都集中在 Web 平台上。


因为当你专注于解决具体的用例时,就可以做出更好的设计。这个语言当然会跑在 Web 上,所以从这个用例入手就很合适。


于是我们的 MVP 就有了一个很明确的范围。WebAssembly 只需要与一种语言——JavaScript 通信就够了。


这件事情做起来还是比较容易的。在浏览器中,WebAssembly 和 JS 都在同一个引擎中运行,所以它们通过引擎就可以有效地相互通信了。



一个 js 文件要求引擎调用 WebAssembly 函数



引擎要求 WebAssembly 文件运行该函数


但是当 JS 和 WebAssembly 试图通信时会出现一个问题…它们的类型不一样。


目前,WebAssembly 只能以数字类型通信。JavaScript 支持数字,此外还支持很多类型。


甚至数字类型都不一样。WebAssembly 有 4 种数字类型:int32、int64、float32 和 float64。JavaScript 目前只有 Number(很快会有另一种数字类型BigInt)。


这些类型的差异不仅是名称不同,它们的值也以不同的方式存储在内存中。


在 JavaScript 中,任何值都被放入一个称为 box 的东西里面,和值的类型无关。


相反,WebAssembly 的数字具有静态类型。因此它不需要(也不理解)JS 的 box。


这种差异使它们彼此之间难以沟通。



JS 想要 5+7,而 Wasm 的回答是 9.2368828e + 18


但如果要将值从一种数字类型转换为另一种,规则就非常简单。


因为它很简单,所以很容易写下来。你可以在 WebAssembly 的 JS API 规范中找到它。



wasm 数字类型和 JS 数字类型之间的映射


这一映射是硬编码到引擎中的。


就好像引擎有一本参考书。每当引擎要传递参数或在 JS 和 WebAssembly 之间返回值时,它就会翻一下这本参考书,看看该如何转换这些值。



JS 要求引擎调用 wasm 的 add 函数计算 5+7,引擎就在书中查找转换方法


因为类型的数量不多(只有数字),这种映射也比较容易。这对 MVP 来说是个好消息,用不着做很多困难的设计决策了。


但它给 WebAssembly 的开发人员带来了不便。要在 JS 和 WebAssembly 之间传递字符串,你必须找到一种方法将字符串转换为数字数组,还要将数字数组转换回字符串。



JS 将数字放入 WebAssembly 的内存中


这种事情做起来不难但是很无聊,所以出现了很多工具来简化这个过程。


例如 Rust 的wasm-bindgen和 Emscripten 的Embind这样的工具会自动用 JS 胶水代码包装 WebAssembly 模块,这些代码可以实现从字符串到数字的转换。



JS 文件需要将字符串传递给 Wasm,而 JS 胶水代码负责中转工作


这些工具也可以为其他高级类型执行此类转换,例如带有属性的复杂对象等。


这种方法很有用,但有些情况下也会出现问题。


例如,有时你只想通过 WebAssembly 传递字符串。你希望 JavaScript 函数将字符串传递给 WebAssembly 函数,然后让 WebAssembly 将其传递给另一个 JavaScript 函数。


这个过程是这样的:


  1. 第一个 JavaScript 函数将字符串传递给 JS 胶水代码。

  2. JS 胶水代码将该字符串对象转换为数字,然后将这些数字放入线性内存中。

  3. 然后将一个数字(指向字符串开头的指针)传递给 WebAssembly。

  4. WebAssembly 函数将该数字传递给另一侧的 JS 胶水代码。

  5. 第二个 JavaScript 函数从线性内存中提取所有这些数字,然后将它们解码回字符串对象。

  6. 再传递给第二个 JS 函数。



JS 文件将字符串'Hello'传递给 JS 胶水代码



JS 胶水代码将字符串转换为数字并将其放入线性内存中



JS 胶水代码告诉引擎将 2 传递给 wasm



Wasm 告诉引擎将 2 传递给 JS 胶水代码



JS 胶水代码从线性内存中取出数据并将它们转换回字符串



JS 胶水代码将字符串传递给 JS 文件


也就是说 JS 胶水代码在两边只是来回折腾了一次。单单为了重建基本上没区别的对象就得大费周章。


如果字符串可以直接通过 WebAssembly 而不做任何转换就省事多了。


WebAssembly 不会对这个字符串执行任何操作——因为它无法理解这种类型。我们也不会解决这个问题。


但它可以在两个 JS 函数之间来回传递字符串对象,因为它们能理解这种类型。


这也是WebAssembly引用类型提案的一个目的。该提案新添加了一个名为 anyref 的 WebAssembly 基础类型。


使用 anyref 时,JavaScript 只给 WebAssembly 一个引用对象(基本上是一个不公开的内存地址指针)。此引用指向 JS 堆上的对象。然后 WebAssembly 可以将它传递给其他 JS 函数,这些函数知道该如何使用它。



JS 将字符串传递给 Wasm,引擎将其转换为指针



Wasm 将字符串传递给另一个 JS 文件,引擎只将指针传递给它即可


于是 JavaScript 中最烦人的一个互操作性问题就这样解决了。但浏览器中的互操作性问题不只是这个。


浏览器中的类型有一大堆。如果我们要获得良好的性能表现,WebAssembly 要与这些类型都能互操作才行。


WebAssembly 直接与浏览器通信

JS 只是浏览器的一部分。此外浏览器还有许多可用功能,称为 Web API。


这些 Web API 函数通常用 C++或 Rust 编写。它们用自己的方式将对象存储在内存中。


Web API 的参数和返回值可以有很多种类型。很难为每一种类型都手动创建映射。简单起见,我们有一种标准化的方式来讨论这些类型的结构——也就是Web IDL


我们通常是通过 JavaScript 来使用这些函数的。这意味着你传递的是使用 JS 类型的值。那么 JS 类型是怎样转换为 Web IDL 类型的呢?


就像从 WebAssembly 类型到 JavaScript 类型有一套映射一样,从 JavaScript 类型到 Web IDL 类型也有类似的映射。


就好像引擎有另一本参考书写着从 JS 到 Web IDL 该怎么做。此映射也是硬编码到引擎里的。



对于许多类型来说,JavaScript 和 Web IDL 之间的映射非常简单。例如,DOMString 和 JS 的 String 等类型是兼容的,可以直接相互映射。


现在当你尝试从 WebAssembly 调用 Web API 时会发生什么?我们就要解决这个问题。


目前,WebAssembly 类型和 Web IDL 类型之间没有映射。这意味着即使要调用数字这样的简单类型也必须通过 JavaScript。


流程如下:


  1. WebAssembly 将值传递给 JS。

  2. 在此过程中,引擎将该值转换为 JavaScript 类型,并将其放入内存中的 JS 堆中

  3. 然后这个 JS 值被传递给 Web API 函数。在此过程中,引擎将 JS 值转换为 Web IDL 类型,并将其放在内存中渲染器的堆里。



Wasm 将数字传递给 JS



引擎将 int32 转换为 Number 并将其放入 JS 堆中



引擎将 Number 转换为 double,并将其放入渲染器堆中


步骤复杂,还很占内存。


看起来直接从 WebAssembly 创建到 Web IDL 的映射就好了嘛。但实际做起来没那么简单。


对于 boolean 和 unsigned long(这是一个数字)这种简单的 Web IDL 类型来说,从 WebAssembly 到 Web IDL 还能有明确的映射。


但在大多数情况下,Web API 参数是更复杂多样的类型。例如 API 可能需要一个字典,比如具有属性的对象;或一个序列,比如一个数组。


要在 WebAssembly 类型和 Web IDL 类型之间直接映射,我们需要添加一些更高级别的类型。我们的GC提案就是这样做的。有了它,WebAssembly 模块就能够创建 GC 对象(例如结构和数组),也就能映射到复杂多样的 Web IDL 类型上了。


但如果与 Web API 互操作的唯一方法是通过 GC 对象,那么对于像 C++和 Rust 这样不会使用 GC 对象的语言来说就更麻烦了。只要代码与 Web API 交互,就必须创建一个新的 GC 对象,并将值从其线性内存复制到该对象中。


这比我们现在用的 JS 胶水代码也好不到哪儿去。


我们可不想强迫 JS 胶水代码构建 GC 对象——这是在浪费时间和空间。类似地,我们也不希望 WebAssembly 模块落得如此下场。


我们希望使用线性内存的语言(如 Rust 和 C++)能够像使用引擎内置 GC 的语言一样调用 Web API。因此我们需要一种方法来创建线性内存中的对象和 Web IDL 类型之间的映射。


但这里有一个问题。这些语言都用不同的方式来表示线性内存中的事物。我们不能只选择一种语言的表示方法,否则其他语言的效率就会下降了。



但虽然不同语言在内存中针对这些事物的具体布局不一样,它们在一些抽象层面是共通的。


例如对于字符串来说,语言通常有一个指向内存中字符串开头和字符串长度的指针。即使字符串的内部很复杂,调用外部 API 时通常也要将字符串转换为这种格式。


这意味着我们可以将这个字符串缩减为 WebAssembly 可以理解的类型,也就是两个 i32。



线性内存中的字符串 Hello,偏移量为 2,长度为 5。WebAssembly 就能理解这两种类型

我们可以在引擎中硬编码这种映射。因此引擎就有了另一本参考书,也就是WebAssembly的Web IDL映射。

但这里有一个问题。WebAssembly是一种经过类型检查的语言。为了保证安全性,引擎必须检查调用代码是否传递了与被调用者要求的类型相匹配的类型。

这是因为攻击者有办法利用类型不匹配让引擎做不该做的事情。

如果你正在调用的事物接受的是字符串,但是你试图给这个函数传递一个整数,引擎就会大声警告你,它也应该这么做。

所以我们需要一种方法让模块明确地告诉引擎:“我知道Document.createElement()接受的是字符串。但是当我调用它时会向你传递两个整数。你要用这些从线性内存中的数据创建一个DOMString。使用第一个整数作为字符串的起始地址,第二个整数作为其长度。“

Web IDL提案就是做这项工作的。它为WebAssembly模块提供了一种和Web IDL类型之间映射的方法。

这些映射没有硬编码到引擎里。相反,每个模块都带有自己的映射小册子。

Wasm文件把一本小册子交给引擎并说:“这是一本小指南,它将告诉你如何将我的类型转换为接口类型。“

这样引擎就能知道“对于该函数要假装这两个整数是一个字符串,并执行类型检查。”

但模块自带小册子的设计还有一个原因。

有些情况下,平时将字符串存储在线性内存中的模块会使用anyref或GC类型。例如模块只是将从JS函数获取的对象(如DOM节点) 传递到Web API时就会这样做。

因此模块需要针对具体的函数(甚至具体的参数)作出具体的选择,决定该如何处理不同的类型。由于映射是由模块提供的,因此可以为这个模块定制映射。

Wasm会告诉引擎“看清楚了啊…对于一些采用DOMStrings的函数,我会给你两个数字;对于其他函数,我只会把JS给我的DOMString交给你。”

怎样生成这本小册子呢?

编译器会搞定的,它为WebAssembly模块添加了一个自定义部分。所以一般情况下程序员不需要做太多工作。

举个例子,我们看一下Rust工具链怎样来处理最简单的一种情况:将字符串传递给alert函数。

#[wasm_bindgen]
extern "C" {
fn alert(s: &str);
}

程序员只需使用注释#[wasm_bindgen]告诉编译器将此函数包含在小册子中即可。默认情况下编译器会将其视为线性内存字符串,并为我们添加正确的映射。如果我们需要不同的处理方式(例如视为anyref),就必须使用另一个注释告诉编译器。

这样一来我们就可以去掉中间的JS环节了,让值在WebAssembly和Web API之间更快传递。此外我们也用不着传输那么多JS了。

我们支持的语言种类也不会打折扣了,所有类型的语言都能编译到WebAssembly。这些语言都可以将它们的类型映射到Web IDL类型——无论语言是使用线性内存还是GC对象,还是二者并用。

静下心来再思考一下这个解决方案,我们意识到它解决了一个更大的问题。

WebAssembly与所有事物通信

终于回到文章开头的话题了。

有没有一种方法让WebAssembly与使用各种类型系统的事物通信呢?

我们来看看可行的办法。

你可以创建一套硬编码到引擎中的映射,就像WebAssembly到JS和JS到Web IDL的映射那样。

但这意味着你得为每种语言都创建一套对应的映射,然后所有映射都得编码到引擎里,哪个语言更新后对应的映射也得更新。结果会变得一团糟。

早期的编译器就是这样设计的。每对源语言和机器代码语言之间都有一条管道。

这种设计也太复杂了。我们希望这么多语言和平台之间能够相互通信,但它们通信的方式也应该是容易扩展的。

所以我们得换一条路子,做法更像现代的编译器架构。现代编译器的前端和后端是分离的。前端负责源语言到抽象中间层(IR)的部分。后端则是从IR到目标机器代码。

这也就是Web IDL的本质所在。仔细一打量,你会发现Web IDL看起来就像一个IR。

Web IDL是专门针对Web平台的。但在Web平台之外也有很多WebAssembly用例。因此,Web IDL本身并不是一个很好的IR。

但是,如果我们只是用Web IDL作参考来创建一组新的抽象类型呢?

按照这个思路,我们就有了WebAssembly接口类型提案。

这些并不是具体的类型。它们和WebAssembly现在用的int32或float64类型是不一样的。WebAssembly中没有针对它们的操作。

例如,WebAssembly中不会加入任何字符串连接操作。相反,所有操作都在两端的具体类型上执行。

有一点很关键:使用接口类型时,通信双方不会共享表示。相反,默认设置是在两端之间复制值。

这条规则似乎也有一个例外:那就是我之前提到的新引用值(如anyref)。在这种情况下,在两端之间复制的是指向对象的指针。所以两个指针指向同一个东西。理论上,这可能意味着他们需要共享一个表示。

如果引用只是通过WebAssembly模块(就像我上面给出的anyref示例),双方仍然不需要共享表示。毕竟模块不会理解这个类型,只需将其传递给其他函数即可。

但有时双方都希望共享一个表示。例如,GC提案添加了一种创建类型定义的方法,以便双方可以共享表示。在这种情况下要由设计API的开发人员决定要共享多少表示。

这样模块与多种语言通信就变得非常容易了。

在某些情况下,从接口类型到主机具体类型的映射会硬编码到引擎里,比如浏览器中就会是这样。

所以一部分映射会在编译时编码进去,另一部分映射会在加载时被传递给引擎。

但在其他情况下,比如当两个WebAssembly模块相互通信时,它们都会发送自己的小册子。它们各自将自己的函数类型映射到抽象类型上。

你想让使用不同源语言编写的模块能够相互通信的话,只做上面这些还是不够的(我们将来会对此做更多详细说明),但这也是向正确的方向迈出了一大步了。

所以现在你明白了为什么这样做,下面来看看该怎样做。

这些接口类型实际上是怎样工作的?

具体研究细节之前我应该再说一遍:这个提案仍在开发过程中。因此最终版本的提案可能会和本文介绍的有很大区别。

就像路边施工时工人会放上“请注意”的指示牌一样

另外这些都是由编译器处理的。所以等到提案最终定案后,你也只需要知道你的工具链需要你在代码中添加哪些注释即可(例如上面的wasm-bindgen示例)。其实你用不着关心这背后具体的工作机制。

但这个提案的细节非常简单,所以我们来深入探讨一番也无妨。

要解决的问题

我们需要解决的是当模块与另一个模块(或直接与浏览器等主机)通信时,需要在不同类型之间转换值的问题。

我们可能要在以下四个方面做翻译:

对于导出的函数

  • 接受来自调用者的参数。

  • 将值返回给调用者。

对于导入的函数

  • 将参数传递给这个函数。

  • 接受函数的返回值。

你可以将这些方面分成两个方向:

  • 提升,针对离开模块的值。它们从具体类型变为接口类型。

  • 下降,针对进入模块的值。它们从接口类型变为具体类型。

告诉引擎如何在具体类型和接口类型之间转换

因此我们需要一种方法来告诉引擎该对函数的参数和返回值应用哪些转换。我们该怎么做呢?

只需定义一个接口适配器即可。

例如,假设我们有一个编译为WebAssembly的Rust模块。它导出一个greeting_函数,可以在没有任何参数的情况下调用它并返回一个greeting。

写成现在的(WebAssembly文本格式)是这样的。

所以现在这个函数会返回两个整数。

但我们希望它返回的是字符串接口类型。所以我们会添加一个称为接口适配器的东西。

如果引擎能理解接口类型,那么当它看到这个接口适配器时,将使用这个接口包装原来的模块。

引擎不会再导出greeting_函数了,而只会导出包装了原始函数的greeting函数。这个新的greeting函数会返回一个字符串,而不是两个数字。

这就提供了向后兼容性,因为不理解接口类型的引擎就只会导出原始的greeting_函数(返回两个整数的函数)。

接口适配器是怎样告诉引擎将两个整数转换为字符串的呢?

它用的是一系列适配器指令。

上面的两条适配器指令来自这个提案定义的一小组新指令。

上面这两条指令做的事情是:

  1. 使用call-export适配器指令调用原始greeting_函数。这是原始模块导出的函数,它会返回两个数字。这些数字放在堆栈上。

  2. 使用memory-to-string适配器指令将数字转换为组成字符串的字节序列。我们必须在这里指定“mem”,因为一个WebAssembly模块可能会有很多段内存。这会告诉引擎要查看哪段内存。然后引擎从堆栈顶部获取两个整数(指针和长度)并使用它们来确定要使用的数据。

这种方法可能看起来很像一套完整的编程语言。但是这里没有控制流——没有循环或分支。所以虽然我们会为引擎提供指令,这种方法仍然是声明性的。

如果我们的函数将字符串作为参数(例如要问候的人的姓名),又会变成什么样呢?

结果非常相似。我们只需更改适配器函数的接口即可添加参数。然后我们添加两条新的适配器指令。

以下是这些新指令的作用:

  1. 使用arg.get指令获取对字符串对象的引用并将其放在堆栈中。

  2. 使用string-to-memory指令从该对象获取数据并将它们放在线性内存中。同样,我们必须告诉它将数据放入哪段内存。我们还必须告诉它如何分配数据。我们给它一个分配器函数(该函数将是原始模块提供的导出)来处理这些需求。

使用这种指令来处理问题有一个好处:将来我们可以扩展它们,就像我们扩展WebAssembly核心中的指令一样。我们觉得现在定义的指令已经很不错了,但这些指令不见得就永远都够用。

关于工作机制更深入的内容可以参考这个链接

将这些指令发送到引擎上

现在我们怎么把指令发给引擎呢?

这些注释会添加到自定义部分中的二进制文件中。

文件分为两部分。顶部是“已知部分”,例如代码、数据。底部是“自定义部分”,例如接口适配器。

如果引擎能理解接口类型,那就可以使用自定义部分。反之引擎只要忽略它就可以了,你可以使用polyfill读取自定义部分并创建胶水代码。

这与CORBA,Protocol Buffers等有什么不同?

有其他一些标准似乎也能解决这个问题——例如CORBA、Protocol Buffers和Cap’n Proto等。

那么区别在哪里?在于它们解决的是一个复杂得多的问题。

它们的设计目标是让你和不与你共享内存的系统交互——可能是因为对方运行在另一个进程中,也可能是因为它位于网络的另一台机器上。

这意味着你得让中间件,也就是对象的“中间表示”长途跋涉跨越边界才能送达目的地。

因此这些标准需要定义可以有效跨越边界的序列化格式。这也是他们标准化过程的重要组成部分。

虽然这看起来很像我们的路线,但本质上完全是另一回事。

对于接口类型来说,这个“IR”永远不需要离开引擎。模块本身甚至都看不到它。

模块只能看到引擎在过程结束时为它们提供的内容——也就是复制到线性内存中的内容或作为引用的内容。因此,我们不必告诉引擎为这些类型提供哪种布局——并不需要做这种事情。

我们要指定的是与引擎通信的方式。这是你发送给引擎的手册写的声明性语言。

这里有一个副作用很意义:因为这一切都是声明性的,引擎就能知道什么时候用不着翻译——比如说两边的两个模块使用相同类型的情况——这种时候就可以完全跳过翻译步骤。

新功能如何使用?

如前所述,这是一个尚处于早期阶段的提案。换句话说什么事情都可能会快速变化,不适合在生产环境中使用。

但如果你只是想试试看的话,我们已经提供了对应的工具链,从生产到消费一应俱全:

  • Rust工具链。

  • wasm-bindgen。

  • Wasmtime WebAssembly运行时。

这些工具都是我们维护的,标准也是我们在制定,所以这些工具都会随着标准进化而同步更新。只要你随时更新到这些工具的最新版本,那么应该就不会出什么大问题了。

所以即使是现在你也有很多方法可以尝试这个新功能。要获得最新版本可以查看这个链接

英文原文:

https://hacks.mozilla.org/2019/08/webassembly-interface-types/






2019 年 9 月 04 日 08:4011397

评论

发布
暂无评论
发现更多内容

2.8 第二周课后练习

lithium

极客时间 架构师训练

CAP原理

A p7+

架构 2 期 - 第二周作业(2)

浮生一梦

极客大学架构师训练营 第二周总结 2组

Week 6 作业02

Croesus

极客时间架构师训练营1期-第6周总结

Kaven

极客时间架构师培训 1 期 - 第 6周作业

Kaven

依赖倒置原则和优化设计相关

DL

Week 6 作业01

Croesus

架构师训练营第二周作业

Sandman

极客大学架构师训练营

架构师训练营第一期第六章作业-简述CAP理论

睡不着摇一摇

极客大学架构师训练营

第六周总结

fmouse

极客大学架构师训练营

CAP原理, Doris 临时失效的处理过程

garlic

极客大学架构师训练营

架构师训练营 - 第六周总结

一个节点

极客大学架构师训练营

架构师训练营第二周作业2

韩儿

第二周学习总结

lithium

极客大学 架构师训练

第六周课后练习

天天向上

极客大学架构师训练营

架构 2 期 - 第二周作业(1)

浮生一梦

极客大学架构师训练营 第二周作业 2组

极客大学架构师课程作业-第二周

井中人

极客大学架构师训练营

第六周 技术选型(2)作业

钟杰

极客大学架构师训练营

第六周 技术选型(2)学习总结

钟杰

极客大学架构师训练营

作业-框架设计

arcyao

架构师训练营第二周作业1

韩儿

第二周设计原则

Geek_9527

第二周作业

CraspLion

架构师入门学习感悟二

莫问

CAP原理

java安全编码指南之:序列化Serialization

程序那些事

java安全编码 java安全 java安全编码指南 java代码规范

架构师训练营第六周命题作业

成长者

极客大学架构师训练营

Redis Cluster你弄明白了吗?

Man

分布式 中间件 redis cluster

芯片破壁者(十八):CPU战争三十年

脑极体

架构师训练营第 1 期 - 第六周作业提交

Todd-Lee

极客大学架构师训练营

2021 ThoughtWorks 技术雷达峰会

2021 ThoughtWorks 技术雷达峰会

漫画详解:WebAssembly与所有语言的互操作!-InfoQ