CI/CD 流水線創建方法:Monad、Arrow 還是 Dart ?

{"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"本文將用三種方法來創建 CI\/CD 流水線。Monad 不能對流水線進行靜態分析,Arrow 語法很難用,我稱之爲 Dart(不知道它是否已經有名字了)的一種輕量級的 Arrow 方法可以像 Arrow 一樣進行靜態分析,但語法比 Monad 更簡單。"}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我需要構建一個用於創建 CI\/CD 流水線的系統。它起初是爲了構建一個 CI 系統,測試 Github 上的 OCaml 項目(針對多個版本的 OCaml 編譯器和多個操作系統,測試每個提交)。下面是一個簡單的流水線,獲取某個 Git 分支最新的提交,構建,並執行測試用例。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"【譯者注】CI\/CD:持續集成(Continuous Integration)和持續部署(Continuous Deployment)簡稱,指在開發過程中自動執行一系列腳本來減低開發引入 bug 的概率,在新代碼從開發到部署的過程中,儘量減少人工的介入。"}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/wechat\/images\/03\/03094295ec17efb9831f2b03fd855155.png","alt":null,"title":null,"style":null,"href":null,"fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這裏的配色標識是:綠色的方框是已經完成,橙色的是正在進行,灰色的意味着這一步還不能開始。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這裏有一個稍微複雜點的例子,它還下載了一個 Docker 基礎鏡像,使用兩個不同版本的 OCaml 編譯器並行構建提交,然後測試得到的鏡像。紅框表示此步驟失敗:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/wechat\/images\/83\/8356fe95a198cc6274a898f07d0170a2.png","alt":null,"title":null,"style":null,"href":null,"fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"一個更復雜的例子是測試項目本身,然後搜索依賴它的其他項目,並根據新版本測試這些項目:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/wechat\/images\/18\/18911e088eccff2278cbfc04774463b1.png","alt":null,"title":null,"style":null,"href":null,"fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在這裏,圓圈意味着在檢查反向依賴項之前,我們應該等待測試通過。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們可以用 YAML 或類似的方法來描述這些管道,但這將是非常有限的。相反,我決定使用一種特定於領域的嵌入式語言,這樣我們就可以免費使用宿主語言的特性(例如字符串操作、變量、函數、導入、類型檢查等)。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"最明顯的方法是使每個框成爲正則函數。然後上面的第一個例子可以是(這裏,使用 OCaml 語法):"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"sql"},"content":[{"type":"text","text":"let example1 commit =\n let src = fetch commit in\n let image = build src in\n test image\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"第二個可能是:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"cs"},"content":[{"type":"text","text":"let example2 commit =\n let src = fetch commit in\n let base = docker_pull \"ocaml\/opam2\" in\n let build ocaml_version =\n let dockerfile = make_dockerfile ~base ~ocaml_version in\n let image = build ~dockerfile src ~label:ocaml_version in\n test image\n in\n build \"4.07\";\n build \"4.08\"\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"第三個可能是這樣的:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"bash"},"content":[{"type":"text","text":"let example3 commit =\n let src = fetch commit in\n let image = build src in\n test image;\n let revdeps = get_revdeps src in\n List.iter example1 revdeps\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"不過,我們想在語言中添加一些附加功能:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"管道步驟應儘可能並行運行。上面的 example2 函數將一次完成一個構建。"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"管道步驟應在其輸入更改時重新計算。e、 當我們作出新的承諾時,我們需要重建。"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"用戶應該能夠查看每個步驟的進度。"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"用戶應該能夠爲任何步驟觸發重建。"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們應該能夠從代碼中自動生成圖表,這樣我們就可以在運行管道之前看到它將做什麼。"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"一步的失敗不應該使整個管道停止。"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"對於這篇博客文章來說,確切的附加功能並不重要,因此爲了簡單起見,我將重點放在同時運行步驟上。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"Monad 方法"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"【譯者注】Monad:函子,單子,來自 Haskell 編程語言,是函數式編程中,一種定義將函數(函子)組合起來的結構方式,它除了返回值以外,還需要一個上下文。常見的 Monad 有計算任務,分支任務,或者 I\/O 操作。"}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如果沒有額外的功能,我們有如下功能:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"sql"},"content":[{"type":"text","text":"val fetch : commit -> source\nval build : source -> image\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"您可以將其理解爲“build 是一個獲取源值並返回(Docker)鏡像的函數”。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這些函數很容易組合在一起,形成一個更大的函數來獲取提交併構建它:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"swift"},"content":[{"type":"text","text":"let fab c =\n let src = fetch c in\n build src\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/wechat\/images\/e0\/e0580d3f97ed7d9006989378ded9321c.png","alt":null,"title":null,"style":null,"href":null,"fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們還可以將其縮短爲 build(fetch c)或 fetch c |>build。OCaml 中的|>(pipe)運算符只調用其右側的函數,而參數在其左側。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"爲了將這些函數擴展爲併發的,我們可以讓它們返回承諾,例如,"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"sql"},"content":[{"type":"text","text":"val fetch : commit -> source promise\nval build : source -> image promise\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"但是現在我們無法使用 let(或|>)輕鬆組合它們,因爲 fetch 的輸出類型與 build 的輸入不匹配。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"但是,我們可以定義一個類似的操作,let(或>>=)來處理承諾。它立即返回對最終結果的承諾,並在第一個承諾實現後調用 let* 的主體。那麼我們有:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"swift"},"content":[{"type":"text","text":"let fab c =\n let* src = fetch c in\n build src\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"換句話說,通過在周圍撒上幾個星號字符,我們可以將簡單的舊管道變成一個新的併發管道!使用 let* 編寫 promise returning 函數的時間規則與使用 let 編寫常規函數的時間規則完全相同,因此使用 promise 編寫程序與編寫常規程序一樣簡單。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"僅僅使用 let *不會在我們的管道中添加任何併發(它只允許它與其他代碼併發執行)。但是我們可以爲此定義額外的函數,比如 all 一次計算一個列表中的每個承諾,或者使用 and 運算符指示兩個事物應該並行運行:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"除了處理承諾外,我們還可以爲可能返回錯誤的函數(只有在第一個值成功時才調用 let 的主體)或爲實時更新(每次輸入更改時都調用主體)或爲所有這些東西一起定義 let*。這是單子的基本概念。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這其實很管用。在 2016 年,我用這種方法做了 DataKitCI,它最初用於 Docker-for-Mac 上的 CI 系統。之後,Madhavapeddy 用它創建了 opam-repo-ci,這是 opam-repository 上的 CI 系統,OCaml 上主要的軟件倉庫。這將檢查每個新的 PR 以查看它添加或修改了哪些包,針對多個 OCaml 編譯器版本和 Linux 發行版(Debian、Ubuntu、Alpine、CentOS、Fedora 和 OpenSUSE)測試每個包,然後根據更改的包查找所有包的所有版本,並測試這些版本。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"使用 monad 的主要問題是我們不能對管道進行靜態分析。考慮上面的 example2 函數。在查詢 GitHub 以獲得測試提交之前,我們無法運行該函數,因此不知道它將做什麼。一旦我們有了 commit,我們就可以調用 example2commit,但是在 fetch 和 docker_pull 操作完成之前,我們無法計算 let* 的主體來找出管道接下來將做什麼。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"換言之,我們只能繪製圖表,顯示已經執行或正在執行的管道位,並且必須使用和 * 手動指示併發的機會。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"Arrow 方法"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Arrow 使管道的靜態分析成爲可能。而不是我們的一元函數:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"sql"},"content":[{"type":"text","text":"val fetch : commit -> source promise\nval build : source -> image promise\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們可以定義箭頭類型:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"bash"},"content":[{"type":"text","text":"type ('a, 'b) arrow\n\nval fetch : (commit, source) arrow\nval build : (source, image) arrow\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"a('a,'b)arrow 是一個接受 a 類型輸入並生成 b 類型結果的管道。如果我們定義類型('a,'b)arrow='a->'b promise,則這與一元版本相同。但是,我們可以將箭頭類型抽象化,並對其進行擴展,以存儲我們需要的任何靜態信息。例如,我們可以標記箭頭:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"nginx"},"content":[{"type":"text","text":"type ('a, 'b) arrow = {\n f : 'a -> 'b promise;\n label : string;\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這裏,箭頭是一個記錄。f 是舊的一元函數,label 是“靜態分析”。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"用戶看不到 arrow 類型的內部,必須使用 arrow 實現提供的函數來構建管道。有三個基本功能可用:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"kotlin"},"content":[{"type":"text","text":"val arr : ('a -> 'b) -> ('a, 'b) arrow\nval ( >>> ) : ('a, 'b) arrow -> ('b, 'c) arrow -> ('a, 'c) arrow\nval first : ('a, 'b) arrow -> (('a * 'c), ('b * 'c)) arrow\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"arr 接受純函數並給出等價的箭頭。對於我們的承諾示例,這意味着箭頭返回已經實現的承諾。>>>把兩個箭頭連在一起。首先從“a”到“b”取一個箭頭,改爲成對使用。該對的第一個元素將由給定的箭頭處理,第二個組件將原封不動地返回。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們可以讓這些操作自動創建帶有適當 f 和 label 字段的新箭頭。例如,在 a>>>b 中,結果 label 字段可以是字符串{a.label}>>{b.label}。這意味着我們可以顯示管道,而不必先運行它,如果需要的話,我們可以很容易地用更結構化的內容替換 label。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們的第一個例子是:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"sql"},"content":[{"type":"text","text":"let example1 commit =\n let src = fetch commit in\n let image = build src in\n test image\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"to"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"bash"},"content":[{"type":"text","text":"let example1 =\n fetch >>> build >>> test\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"雖然我們不得不放棄變量名,但這似乎很令人愉快。但事情開始變得複雜,有了更大的例子。例如 2,我們需要定義幾個標準組合:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"(** Process the second component of a tuple, leaving the first unchanged. *)\nlet second f =\n let swap (x, y) = (y, x) in\n arr swap >>> first f >>> arr swap\n\n(** [f *** g] processes the first component of a pair with [f] and the second\n with [g]. *)\nlet ( *** ) f g =\n first f >>> second g\n\n(** [f &&& g] processes a single value with [f] and [g] in parallel and\n returns a pair with the results. *)\nlet ( &&& ) f g =\n arr (fun x -> (x, x)) >>> (f *** g)\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Then, example2 changes from:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"cs"},"content":[{"type":"text","text":"let example2 commit =\n let src = fetch commit in\n let base = docker_pull \"ocaml\/opam2\" in\n let build ocaml_version =\n let dockerfile = make_dockerfile ~base ~ocaml_version in\n let image = build ~dockerfile src ~label:ocaml_version in\n test image\n in\n build \"4.07\";\n build \"4.08\"\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"to:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"kotlin"},"content":[{"type":"text","text":"let example2 =\n let build ocaml_version =\n first (arr (fun base -> make_dockerfile ~base ~ocaml_version))\n >>> build_with_dockerfile ~label:ocaml_version\n >>> test\n in\n arr (fun c -> ((), c))\n >>> (docker_pull \"ocaml\/opam2\" *** fetch)\n >>> (build \"4.07\" &&& build \"4.08\")\n >>> arr (fun ((), ()) -> ())\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們已經丟失了大多數變量名,而不得不使用元組,記住我們的值在哪裏。這裏有兩個值並不是很糟糕,但是隨着更多的值被添加並且我們開始嵌套元組,它變得非常困難。我們還失去了在 build~dockerfile src 中使用可選標記參數的能力,而是需要使用一個新操作,該操作接受 dockerfile 和源的元組。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"假設現在運行測試需要從源代碼獲取測試用例。在原始代碼中,我們只需使用:src 將測試圖像更改爲測試圖像。在 arrow 版本中,我們需要在生成步驟之前複製源代碼,使用帶 dockerfile 的 first build_ 運行生成,並確保參數是新測試使用的正確方法。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"Dart 方法"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我開始懷疑是否有一種更簡單的方法來實現與箭頭相同的靜態分析,但是沒有無點語法,而且似乎有。考慮示例 1 的一元版本。我們有:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"bash"},"content":[{"type":"text","text":"val build : source -> image promise\nval test : image -> results promise\n\nlet example1 commit =\n let* src = fetch commit in\n let* image = build src in\n test image\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如果你不知道蒙娜茲的事,你還有別的辦法。您可以定義 build 和 test,將 promises 作爲輸入,而不是使用 let* 等待獲取完成,然後使用源調用 build:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"bash"},"content":[{"type":"text","text":"val build : source promise -> image promise\nval test : image promise -> results promise\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"畢竟,fetching 給了你一個源代碼承諾,你想要一個圖像承諾,所以這看起來很自然。我們甚至可以以承諾爲例。然後看起來是這樣的:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"sql"},"content":[{"type":"text","text":"let example1 commit =\n let src = fetch commit in\n let image = build src in\n test image\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"很好,因爲它和我們剛開始的簡單版本是一樣的。問題是效率低下:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"bulletedlist","content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們用承諾的方式調用 example1(我們還不知道它是什麼)。"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們不必等待找出要測試的提交,而是調用 fetch,獲取某個源的承諾。"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"不需要等待獲取源代碼,我們就調用 build,獲取圖像的承諾。"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"不用等待構建,我們調用 test,得到結果的承諾。"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們立即返回測試結果的最終承諾,但我們還沒有做任何真正的工作。相反,我們建立了一長串的承諾,浪費了記憶。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"但是,在這種情況下,我們希望執行靜態分析。i、 我們想在內存中建立一些表示流水線的數據結構……這正是我們對 monad 的“低效”使用所產生的結果!"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"爲了使其有用,我們需要基本操作(比如 fetch)來爲靜態分析提供一些信息(比如標籤)。OCaml 的 let 語法沒有爲標籤提供明顯的位置,但是我能夠定義一個運算符(let**),該運算符返回一個接受 label 參數的函數。它可用於生成如下基本操作:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"sql"},"content":[{"type":"text","text":"let fetch commit =\n \"fetch\" |>\n let** commit = commit in\n (* (standard monadic implementation of fetch goes here) *)\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"因此,fetch 接受一個提交的承諾,對它執行一個單字節綁定以等待實際的提交,然後像以前一樣繼續,但它將綁定標記爲一個 fetch 操作。如果 fetch 包含多個參數,則可以使用 and* 並行等待所有參數。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"理論上,let**In fetch 的主體可以包含進一步的綁定。如果那樣的話,我們在一開始就無法分析整個管道。但是,只要原語在開始時等待所有輸入,並且不在內部進行任何綁定,我們就可以靜態地發現整個管道。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們可以選擇是否將這些綁定操作公開給應用程序代碼。如果 let*(或 let**)被公開,那麼應用程序就可以使用 monad 的所有表達能力,但是在某些承諾解決之前,我們將無法顯示整個管道。如果我們隱藏它們,那麼應用程序只能生成靜態管道。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"到目前爲止,我的方法是使用 let* 作爲逃生艙口,這樣就可以建造任何所需的管道,但我後來用更專業的操作來代替它的任何用途。例如,我添加了:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"nginx"},"content":[{"type":"text","text":"val list_map : ('a t -> 'b t) -> 'a list t -> 'b list t\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這將處理運行時才知道的列表中的每個項。然而,我們仍然可以靜態地知道我們將應用於每個項的管道,即使我們不知道項本身是什麼。list_map 本來可以使用 let* 實現,但這樣我們就無法靜態地看到管道。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"下面是另外兩個使用 dart 方法的示例:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"cs"},"content":[{"type":"text","text":"let example2 commit =\n let src = fetch commit in\n let base = docker_pull \"ocaml\/opam2\" in\n let build ocaml_version =\n let dockerfile =\n let+ base = base in\n make_dockerfile ~base ~ocaml_version in\n let image = build ~dockerfile src ~label:ocaml_version in\n test image\n in\n all [\n build \"4.07\";\n build \"4.08\"\n ]\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"與原來相比,我們有一個 all 來合併結果,並且在計算 dockerfile 時有一個額外的 let+base=base。let+ 只是 map 的另一種語法,在這裏使用,因爲我選擇不更改 make_dockerfile 的簽名。或者,我們可以讓你的 dockerfile 接受一個基本圖像的承諾,並在裏面做地圖。因爲 map 需要一個純實體(make_dockerfile 只生成一個字符串;沒有承諾或錯誤),所以它不需要在圖表上有自己的框,並且我們不會因爲允許使用它而丟失任何東西。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"cs"},"content":[{"type":"text","text":"let example3 commit =\n let src = fetch commit in\n let image = build src in\n let ok = test image in\n let revdeps = get_revdeps src in\n gate revdeps ~on:ok |>\n list_iter ~pp:Fmt.string example1\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這顯示了另一個自定義操作:gate revdeps~on:ok 是一個承諾,只有在 revdeps 和 ok 都解決後才能解決。這將阻止它在庫自己的測試通過之前測試庫的 revdeps,即使如果我們希望它可以並行地這樣做。而對於 monad,我們必須在需要的地方顯式地啓用併發(使用和 *),而對於 dart,我們必須在不需要的地方顯式地禁用併發(使用 gate)。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我還添加了一個 list-iter 便利函數,併爲它提供了一個漂亮的 printer 參數,這樣一旦知道列表輸入,我們就可以在圖表中標記案例。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"最後,雖然我說過不能在原語中使用 let*,但仍然可以使用其他一些 monad(它不會生成圖)。實際上,在實際系統中,我對原語使用了一個單獨的 let>操作符。這就要求主體使用底層 promise 庫提供的非圖生成承諾,因此不能在原語的主體中使用 let*(或 let>)。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"和 Arrow 進行比較"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"給定一個“dart”,您可以通過定義例如。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"bash"},"content":[{"type":"text","text":"type ('a, 'b) arrow = 'a promise -> 'b promise\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"那麼 arr 就是 map,f>>>g 就是有趣的 x->g(fx)。第一個也可以很容易地定義,假設你有某種函數來並行地做兩件事(比如上面的和我們的)。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"因此,dart API(即使有 let*hidden)仍然足以表示任何可以使用箭頭 API 表示的管道。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Haskell 箭頭教程 使用一個箭頭是有狀態函數的示例。例如,有一個 total 箭頭,它返回它的輸入和以前調用它的每個輸入的總和。e、 g. 用輸入 1 2 3 調用三次,產生輸出 1 3 6。對輸入序列運行管道將返回輸出序列。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"本教程使用 total 定義 mean1 函數,如下所示:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"ini"},"content":[{"type":"text","text":"mean1 = (total &&& (arr (const 1) >>> total)) >>> arr (uncurry (\/))\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"因此,此管道複製每個輸入編號,將第二個編號替換爲 1,將兩個流相加,然後用其比率替換每對。每次將另一個數字放入管道時,都會得到迄今爲止輸入的所有值的平均值。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"使用 dart 樣式的等效代碼是(OCaml 使用 \/。對於浮點除法):"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"let mean values =\n let t = total values in\n let n = total (const 1.0) in\n map (uncurry (\/.)) (pair t n)\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這對我來說更容易理解。通過定義標準運算符 let+(對於 map)和 +(對於 pair),我們可以稍微簡化代碼:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"javascript"},"content":[{"type":"text","text":"let (let+) x f = map f x\nlet (and+) = pair\n\nlet mean values =\n let+ t = total values\n and+ n = total (const 1.0) in\n t \/. n\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"無論如何,這不是一個很好的箭頭示例,因爲我們不使用一個狀態函數的輸出作爲另一個狀態函數的輸入,所以這實際上只是一個簡單的 applicative."}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"不過,我們可以很容易地用另一個有狀態函數擴展示例管道,也許可以添加一些平滑處理。這看起來像箭頭符號中的 mean1>>>平滑,省道符號中的值|>平均值|>平滑(或平滑(平均值))。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"注意:Haskell 還有一個 Arrows 語法擴展,它允許 Haskell 代碼編寫爲:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"properties"},"content":[{"type":"text","text":"mean2 = proc value -> do\n t -------------------------------------------{ counter: 0 }-\nutop #\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"以下是管道的外觀(單擊可查看完整尺寸)"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/wechat\/images\/44\/44be7d6514f0ab8aa5dc37ed55c14c94.png","alt":null,"title":null,"style":null,"href":null,"fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"它每週提取 opam 存儲庫的最新 Git 提交,然後爲每個發行版構建包含該內容的基本映像和 opam 包管理器,然後爲每個受支持的編譯器變體構建一個映像。許多圖片是建立在多個架構(amd64、arm32、arm64 和 ppc64)上的,並被推到 Docker Hub 的一個暫存區。然後,管道將所有散列組合起來,將一個多架構清單推送到 Docker Hub。還有一些別名(例如,debian 表示 debian-10-ocaml-4.09)。最後,如果有任何問題,則管道會將錯誤發送到鬆弛通道。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"您可能想知道,我們是否真的需要一個管道來實現這一點,而不是從 cron 作業運行一個簡單的腳本。但是擁有一個管道可以讓我們在運行它之前看到管道將要做什麼,觀察管道的進度,單獨重新啓動失敗的作業,等等,幾乎與我們編寫的代碼相同。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如果你想看完成的流水線,可以閱讀 pipeline.ml。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"OCaml CI"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"ocurrent\/ocaml-ci 是一個用於測試 OCaml 項目的(實驗性的)GitHub 應用程序。管道獲取應用程序的安裝列表,獲取每個安裝的已配置存儲庫,獲取每個存儲庫的分支和 PRs,然後針對多個 Linux 發行版和 OCaml 編譯器版本測試每個存儲庫的頭部。如果項目使用 ocamlformat,它還會檢查提交的格式是否與 ocamlformat 的格式完全相同。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/wechat\/images\/81\/819fa9313cfff8541d973c20faaa55a5.png","alt":null,"title":null,"style":null,"href":null,"fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"結果作爲提交狀態被推回到 GitHub,並記錄在 web 和 tty ui 的本地索引中。這裏有很多紅色,主要是因爲如果一個項目不支持特定版本的 OCaml,那麼構建會被標記爲失敗,並在管道中顯示爲紅色,儘管在生成 GitHub 狀態報告時會過濾掉這些失敗。我們可能需要一個新的顏色跳過階段。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"結論"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"編寫 CI\/CD 管道很方便,就好像它們是一次連續運行這些步驟並始終成功的單點腳本一樣,然後只要稍作更改,管道就會在輸入更改時運行這些步驟,同時提供日誌記錄、錯誤報告、取消和重建支持。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"使用 monad 可以很容易地將任何程序轉換爲具有這些特性的程序,但是,與常規程序一樣,在運行某些數據之前,我們不知道該程序將如何處理這些數據。特別是,我們只能自動生成顯示已經開始的步驟的圖表。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"傳統的靜態分析方法是使用箭頭。這比單元格稍微有限,因爲流水線的結構不能根據輸入數據而改變,儘管我們可以增加有限的靈活性,例如可選的步驟或兩個分支之間的選擇。但是,使用箭頭符號編寫管道是很困難的,因爲我們必須使用無點樣式(沒有變量)編程。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"通過以一種不尋常的方式使用 monad(這裏稱爲“dart”),我們可以獲得靜態分析的相同好處。我們的函數不是接受純值並返回包裝值的函數,而是接受並返回包裝值。這導致語法看起來與普通編程相同,但允許靜態分析(代價是無法直接操作包裝的值)。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如果我們隱藏(或不使用)monad 的 let*(bind)函數,那麼我們創建的管道總是可以靜態地確定的。如果我們使用綁定,那麼管道中會有隨着管道運行而擴展到更多管道階段的孔。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"基本步驟可以通過使用單個“標籤綁定”創建,其中標籤爲原子組件提供靜態分析。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我以前從未見過使用過這種模式(或者在 arrow 文檔中提到過),它似乎提供了與 arrow 完全相同的好處,而且難度要小得多。如果這個名字是真的,告訴我!"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這項工作由 OCaml 實驗室資助。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"原文鏈接:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"link","attrs":{"href":"https:\/\/roscidus.com\/blog\/blog\/2019\/11\/14\/cicd-pipelines\/","title":"","type":null},"content":[{"type":"text","text":"https:\/\/roscidus.com\/blog\/blog\/2019\/11\/14\/cicd-pipelines\/"}]}]}]}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章