[Golang]从0到1写一个web服务(上)

一根薯条 · · 138 次点击 · · 开始浏览    

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

img

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

image.png

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

image.png

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

image.png

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

image.png

此时有的同学可能会讲,测试怎么还能老依赖浏览器呢,我电脑上如果没有浏览器还不能测试了吗?用浏览器测试确实不太优雅,所以让我们接着继续写一个测试文件,搞个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可以使用。

image.png

果不其然其他程序员有类似的困扰并解决了问题,这个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文件。

image.png

接着我们设置几个变量:

  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

image.png

接着,来更新一下项目的目录结构,随着项目的迭代,如果功能函数都写到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里面拿,别人传啥也不知道,还得自己做参数校验,而且你这么写,和写动态语言有啥区别,根本看不出来入参、出参是什么!

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

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

image.png

没错,这些都是问题,让我们来一个一个解决,首先先写个公共的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来访问数据库里的数据,像这样:


image.png

在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
├── 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性能调优相关的内容吧。


有疑问加站长微信联系(非本文作者)

本文来自:简书

感谢作者:一根薯条

查看原文:[Golang]从0到1写一个web服务(上)

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:1006366459

138 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传