web示例

https://github.com/longjoy/micro-go-course/tree/master/section08/user

1
2
3
$ mkdir micro-go-web
$ cd micro-go-web
$ go mod init example.com/micro-go-web

DAO(Data Access Object)是一个数据访问接口

go.mod 文件生成之后,会被 go toolchain 掌控维护,在我们执行 go run、go build、go get、go mod 等各类命令时自动修改和维护 go.mod 文件中的依赖内容。

我们可以通过 Go Modules 引入远程依赖包,如 Git Hub 中开源的 Go 开发工具包。但可能会由于网络环境问题,我们在拉取 GitHub 中的开发依赖包时,有时会失败,在此我推荐使用七牛云搭建的 GOPROXY,可以方便我们在开发中更好地拉取远程依赖包。在项目目录下执行以下命令即可配置新的 GOPROXY:

1
go env -w GOPROXY=https://goproxy.cn,direct

除了 go mod init,还有 go mod downloadgo mod tidy 两个 Go Modules 常用命令。
其中,go mod download 命令可以在我们手动修改 go.mod 文件后,手动更新项目的依赖关系;
go mod tidygo mod download 命令类似,但不同的是它会移除掉 go.mod 中没被使用的 require 模块。

应用项目结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
├── dao
│   ├── mysql.go
│   ├── user_dao.go
│   └── user_dao_test.go
├── endpoint
│   └── user_endpoint.go
├── go.mod
├── init.sql
├── main.go
├── redis
│   └── redis.go
├── service
│   ├── user_service.go
│   └── user_service_test.go
└── transport
    └── http.go

5 directories, 11 files

我们可以看到应用的项目结构分别由以下 "包" 组成:

  • dao 包,提供 MySQL 数据层持久化能力
  • endpoint 包,负责接收请求,并调用 service 包中的业务接口处理请求后返回响应
  • redis 包,提供 redis 数据层操作能力
  • service 包,提供主要业务实现接口
  • transport 包,对外暴露项目的服务接口
  • main, 应用主入口

在具体进行开发之前,使用 go get 引入以下依赖包:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Go-kit 框架 
go get github.com/go-kit/kit@v0.10.0 
# Redis 分布式锁 
go get github.com/go-redsync/redsync@v1.4.2 
# mysql 驱动 
go get github.com/go-sql-driver/mysql@v1.5.0
# redis 客户端 
go get github.com/gomodule/redigo@v2.0.0+incompatible
# mux 路由 
go get github.com/gorilla/mux@v1.7.4
# gorm mysql orm 框架
go get github.com/jinzhu/gorm@v1.9.14 
go get github.com/go-kit/kit/log@v0.10.0

接下来我们就按照 service、endpoint、transport 和 main 的顺序构建整个项目。

service 包中主要提供用户服务的业务接口方法。
Go 中可以通过 type 和 interface 关键字定义接口,接口代表了调用方和实现方共同遵守的协议,其内定义一系列将要被实现的函数。
在 Go 中,一般使用结构体实现接口,如 service 包中定义的 UserService 接口由 UserServiceImpl 结构体实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
package service

import (
    "context"
    "errors"
    "log"
    "time"

    "example.com/micro-go-web/dao"
    "example.com/micro-go-web/redis"
    "github.com/jinzhu/gorm"
)

type UserService interface {
    // 登录接口
    Login(ctx context.Context, email, password string) (*UserInfoDTO, error)
    // 注册接口
    Register(ctx context.Context, vo *RegisterUserVO) (*UserInfoDTO, error)
}

type UserInfoDTO struct {
    ID       int64  `json:"id"`
    Username string `json:"username"`
    Email    string `json:"email"`
}

type UserServiceImpl struct {
    userDAO dao.UserDAO
}

func (userService UserServiceImpl) Register(ctx context.Context, vo *RegisterUserVO) (*UserInfoDTO, error) {
    lock := redis.GetRedisLock(vo.Email, time.Duration(5)*time.Second)
    err := lock.Lock()
    if err != nil {
        log.Printf("err : %s", err)
        return nil, ErrRegistering
    }
    defer lock.Unlock()

    existUser, err := userService.userDAO.SelectByEmail(vo.Email)

    if (err == nil && existUser == nil) || err == gorm.ErrRecordNotFound {
        newUser := &dao.UserEntity{
            Username: vo.Username,
            Password: vo.Password,
            Email:    vo.Email,
        }
        err = userService.userDAO.Save(newUser)
        if err == nil {
            return &UserInfoDTO{
                ID:       newUser.ID,
                Username: newUser.Username,
                Email:    newUser.Email,
            }, nil
        }
    }
    if err == nil {
        err = ErrUserExisted
    }
    return nil, err
}

func (userService *UserServiceImpl) Login(ctx context.Context, email, password string) (*UserInfoDTO, error) {
    user, err := userService.userDAO.SelectByEmail(email)
    if err == nil {
        if user.Password == password {
            return &UserInfoDTO{
                ID:       user.ID,
                Username: user.Username,
                Email:    user.Email,
            }, nil
        } else {
            return nil, ErrPassword
        }
    } else {
        log.Printf("err : %s", err)
    }
    return nil, err
}

type RegisterUserVO struct {
    Username string
    Password string
    Email    string
}

var (
    ErrUserExisted = errors.New("user is existed")
    ErrPassword    = errors.New("email and password are not match")
    ErrRegistering = errors.New("email is registering")
)

func MakeUserServiceImpl(userDAO dao.UserDAO) UserService {
    return &UserServiceImpl{
        userDAO: userDAO,
    }
}

在 Go 中,我们可以为一个函数指定其唯一的接收器,接收器可以为任意类型,具备接收器的函数在 Go 中被称作方法。
接收器类似面向对象语言中的 this 或者 self,我们可以在方法内部直接使用和修改接收器中的相关属性。
接收器可以分为指针类型和非指针类型,在方法内部对指针类型的接收器修改将会直接反馈到原接收器,而非指针类型的接收器在方法中被操作的数据为原接收器的值拷贝,对其修改并不会影响到原接收器的数据。

在具体使用时可以根据需要指定接收器的类型,比如当接收器占用内存较大或者需要对原接收器的属性进行修改时,可以使用指针类型接收器;
当接收器占用内存较小,且方法只会读取接收器内的属性时,可以采用非指针类型接收器。
在上面 UserService 接口的实现中,我们指定了 UserServiceImpl 接收器类型为指针类型。

Go 中接口属于非侵入式设计,要实现接口仅需满足以下两个条件:

  • 接口中所有方法均被实现;
  • 接收器添加的方法签名和接口的方法签名完全一致。

在上述代码中,UserServiceImpl 结构体就完全实现了 UserService 接口中定义的方法,因此可以说 UserServiceImpl 结构体实现了 UserService 接口。

在 UserInfoDTO 结构体的定义中,我们还使用了 StructTag 为结构体内的字段添加额外的信息。
StructTag 一般由一个或者多个键值对组成,用来表述结构体中字段可携带的额外信息。
UserInfoDTO 中 json 键类的 StructTag 说明了该字段在 JSON 序列化时的名称,比如 ID 在序列化时会变为 id。

endpoint 包中,我们需要构建 RegisterEndpoint 和 LoginEndpoint,将请求转化为 UserService 接口可以处理的参数,并将处理的结果封装为对应的 response 结构体返回给 transport 包。如下代码所示:

endpoint/user_endpoint.go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
package endpoint

import (
    "context"

    "example.com/micro-go-web/service"
    "github.com/go-kit/kit/endpoint"
)

type UserEndpoints struct {
    RegisterEndpoint endpoint.Endpoint
    LoginEndpoint    endpoint.Endpoint
}

type LoginRequest struct {
    Email    string
    Password string
}

type LoginResponse struct {
    UserInfo *service.UserInfoDTO `json:"user_info"`
}

func MakeLoginEndpoint(userService service.UserService) endpoint.Endpoint {
    return func(ctx context.Context, request interface{}) (response interface{}, err error) {
        req := request.(*LoginRequest)
        userInfo, err := userService.Login(ctx, req.Email, req.Password)
        return &LoginResponse{UserInfo: userInfo}, err

    }
}

type RegisterRequest struct {
    Username string
    Email    string
    Password string
}

type RegisterResponse struct {
    UserInfo *service.UserInfoDTO `json:"user_info"`
}

func MakeRegisterEndpoint(userService service.UserService) endpoint.Endpoint {
    return func(ctx context.Context, request interface{}) (response interface{}, err error) {
        req := request.(*RegisterRequest)
        userInfo, err := userService.Register(ctx, &service.RegisterUserVO{
            Username: req.Username,
            Password: req.Password,
            Email:    req.Email,
        })
        return &RegisterResponse{UserInfo: userInfo}, err
    }
}

Endpoint 代表了一个通用的函数原型,负责接收请求,处理请求,并返回结果。
因为 Endpoint 的函数形式是固定的,所以我们可以在外层给 Endpoint 装饰一些额外的能力,比如熔断、日志、限流、负载均衡等能力,这些能力在 Go-kit 框架中都有相应的 Endpoint 装饰器。

transport 包中,我们需要将构建好的 Endpoint 通过 HTTP 或者 RPC 的方式暴露出去。如下代码所示:

transport/http.go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
package transport

import (
    "context"
    "encoding/json"
    "errors"
    "net/http"
    "os"

    "example.com/micro-go-web/endpoint"
    "github.com/go-kit/kit/log"
    "github.com/go-kit/kit/transport"
    kithttp "github.com/go-kit/kit/transport/http"
    "github.com/gorilla/mux"
)

var (
    ErrorBadRequest = errors.New("invalid request parameter")
)

// MakeHttpHandler make http handler use mux
func MakeHttpHandler(ctx context.Context, endpoints *endpoint.UserEndpoints) http.Handler {
    r := mux.NewRouter()

    kitLog := log.NewLogfmtLogger(os.Stderr)

    kitLog = log.With(kitLog, "ts", log.DefaultTimestampUTC)
    kitLog = log.With(kitLog, "caller", log.DefaultCaller)

    options := []kithttp.ServerOption{
        kithttp.ServerErrorHandler(transport.NewLogErrorHandler(kitLog)),
        kithttp.ServerErrorEncoder(encodeError),
    }

    r.Methods("POST").Path("/register").Handler(kithttp.NewServer(
        endpoints.RegisterEndpoint,
        decodeRegisterRequest,
        encodeJSONResponse,
        options...,
    ))

    r.Methods("POST").Path("/login").Handler(kithttp.NewServer(
        endpoints.LoginEndpoint,
        decodeLoginRequest,
        encodeJSONResponse,
        options...,
    ))

    return r
}

func decodeRegisterRequest(_ context.Context, r *http.Request) (interface{}, error) {
    username := r.FormValue("username")
    password := r.FormValue("password")
    email := r.FormValue("email")

    if username == "" || password == "" || email == "" {
        return nil, ErrorBadRequest
    }
    return &endpoint.RegisterRequest{
        Username: username,
        Password: password,
        Email:    email,
    }, nil
}

func decodeLoginRequest(_ context.Context, r *http.Request) (interface{}, error) {
    email := r.FormValue("email")
    password := r.FormValue("password")

    if email == "" || password == "" {
        return nil, ErrorBadRequest
    }
    return &endpoint.LoginRequest{
        Email:    email,
        Password: password,
    }, nil
}

func encodeJSONResponse(ctx context.Context, w http.ResponseWriter, response interface{}) error {
    w.Header().Set("Content-Type", "application/json;charset=utf-8")
    return json.NewEncoder(w).Encode(response)
}

func encodeError(_ context.Context, err error, w http.ResponseWriter) {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    switch err {
    default:
        w.WriteHeader(http.StatusInternalServerError)
    }
    json.NewEncoder(w).Encode(map[string]interface{}{
        "error": err.Error(),
    })
}

在上述代码中,我们使用 mux 作为 HTTP 请求的路由和分发器,相比 Go 中原生态的 HTTP 路由包,mux 的路由代码可读性高、路由规则更清晰。
上述代码分别将 RegisterEndpoint 和 LoginEndpoint 暴露到 HTTP 的 /register/login 路径下,并指定对应的解码方法和编码方法。
解码方法会将 HTTP 请求体中的请求数据解析封装为 XXXRequest 结构体传给对应的 Endpoint 处理,而编码方法会将 Endpoint 处理返回的 XXXResponse 结构体编码为 HTTP 响应返回客户端。

最后是在 main 函数中依次组建 service、endpoint 和 transport,并启动 Web 服务器,代码如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
package main

import (
    "context"
    "flag"
    "fmt"
    "log"
    "net/http"
    "os"
    "os/signal"
    "strconv"
    "syscall"

    "example.com/micro-go-web/dao"
    "example.com/micro-go-web/endpoint"
    "example.com/micro-go-web/redis"
    "example.com/micro-go-web/service"
    "example.com/micro-go-web/transport"
)

func main() {
    var (
        // 服务地址和服务名
        servicePort = flag.Int("service.port", 10086, "service port")
    )

    flag.Parse()

    ctx := context.Background()
    errChan := make(chan error)

    err := dao.InitMysql("127.0.0.1", "3306", "root", "woaini123", "user")
    if err != nil {
        log.Fatal(err)
    }

    err = redis.InitRedis("127.0.0.1", "6379", "")
    if err != nil {
        log.Fatal(err)
    }

    userService := service.MakeUserServiceImpl(&dao.UserDAOImpl{})

    userEndpoints := &endpoint.UserEndpoints{
        endpoint.MakeRegisterEndpoint(userService),
        endpoint.MakeLoginEndpoint(userService),
    }

    r := transport.MakeHttpHandler(ctx, userEndpoints)

    go func() {
        errChan <- http.ListenAndServe(":"+strconv.Itoa(*servicePort), r)
    }()

    go func() {
        // 监控系统信号,等待 ctrl + c 系统信号通知服务关闭
        c := make(chan os.Signal, 1)
        signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
        errChan <- fmt.Errorf("%s", <-c)
    }()

    error := <-errChan
    log.Println(error)
}

在上述代码中,我们依次构建了 service、endpoint 和 transport,并在 10086 端口启动了 Web 服务器,最后通过监听对应的 ctrl + c 系统信号关闭服务。

通过上述流程,我们就详细介绍完了如何基于 Go-kit 开发一个 Web 项目,在配置好相应的 Go Modules 代理、MySQL 数据库和 Redis 数据库后即可通过 go run 命令启动,启动后可以通过请求相应的 HTTP 接口验证效果,如下 curl 命令例子所示:

1
2
3
4
5
6
7
$ curl -X POST  http://localhost:10086/register -H 'content-type: application/x-www-form-urlencoded' -d 'email=aoho%40mail.com&password=aoho&username=aoho'

{"user_info":{"id":1,"username":"aoho","email":"aoho@mail.com"}}

$ curl -X POST http://localhost:10086/login -H 'content-type: application/x-www-form-urlencoded'  -d 'email=aoho%40mail.com&password=aoho'

{"user_info":{"id":1,"username":"aoho","email":"aoho@mail.com"}}