从0写一个 Go Web 服务 (上)

共 16122字,需浏览 33分钟

 ·

2021-05-20 01:12

学生时代曾和几个朋友做了一个笔记本小应用,当时我的角色是pm + dba,最近心血来潮,想把这个玩意自己实现一遍,顺便写一篇文章记录整个过程。

笔者的职业目前是一个后端程序员,最常用的语言是Golang,恰好Golang自带的的net/http包非常方便,这次就用Golang写这个服务。首先打开我心爱的GoLand,New一个Project,给项目起一个酷炫的名字。

接着写个Hello, world。同时为方便项目管理,引入git这个版本控制工具来管理代码。随着一发git init 加commit,这个项目就算诞生了; )

作为一个互联网公司的程序员,公司追求的是快速试错,迭代前进,此路不通换一条继续试验。这就需要管理PM和运营老板的预期,现在要从0到1写一个web服务,就需要详细拆解一下需求,搞一个TODO list。同时明确项目的milestone,快速迭代,到达stone立马上线,随后再慢慢地丰富细节~

那么先来搞第一个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/,可以看到这样的效果。

此时有的同学可能会讲,测试怎么还能老依赖浏览器呢,我电脑上如果没有浏览器还不能测试了吗?用浏览器测试确实不太优雅,所以让我们接着继续写一个测试文件,搞个http Client,自己跑一下效果,并来一发commit。

package main

import (
"fmt"
"io/ioutil"
"net/http"
"testing"
"time"
)

func Test_hello(t *testing.T) {
go main()
time.Sleep(time.Second) // 给main函数续一秒,确保main在http.Get之前执行
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 这里不优雅
fmt.Println(string(body) == `
__ _ _ ____ ____ __ _ _ ____ __ _ __ ____ ____ ____ __ ____ ____
/ _\/ )( ( __) ___)/ \( \/ | __) ( ( \/ (_ _| __) ___) / _\( _ ( _ \
/ \ /\ /) _)\___ ( O ) \/ \) _) / ( O ))( ) _)\___ \ / \) __/) __/
\_/\_(_/\_|____|____/\__/\_)(_(____) \_)__)\__/(__)(____|____/ \_/\_(__) (__)

`)
}

接下来,让我们实现一下这个记事本的CURD功能让它先稍微可用起来。首先设计一下路由:

GET	    /note    // 获取所有note 
POST    /note    // 新增一个note
GET     /note/:id    // 根据id获取某个note
DELETE    /note/:id    // 根据id删除某个note

敏锐的同学一下就可以看出来,这里我们用了早些年特别流行的RESTful路由设计。但是这里有个小问题:golang自带的net/http包里面对牛逼的RESTful支持的并不好。但是问题不大,让我们去程序员聚集的Github上找找有没有支持REST的router可以使用。

果不其然其他程序员有类似的困扰并解决了问题,这个12k星的项目完全可以cover我们的需求。httprouter可以根据HTTP方法(GET, POST, PUT, PATCH, DELETE等) build出一棵棵的压缩字典树(Radix Tree),树的节点是URL中的一个path块或path块的公共前缀,将树和 URL对应的handler函数绑定起来,就可以使用了。

要引入第三方包,就涉及到包管理问题,golang之前没有一个官方的包管理工具,一直被人所诟病。不过前些日子官方推出了Go Module这个工具,虽然目前这个工具还有一些问题,但是这是官方出品并强推的,肯定不会死,让我们通过go module把httprouter引用进来。

在package中执行一下go mod init xxx 命令,此时你会发现,项目的文件里面多了一个go.mod文件。

接着我们设置几个变量:

  1. GO111MODULE : 这个变量用来控制是否启用Go Modules,选auto。

  2. GOPROXY : 由于一些无法抵抗的原因,我们无法访问golang的proxy地址proxy.golang.org。这里可以通过设置Proxy地址来代替官方的proxy,比如说七牛的goproxy.cn

  3. 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
接着,来更新一下项目的目录结构,随着项目的迭代,如果功能函数都写到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中,我们把之前规划的curd功能实现一下:

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))))
}

看到这里,可能有人要喷了:

哎,你这个玩意,怎么在业务逻辑里各种拼json,写回去response中啊,这块明显是可以复用的逻辑啊!

哎,你这个玩意,获取入参的时候怎么这么挫啊,直接从URL里面拿,别人传啥也不知道,还得自己做参数校验,而且你这么写,和写动态语言有啥区别,根本看不出来入参、出参是什么!

哎,你这个玩意,怎么数据都不入数据库啊,服务一重启,数据就没了!

哎,你这个玩意,怎么连个日志都没有啊,你这线上出问题了,怎么查啊!

没错,这些都是问题,让我们来一个一个解决,首先先写个公共的handler,规范一下我们HTTP handle func 入参、出参。这里通过反射分析handleFunc这个interface来做,这样在注册路由的时候就会对我们的handler函数参数做规范:

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")
}
...
}

我们规定函数的签名统一为:func(ctx context.Context, req interface{}) (interface{}, error),并在CheckHandlerFunc中对interface进行校验确保其符合我们的签名。这里用反射去哪接口的属性即可。

接着,我们把用户入参绑定到定义好的结构体上,让我们不用手动去parserHTTP参数,这个需求本质上是bind功能,原理就是根据不同的Content-Type去用不同的方式解析出http 参数,并通过tag用反射绑定到用户自定义的结构体上。这块自己写比较无聊,因为需要大量重复的Content-Type判断,于是再去github上找找开源库。找到两个star比较多的库:https://github.com/mholt/bindinghttps://github.com/martini-contrib/binding,但这两个库各有缺点:mholt/binding的问题是你需要显式实现一个FiledMap方法,把参数显示绑定到FieldMap上,这个不能忍,pass。martini-contrib/binding是Martini框架中单独抽出来的binding部分,由于其是Martini框架中抽出来的,很多类型在martini中都被预先改写了,这对对只想用binding功能的我来说不太友好,于是也被pass。

接着,我们去一些star数多的开源web框架上打打主意,gin框架里面的binding包没有上面两个包的缺点。我们可以单独把binding部分fork出来搞一个项目,地址是:https://github.com/yigenshutiao/binding。引用这个包的时候遇到了一些go module的一些小问题。解决方法也比较简单,把binding里go.mod模块的声明改一下即可。

github.com/yigenshutiao/binding: github.com/yigenshutiao/binding@v0.0.0-20201106105450-4811da8aa50c: 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中的参数绑定到我们的结构体上,但是注意由于我们使用RESTful的方式传参,还需要把http.router中Params的参数也绑定到结构体上,这里写了一个Map2Struct的util函数,原理也是根据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
}

绑定了参数之后,可以对用户传进来的参数进行校验,校验要做的工作是在处理业务逻辑之前,提前看参数是否符合我们的预期,这里引入一个叫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
}

这里继续找到了一个开源库:gopkg.in/go-playground/validator.v9。使用也比较简单:初始化一个validator,并调用其Struct方法来做校验即可。这玩意对嵌套的struct也可以做validate,原理是这样的:内部有一些自定义的校验函数,把用户定义的Struct看做一颗嵌套结构的树(如果有嵌套结构体的话),然后去遍历这个树上面的每一个节点,用反射现场去取节点的tag规则,节点的值,并调用其自定义函数进行校验,如果校验失败,直接返回false,否则继续遍历这棵树。

if err := Validate.Struct(request); err != nil {
return
}

到现在,调用业务逻辑的前置工作已经做得差不多了,接着,让我们写一个比较恶心的反射函数来调用业务函数

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
}

随后,把函数返回值Response2JSON这部分的功能也完善一下,这里就是预先定义好我们的返回格式结构体,并把数据放到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)
}

接着让我们喝一口可乐,再把数据做持久化,这就涉及到数据库以及数据库的选择,数据库有关系型数据库、kv数据库、列式数据库、NoSQL等。

我们这里选MySQL这个关系型数据库做持久化存储,MySQL采用固定的schema存储数据,支持事务,内置多种查询引擎,还有完善的Binlog供数据导出和恢复。虽然我们的应用和事务没什么关系~

接着来看一下如何与数据库交互,每种数据库都会提供一种查询DSL供用户访问数据库,其中最流行的DSL非SQL莫属,我们可以通过MySQL提供的client终端编写SQL来访问数据库里的数据,像这样:

在web程序中,有这几种方式让我们与DB进行交互:SQL、SQL Query Builder和ORM。SQL其实就是在程序中写最原始的SQL与DB进行交互,SQL Query Builder在SQL上做了一层抽象,他规范了查询模式,提供一些方法让你可以拼出SQL,由于其抽象程度不高,其实可以根据SQL builder想象出原始的SQL是什么样;ORM全名是object-relational mappers,做的事情是把数据库中的表映射为类或者结构体,然后用其自带的方法对这些类做和数据库中CRUD等价的操作。

在这几种方式中,ORM的对DB的抽象程度最高,手工管理的成本最低,SQL Builder本质是拼出一个SQL去执行,而直接写SQL的方式需要对返回结果做一些处理。ORM把很多细节都因此掉了,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
}

接下来让我们给项目加上日志,目前我们的程序像这张图一样:代码中出了问题根本没有排查的依据。日志是线上排查问题的重要途径。没有日志的程序就像一个黑洞一样,你根本不知道它发生了什么事情。打日志这件事情需要贯彻到具体的业务代码逻辑中,这里为了说明,所以前期没加日志,读者看到了不要模仿,没加日志不是我的本意~让我们赶紧在代码的各个关键地方把日志补上。

go语言自带的log库定义了一个名为std的默认的Logger,它只有三个种Level:Print、Panic、Fatal,我们可以用其提供的New函数自定义一个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)
}

...

写到这里程序基本差不多了,现在让我们写个简单的脚本mock一些数据,为一会测试程序的吞吐量做准备:

import requests

def main():
for i in range(10000):
requests.post("http://127.0.0.1:8000/note",{'content': '呵呵'+str(i)})

main()

跑完这个脚本之后可以看到MySQL表里满屏幕的呵呵.

现在来用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里面全部的数据枚举出来。让我们改造一下接口,传入一个size, offset参数,并让结果按照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列建一个索引,结果有一些优化:

➜  ~ 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

还可以根据业务场景把首页热点信息放入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时候,连接的配置都是直接写死在代码中的,这好吗,这很不好。意味着如果你换个环境运行这份代码,还得去代码里翻连接配置,让我们把这些配置抽出来,放到一个公共的地方~

setting, err := mysql.ParseURL("root:123456@/notes")
client := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",})

配置加载的原理是把配置写到json文件里面,通过实现定义好的结构体读入变量中,连接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一发记个时间。同理,如果想看清楚调用缓存花费的时间、调用MySQL花费的时间,在存储公用的调用函数中用这种方式看清即可。

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是什么写的,然后抄就完事了。比如,之前发现这么一篇文章。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性能调优相关的内容吧。


推荐阅读


福利

我为大家整理了一份从入门到进阶的Go学习资料礼包,包含学习建议:入门看什么,进阶看什么。关注公众号 「polarisxu」,回复 ebook 获取;还可以回复「进群」,和数万 Gopher 交流学习。

浏览 18
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报