首頁>技術>

導讀:在密集的業務開發中,協同的工程師是很多的,複雜的環境和專案不一致會導致各自的學習、溝通成本變高,間接導致效率低下。本文整理自 bilibili 資料平臺部技術總監毛劍在 QCon 全球軟體開發大會(上海站)2019 的演講,他重點講述了 Go 語言在工程專案佈局、單元測試、API 設計,以及一體化的工具鏈的實戰。

我今天主要是講一講我們在 bilibili(以下簡稱 B 站) 的 Go 語言的一些工程化實踐,因為確實是有很多內容值得去講。我們知道現在用 Go 語言做雲原生這種是蠻多的,但是真正在大型業務系統裡面,甚至上千個服務化的工程管理這方面的分享相對來說還是比較少,我之前幾次演講說得比較多的就是偏微服務可用性,或者 Go 的一些具體的實踐,而這次的內容是工程化,比較通用一點。

整個內容分為四個部分,首先就是講一下 Go 在工程裡面,我們的目錄的一些組織結構,比方工程是怎麼組織的,目錄是怎麼組織的,每一個服務是怎麼組織的,每一些服務有一些什麼樣的角色。第二個的話我們會講一下在 Go 裡面的一些單元測試在我廠的一些實踐。第三塊的話就是,我們把之前做的一些這種工具化或者約定或者目錄結構的一些規範,能不能把它變成一些工具鏈的思考。最後的話簡單提一下我自己對配置檔案的一些理解。

Layout Go 工程專案的整體組織

首先我們看一下整個 Go 工程是怎麼組織起來的。

很多同事都在用 GitLab 的,GitLab 的一個 group 裡面可以建立很多 project。如果我們進行微服務化改造,以前很多巨石架構的應用可能就拆成了很多個獨立的小應用。那麼這麼多小應用,你是要建 N 個 project 去維護,還是說按照部門或者組來組織這些專案呢?在 B 站的話,我們之前因為是 Monorepo,現在是按照部門去組織管理程式碼,就是說在單個 GitLab 的 project 裡面是有多個 app 的,每一個 app 就表示一個獨立的微服務,它可以獨立去交付部署。所以說我們看到下面這張圖裡面,app 的目錄裡面是有好多個子目錄的,比方說我們的評論服務,會員服務。跟 app 同級的目錄有一個叫 pkg,可以存放業務有關的公共庫。這是我們的一個組織方式。當然,還有一種方式,你可以按照 GitLab 的 project 去組織,但我覺得這樣的話可能相對要建立的 project 會非常多。

如果你按部門組織的話,部門裡面有很多 app,app 目錄怎麼去組織?我們實際上會給每一個 app 取一個全域性唯一名稱,可以理解為有點像 DNS 那個名稱。我們對業務的命名也是一樣的,我們基本上是三段式的命名,比如賬號業務,它是一個賬號業務、服務、子服務的三段命名。三段命名以後,在這個 app 目錄裡面,你也可以按照這三層來組織。比如我們剛剛說的賬號目錄,我可能就是 account 目錄,然後 VIP,在 VIP 目錄下可能會放各種各樣的不同角色的微服務,比方說可能有一些是做 job,做定時任務或者流式處理的一些任務,有可能是做對外暴露的 API 的一些服務,這個就是我們關於整個大的 app 的組織的一種形式。

微服務中的 app 服務分類

微服務中單個 app 的服務裡又分為幾類不同的角色。我們基本上會把 app 分為 interface(BFF)、service、job(補充:還有一個 task,偏向定時執行,job 偏向流式) 和 admin。

Interface 是對外的業務閘道器服務,因為我們最終是面向終端使用者的 API,面向 app,面向 PC 場景的,我們把這個叫成業務閘道器。因為我們不是統一的閘道器,我們可能是按照大的業務線去獨立分拆的一些子閘道器,這個的話可以作為一個對外暴露的 HTTP 介面的一個目錄去組織它的程式碼,當然也可能是 gRPC 的(參考 B 站對外的 gRPC Moss 分享)。

Service 這個角色主要是面向對內通訊的微服務,它不直接對外。也就是說,業務閘道器的請求會轉發或者是會 call 我們的內部的 service,它們之間的通訊可能是使用自己的 RPC,在 b 站我們主要是使用 gRPC。使用 gRPC 通訊以後,service 它因為不直接對外,service 之間可能也可以相互去 call。

Admin 區別於 service,很多應用除了有面向用戶的一些介面,實際上還有面向企業內部的一些運營側的需求,通常資料許可權更高,從安全設計角度需要程式碼物理層面隔離,避免意外。

第四個是 ecode。我們當時也在內部爭論了很久,我們的錯誤碼定義到底是放在哪裡?我們目前的做法是,一個應用裡面,假設你有多種角色,它們可能會複用一些錯誤碼。所以說我們會把我們的 ecode 給單獨抽出來,在這一個應用裡面是可以複用的。注意,它只在這一個應用裡面複用,它不會去跨服跨目錄應用,它是針對業務場景的一個業務錯誤碼的組織。

App 目錄組織

我們除了一個應用裡面多種角色的這種情況,現在展開講一下具體到一個 service 裡面,它到底是怎麼組織的。我們的 app 目錄下大概會有 api、cmd、configs、 internal 目錄,目錄裡一般還會放置 README、CHANGELOG、OWNERS。

API 是放置 api 定義以及對應的生成的 client 程式碼,包含基於 pb 定義(我們使用 PB 作為 DSL 描述 API) 生成的 swagger.json。

而 cmd,就是放 main 函式的。Configs 目錄主要是放一些服務所需的配置檔案,比方說說我們可能會使用 TOML 或者是使用 YAML 檔案。

Internal 的話,它裡面有四個子目錄,分別是 model、dao、service 和 server。Model 的定位職責就是對我們底層儲存的持久化層或者儲存層的資料的對映,它是具體的 Go 的一個 struct。我們再看 dao,你實際就是要操作 MySQL 或者 Redis,最終返回的就是這些 model(儲存對映)。Service 組織起來比較簡單,就是我們通過 dao 裡面的各個方法來完成一個完整的業務邏輯。我們還看到有個 server,因為我一個微服務有可能企業內部不一定所有 RPC 都統一,那我們處於過渡階段,所以 server 裡面會有兩個小目錄,一個是 HTTP 目錄,暴露的是 HTTP 介面,還有一個是 gRPC 目錄,我們會暴露 gRPC 的協議。所以在 server 裡面,兩個不同的啟動的 server,就是說一個服務和啟動兩個埠,然後去暴露不同的協議,HTTP 接 RPC,它實際上會先 call 到 service,service 再 call 到 dao,dao 實際上會使用 model 的一些資料定義 struct。但這裡面有一個非常重要的就是,因為這個結構體不能夠直接返回給我們的 api 做外對外暴露來使用,為什麼?因為可能從資料庫裡面取的敏感欄位,當我們實際要返回到 api 的時候,可能要隱藏掉一些欄位,在 Java 裡面,會抽象的一個叫 DTO 的物件,它只是用來傳輸用的,同理,在我們 Go 裡面,實際也會把這些 model 的一些結構體對映成 api 裡面的結構體(基於 PB Message 生成程式碼後的 struct)。

Rob Pike 當時說過的一句話,a little copying is better than a little dependency,我們就遵循了這個理念。在我們這個目錄結構裡面,有 internal 目錄,我們知道 Go 的目錄只允許這個目錄裡面的人去 import 到它,跨目錄的人實際是不能直接引用到它的。所以說,我們看到 service 有一個 model,那我的 job 程式碼,我做一些定時任務的程式碼或者是我的閘道器程式碼有可能會對映同一個 model,那是不是要把這個 model 放到上一級目錄讓大家共享?對於這個問題,其實我們當時內部也爭論過很久。我們認為,每一個微服務應該只對自己的 model 負責,所以我們寧願去做一小部分的程式碼 copy,也不會去為了幾個服務之間要共享這一點點程式碼,去把這個 model 提到和 app 目錄級別去共用,因為你一改全錯,當然了,你如果是拷貝的話,就是每個地方都要去改,那我們覺得,依賴的問題可能會比拷貝程式碼相對來說還是要更復雜的。

這個是一個標準的 PB 檔案,就是我們內部的一個 demo 的 service。最上面的 package 是 PB 的包名,demo.service.v1,這個包使用的是三段式命名,全域性唯一的名稱。那這個名稱為什麼不是用 ID?我見過有些公司對內部做的 CMDB 或者做服務樹去管理企業內部微服務的時候,是用了一些名稱加上 ID 來搞定唯一性,但是我們知道後面那一串 ID 數字是不容易被傳播或者是不容易被記住的,這也是 DNS 出來的一個意義,所以我們用絕對唯一的一個名稱來表示這個包的名字,在後面帶上這一個 PB 檔案的版本號 V1。

我們看第二段定義,它有個 Service Demo 程式碼,其實就表示了我們這個服務要啟動的服務的一個名稱,我們看到這個服務名稱裡面有很多個 RPC 的方法,表示最終這一個應用或者這個 service 要對外暴露這幾個 RPC 的方法。這裡面有個小細節,我們看一下 SayHello 這個方法,實際它有 option 的一個選項。通過這一個 PB 檔案,你既可以描述出你要暴露的是 gRPC 協議,又暴露出 HTTP 的一個介面,這個好處是你只需要一個 PB 檔案描述你暴露的所有 api。我們回想一下,我們剛剛目錄裡面有個 api 目錄,實際這裡面就是放這一個 PB 檔案,描述這一個工程到底返回的介面是什麼。不管是 gRPC 還是 HTTP 都是這一個檔案。還有一個好處是什麼?實際上我們可以在 PB 檔案裡面加上很多的註釋。用 PB 檔案的好處是你不需要額外地再去寫文件,因為寫文件和寫服務的定義,它本質上是兩個步驟,特別容易不一致,介面改了,文件不同步。我們如果基於這一個 PB 檔案,它生成的 service 程式碼或者呼叫程式碼或者是文件都是唯一的。

依賴順序與 api 維護

就像我剛剛講到的,model 是一個儲存層的結構體的一一對映,dao 處理一些資料讀寫包,比方說資料庫快取,server 的話就是啟動了一些 gRPC 或者 HTTP Server,所以它整個依賴順序如下:main 函式啟動 server,server 會依賴 api 定義好的 PB 檔案,定義好這些方法或者是服務名之後,實際上生成程式碼的時候,比方說 protocbuf 生成程式碼的時候,它會把抽象 interface 生成好。然後我們看一下 service,它實際上是弱依賴的 api,就是說我的 server 啟動以後,要註冊一個具體的業務程式碼的邏輯,對映方法,對映名字,實際上是弱依賴的 api 生成的 interface 的程式碼,你就可以很方便地啟動你的 server,把你具體的 service 的業務邏輯給注入到這個 server,和方法進行一一繫結。最後,dao 和 service 實際上都會依賴這個 model。

因為我們在 PB 裡面定義了一些 message,這些 message 生成的 Go 的 struct 和剛剛 model 的 struct 是兩個不同的物件,所以說你要去手動 copy 它,把它最終返回。但是為了快捷,你不可能每次手動去寫這些程式碼,因為它要做 mapping,所以我們又把 K8s 裡類似 DeepCopy 的兩個結構體相互拷貝的工具給摳出來了,方便我們內部 model 和 api 的 message 兩個程式碼相互拷貝的時候,可以少寫一些程式碼,減少一些工作量。

上面講的就是我們關於工程的一些 layout 實踐。簡單回溯一下,大概分為幾塊,第一就是 app 是怎麼組織的,app 裡面有多種角色的服務是怎麼組織的,第三就是一個 app 裡面的目錄是怎麼組織的,最後我重點講了一下 api 是怎麼維護的。

Unittest 測試方法論

現在回顧一下單元測試。我們先看這張圖,這張圖是我從《Google 軟體測試之道》這本書裡面摳出來的,它想表達的意思就是最小型的測試不能給我們的最終專案的品質帶來最大的信心,它比較容易帶來一些優秀的程式碼品質,良好的異常處理等等。但是對於一個面向使用者場景的服務,你只有做大型測試,比方做介面測試,在 App 上驗收功能的這種測試,你應用交付的信心可能會更足。這個其實要表達的就是一個“721 原則”。我們就是 70% 寫小型測試,可以理解為單元測試,因為它相對來說好寫,針對方法級別。20% 是做一些中型測試,可能你要連調幾個專案去完成你的 api。剩下 10% 是大型測試,因為它是最終面向使用者場景的,你要去使用我們的 App,或者用一些測試 App 去測試它。這個就是測試的一些簡單的方法論。

單元測試原則

我們怎麼去對待 Go 裡面的單元測試?在《Google 軟體測試之道》這本書裡面,它強調的是對於一個小型測試,一個單元測試,它要有幾個特質。它不能依賴外部的一些環境,比如我們公司有測試環境,有持續整合環境,有功能測試環境,你不能依賴這些環境構建自己的單元測試,因為測試環境容易被破壞,它容易有資料的變更,資料容易不一致,你之前構建的案例重跑的話可能就會失敗。

我覺得單元測試主要有四點要求。第一,快速,你不能說你跑個單元測試要幾分鐘。第二,要環境一致,也就是說你跑測試前和跑測試後,它的環境是一致的。第三,你寫的所有單元測試的方法可以以任意順序執行,不應該有先後的依賴,如果有依賴,也是在你測試的這個方法裡面,自己去 setup 和 teardown,不應該有 Test Stub 函式存在順序依賴。第四,基於第三點,你可以做並行的單元測試,假設我寫了一百個單元測試,一個個跑肯定特別慢。

doker-compose

最近一段時間,我們演進到基於 docker-compose 實現跨平臺跨語言環境的容器依賴管理方案,以解決執行 unittest 場景下的容器依賴問題。

首先,你要跑單元測試,你不應該用 VPN 連到公司的環境,好比我在星巴克點杯咖啡也可以寫單元測試,也可以跑成功。基於這一點,Docker 實際上是非常好的解決方式。我們也有同學說,其他語言有一些 in-process 的 mock,是不是可以啟動 MySQL 的 mock ,然後在 in-process 上跑?可以,但是有一個問題,你每一個語言都要寫一個這樣的 mock ,而且要寫非常多種,因為我們中介軟體越來越多,MySQL,HBase,Kafka,什麼都有,你很難覆蓋所有的元件 Mock。這種 mock 或者 in-process 的實現不能完整地代表線上的情況,比方說,你可能 mock 了一個 MySQL,檢測到 query 或者 insert ,沒問題,但是你實際要跑一個 transaction,要驗證一些功能就未必能做得非常完善了。所以基於這個原因,我們當時選擇了 docker-compose,可以很好地解決這個問題。

我們對開發人員的要求就是,你本地需要裝 Docker,我們開發人員大部分都是用 Mac,相對來說也比較簡單,Windows 也能搞定,如果是 Linux 的話就更簡單了。本地安裝 Docker,本質上的理解就是無侵入式的環境初始化,因為你在容器裡面,你拉起一個 MySQL,你自己來初始化資料。在這個容器被銷燬以後,它的環境實際上就滿足了我們剛剛提的環境一致的問題,因為它相當於被重置了,也可以很方便地快速重置環境,也可以隨時隨地執行,你不需要依賴任何外部服務,這個外部服務指的是像 MySQL 這種外部服務。當然,如果你的單元測試依賴另外一個 RPC 的 service 的話,PB 的定義會生成一個 interface,你可以把那個 interface 程式碼給 mock 掉,所以這個也是能做掉的。對於小型測試來說,你不依賴任何外部環境,你也能夠快速完成。

另外,docker-compose 是宣告式的 API,你可以宣告你要用 MySQL,Redis,這個其實就是一個配置檔案,非常簡單。這個就是我們在單元測試上的一些實踐。

我們現在看一下,service 目錄裡面多了一個 test 目錄,我們會在這個裡面放 docker-compose 的 YAML 檔案來表示這次單元化測試需要初始化哪些資源,你要構建自己的一些測試的資料集。因為是這樣的,你是寫 dao 層的單元測試的話,可能就需要 database.sql 做一些資料的初始化,如果你是做 service 的單元測試的話,實際你可以把整個 dao 給 mock 掉,我覺得反而還相對簡單,所以我們主要針對場景就是在 dao 裡面偏持久層的,利用 docker-compose 來解決。

容器的拉起,容器的銷燬,這些工作到底誰來做?是開發同學自己去拉起和銷燬,還是說你能夠把它做成一個 Library,讓我們的同學寫單元測試的時候比較方便?我傾向的是後者。所以在我們最終寫單元測試的時候,你可以很方便地 setup 一個依賴檔案,去 setup 你的容器的一些資訊,或者把它銷燬掉。所以說,你把環境準備好以後,最終可以跑測試程式碼也非常方便。當然我們也提供了一些命令函,就是 binary 的一些工具,它可以針對各個語言方便地拉起容器和銷燬容器,然後再去執行程式碼,所以我們也提供了一些快捷的方式。

剛剛我也提到了,就是我們對於 service 也好,API 也好,因為依賴下層的 dao 或者依賴下層的 service,你都很方便 mock 掉,這個寫單元測試相對簡單,這個我不展開講,你可以使用 GoMock 或者 GoMonkey 實現這個功能。

Toolchain

我們利用多個 docker-compose 來解決 dao 層的單元測試,那對於我剛剛提到的專案的一些規範,單元測試的一些模板,甚至是我寫了一些 dao 的一些佔位符,或者寫了一些 service 程式碼的一些佔位符,你有沒有考慮過這種約束有沒有人會去遵循?所以我這裡要強調一點,工具一定要大於約束和文件,你寫了約束,寫了文件,那麼你最終要通過工具把它落實。所以在我們內部會有一個類似 go tool 的腳手架,叫 Kratos Tool,把我們剛剛說的約定規範都通過這個工具一鍵初始化。

對於我們內部的工具集,我們大概會分為幾塊。第一塊就是 API 的,就是你寫一個 PB 檔案,你可以基於這個 PB 檔案生成 gRPC,HTTP 的框架程式碼,你也可以基於這個 PB 檔案生成 swagger 的一些 JSON 檔案或者是 Markdown 檔案。當然了,我們還會生成一些 API,用於 debug 的 client 方便去除錯,因為我們知道,gRPC 除錯起來相對麻煩一些,你要去寫程式碼。

還有一些工具是針對 project 的,一鍵生成整個應用的 layout,非常方便。我們還提了 model,就是方便 model 和 DTO,DTO 就是 API 裡面定義的 message 的 struct 做 DeepCopy,這個也是一個工具。

對於 cache 的話,我們操作 memcache,操作 Redis 經常會要做什麼邏輯?假如我們有一個 cache aside 場景,你讀了一個 cache,cache miss 要回原 DB,你要把這個快取回塞回去,甚至你可能這個回塞快取想非同步化,甚至是你要去讀這個 DB 的時候要做歸併回源(singleflight),我們把這些東西做成一些工具,讓它整個回源到 DB 的邏輯更加簡單,就是把這些場景描述出來,然後你通過工具可以一鍵生成這些程式碼,所以也是會比較方便。

我們再看最後一個,就是 test 的一些工具。我們會基於專案裡面,比方說 dao 或者是 service 定義的 interface 去幫你寫好 mock 的程式碼,我直接在裡面填,只要填程式碼邏輯就行了,所以也會加速我們的生產。

上圖是 Kratos 的一個 demo,基本就是支援了一些 command。這裡就是一個 kratos new kratos-demo 的一個工程,-d YourPath 把它導到某一個路徑去,--proto 順便把 API 裡面的 proto 程式碼也生成了,所以非常簡單,一行就可以很快速啟動一個 HTTP 或者 gRPC 服務。

我們知道,一個微服務的框架實際非常重,有很多初始化的方式等等,非常麻煩。所以說,你通過腳手架的方式就會非常方便,工具大於約定和文件這個這個理念就是這麼來的。

Configuration

講完工具以後,最後講一下配置檔案。我為什麼單獨提一下配置檔案?實際它也是工程化的一部分。我們一個線上的業務服務包含三大塊,第一,應用程式,第二,配置檔案,第三,資料集。配置檔案最容易導致線上出 bug,因為你改一行配置,整個行為可能跟 App 想要的行為完全不一樣。而且我們的程式碼的開發交付需要經過哪些流程?需要 commit 程式碼,需要 review,需要單元測試,需要 CD,需要交付到線上,需要灰度,它的整個流程是非常長的。在一步步的環境裡面,你的 bug 需要前置解決,越前置解決,成本越低。因為你的程式碼的開發流程是這麼一個 pipeline,所以 bug 最終流到線上的概率很低,但是配置檔案沒有經過這麼複雜的流程,可能大家發現線上有個問題,決定要改個線上配置,就去配置中心或者配置檔案改,然後 push 上線,接著就問題了,這個其實很常見。

從 SRE 的角度來說,導致線上故障的主因就是來自配置變更,所以 SRE 很大的工作是控制變更管理,如果能把變更管理做好,實際上很多問題都不會出現。配置既然在整個應用裡面這麼重要,那在我們整個框架或者在 Go 的工程化實踐裡面,我們應該對配置檔案做一些什麼事情?

我覺得是幾個。第一,我們的目標是什麼?配置檔案不應該太複雜,我見過很多框架,或者是業務的一些框架,它實際功能非常強大,但是它的配置檔案超級多。我就發現有個習慣,只要有一個同事寫錯了這個配置,當我新起一個專案的時候,一定會有人把這個錯誤的配置拷貝到另外一個系統裡面去。然後當發現這個應用出問題的時候,我們一般都會內部說一下,你看看其他同事有沒有也配錯的,實際這個配錯概率非常高。因為你的配置選項越多,複雜性越高,它越容易出錯。所以第一個要素就是說,儘量避免複雜的配置檔案。配得越多,越容易出錯。

第二,實際我們的配置方式也非常多,有些用 JSON,有些用 YAML,有些用 Properties,有些用 INI。那能不能收斂成通用的一種方式呢?無論它是用 Python 的指令碼也好,或者是用 JSON 也好,你只要有一種唯一的約定,不需要太多樣的配置方式,對我們的運維,對我們的 SRE 同時來說,他跨專案的變更成本會變低。

第三,一定要往簡單化去努力。這句話其實包含了幾個方面的含義。首先,我們很多配置它到底是必須的還是可選的,如果是可選,配置檔案是不是就可以把它踢掉,甚至不要出現?我曾經有一次看到我們 Java 同事的配置 retry 有一個重試預設是零,內部重試是 80 次,直接把 Redis cluster 打故障了,為什麼?其實這種事故很低階,所以簡單化努力的另外一層含義是指,我們在框架層面,尤其是提供 SDK 或者是提供 framework 的這些同事儘量要做一些防禦程式設計,讓這種錯配漏配也處於一個可控的範圍,比方重試 80 次,你覺得哪個 SDK 會這麼做?所以這個是我們要考慮的。但是還有一點要強調的是,我們對於業務開發的同事,我們的配置應該足夠的簡單,這個簡單還包含,如果你的日誌基本上都是寫在這個目錄,你就不要提供這個配置給他,反而不容易出錯。但是對於我們內部的一些 infrastructure,它可能需要非常複雜的配置來優化,根據我的場景去做優化,所以它是兩種場景,一種是業務場景,足夠簡單,一種是我要針對我的通用的 infrastructure 去做場景的優化,需要很複雜的配置,所以它是兩種場景,所以我們要想清楚你的業務到底是哪一種形態。

還有一個問題就是我們配置檔案一定要做好許可權的變更和跟蹤,因為我們知道上線出問題的時候,我們的第一想法不是查 bug,是先止損,止損先找最近有沒有變更。如果發現有變更,一般是先回滾,回滾的時候,我們通常只回滾了應用程式,而忘記回滾了配置。每個公司可能內部的配置中心,或者是配置場景,或者跟我們的二進位制的交付上線都不一樣,那麼這裡的理念就是你的應用程式和配置檔案一定是同一個版本,或者是某種意義上讓他們產生一個版本的對映,比方說你的應用程式 1.0,你的配置檔案 2.0,它們之間存在一個強繫結關係,我們在回滾的時候應該是一起回滾的。我們曾經也因為類似的一些不相容的配置的變更,二進位制程式上線,但配置檔案忘記回滾,出現過事故,所以這個是要強調的。

另外,配置的變更也要經過 review,如果沒問題,應該也是按照 App 釋出一樣,先灰度,再放量,再全量等等類似的一種方式去推,演進式的這種釋出,我們也叫滾動釋出,我覺得配置檔案也是一樣的思路。

作者介紹:毛劍,目前就職於 bilibili(B 站)、負責資料平臺部,近十年的服務端研發經驗。擅長高效能、高可用的服務端研發,熟悉 Go 語言。在 B 站參與了從巨石架構到微服務的完整轉型,現專注在大資料在 bilibili 的資料中臺建設。

同時開源業內比較有影響力的專案:

https://github.com/Terry-Mao/goim 分散式 IM 長連線廣播服務https://github.com/Terry-Mao/bfs分散式小檔案儲存;https://github.com/bilibili/kratos Go 微服務框架,包含大量微服務相關框架及工具。

最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • PHP 核心特性 - Trait(Life)