從0寫一個 Go Web 服務 (上)
學生時代曾和幾個朋友做了一個筆記本小應用,當時我的角色是pm + dba,最近心血來潮,想把這個玩意自己實現(xiàn)一遍,順便寫一篇文章記錄整個過程。
筆者的職業(yè)目前是一個后端程序員,最常用的語言是Golang,恰好Golang自帶的的net/http包非常方便,這次就用Golang寫這個服務。首先打開我心愛的GoLand,New一個Project,給項目起一個酷炫的名字。
接著寫個Hello, world。同時為方便項目管理,引入git這個版本控制工具來管理代碼。隨著一發(fā)git init 加commit,這個項目就算誕生了; )
作為一個互聯(lián)網公司的程序員,公司追求的是快速試錯,迭代前進,此路不通換一條繼續(xù)試驗。這就需要管理PM和運營老板的預期,現(xiàn)在要從0到1寫一個web服務,就需要詳細拆解一下需求,搞一個TODO list。同時明確項目的milestone,快速迭代,到達stone立馬上線,隨后再慢慢地豐富細節(jié)~
那么先來搞第一個todo,Go語言自帶的net/http包非常方便,寫web server比較簡單。讓我們先把項目的slogan輸出到瀏覽器上。
package main
import (
"log"
"net/http"
)
func Hello(wr http.ResponseWriter, r *http.Request, _ httprouter.Params) {
_, err := wr.Write([]byte(`
__ _ _ ____ ____ __ _ _ ____ __ _ __ ____ ____ ____ __ ____ ____
/ _\/ )( ( __) ___)/ \( \/ | __) ( ( \/ (_ _| __) ___) / _\( _ ( _ \
/ \ /\ /) _)\___ ( O ) \/ \) _) / ( O ))( ) _)\___ \ / \) __/) __/
\_/\_(_/\_|____|____/\__/\_)(_(____) \_)__)\__/(__)(____|____/ \_/\_(__) (__)
`))
if err != nil {
return
}
}
func main() {
http.HandleFunc("/", hello)
err := http.ListenAndServe(":8010", nil)
if err != nil {
log.Fatal(err)
}
}
然后啟動項目,隨便打開一個瀏覽器,輸入http://localhost:8000/,可以看到這樣的效果。

此時有的同學可能會講,測試怎么還能老依賴瀏覽器呢,我電腦上如果沒有瀏覽器還不能測試了嗎?用瀏覽器測試確實不太優(yōu)雅,所以讓我們接著繼續(xù)寫一個測試文件,搞個http Client,自己跑一下效果,并來一發(fā)commit。
package main
import (
"fmt"
"io/ioutil"
"net/http"
"testing"
"time"
)
func Test_hello(t *testing.T) {
go main()
time.Sleep(time.Second) // 給main函數(shù)續(xù)一秒,確保main在http.Get之前執(zhí)行
resp, err := http.Get("http://localhost:8000/")
if err != nil {
t.Error("curl failed")
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Error("read body failed")
}
// TODO 這里不優(yōu)雅
fmt.Println(string(body) == `
__ _ _ ____ ____ __ _ _ ____ __ _ __ ____ ____ ____ __ ____ ____
/ _\/ )( ( __) ___)/ \( \/ | __) ( ( \/ (_ _| __) ___) / _\( _ ( _ \
/ \ /\ /) _)\___ ( O ) \/ \) _) / ( O ))( ) _)\___ \ / \) __/) __/
\_/\_(_/\_|____|____/\__/\_)(_(____) \_)__)\__/(__)(____|____/ \_/\_(__) (__)
`)
}
接下來,讓我們實現(xiàn)一下這個記事本的CURD功能讓它先稍微可用起來。首先設計一下路由:
GET /note // 獲取所有note
POST /note // 新增一個note
GET /note/:id // 根據(jù)id獲取某個note
DELETE /note/:id // 根據(jù)id刪除某個note
敏銳的同學一下就可以看出來,這里我們用了早些年特別流行的RESTful路由設計。但是這里有個小問題:golang自帶的net/http包里面對牛逼的RESTful支持的并不好。但是問題不大,讓我們去程序員聚集的Github上找找有沒有支持REST的router可以使用。
果不其然其他程序員有類似的困擾并解決了問題,這個12k星的項目完全可以cover我們的需求。httprouter可以根據(jù)HTTP方法(GET, POST, PUT, PATCH, DELETE等) build出一棵棵的壓縮字典樹(Radix Tree),樹的節(jié)點是URL中的一個path塊或path塊的公共前綴,將樹和 URL對應的handler函數(shù)綁定起來,就可以使用了。
要引入第三方包,就涉及到包管理問題,golang之前沒有一個官方的包管理工具,一直被人所詬病。不過前些日子官方推出了Go Module這個工具,雖然目前這個工具還有一些問題,但是這是官方出品并強推的,肯定不會死,讓我們通過go module把httprouter引用進來。
在package中執(zhí)行一下go mod init xxx 命令,此時你會發(fā)現(xiàn),項目的文件里面多了一個go.mod文件。

接著我們設置幾個變量:
GO111MODULE : 這個變量用來控制是否啟用Go Modules,選auto。
GOPROXY : 由于一些無法抵抗的原因,我們無法訪問golang的proxy地址
proxy.golang.org。這里可以通過設置Proxy地址來代替官方的proxy,比如說七牛的goproxy.cn。GOPATH: 寫go的人大概都知道這個變量,在沒有go module之前,項目中import只能通過GOPATH設置的
GOPATH/src的限定路徑來導入包,我們所有的go代碼都得放到GOPATH之下,有了Go Modules之后,我們可以想放哪里就放哪里了。
同時,在Goland里也設置一下Go Modules。Go Modules可以使用之前的go get 命令來拉去包,比如這樣:go get -u github.com/julienschmidt/httprouter。
接著,來更新一下項目的目錄結構,隨著項目的迭代,如果功能函數(shù)都寫到main.go的話非常不利于管理,這里把代碼做一下拆分。首先開一個logic目錄,用來放項目的主要邏輯;接著開一個model目錄,用來存放note模型,接著在main同級下寫一個route目錄,引入httprouter。
此時項目的布局像這樣:
$ tree
.
├── go.mod
├── go.sum
├── logic
│ ├── note.go
│ └── note_test.go
├── main.go
├── main_test.go
├── model
│ └── dto.go
└── route.go
route.go文件大概長這樣:
func initRouter() *httprouter.Router {
router := httprouter.New()
router.GET("/", logic.Hello)
router.GET("/note", logic.GetAll)
...
router.DELETE("/note/:id", logic.Delete)
return router
}
而main文件要把router做一下初始化,并注冊到HTTPHandler中。
func main() {
mux := initRouter()
err := http.ListenAndServe(":8010", mux)
if err != nil {
log.Fatal(err)
}
}
在note.go中,我們把之前規(guī)劃的curd功能實現(xiàn)一下:
package logic
import (
"crypto/md5"
"encoding/json"
"fmt"
"math/rand"
"net/http"
"notes/model"
"time"
"github.com/julienschmidt/httprouter"
)
func Hello(wr http.ResponseWriter, r *http.Request, _ httprouter.Params) {
_, err := wr.Write([]byte("This is a awesome note app!"))
if err != nil {
return
}
}
var notes = map[string]model.Note{}
func GetAll(wr http.ResponseWriter, r *http.Request, _ httprouter.Params) {
data := map[string]interface{}{
"msg": "success",
"data": notes,
}
res, err := json.Marshal(data)
if err != nil {
return
}
_, err = wr.Write(res)
if err != nil {
return
}
}
func GetOne(wr http.ResponseWriter, r *http.Request, p httprouter.Params) {
id := p.ByName("id")
if _, exist := notes[id]; !exist {
return
}
data := map[string]interface{}{
"msg": "success",
"data": notes[id],
}
res, err := json.Marshal(data)
if err != nil {
return
}
_, err = wr.Write(res)
if err != nil {
return
}
}
func Add(wr http.ResponseWriter, r *http.Request, _ httprouter.Params) {
content := r.URL.Query().Get("content")
if content == "" {
return
}
id := genID(content)
note := model.Note{
ID: id,
Content: content,
StartTime: time.Now(),
UpdateTime: time.Now(),
}
notes[id] = note
data := map[string]interface{}{
"msg": "success",
"data": "",
}
res, err := json.Marshal(data)
if err != nil {
return
}
_, err = wr.Write(res)
if err != nil {
return
}
}
func Delete(wr http.ResponseWriter, r *http.Request, p httprouter.Params) {
id := r.URL.Query().Get("id")
if _, exist := notes[id]; exist {
delete(notes, id)
}
data := map[string]interface{}{
"msg": "success",
"data": "",
}
res, err := json.Marshal(data)
if err != nil {
return
}
_, err = wr.Write(res)
if err != nil {
return
}
}
// 生成一個隨機id
func genID(content string) string {
rand.Seed(time.Now().UnixNano())
i := rand.Intn(10000)
return fmt.Sprintf("%x", md5.Sum([]byte(content+string(i))))
}
看到這里,可能有人要噴了:
哎,你這個玩意,怎么在業(yè)務邏輯里各種拼json,寫回去response中啊,這塊明顯是可以復用的邏輯?。?/p>
哎,你這個玩意,獲取入?yún)⒌臅r候怎么這么挫啊,直接從URL里面拿,別人傳啥也不知道,還得自己做參數(shù)校驗,而且你這么寫,和寫動態(tài)語言有啥區(qū)別,根本看不出來入?yún)?、出參是什么?/p>
哎,你這個玩意,怎么數(shù)據(jù)都不入數(shù)據(jù)庫啊,服務一重啟,數(shù)據(jù)就沒了!
哎,你這個玩意,怎么連個日志都沒有啊,你這線上出問題了,怎么查??!
沒錯,這些都是問題,讓我們來一個一個解決,首先先寫個公共的handler,規(guī)范一下我們HTTP handle func 入?yún)?、出參。這里通過反射分析handleFunc這個interface來做,這樣在注冊路由的時候就會對我們的handler函數(shù)參數(shù)做規(guī)范:
type Controller struct {
// handleFunc 的格式需要是func(ctx context.Context, req interface{}) (interface{}, error)
handleFunc interface{}
}
func HTTPHandler(handleFunc interface{}) httprouter.Handle {
controller := &Controller{handleFunc: handleFunc}
checkHandleFunc(handleFunc)
return controller.HandleHTTP
}
func checkHandleFunc(handleFunc interface{}) {
value := reflect.ValueOf(handleFunc)
typeOf := reflect.TypeOf(handleFunc)
if value.Kind() != reflect.Func {
panic("[checkHandleFunc] handleFunc is not a func")
}
...
}
我們規(guī)定函數(shù)的簽名統(tǒng)一為:func(ctx context.Context, req interface{}) (interface{}, error),并在CheckHandlerFunc中對interface進行校驗確保其符合我們的簽名。這里用反射去哪接口的屬性即可。
接著,我們把用戶入?yún)⒔壎ǖ蕉x好的結構體上,讓我們不用手動去parserHTTP參數(shù),這個需求本質上是bind功能,原理就是根據(jù)不同的Content-Type去用不同的方式解析出http 參數(shù),并通過tag用反射綁定到用戶自定義的結構體上。這塊自己寫比較無聊,因為需要大量重復的Content-Type判斷,于是再去github上找找開源庫。找到兩個star比較多的庫:https://github.com/mholt/binding和https://github.com/martini-contrib/binding,但這兩個庫各有缺點:mholt/binding的問題是你需要顯式實現(xiàn)一個FiledMap方法,把參數(shù)顯示綁定到FieldMap上,這個不能忍,pass。martini-contrib/binding是Martini框架中單獨抽出來的binding部分,由于其是Martini框架中抽出來的,很多類型在martini中都被預先改寫了,這對對只想用binding功能的我來說不太友好,于是也被pass。
接著,我們去一些star數(shù)多的開源web框架上打打主意,gin框架里面的binding包沒有上面兩個包的缺點。我們可以單獨把binding部分fork出來搞一個項目,地址是:https://github.com/yigenshutiao/binding。引用這個包的時候遇到了一些go module的一些小問題。解決方法也比較簡單,把binding里go.mod模塊的聲明改一下即可。
github.com/yigenshutiao/binding: github.com/yigenshutiao/[email protected]: parsing go.mod:
module declares its path as: binding
but was required as: github.com/yigenshutiao/binding
replace binding => github.com/yigenshutiao/binding v0.0.2 // indirect
有了bind之后,我們就可以非常開心把http.Request中的參數(shù)綁定到我們的結構體上,但是注意由于我們使用RESTful的方式傳參,還需要把http.router中Params的參數(shù)也綁定到結構體上,這里寫了一個Map2Struct的util函數(shù),原理也是根據(jù)tag做反射綁定。
if len(p) > 0 {
for i := range p {
param[p[i].Key] = p[i].Value
}
if err := util.ConvertMapToStruct(param, request); err != nil {
return
}
}
request, err := c.bindRequest(r, request)
if err != nil {
return
}
綁定了參數(shù)之后,可以對用戶傳進來的參數(shù)進行校驗,校驗要做的工作是在處理業(yè)務邏輯之前,提前看參數(shù)是否符合我們的預期,這里引入一個叫validator的東西,它的功能如同Python里中裝飾器一樣,但是原理不太相同。首先我們對每個請求定義一個獨立的結構體,并在結構體中加入validatetag,做校驗。
type Note struct {
ID string `json:"id" form:"id" validate:"gt=0"`
Content string `json:"content" form:"content" validate:"required"`
StartTime time.Time
UpdateTime time.Time
}
這里繼續(xù)找到了一個開源庫:gopkg.in/go-playground/validator.v9。使用也比較簡單:初始化一個validator,并調用其Struct方法來做校驗即可。這玩意對嵌套的struct也可以做validate,原理是這樣的:內部有一些自定義的校驗函數(shù),把用戶定義的Struct看做一顆嵌套結構的樹(如果有嵌套結構體的話),然后去遍歷這個樹上面的每一個節(jié)點,用反射現(xiàn)場去取節(jié)點的tag規(guī)則,節(jié)點的值,并調用其自定義函數(shù)進行校驗,如果校驗失敗,直接返回false,否則繼續(xù)遍歷這棵樹。
if err := Validate.Struct(request); err != nil {
return
}
到現(xiàn)在,調用業(yè)務邏輯的前置工作已經做得差不多了,接著,讓我們寫一個比較惡心的反射函數(shù)來調用業(yè)務函數(shù)
func (c *Controller) callFunc(r *http.Request, request interface{}) (interface{}, error) {
var err error
f := reflect.ValueOf(c.handleFunc)
returnVal := f.Call([]reflect.Value{reflect.ValueOf(r.Context()), reflect.ValueOf(request)})
response := returnVal[0].Interface()
if returnVal[1].Interface() != nil {
var ok bool
err, ok = returnVal[1].Interface().(error)
if !ok {
fmt.Println(returnVal[1].Interface())
}
}
return response, err
}
隨后,把函數(shù)返回值Response2JSON這部分的功能也完善一下,這里就是預先定義好我們的返回格式結構體,并把數(shù)據(jù)放到data部分,并寫入ResponseWriter即可。
type HTTPResponse struct {
Msg string `json:"msg"`
Data interface{} `json:"data"`
}
func response2JSON(ctx context.Context, wr http.ResponseWriter, resp interface{}, err error) string {
respData := &HTTPResponse{
Msg: Success,
Data: resp,
}
if err != nil {
respData = &HTTPResponse{
Msg: Failed,
Data: resp,
}
}
res, err := json.Marshal(respData)
if err != nil {
respData = &HTTPResponse{
Msg: JSONMarshalFailed,
Data: nil,
}
res = []byte(form2JSON(respData))
}
if err := writeResponse(wr, res); err != nil {
logging.Errorf("[response2JSON] writeResponse err :%v", err)
return ""
}
return string(res)
}
接著讓我們喝一口可樂,再把數(shù)據(jù)做持久化,這就涉及到數(shù)據(jù)庫以及數(shù)據(jù)庫的選擇,數(shù)據(jù)庫有關系型數(shù)據(jù)庫、kv數(shù)據(jù)庫、列式數(shù)據(jù)庫、NoSQL等。
我們這里選MySQL這個關系型數(shù)據(jù)庫做持久化存儲,MySQL采用固定的schema存儲數(shù)據(jù),支持事務,內置多種查詢引擎,還有完善的Binlog供數(shù)據(jù)導出和恢復。雖然我們的應用和事務沒什么關系~
接著來看一下如何與數(shù)據(jù)庫交互,每種數(shù)據(jù)庫都會提供一種查詢DSL供用戶訪問數(shù)據(jù)庫,其中最流行的DSL非SQL莫屬,我們可以通過MySQL提供的client終端編寫SQL來訪問數(shù)據(jù)庫里的數(shù)據(jù),像這樣:

在web程序中,有這幾種方式讓我們與DB進行交互:SQL、SQL Query Builder和ORM。SQL其實就是在程序中寫最原始的SQL與DB進行交互,SQL Query Builder在SQL上做了一層抽象,他規(guī)范了查詢模式,提供一些方法讓你可以拼出SQL,由于其抽象程度不高,其實可以根據(jù)SQL builder想象出原始的SQL是什么樣;ORM全名是object-relational mappers,做的事情是把數(shù)據(jù)庫中的表映射為類或者結構體,然后用其自帶的方法對這些類做和數(shù)據(jù)庫中CRUD等價的操作。
在這幾種方式中,ORM的對DB的抽象程度最高,手工管理的成本最低,SQL Builder本質是拼出一個SQL去執(zhí)行,而直接寫SQL的方式需要對返回結果做一些處理。ORM把很多細節(jié)都因此掉了,SQL Builder這種方式對于DBA同學來說可能還是有點不直觀。比如筆者見過某DBA同學吐槽RD使用SQL builder時不管SQL查詢的字段是什么,都先拷貝一段的SQL Builder代碼,關鍵是這段代碼大部分內容用不上。。。
最終在本項目中采用了介于SQL和SQL Builder之間的與DB的交互方式。這樣做的好處是,寫到文件最上方的SQL常量可以給DBA做SQL審計,下面對返回結果進行封裝,不必寫重復的代碼。最終的代碼像這樣:
// 一段dao層的go代碼
const (
getAllNotes = `SELECT * FROM %v WHERE xx=:xx`
)
func GetAllNotes(ctx context.Context) ([]model.NewNote, error) {
var (
err error
res []model.NewNote
params = map[string]interface{}{}
)
if err = GetList(ctx, sourceTableName,
getAllNotes, params, &res); err != nil {
return nil, err
}
return res, nil
}
接下來讓我們給項目加上日志,目前我們的程序像這張圖一樣:代碼中出了問題根本沒有排查的依據(jù)。日志是線上排查問題的重要途徑。沒有日志的程序就像一個黑洞一樣,你根本不知道它發(fā)生了什么事情。打日志這件事情需要貫徹到具體的業(yè)務代碼邏輯中,這里為了說明,所以前期沒加日志,讀者看到了不要模仿,沒加日志不是我的本意~讓我們趕緊在代碼的各個關鍵地方把日志補上。
go語言自帶的log庫定義了一個名為std的默認的Logger,它只有三個種Level:Print、Panic、Fatal,我們可以用其提供的New函數(shù)自定義一個logger并簡單定義一些行為:
var Logger *log.Logger
func InitLog() {
openFile, err := os.OpenFile("./log.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
Logger.Printf("[InitLog] open log file failed | err:%v", err)
}
Logger = log.New(openFile, "NOTE:", log.Lshortfile|log.LstdFlags)
}
func Info(v ...interface{}) {
Logger.SetPrefix(INFO)
Logger.Println(v)
}
func Infof(fmt string, v ...interface{}) {
Logger.SetPrefix(INFO)
Logger.Printf(fmt, v)
}
...
寫到這里程序基本差不多了,現(xiàn)在讓我們寫個簡單的腳本mock一些數(shù)據(jù),為一會測試程序的吞吐量做準備:
import requests
def main():
for i in range(10000):
requests.post("http://127.0.0.1:8000/note",{'content': '呵呵'+str(i)})
main()
跑完這個腳本之后可以看到MySQL表里滿屏幕的呵呵.

現(xiàn)在來用wrk測試一下GET /note接口,跑個性能測試:
? ~ wrk -c 10 -d 10s -t10 http://localhost:8000/note
Running 10s test @ http://localhost:8000/note
10 threads and 10 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.22s 419.12ms 1.74s 50.00%
Req/Sec 0.09 0.30 1.00 90.91%
11 requests in 10.05s, 21.19MB read
Socket errors: connect 0, read 0, write 0, timeout 7
Requests/sec: 1.09
Transfer/sec: 2.11MB
結果有些尷尬,QPS 1,延遲平均1.22s,原因是這個接口每次都會把DB里面全部的數(shù)據(jù)枚舉出來。讓我們改造一下接口,傳入一個size, offset參數(shù),并讓結果按照create_time排序:
~ wrk -c 10 -d 10s -t10 http://localhost:8000/note?size=20&offset=100
Running 10s test @ http://localhost:8000/note?size=20&offset=100
10 threads and 10 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 55.99ms 75.48ms 455.66ms 83.92%
Req/Sec 50.52 46.93 200.00 83.09%
4252 requests in 10.10s, 9.08MB read
Requests/sec: 420.92
Transfer/sec: 0.90MB
接著在表中的的create_time列建一個索引,結果有一些優(yōu)化:
? ~ wrk -c 10 -d 10s -t10 http://localhost:8000/note?size=20&offset=0
Running 10s test @ http://localhost:8000/note?size=20&offset=1
10 threads and 10 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 26.54ms 31.08ms 196.73ms 84.79%
Req/Sec 61.07 56.17 252.00 80.92%
6001 requests in 10.07s, 12.37MB read
Requests/sec: 595.65
Transfer/sec: 1.23MB
還可以根據(jù)業(yè)務場景把首頁熱點信息放入cache中,這樣服務的吞吐量會進一步增加:
set note_info_0_20 "[{\"ID\":1,\"Content\":\"hello,world\",\"StartTime\":\"2020-11-17T13:19:13Z\",\"UpdateTime\":\"2020-11-17T13:19:13Z\"},\......\{\"ID\":21,\"Content\":\"\xe5\x91\xb5\xe5\x91\xb516\",\"StartTime\":\"2020-11-24T00:09:05Z\",\"UpdateTime\":\"2020-11-24T00:09:05Z\"}]"
目前我們在連接db時候,連接的配置都是直接寫死在代碼中的,這好嗎,這很不好。意味著如果你換個環(huán)境運行這份代碼,還得去代碼里翻連接配置,讓我們把這些配置抽出來,放到一個公共的地方~
setting, err := mysql.ParseURL("root:123456@/notes")
client := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",})
配置加載的原理是把配置寫到json文件里面,通過實現(xiàn)定義好的結構體讀入變量中,連接db時候去讀取這些變量的值。
type MySQLConfig struct {
Database string `json:"database"`
Dsn string `json:"dsn"`
DbDriver string `json:"dbdriver"`
MaxOpenConn int `json:"maxopenconn"`
MaxIdleConn int `json:"maxidleconn"`
ConnMaxLifetime int `json:"connmaxlifetime"`
}
var config MySQLConfig
if err := confutil.LoadConfigJSON(MySQLPath, &config); err != nil {
logging.Fatal("[initDB] load config failed")
}
接著,為了看清楚調用服務所花費的時間,我們完善一下日志,在HandleHTTP中defer一發(fā)記個時間。同理,如果想看清楚調用緩存花費的時間、調用MySQL花費的時間,在存儲公用的調用函數(shù)中用這種方式看清即可。
start := time.Now()
defer func() {
procTimeMS := time.Since(start).Seconds() * 1000
logging.Infof("request=%v|resp=%v|proc_time%.2f", request, respStr, procTimeMS)
}()
到這里,項目的milestone基本達成,讓我們來加個tag
git tag v0.0.1
git push --tag
接著給我們的項目加個Makefile,寫Makefile個人比較傾向于直接找牛逼的開源框架,看看人家的Makefile是什么寫的,然后抄就完事了。比如,之前發(fā)現(xiàn)這么一篇文章。https://colobu.com/2017/06/27/Lint-your-golang-code-like-a-mad-man/,這里很多工具都可以放到我們的Makefile中,如果像我這樣比較懶的人,可以順著這位大佬的博客找到他的github,找到star最多的框架,點開Makefile,哇,真是一片寶藏啊: https://github.com/smallnest/rpcx/blob/master/Makefile ;)
最終項目的目錄結構像是這樣:
├── Makefile
├── README.md
├── common
│ ├── bind.go
│ ├── server.go
│ ├── server_test.go
│ ├── validator.go
│ └── validator_test.go
├── config
│ ├── database.json
│ └── redis.json
├── go.mod
├── go.sum
├── init.go
├── logging
│ └── logger.go
├── logic
│ ├── note.go
│ └── note_test.go
├── main.go
├── main_test.go
├── model
│ └── dto.go
├── route.go
├── storage
│ ├── mock_data.py
│ ├── note.go
│ ├── note_test.go
│ ├── notes.sql
│ └── util
│ └── command.go
└── util
├── confutil.go
└── datautil.go
至此,項目本身的代碼編寫就結束了。我們的標題是從0到1寫一個web服務,服務還包括部署相關的內容。這里先按下不表,下篇內容再著重聊聊服務部署、golang性能調優(yōu)相關的內容吧。
推薦閱讀
