本文章之前发过,但是排版不好,调整排版之后重新和下篇一起发出来。

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

net/http

image.png

git init

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

net/http
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
net/http

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

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

go mod init xxxgo.mod

接着我们设置几个变量:

proxy.golang.orggoproxy.cnGOPATH/src
go getgo get -u github.com/julienschmidt/httprouter
main.go

此时项目的布局像这样:

$ 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
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里面拿,别人传啥也不知道,还得自己做参数校验,而且你这么写,和写动态语言有啥区别,根本看不出来入参、出参是什么!

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

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

image.png

handleFunc
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)
Content-TypeContent-Typehttps://github.com/mholt/bindinghttps://github.com/martini-contrib/bindingmholt/bindingmartini-contrib/binding
https://github.com/yigenshutiao/binding
github.com/yigenshutiao/binding: [email protected]c: 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
}
validate
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.v9Struct
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
}

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

Print、Panic、Fatal
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()
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
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
├── log.txt
├── 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性能调优相关的内容吧。