写点什么

CI/CD 流水线创建方法:Monad、Arrow 还是 Dart ?

  • 2021-02-18
  • 本文字数:8674 字

    阅读完需:约 28 分钟

CI/CD 流水线创建方法:Monad、Arrow 还是 Dart ?

本文将用三种方法来创建 CI/CD 流水线。Monad 不能对流水线进行静态分析,Arrow 语法很难用,我称之为 Dart(不知道它是否已经有名字了)的一种轻量级的 Arrow 方法可以像 Arrow 一样进行静态分析,但语法比 Monad 更简单。


我需要构建一个用于创建 CI/CD 流水线的系统。它起初是为了构建一个 CI 系统,测试 Github 上的 OCaml 项目(针对多个版本的 OCaml 编译器和多个操作系统,测试每个提交)。下面是一个简单的流水线,获取某个 Git 分支最新的提交,构建,并执行测试用例。


【译者注】CI/CD:持续集成(Continuous Integration)和持续部署(Continuous Deployment)简称,指在开发过程中自动执行一系列脚本来减低开发引入 bug 的概率,在新代码从开发到部署的过程中,尽量减少人工的介入。



这里的配色标识是:绿色的方框是已经完成,橙色的是正在进行,灰色的意味着这一步还不能开始。


这里有一个稍微复杂点的例子,它还下载了一个 Docker 基础镜像,使用两个不同版本的 OCaml 编译器并行构建提交,然后测试得到的镜像。红框表示此步骤失败:



一个更复杂的例子是测试项目本身,然后搜索依赖它的其他项目,并根据新版本测试这些项目:



在这里,圆圈意味着在检查反向依赖项之前,我们应该等待测试通过。


我们可以用 YAML 或类似的方法来描述这些管道,但这将是非常有限的。相反,我决定使用一种特定于领域的嵌入式语言,这样我们就可以免费使用宿主语言的特性(例如字符串操作、变量、函数、导入、类型检查等)。


最明显的方法是使每个框成为正则函数。然后上面的第一个例子可以是(这里,使用 OCaml 语法):


let example1 commit =  let src = fetch commit in  let image = build src in  test image
复制代码


第二个可能是:


let example2 commit =  let src = fetch commit in  let base = docker_pull "ocaml/opam2" in  let build ocaml_version =    let dockerfile = make_dockerfile ~base ~ocaml_version in    let image = build ~dockerfile src ~label:ocaml_version in    test image  in  build "4.07";  build "4.08"
复制代码


第三个可能是这样的:


let example3 commit =  let src = fetch commit in  let image = build src in  test image;  let revdeps = get_revdeps src in  List.iter example1 revdeps
复制代码


不过,我们想在语言中添加一些附加功能:


  • 管道步骤应尽可能并行运行。上面的 example2 函数将一次完成一个构建。

  • 管道步骤应在其输入更改时重新计算。e、 当我们作出新的承诺时,我们需要重建。

  • 用户应该能够查看每个步骤的进度。

  • 用户应该能够为任何步骤触发重建。

  • 我们应该能够从代码中自动生成图表,这样我们就可以在运行管道之前看到它将做什么。

  • 一步的失败不应该使整个管道停止。


对于这篇博客文章来说,确切的附加功能并不重要,因此为了简单起见,我将重点放在同时运行步骤上。

Monad 方法


【译者注】Monad:函子,单子,来自 Haskell 编程语言,是函数式编程中,一种定义将函数(函子)组合起来的结构方式,它除了返回值以外,还需要一个上下文。常见的 Monad 有计算任务,分支任务,或者 I/O 操作。


如果没有额外的功能,我们有如下功能:


val fetch : commit -> sourceval build : source -> image
复制代码


您可以将其理解为“build 是一个获取源值并返回(Docker)镜像的函数”。


这些函数很容易组合在一起,形成一个更大的函数来获取提交并构建它:


let fab c =  let src = fetch c in  build src
复制代码



我们还可以将其缩短为 build(fetch c)或 fetch c |>build。OCaml 中的|>(pipe)运算符只调用其右侧的函数,而参数在其左侧。


为了将这些函数扩展为并发的,我们可以让它们返回承诺,例如,


val fetch : commit -> source promiseval build : source -> image promise
复制代码


但是现在我们无法使用 let(或|>)轻松组合它们,因为 fetch 的输出类型与 build 的输入不匹配。


但是,我们可以定义一个类似的操作,let(或>>=)来处理承诺。它立即返回对最终结果的承诺,并在第一个承诺实现后调用 let* 的主体。那么我们有:


let fab c =  let* src = fetch c in  build src
复制代码


换句话说,通过在周围撒上几个星号字符,我们可以将简单的旧管道变成一个新的并发管道!使用 let* 编写 promise returning 函数的时间规则与使用 let 编写常规函数的时间规则完全相同,因此使用 promise 编写程序与编写常规程序一样简单。


仅仅使用 let *不会在我们的管道中添加任何并发(它只允许它与其他代码并发执行)。但是我们可以为此定义额外的函数,比如 all 一次计算一个列表中的每个承诺,或者使用 and 运算符指示两个事物应该并行运行:


除了处理承诺外,我们还可以为可能返回错误的函数(只有在第一个值成功时才调用 let 的主体)或为实时更新(每次输入更改时都调用主体)或为所有这些东西一起定义 let*。这是单子的基本概念。


这其实很管用。在 2016 年,我用这种方法做了 DataKitCI,它最初用于 Docker-for-Mac 上的 CI 系统。之后,Madhavapeddy 用它创建了 opam-repo-ci,这是 opam-repository 上的 CI 系统,OCaml 上主要的软件仓库。这将检查每个新的 PR 以查看它添加或修改了哪些包,针对多个 OCaml 编译器版本和 Linux 发行版(Debian、Ubuntu、Alpine、CentOS、Fedora 和 OpenSUSE)测试每个包,然后根据更改的包查找所有包的所有版本,并测试这些版本。


使用 monad 的主要问题是我们不能对管道进行静态分析。考虑上面的 example2 函数。在查询 GitHub 以获得测试提交之前,我们无法运行该函数,因此不知道它将做什么。一旦我们有了 commit,我们就可以调用 example2commit,但是在 fetch 和 docker_pull 操作完成之前,我们无法计算 let* 的主体来找出管道接下来将做什么。


换言之,我们只能绘制图表,显示已经执行或正在执行的管道位,并且必须使用和 * 手动指示并发的机会。

Arrow 方法


Arrow 使管道的静态分析成为可能。而不是我们的一元函数:


val fetch : commit -> source promiseval build : source -> image promise
复制代码


我们可以定义箭头类型:


type ('a, 'b) arrow
val fetch : (commit, source) arrowval build : (source, image) arrow
复制代码


a('a,'b)arrow 是一个接受 a 类型输入并生成 b 类型结果的管道。如果我们定义类型('a,'b)arrow='a->'b promise,则这与一元版本相同。但是,我们可以将箭头类型抽象化,并对其进行扩展,以存储我们需要的任何静态信息。例如,我们可以标记箭头:


type ('a, 'b) arrow = {  f : 'a -> 'b promise;  label : string;}
复制代码


这里,箭头是一个记录。f 是旧的一元函数,label 是“静态分析”。


用户看不到 arrow 类型的内部,必须使用 arrow 实现提供的函数来构建管道。有三个基本功能可用:


val arr : ('a -> 'b) -> ('a, 'b) arrowval ( >>> ) : ('a, 'b) arrow -> ('b, 'c) arrow -> ('a, 'c) arrowval first : ('a, 'b) arrow -> (('a * 'c), ('b * 'c)) arrow
复制代码


arr 接受纯函数并给出等价的箭头。对于我们的承诺示例,这意味着箭头返回已经实现的承诺。>>>把两个箭头连在一起。首先从“a”到“b”取一个箭头,改为成对使用。该对的第一个元素将由给定的箭头处理,第二个组件将原封不动地返回。


我们可以让这些操作自动创建带有适当 f 和 label 字段的新箭头。例如,在 a>>>b 中,结果 label 字段可以是字符串{a.label}>>{b.label}。这意味着我们可以显示管道,而不必先运行它,如果需要的话,我们可以很容易地用更结构化的内容替换 label。


我们的第一个例子是:


let example1 commit =  let src = fetch commit in  let image = build src in  test image
复制代码


to


let example1 =  fetch >>> build >>> test
复制代码


虽然我们不得不放弃变量名,但这似乎很令人愉快。但事情开始变得复杂,有了更大的例子。例如 2,我们需要定义几个标准组合:


(** Process the second component of a tuple, leaving the first unchanged. *)let second f =  let swap (x, y) = (y, x) in  arr swap >>> first f >>> arr swap
(** [f *** g] processes the first component of a pair with [f] and the second with [g]. *)let ( *** ) f g = first f >>> second g
(** [f &&& g] processes a single value with [f] and [g] in parallel and returns a pair with the results. *)let ( &&& ) f g = arr (fun x -> (x, x)) >>> (f *** g)
复制代码


Then, example2 changes from:


let example2 commit =  let src = fetch commit in  let base = docker_pull "ocaml/opam2" in  let build ocaml_version =    let dockerfile = make_dockerfile ~base ~ocaml_version in    let image = build ~dockerfile src ~label:ocaml_version in    test image  in  build "4.07";  build "4.08"
复制代码


to:


let example2 =  let build ocaml_version =    first (arr (fun base -> make_dockerfile ~base ~ocaml_version))    >>> build_with_dockerfile ~label:ocaml_version    >>> test  in  arr (fun c -> ((), c))  >>> (docker_pull "ocaml/opam2" *** fetch)  >>> (build "4.07" &&& build "4.08")  >>> arr (fun ((), ()) -> ())
复制代码


我们已经丢失了大多数变量名,而不得不使用元组,记住我们的值在哪里。这里有两个值并不是很糟糕,但是随着更多的值被添加并且我们开始嵌套元组,它变得非常困难。我们还失去了在 build~dockerfile src 中使用可选标记参数的能力,而是需要使用一个新操作,该操作接受 dockerfile 和源的元组。


假设现在运行测试需要从源代码获取测试用例。在原始代码中,我们只需使用:src 将测试图像更改为测试图像。在 arrow 版本中,我们需要在生成步骤之前复制源代码,使用带 dockerfile 的 first build_ 运行生成,并确保参数是新测试使用的正确方法。

Dart 方法


我开始怀疑是否有一种更简单的方法来实现与箭头相同的静态分析,但是没有无点语法,而且似乎有。考虑示例 1 的一元版本。我们有:


val build : source -> image promiseval test : image -> results promise
let example1 commit = let* src = fetch commit in let* image = build src in test image
复制代码


如果你不知道蒙娜兹的事,你还有别的办法。您可以定义 build 和 test,将 promises 作为输入,而不是使用 let* 等待获取完成,然后使用源调用 build:


val build : source promise -> image promiseval test : image promise -> results promise
复制代码


毕竟,fetching 给了你一个源代码承诺,你想要一个图像承诺,所以这看起来很自然。我们甚至可以以承诺为例。然后看起来是这样的:


let example1 commit =  let src = fetch commit in  let image = build src in  test image
复制代码


很好,因为它和我们刚开始的简单版本是一样的。问题是效率低下:


  • 我们用承诺的方式调用 example1(我们还不知道它是什么)。

  • 我们不必等待找出要测试的提交,而是调用 fetch,获取某个源的承诺。

  • 不需要等待获取源代码,我们就调用 build,获取图像的承诺。

  • 不用等待构建,我们调用 test,得到结果的承诺。


我们立即返回测试结果的最终承诺,但我们还没有做任何真正的工作。相反,我们建立了一长串的承诺,浪费了记忆。


但是,在这种情况下,我们希望执行静态分析。i、 我们想在内存中建立一些表示流水线的数据结构……这正是我们对 monad 的“低效”使用所产生的结果!


为了使其有用,我们需要基本操作(比如 fetch)来为静态分析提供一些信息(比如标签)。OCaml 的 let 语法没有为标签提供明显的位置,但是我能够定义一个运算符(let**),该运算符返回一个接受 label 参数的函数。它可用于生成如下基本操作:


let fetch commit =  "fetch" |>  let** commit = commit in  (* (standard monadic implementation of fetch goes here) *)
复制代码


因此,fetch 接受一个提交的承诺,对它执行一个单字节绑定以等待实际的提交,然后像以前一样继续,但它将绑定标记为一个 fetch 操作。如果 fetch 包含多个参数,则可以使用 and* 并行等待所有参数。


理论上,let**In fetch 的主体可以包含进一步的绑定。如果那样的话,我们在一开始就无法分析整个管道。但是,只要原语在开始时等待所有输入,并且不在内部进行任何绑定,我们就可以静态地发现整个管道。


我们可以选择是否将这些绑定操作公开给应用程序代码。如果 let*(或 let**)被公开,那么应用程序就可以使用 monad 的所有表达能力,但是在某些承诺解决之前,我们将无法显示整个管道。如果我们隐藏它们,那么应用程序只能生成静态管道。


到目前为止,我的方法是使用 let* 作为逃生舱口,这样就可以建造任何所需的管道,但我后来用更专业的操作来代替它的任何用途。例如,我添加了:


val list_map : ('a t -> 'b t) -> 'a list t -> 'b list t
复制代码


这将处理运行时才知道的列表中的每个项。然而,我们仍然可以静态地知道我们将应用于每个项的管道,即使我们不知道项本身是什么。list_map 本来可以使用 let* 实现,但这样我们就无法静态地看到管道。


下面是另外两个使用 dart 方法的示例:


let example2 commit =  let src = fetch commit in  let base = docker_pull "ocaml/opam2" in  let build ocaml_version =    let dockerfile =      let+ base = base in      make_dockerfile ~base ~ocaml_version in    let image = build ~dockerfile src ~label:ocaml_version in    test image  in  all [    build "4.07";    build "4.08"  ]
复制代码


与原来相比,我们有一个 all 来合并结果,并且在计算 dockerfile 时有一个额外的 let+base=base。let+ 只是 map 的另一种语法,在这里使用,因为我选择不更改 make_dockerfile 的签名。或者,我们可以让你的 dockerfile 接受一个基本图像的承诺,并在里面做地图。因为 map 需要一个纯实体(make_dockerfile 只生成一个字符串;没有承诺或错误),所以它不需要在图表上有自己的框,并且我们不会因为允许使用它而丢失任何东西。


let example3 commit =  let src = fetch commit in  let image = build src in  let ok = test image in  let revdeps = get_revdeps src in  gate revdeps ~on:ok |>  list_iter ~pp:Fmt.string example1
复制代码


这显示了另一个自定义操作:gate revdeps~on:ok 是一个承诺,只有在 revdeps 和 ok 都解决后才能解决。这将阻止它在库自己的测试通过之前测试库的 revdeps,即使如果我们希望它可以并行地这样做。而对于 monad,我们必须在需要的地方显式地启用并发(使用和 *),而对于 dart,我们必须在不需要的地方显式地禁用并发(使用 gate)。


我还添加了一个 list-iter 便利函数,并为它提供了一个漂亮的 printer 参数,这样一旦知道列表输入,我们就可以在图表中标记案例。


最后,虽然我说过不能在原语中使用 let*,但仍然可以使用其他一些 monad(它不会生成图)。实际上,在实际系统中,我对原语使用了一个单独的 let>操作符。这就要求主体使用底层 promise 库提供的非图生成承诺,因此不能在原语的主体中使用 let*(或 let>)。

和 Arrow 进行比较


给定一个“dart”,您可以通过定义例如。


type ('a, 'b) arrow = 'a promise -> 'b promise
复制代码


那么 arr 就是 map,f>>>g 就是有趣的 x->g(fx)。第一个也可以很容易地定义,假设你有某种函数来并行地做两件事(比如上面的和我们的)。


因此,dart API(即使有 let*hidden)仍然足以表示任何可以使用箭头 API 表示的管道。


Haskell 箭头教程 使用一个箭头是有状态函数的示例。例如,有一个 total 箭头,它返回它的输入和以前调用它的每个输入的总和。e、 g. 用输入 1 2 3 调用三次,产生输出 1 3 6。对输入序列运行管道将返回输出序列。


本教程使用 total 定义 mean1 函数,如下所示:


mean1 = (total &&& (arr (const 1) >>> total)) >>> arr (uncurry (/))
复制代码


因此,此管道复制每个输入编号,将第二个编号替换为 1,将两个流相加,然后用其比率替换每对。每次将另一个数字放入管道时,都会得到迄今为止输入的所有值的平均值。


使用 dart 样式的等效代码是(OCaml 使用 /。对于浮点除法):


let mean values =  let t = total values in  let n = total (const 1.0) in  map (uncurry (/.)) (pair t n)
复制代码


这对我来说更容易理解。通过定义标准运算符 let+(对于 map)和 +(对于 pair),我们可以稍微简化代码:


let (let+) x f = map f xlet (and+) = pair
let mean values = let+ t = total values and+ n = total (const 1.0) in t /. n
复制代码


无论如何,这不是一个很好的箭头示例,因为我们不使用一个状态函数的输出作为另一个状态函数的输入,所以这实际上只是一个简单的 applicative.


不过,我们可以很容易地用另一个有状态函数扩展示例管道,也许可以添加一些平滑处理。这看起来像箭头符号中的 mean1>>>平滑,省道符号中的值|>平均值|>平滑(或平滑(平均值))。


注意:Haskell 还有一个 Arrows 语法扩展,它允许 Haskell 代码编写为:


mean2 = proc value -> do    t <- total -< value    n <- total -< 1    returnA -< t / n
复制代码


这更像是飞镖符号。

更多示例


我在 ocurrent/ocurrent 上建立了一个使用这些思想的稍微扩展版本的库。子目录 lib_term 是与这篇博客文章相关的部分,在 TERM 中描述了各种组合词。


其他目录处理更具体的细节,例如与 Lwt promise 库的集成,提供管理 web UI 或 Cap’n Proto RPC 接口,以及带有用于使用 Git、GitHub、Docker 和 Slack 的原语的插件。


OCaml Docker 基础镜像构建


ocurrent/docker-base-images 包含一个管道,用于为各种 Linux 发行版、CPU 架构、OCaml 编译器版本和配置选项构建 OCaml 的 Docker 基本映像。例如,要在 Debian 10 上测试 OCAML4.09,可以执行以下操作:


$ docker run --rm -it ocurrent/opam:debian-10-ocaml-4.09
:~$ ocamlopt --version4.09.0
:~$ opam depext -i utop[...]
:~$ utop----+-------------------------------------------------------------+------------------ | Welcome to utop version 2.4.2 (using OCaml version 4.09.0)! | +-------------------------------------------------------------+
Type #utop_help for help about using utop.
-( 11:50:06 )-< command 0 >-------------------------------------------{ counter: 0 }-utop #
复制代码


以下是管道的外观(单击可查看完整尺寸)



它每周提取 opam 存储库的最新 Git 提交,然后为每个发行版构建包含该内容的基本映像和 opam 包管理器,然后为每个受支持的编译器变体构建一个映像。许多图片是建立在多个架构(amd64、arm32、arm64 和 ppc64)上的,并被推到 Docker Hub 的一个暂存区。然后,管道将所有散列组合起来,将一个多架构清单推送到 Docker Hub。还有一些别名(例如,debian 表示 debian-10-ocaml-4.09)。最后,如果有任何问题,则管道会将错误发送到松弛通道。


您可能想知道,我们是否真的需要一个管道来实现这一点,而不是从 cron 作业运行一个简单的脚本。但是拥有一个管道可以让我们在运行它之前看到管道将要做什么,观察管道的进度,单独重新启动失败的作业,等等,几乎与我们编写的代码相同。


如果你想看完成的流水线,可以阅读 pipeline.ml。


OCaml CI


ocurrent/ocaml-ci 是一个用于测试 OCaml 项目的(实验性的)GitHub 应用程序。管道获取应用程序的安装列表,获取每个安装的已配置存储库,获取每个存储库的分支和 PRs,然后针对多个 Linux 发行版和 OCaml 编译器版本测试每个存储库的头部。如果项目使用 ocamlformat,它还会检查提交的格式是否与 ocamlformat 的格式完全相同。



结果作为提交状态被推回到 GitHub,并记录在 web 和 tty ui 的本地索引中。这里有很多红色,主要是因为如果一个项目不支持特定版本的 OCaml,那么构建会被标记为失败,并在管道中显示为红色,尽管在生成 GitHub 状态报告时会过滤掉这些失败。我们可能需要一个新的颜色跳过阶段。

结论


编写 CI/CD 管道很方便,就好像它们是一次连续运行这些步骤并始终成功的单点脚本一样,然后只要稍作更改,管道就会在输入更改时运行这些步骤,同时提供日志记录、错误报告、取消和重建支持。


使用 monad 可以很容易地将任何程序转换为具有这些特性的程序,但是,与常规程序一样,在运行某些数据之前,我们不知道该程序将如何处理这些数据。特别是,我们只能自动生成显示已经开始的步骤的图表。


传统的静态分析方法是使用箭头。这比单元格稍微有限,因为流水线的结构不能根据输入数据而改变,尽管我们可以增加有限的灵活性,例如可选的步骤或两个分支之间的选择。但是,使用箭头符号编写管道是很困难的,因为我们必须使用无点样式(没有变量)编程。


通过以一种不寻常的方式使用 monad(这里称为“dart”),我们可以获得静态分析的相同好处。我们的函数不是接受纯值并返回包装值的函数,而是接受并返回包装值。这导致语法看起来与普通编程相同,但允许静态分析(代价是无法直接操作包装的值)。


如果我们隐藏(或不使用)monad 的 let*(bind)函数,那么我们创建的管道总是可以静态地确定的。如果我们使用绑定,那么管道中会有随着管道运行而扩展到更多管道阶段的孔。


基本步骤可以通过使用单个“标签绑定”创建,其中标签为原子组件提供静态分析。


我以前从未见过使用过这种模式(或者在 arrow 文档中提到过),它似乎提供了与 arrow 完全相同的好处,而且难度要小得多。如果这个名字是真的,告诉我!


这项工作由 OCaml 实验室资助。


原文链接:


https://roscidus.com/blog/blog/2019/11/14/cicd-pipelines/

2021-02-18 10:245267

评论

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

明道云联合契约锁共建人事场景电子签约解决方案

明道云

零代码平台在政府智慧城市领域的应用

明道云

React源码分析1-jsx转换及React.createElement

flyzz177

React

chatGPT的爆火,是计算机行业这次真的“饱和”了?

千锋IT教育

从vivo的创新方法论中,读懂高端突破的“因果”

脑极体

React Context源码是怎么实现的呢

flyzz177

React

低代码实现探索(五十四)低代码的描述文本

零道云-混合式低代码平台

【从零开始学爬虫】采集收视率排行数据

前嗅大数据

大数据 数据采集 爬虫软件 爬虫教程 数据采集教程

Linux RT 进程引发内核频繁卡死的优化方案

火山引擎边缘云

云计算 Linux 云原生 边缘计算 火山引擎边缘计算

5分钟带你彻底掌握async底层实现原理!

千锋IT教育

AH协议

穿过生命散发芬芳

12月月更 AH协议

架构实战 - 模块3作业

mm

学生管理系统架构 #架构实战营

明道云携手衡石科技共建企业应用数据分析联合解决方案

明道云

前端leetcde算法面试套路之堆

js2030code

JavaScript LeetCode

js异步编程面试题你能答上来几道

loveX001

JavaScript

聊聊产品中的状态机设计

产品海豚湾

产品经理 产品设计 产品开发 需求分析 主业务流程梳理

极客时间运维进阶训练营第八周作业

9527

阿里前端常考面试题集锦

loveX001

JavaScript

2022年的各大平台小游戏生态发展到哪一步了?

FN0

游戏开发 小游戏开发 小程序游戏开发

React源码分析(二)渲染机制

flyzz177

React

深入react源码看setState究竟做了什么?

flyzz177

React

用javascript分类刷leetcode23.并查集(图文视频讲解)

js2030code

JavaScript LeetCode

2022面试官常考的前端面试题

loveX001

JavaScript

通过WSL2运行GUI程序

吴脑的键客

WSL2 GUI

2022-12-15:寻找用户推荐人。写一个查询语句,返回一个客户列表,列表中客户的推荐人的编号都 不是 2。 对于示例数据,结果为: +------+ | name | +------+ | Wil

福大大架构师每日一题

数据库· 福大大

2022全球边缘计算大会,火山引擎荣获“优质边缘云服务提供商”称号

火山引擎边缘云

云原生 CDN 边缘计算 边云协同 火山引擎边缘计算

前端leetcde算法面试套路之回溯

js2030code

JavaScript LeetCode

js事件循环与macro&micro任务队列-前端面试进阶

loveX001

JavaScript

KCL 与其他 Kubernetes 配置管理工具的异同 - Kustomize 篇 [一个自研编程语言能做什么?(系列 2)]

Peefy

开发者 工具 编程语言 Kubernetes Serverless #DevOps

React源码分析(三):useState,useReducer

flyzz177

React

细说react源码中的合成事件

flyzz177

React

CI/CD 流水线创建方法:Monad、Arrow 还是 Dart ?_架构_Thomas Leonard_InfoQ精选文章