圖靈完備的一個重要特性是計算機程序可以生成另一個程序1,很多人可能認為生成代碼在軟件中并不常見,但是實際上它在很多場景中都扮演了重要的角色。Go 語言中的測試就使用了代碼生成機制,go test 命令會掃描包中的測試用例并生成程序、編譯并執行它們,我們在這一節中就會介紹 Go 語言中的代碼生成機制。
元編程(Metaprogramming)是計算機編程中一個非常重要、也很有趣的概念,維基百科上將元編程描述成一種計算機程序可以將代碼看待成數據的能力2。
Metaprogramming is a programming technique in which computer programs have the ability to treat programs as their data.
如果能夠將代碼看做數據,那么代碼就可以像數據一樣在運行時被修改、更新和替換;元編程賦予了編程語言更加強大的表達能力,能夠讓我們將一些計算過程從運行時挪到編譯時、通過編譯期間的展開生成代碼或者允許程序在運行時改變自身的行為??偠灾?,元編程其實是一種使用代碼生成代碼的方式,無論是編譯期間生成代碼,還是在運行時改變代碼的行為都是『生成代碼』的一種。
現代的編程語言大都會為我們提供不同的元編程能力,從總體來看,根據『生成代碼』的時機不同,我們將元編程能力分為兩種類型,其中一種是編譯期間的元編程,例如:宏和模板;另一種是運行期間的元編程,也就是運行時,它賦予了編程語言在運行期間修改行為的能力,當然也有一些特性既可以在編譯期實現,也可以在運行期間實現。
Go 語言作為編譯型的編程語言,它提供了比較有限的運行時元編程能力,例如:反射特性,然而由于性能的問題,反射在很多場景下都不被推薦使用。當然除了反射之外,Go 語言還提供了另一種編譯期間的代碼生成機制 — go generate,它可以在代碼編譯之前根據源代碼生成代碼。
Go 語言的代碼生成機制會讀取包含預編譯指令的注釋,然后執行注釋中的命令讀取包中的文件,它們將文件解析成抽象語法樹并根據語法樹生成新的 Go 語言代碼和文件,生成的代碼會在項目的編譯期間與其他代碼一起編譯和運行。
//go:generate command argument...
go generate 不會被 go build 等命令自動執行,該命令需要顯式的觸發,手動執行該命令時會在文件中掃描上述形式的注釋并執行后面的執行命令,需要注意的是 go:generate 和前面的 // 之間沒有空格,這種不包含空格的注釋一般是 Go 語言的編譯器指令,而我們在代碼中的正常注釋都應該保留這個空格4。
代碼生成最常見的例子就是官方提供的 stringer5,這個工具可以掃描如下所示的常量定義,然后為當前常量類型 Piller 生成對應的 String() 方法:
// pill.go
package painkiller
//go:generate stringer -type=Pill
type Pill int
const (
Placebo Pill=iota
Aspirin
Ibuprofen
Paracetamol
Acetaminophen=Paracetamol
)
當我們在上述文件中加入 //go:generate stringer -type=Pill 注釋并調用 go generate 命令時,在同一目錄下會出現如下所示的 pill_string.go 文件,該文件中包含兩個函數,分別是 _ 和 String:
// Code generated by "stringer -type=Pill"; DO NOT EDIT.
package painkiller
import "strconv"
func _() {
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
var x [1]struct{}
_=x[Placebo-0]
_=x[Aspirin-1]
_=x[Ibuprofen-2]
_=x[Paracetamol-3]
}
const _Pill_name="PlaceboAspirinIbuprofenParacetamol"
var _Pill_index=[...]uint8{0, 7, 14, 23, 34}
func (i Pill) String() string {
if i < 0 || i >=Pill(len(_Pill_index)-1) {
return "Pill(" + strconv.FormatInt(int64(i), 10) + ")"
}
return _Pill_name[_Pill_index[i]:_Pill_index[i+1]]
}
這段生成的代碼很值得我們學習,它通過編譯器的檢查提供了非常健壯的 String 方法。我們在這里不展示具體的使用過程,本節將重點分析從執行 go generate 到生成對應 String 方法的整個過程,幫助各位理解代碼生成機制的工作原理,代碼生成的過程可以分成以下兩個部分:
當我們在命令行中執行 go generate 命令時,它會調用源代碼中的 cmd/go/internal/generate.runGenerate 函數掃描包中的預編譯指令,該函數會遍歷命令行傳入包中的全部文件并依次調用 cmd/go/internal/generate.generate:
func runGenerate(cmd *base.Command, args []string) {
...
for _, pkg :=range load.Packages(args) {
...
pkgName :=pkg.Name
for _, file :=range pkg.InternalGoFiles() {
if !generate(pkgName, file) {
break
}
}
pkgName +="_test"
for _, file :=range pkg.InternalXGoFiles() {
if !generate(pkgName, file) {
break
}
}
}
}
cmd/go/internal/generate.generate 函數會打開傳入的文件并初始化一個用于掃描 cmd/go/internal/generate.Generator 的結構體:
func generate(pkg, absFile string) bool {
fd, err :=os.Open(absFile)
if err !=nil {
log.Fatalf("generate: %s", err)
}
defer fd.Close()
g :=&Generator{
r: fd,
path: absFile,
pkg: pkg,
commands: make(map[string][]string),
}
return g.run()
}
結構體 cmd/go/internal/generate.Generator 的私有方法 cmd/go/internal/generate.Generator.run 會在對應的文件中掃描指令并執行,該方法的實現原理很簡單,我們在這里簡單展示一下該方法的簡化實現:
func (g *Generator) run() (ok bool) {
input :=bufio.NewReader(g.r)
for {
var buf []byte
buf, err=input.ReadSlice('\n')
if err !=nil {
if err==io.EOF && isGoGenerate(buf) {
err=io.ErrUnexpectedEOF
}
break
}
if !isGoGenerate(buf) {
continue
}
g.setEnv()
words :=g.split(string(buf))
g.exec(words)
}
return true
}
上述代碼片段會按行讀取被掃描的文件并調用 cmd/go/internal/generate.isGoGenerate 判斷當前行是否以 //go:generate 注釋開頭,如果該行確定以 //go:generate 開頭,那么就會解析注釋中的命令和參數并調用 cmd/go/internal/generate.Generator.exec 運行當前命令。
stringer 充分利用了 Go 語言標準庫對編譯器各種能力的支持,其中包括用于解析抽象語法樹的 go/ast、用于格式化代碼的 go/fmt 等,Go 通過標準庫中的這些包對外直接提供了編譯器的相關能力,讓使用者可以直接在它們上面構建復雜的代碼生成機制并實施元編程技術。
作為二進制文件,stringer 命令的入口就是如下所示的 main 函數,在下面的代碼中,我們初始化了一個用于解析源文件和生成代碼的 Generator,然后開始拼接生成的文件:
func main() {
types :=strings.Split(*typeNames, ",")
...
g :=Generator{
trimPrefix: *trimprefix,
lineComment: *linecomment,
}
...
g.Printf("// Code generated by \"stringer %s\"; DO NOT EDIT.\n", strings.Join(os.Args[1:], " "))
g.Printf("\n")
g.Printf("package %s", g.pkg.name)
g.Printf("\n")
g.Printf("import \"strconv\"\n")
for _, typeName :=range types {
g.generate(typeName)
}
src :=g.format()
baseName :=fmt.Sprintf("%s_string.go", types[0])
outputName=filepath.Join(dir, strings.ToLower(baseName))
if err :=ioutil.WriteFile(outputName, src, 0644); err !=nil {
log.Fatalf("writing output: %s", err)
}
}
從這段代碼中我們能看到最終生成文件的輪廓,最上面的調用的幾次 Generator.Printf 會在內存中寫入文件頭的注釋、當前包名以及引入的包等,隨后會為待處理的類型依次調用 Generator.generate,這里會生成一個簽名為 _ 的函數,通過編譯器保證枚舉類型的值不會改變:
func (g *Generator) generate(typeName string) {
values :=make([]Value, 0, 100)
for _, file :=range g.pkg.files {
file.typeName=typeName
file.values=nil
if file.file !=nil {
ast.Inspect(file.file, file.genDecl)
values=append(values, file.values...)
}
}
g.Printf("func _() {\n")
g.Printf("\t// An \"invalid array index\" compiler error signifies that the constant values have changed.\n")
g.Printf("\t// Re-run the stringer command to generate them again.\n")
g.Printf("\tvar x [1]struct{}\n")
for _, v :=range values {
g.Printf("\t_=x[%s - %s]\n", v.originalName, v.str)
}
g.Printf("}\n")
runs :=splitIntoRuns(values)
switch {
case len(runs)==1:
g.buildOneRun(runs, typeName)
...
}
}
隨后調用的 Generator.buildOneRun 會生成兩個常量的聲明語句并為類型定義 String 方法,其中引用的 stringOneRun 常量是方法的模板,與 Web 服務的前端 HTML 模板比較相似:
func (g *Generator) buildOneRun(runs [][]Value, typeName string) {
values :=runs[0]
g.Printf("\n")
g.declareIndexAndNameVar(values, typeName)
g.Printf(stringOneRun, typeName, usize(len(values)), "")
}
const stringOneRun=`func (i %[1]s) String() string {
if %[3]si >=%[1]s(len(_%[1]s_index)-1) {
return "%[1]s(" + strconv.FormatInt(int64(i), 10) + ")"
}
return _%[1]s_name[_%[1]s_index[i]:_%[1]s_index[i+1]]
}
整個生成代碼的過程就是使用編譯器提供的庫解析源文件并按照已有的模板生成新的代碼,這與 Web 服務中利用模板生成 HTML 文件沒有太多的區別,只是最終生成的文件的用途稍微有一些不同,
Go 語言的標準庫中暴露了編譯器的很多能力,其中包含詞法分析和語法分析,我們可以直接利用這些現成的解析器編譯 Go 語言的源文件并獲得抽象語法樹,有了識別源文件結構的能力,我們就可以根據源文件對應的抽象語法樹自由地生成更多的代碼,使用元編程技術來減少代碼重復、提高工作效率。
下文章來源于GoUpUp ,作者dj
最近在整理我們項目代碼的時候,發現有很多活動的代碼在結構和提供的功能上都非常相似。為了方便今后的開發,我花了一點時間編寫了一個生成代碼框架的工具,最大程度地降低重復勞動。代碼本身并不復雜,且與項目代碼關聯性較大,這里就不展開介紹了。在這個過程中,我發現 Go 標準的模板庫text/template和html/template使用起來比較束手束腳,很不方便。我從 GitHub 了解到quicktemplate這個第三方模板庫,功能強大,語法簡單,使用方便。今天我們就來介紹一下quicktemplate。
本文代碼使用 Go Modules。
先創建代碼目錄并初始化:
$ mkdir quicktemplate && cd quicktemplate
$ go mod init github.com/darjun/go-daily-lib/quicktemplate
quicktemplate會將我們編寫的模板代碼轉換為 Go 語言代碼。因此我們需要安裝quicktemplate包和一個名為qtc的編譯器:
$ go get -u github.com/valyala/quicktemplate
$ go get -u github.com/valyala/quicktemplate/qtc
首先,我們需要編寫quicktemplate格式的模板文件,模板文件默認以.qtpl作為擴展名。下面我編寫了一個簡單的模板文件greeting.qtpl:
All text outside function is treated as comments.
{% func Greeting(name string, count int) %}
{% for i :=0; i < count; i++ %}
Hello, {%s name %}
{% endfor %}
{% endfunc %}
模板語法非常簡單,我們只需要簡單了解以下 2 點:
將greeting.qtpl保存到templates目錄,然后執行qtc命令。該命令會生成對應的 Go 文件greeting.qtpl.go,包名為templates。現在,我們就可以使用這個模板了:
package main
import (
"fmt"
"github.com/darjun/go-daily-lib/quicktemplate/get-started/templates"
)
func main() {
fmt.Println(templates.Greeting("dj", 5))
}
調用模板函數,傳入參數,返回渲染后的文本:
$ go run .
Hello, dj
Hello, dj
Hello, dj
Hello, dj
Hello, dj
{%s name %}執行文本替換,{% for %}循環生成重復文本。輸出中出現多個空格和換行,這是因為函數內除了語法結構,其他內容都會原樣保留,包括空格和換行。
需要注意的是,由于quicktemplate是將模板轉換為 Go 代碼使用的,所以如果模板有修改,必須先執行qtc命令重新生成 Go 代碼,否則修改不生效。
quicktemplate支持 Go 常見的語法結構,if/for/func/import/return。而且寫法與直接寫 Go 代碼沒太大的區別,幾乎沒有學習成本。只是在模板中使用這些語法時,需要使用{%和%}包裹起來,而且if和for等需要添加endif/endfor明確表示結束。
上面我們已經看到如何渲染傳入的參數name,使用{%s name %}。由于name是 string 類型,所以在{%后使用s指定類型。quicktemplate還支持其他類型的值:
先編寫模板:
{% func Types(a int, b float64, c []byte, d string) %}
int: {%d a %}, float64: {%f.2 b %}, bytes: {%z c %}, string with quotes: {%q d %}, string without quotes: {%j d %}.
{% endfunc %}
然后使用:
func main() {
fmt.Println(templates.Types(1, 5.75, []byte{'a', 'b', 'c'}, "hello"))
}
運行:
$ go run .
int: 1, float64: 5.75, bytes: abc, string with quotes: "hello", string without quotes: hello.
quicktemplate支持在模板中調用模板函數、標準庫的函數。由于qtc會直接生成 Go 代碼,我們甚至還可以在同目錄下編寫自己的函數給模板調用,模板 A 中也可以調用模板 B 中定義的函數。
我們先在templates目錄下編寫一個文件rank.go,定義一個Rank函數,傳入分數,返回評級:
package templates
func Rank(score int) string {
if score >= 90 {
return "A"
} else if score >= 80 {
return "B"
} else if score >= 70 {
return "C"
} else if score >= 60 {
return "D"
} else {
return "E"
}
}
然后我們可以在模板中調用這個函數:
{% import "fmt" %}
{% func ScoreList(name2score map[string]int) %}
{% for name, score :=range name2score %}
{%s fmt.Sprintf("%s: score-%d rank-%s", name, score, Rank(score)) %}
{% endfor %}
{% endfunc %}
編譯模板:
$ qtc
編寫程序:
func main() {
name2score := make(map[string]int)
name2score["dj"] = 85
name2score["lizi"] = 96
name2score["hjw"] = 52
fmt.Println(templates.ScoreList(name2score))
}
運行程序輸出:
$ go run .
dj: score-85 rank-B
lizi: score-96 rank-A
hjw: score-52 rank-E
由于我們在模板中用到fmt包,需要先使用{% import %}將該包導入。
在模板中調用另一個模板的函數也是類似的,因為模板最終都會轉為 Go 代碼。Go 代碼中有同樣簽名的函數。
quicktemplate常用來編寫 HTML 頁面的模板:
{% func Index(name string) %}
<html>
<head>
<title>Awesome Web</title>
</head>
<body>
<h1>Hi, {%s name %}
<p>Welcome to the awesome web!!!</p>
</body>
</html>
{% endfunc %}
下面編寫一個簡單的 Web 服務器:
func index(w http.ResponseWriter, r *http.Request) {
templates.WriteIndex(w, r.FormValue("name"))
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", index)
server := &http.Server{
Handler: mux,
Addr: ":8080",
}
log.Fatal(server.ListenAndServe())
}
qtc會生成一個Write*的方法,它接受一個io.Writer的參數。將模板渲染的結果寫入這個io.Writer中,我們可以直接將http.ResponseWriter作為參數傳入,非常便捷。
運行:
$ qtc
$ go run .
瀏覽器輸入localhost:8080?name=dj查看結果。
quicktemplate至少有下面 3 個優勢:
從我個人的實際使用情況來看,確實很方便,很實用。感興趣的還可以去看看qtc生成的 Go 代碼。
大家如果發現好玩、好用的 Go 語言庫,歡迎到 Go 每日一庫 GitHub 上提交 issue
文翻譯自 https://github.com/evrone/go-clean-template,由于本人翻譯水平有限,翻譯不當之處煩請指出。希望大家看了這篇文章能有所幫助。感謝捧場。
概括
模板的作用 :
模版使用了 Robert Martin ( 也叫 Bob 叔叔 ) 的原則[1]。
Go-clean-template[2] 此倉庫由 Evrone[3] 創建及維護。
目錄內容
本地開發
# Postgres, RabbitMQ
$ make compose-up
# Run app with migrations
$ make run
集成測試 ( 可以在 CI 中運行 )
# DB, app + migrations, integration tests
$ make compose-up-integration-test
├── cmd
│ └── app
│ └── main.go
├── config
│ ├── config.go
│ └── config.yml
├── docs
│ ├── docs.go
│ ├── swagger.json
│ └── swagger.yaml
├── go.mod
├── go.sum
├── integration-test
│ ├── Dockerfile
│ └── integration_test.go
├── internal
│ ├── app
│ │ ├── app.go
│ │ └── migrate.go
│ ├── delivery
│ │ ├── amqp_rpc
│ │ │ ├── router.go
│ │ │ └── translation.go
│ │ └── http
│ │ └── v1
│ │ ├── error.go
│ │ ├── router.go
│ │ └── translation.go
│ ├── domain
│ │ └── translation.go
│ └── service
│ ├── interfaces.go
│ ├── repo
│ │ └── translation_postgres.go
│ ├── translation.go
│ └── webapi
│ └── translation_google.go
├── migrations
│ ├── 20210221023242_migrate_name.down.sql
│ └── 20210221023242_migrate_name.up.sql
└── pkg
├── httpserver
│ ├── options.go
│ └── server.go
├── logger
│ ├── interface.go
│ ├── logger.go
│ └── zap.go
├── postgres
│ ├── options.go
│ └── postgres.go
└── rabbitmq
└── rmq_rpc
├── client
│ ├── client.go
│ └── options.go
├── connection.go
├── errors.go
└── server
├── options.go
└── server.go
cmd/app/main.go
配置和日志實例的初始化,main 函數中調用internal/app/app.go 文件中 的 Run 函數,main 函數將會在此 "延續"。
config
配置。首先讀取 config.yml,然后用環境變量覆蓋相匹配的 yaml 配置。配置的結構體在 config.go 文件中。env-required: true 結構體標簽強制您指定一個值 ( 在 yaml 或在環境變量中 )。
對于配置讀取,我們選擇 cleanenv[4] 庫。它在 GitHub 上沒有很多 star,但很簡單且滿足所有的需求。
從 yaml 中讀取配置違背了12 要素,但在實踐中,它比從環境變量中讀取整個配置更方便。假設默認值定義在 yaml 中,敏感的變量定義在環境變量中。
docs
Swagger 文檔??梢杂?swag[5] 庫自動生成。而你不需要自己改正任何事情。
integration-test
集成測試。它們作為單獨的容器啟動,緊挨著應用程序容器。使用 go-hit[6] 測試 REST API 非常方便。
internal/app
在 app.go 文件中一般會有一個 Run 函數,它“延續”了main函數。
這是創建所有主要對象的地方。依賴注入通過“ New...”構造函數 ( 參見依賴注入 ) 。這種技術允許我們使用依賴注入原則對應用程序進行分層,使得業務邏輯獨立于其他層。
接下來,為了優雅的完成,我們啟動服務并在select中等待特定的信號。如果 app.go 代碼越來越多,可以將其拆分為多個文件。
對于大量的注入,可以使用 wire[7] 庫 ( wire 是一個代碼生成工具,它使用依賴注入自動連接組件)。
migrate.go 文件用于數據庫自動遷移。如果指定了 migrate 標簽的參數,則會包含它。例如 :
$ go run -tags migrate ./cmd/app
internal/delivery
服務的handler層 ( MVC 控制器 )。模板展示了兩個服務:
服務的路由也以同樣的風格編寫 :
internal/delivery/http
簡單的 REST 版本控制。對于 v2,我們需要添加具有相同內容的 http/v2 文件夾。在 internal/app 程序文件中添加以下行 :
handler :=gin.New()
v1.NewRouter(handler, translationService)
v2.NewRouter(handler, translationService)
你可以使用任何其他的 HTTP 框架甚至是標準的 net/http 庫來代替 Gin。
在 v1/router.go 和上面的 handler 方法中,有一些注釋是用 swag庫來生成 swagger 文檔的。
internal/domain
業務邏輯的實體 ( 模型 ) 可以在任何層中使用。也可以有方法,例如,用于驗證。
internal/service
業務邏輯
Repositories、 webapi、 rpc 和其他業務邏輯結構被注入到業務邏輯結構中 ( 見依賴注入 )。
internal/service/repo
repository 是業務邏輯使用的抽象存儲 ( 數據庫 )。
internal/service/webapi
它是業務邏輯使用的抽象 web API。例如,它可能是業務邏輯通過 REST API 訪問的另一個微服務。包的名稱根據用途而變化。
pkg/rabbitmq
RabbitMQ RPC 模式 :
為了消除業務邏輯對外部包的依賴,使用了依賴注入。
例如,通過 NewService 構造函數,我們將依賴注入到業務邏輯的結構中。這使得業務邏輯獨立 ( 便于移植 )。我們可以重寫接口的實現,而不需要對 service 包進行更改。
package service
import (
// Nothing!
)
type Repository interface {
Get()
}
type Service struct {
repo Repository
}
func NewService(r Repository) *Service{
return &Service{r}
}
func (s *Service) Do() {
s.repo.Get()
}
它還允許我們自動生成模擬參數 ( 例如使用 mockery[8] ) 和輕松地編寫單元測試。
我們可以不受特定實現的約束,來將一個組件更改為另一個組件。如果新組件實現了該接口,則業務邏輯中不需要進行任何更改。
關鍵點
程序員在編寫了大量代碼后才意識到應用程序的最佳架構。
一個好的架構允許盡可能推遲決策。
主要原則
Dependency Inversion ( 與 SOLID 相同 ) 是依賴倒置的原則。依賴關系的方向是從外層到內層。由于這個原因,業務邏輯和實體仍然獨立于系統的其他部分。
因此,應用程序分為內部和外部兩個層次 :
Clean Architecture
業務邏輯的內層應該是整潔的,它應該 :
業務邏輯對 Postgres 或詳細的 web API 一無所知。業務邏輯應該具有一個用于處理抽象數據庫或抽象 web API 的接口。
外層還有其他限制 :
例如,你需要從 HTTP ( 控制器 ) 訪問數據庫。HTTP 和數據庫都在外層,這意味著它們對彼此一無所知。它們之間的通信是通過 service ( 業務邏輯 ) 進行的 :
HTTP > service
service > repository (Postgres)
service < repository (Postgres)
HTTP < service
符號 > 和 < 通過接口顯示層與層邊界的交集,如圖所示 :
Example
或者更復雜的業務邏輯 :
HTTP > service
service > repository
service < repository
service > webapi
service < webapi
service > RPC
service < RPC
service > repository
service < repository
HTTP < service
層級
Example
整潔架構的術語
業務邏輯直接交互的層通常稱為基礎設施層。它們可以是存儲庫 internal/service/repo、web API internal/service/webapi、任何pkg,以及其他微服務。在模板中,_ infrastructure 包位于 internal/service 中。
你可以根據需要去選擇如何調用入口點。選項如下 :
附加層
經典版本的 整潔架構之道[9] 是為構建大型單體應用程序而設計的,它有4層。
在最初的版本中,外層被分為兩個以上的層,兩層之間也存在相互依賴關系倒置 ( 定向內部 ),并通過接口進行通信。
在邏輯復雜的情況下,內層也分為兩個( 接口分離 )。
復雜的工具可以被劃分成更多的附加層,但你應該在確實需要時再添加層。
替代方法
除了整潔架構之道,洋蔥架構和六邊形架構 ( 端口適配器模式 ) 是類似的。兩者都是基于依賴倒置的原則。端口和適配器模式非常接近于整潔架構之道,差異主要在術語上。
寫在最后
Freemen App是一款專注于IT程序員求職招聘的一個求職平臺,旨在幫助IT技術工作者能更好更快入職及努力協調IT技術者工作和生活的關系,讓工作更自由!
程序員專屬求職平臺
[1]原則: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
[2]Go-clean-template: https://evrone.com/go-clean-template?utm_source=github&utm_campaign=go-clean-template
[3]Evrone: https://evrone.com/?utm_source=github&utm_campaign=go-clean-template
[4]cleanenv: https://github.com/ilyakaznacheev/cleanenv
[5]swag: https://github.com/swaggo/swag
[6]go-hit: https://github.com/Eun/go-hit
[7]wire: https://github.com/google/wire
[8]mockery: https://github.com/vektra/mockery
[9]整潔架構之道: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
[10]整潔架構之道: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
[11]12 要素: https://12factor.net/ru/
本文轉載自Go招聘
*請認真填寫需求信息,我們會在24小時內與您取得聯系。