跳到主要内容

业务开发

本章节我们用一个简单的示例去演示一下go-zero中的一些基本功能。

演示工程下载

在正式进入后续文档叙述前,可以先留意一下这里的源码,后续我们会基于这份源码进行功能的递进式演示, 而不是完全从0开始,如果你从快速入门章节过来,这份源码结构对你来说不是问题。

点击这里下载演示工程基础源码

演示工程说明

场景

程序员小明需要借阅一本《西游记》,在没有线上图书管理系统的时候,他每天都要去图书馆前台咨询图书馆管理员,

  • 小明:你好,请问今天《西游记》的图书还有吗?
  • 管理员:没有了,明天再来看看吧。

过了一天,小明又来到图书馆,问:

  • 小明:你好,请问今天《西游记》的图书还有吗?
  • 管理员:没有了,你过两天再来看看吧。

就这样经过多次反复,小明也是徒劳无功,浪费大量时间在来回的路上,于是终于忍受不了落后的图书管理系统, 他决定自己亲手做一个图书查阅系统。

预期实现目标

  • 用户登录 依靠现有学生系统数据进行登录
  • 图书检索 根据图书关键字搜索图书,查询图书剩余数量。

系统分析

服务拆分

  • user
    • api 提供用户登录协议
    • rpc 供search服务访问用户数据
  • search
    • api 提供图书查询协议
提示

这个微小的图书借阅查询系统虽然小,从实际来讲不太符合业务场景,但是仅上面两个功能,已经满足我们对go-zero api/rpc的场景演示了, 后续为了满足更丰富的go-zero功能演示,会在文档中进行业务插入即相关功能描述。这里仅用一个场景进行引入。

注意:user中的sql语句请自行创建到db中去,更多准备工作见准备工作

添加一些预设的用户数据到数据库,便于后面使用,为了篇幅,演示工程不对插入数据这种操作做详细演示。

参考预设数据

INSERT INTO `user` (number,name,password,gender)values ('666','小明','123456','男');

目录拆分

目录拆分是指配合go-zero的最佳实践的目录拆分,这和微服务拆分有着关联,在团队内部最佳实践中, 我们按照业务横向拆分,将一个系统拆分成多个子系统,每个子系统应拥有独立的持久化存储,缓存系统。 如一个商城系统需要有用户系统(user),商品管理系统(product),订单系统(order),购物车系统(cart),结算中心系统(pay),售后系统(afterSale)等组成。

系统结构分析

在上文提到的商城系统中,每个系统在对外(http)提供服务的同时,也会提供数据给其他子系统进行数据访问的接口(rpc),因此每个子系统可以拆分成一个服务,而且对外提供了两种访问该系统的方式api和rpc,因此, 以上系统按照目录结构来拆分有如下结构:

.
├── afterSale
│   ├── api
│   └── rpc
├── cart
│   ├── api
│   └── rpc
├── order
│   ├── api
│   └── rpc
├── pay
│   ├── api
│   └── rpc
├── product
│   ├── api
│   └── rpc
└── user
├── api
└── rpc

rpc调用链建议

在设计系统时,尽量做到服务之间调用链是单向的,而非循环调用,例如:order服务调用了user服务,而user服务反过来也会调用order的服务, 当其中一个服务启动故障,就会相互影响,进入死循环,你order认为是user服务故障导致的,而user认为是order服务导致的,如果有大量服务存在相互调用链, 则需要考虑服务拆分是否合理。

常见服务类型的目录结构

在上述服务中,仅列举了api/rpc服务,除此之外,一个服务下还可能有其他更多服务类型,如rmq(消息处理系统),cron(定时任务系统),script(脚本)等, 因此一个服务下可能包含以下目录结构:

user
├── api // http访问服务,业务需求实现
├── cronjob // 定时任务,定时数据更新业务
├── rmq // 消息处理系统:mq和dq,处理一些高并发和延时消息业务
├── rpc // rpc服务,给其他子系统提供基础数据访问
└── script // 脚本,处理一些临时运营需求,临时数据修复

完整工程目录结构示例

mall // 工程名称
├── common // 通用库
│   ├── randx
│   └── stringx
├── go.mod
├── go.sum
└── service // 服务存放目录
├── afterSale
│   ├── api
│   └── model
│   └── rpc
├── cart
│   ├── api
│   └── model
│   └── rpc
├── order
│   ├── api
│   └── model
│   └── rpc
├── pay
│   ├── api
│   └── model
│   └── rpc
├── product
│   ├── api
│   └── model
│   └── rpc
└── user
├── api
├── cronjob
├── model
├── rmq
├── rpc
└── script

model生成

首先,下载好演示工程 后,我们以user的model来进行代码生成演示。

model是服务访问持久化数据层的桥梁,业务的持久化数据常存在于mysql,mongo等数据库中,我们都知道,对于一个数据库的操作莫过于CURD, 而这些工作也会占用一部分时间来进行开发,我曾经在编写一个业务时写了40个model文件,根据不同业务需求的复杂性,平均每个model文件差不多需要 10分钟,对于40个文件来说,400分钟的工作时间,差不多一天的工作量,而goctl工具可以在10秒钟来完成这400分钟的工作。

准备工作

进入演示工程book,找到user/model下的user.sql文件,将其在你自己的数据库中执行建表。

代码生成(带缓存)

方式一(ddl)

进入service/user/model目录,执行命令

$ cd service/user/model
$ goctl model mysql ddl -src user.sql -dir . -c
Done.

方式二(datasource)

$ goctl model mysql datasource -url="$datasource" -table="user" -c -dir .
Done.
提示

$datasource为数据库连接地址

方式三(intellij 插件)

在Goland中,右键user.sql,依次进入并点击New->Go Zero->Model Code即可生成,或者打开user.sql文件, 进入编辑区,使用快捷键Command+N(for mac OS)或者 alt+insert(for windows),选择Mode Code即可

model生成

提示

intellij插件生成需要安装goctl插件

验证生成的model文件

查看tree

$ tree,依次点击进入 New->Go Zero->Api Code
.
├── user.sql
├── usermodel.go
└── vars.go

api文件编写

编写user.api文件

$ vim service/user/api/user.api  
type (
LoginReq {
Username string `json:"username"`
Password string `json:"password"`
}

LoginReply {
Id int64 `json:"id"`
Name string `json:"name"`
Gender string `json:"gender"`
AccessToken string `json:"accessToken"`
AccessExpire int64 `json:"accessExpire"`
RefreshAfter int64 `json:"refreshAfter"`
}
)

service user-api {
@handler login
post /user/login (LoginReq) returns (LoginReply)
}

生成api服务

方式一

$ cd book/service/user/api
$ goctl api go -api user.api -dir .
Done.

方式二

user.api 文件右键,依次点击进入 New->Go Zero->Api Code ,进入目标目录选择,即api源码的目标存放目录,默认为user.api所在目录,选择好目录后点击OK即可。 api生成 api生成目录选择

方式三

打开user.api,进入编辑区,使用快捷键Command+N(for mac OS)或者 alt+insert(for windows),选择Api Code,同样进入目录选择弹窗,选择好目录后点击OK即可。

业务编码

前面一节,我们已经根据初步需求编写了user.api来描述user服务对外提供哪些服务访问,在本节我们接着前面的步伐, 通过业务编码来讲述go-zero怎么在实际业务中使用。

添加Mysql配置

$ vim service/user/api/internal/config/config.go
package config

import "github.com/zeromicro/go-zero/rest"

type Config struct {
rest.RestConf
Mysql struct{
DataSource string
}

CacheRedis cache.CacheConf
}

完善yaml配置

$ vim service/user/api/etc/user-api.yaml
Name: user-api
Host: 0.0.0.0
Port: 8888
Mysql:
DataSource: $user:$password@tcp($url)/$db?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
CacheRedis:
- Host: $host
Pass: $pass
Type: node
提示

$user: mysql数据库user

$password: mysql数据库密码

$url: mysql数据库连接地址

$db: mysql数据库db名称,即user表所在database

$host: redis连接地址 格式:ip:port,如:127.0.0.1:6379

$pass: redis密码

完善服务依赖

$ vim service/user/api/internal/svc/servicecontext.go
type ServiceContext struct {
Config config.Config
UserModel model.UserModel
}

func NewServiceContext(c config.Config) *ServiceContext {
conn:=sqlx.NewMysql(c.Mysql.DataSource)
return &ServiceContext{
Config: c,
UserModel: model.NewUserModel(conn,c.CacheRedis),
}
}

填充登录逻辑

$ vim service/user/api/internal/logic/loginlogic.go
func (l *LoginLogic) Login(req types.LoginReq) (*types.LoginReply, error) {
if len(strings.TrimSpace(req.Username)) == 0 || len(strings.TrimSpace(req.Password)) == 0 {
return nil, errors.New("参数错误")
}

userInfo, err := l.svcCtx.UserModel.FindOneByNumber(req.Username)
switch err {
case nil:
case model.ErrNotFound:
return nil, errors.New("用户名不存在")
default:
return nil, err
}

if userInfo.Password != req.Password {
return nil, errors.New("用户密码不正确")
}

// ---start---
now := time.Now().Unix()
accessExpire := l.svcCtx.Config.Auth.AccessExpire
jwtToken, err := l.getJwtToken(l.svcCtx.Config.Auth.AccessSecret, now, l.svcCtx.Config.Auth.AccessExpire, userInfo.Id)
if err != nil {
return nil, err
}
// ---end---

return &types.LoginReply{
Id: userInfo.Id,
Name: userInfo.Name,
Gender: userInfo.Gender,
AccessToken: jwtToken,
AccessExpire: now + accessExpire,
RefreshAfter: now + accessExpire/2,
}, nil
}

jwt鉴权

概述

JSON Web令牌(JWT)是一个开放标准(RFC 7519),它定义了一种紧凑而独立的方法,用于在各方之间安全地将信息作为JSON对象传输。由于此信息是经过数字签名的,因此可以被验证和信任。可以使用秘密(使用HMAC算法)或使用RSA或ECDSA的公钥/私钥对对JWT进行签名。

什么时候应该使用JWT

  • 授权:这是使用JWT的最常见方案。一旦用户登录,每个后续请求将包括JWT,从而允许用户访问该令牌允许的路由,服务和资源。单一登录是当今广泛使用JWT的一项功能,因为它的开销很小并且可以在不同的域中轻松使用。

  • 信息交换:JSON Web令牌是在各方之间安全地传输信息的一种好方法。因为可以对JWT进行签名(例如,使用公钥/私钥对),所以您可以确保发件人是他们所说的人。此外,由于签名是使用标头和有效负载计算的,因此您还可以验证内容是否未被篡改。

为什么要使用JSON Web令牌

由于JSON不如XML冗长,因此在编码时JSON的大小也较小,从而使JWT比SAML更为紧凑。这使得JWT是在HTML和HTTP环境中传递的不错的选择。

在安全方面,只能使用HMAC算法由共享机密对SWT进行对称签名。但是,JWT和SAML令牌可以使用X.509证书形式的公用/专用密钥对进行签名。与签署JSON的简单性相比,使用XML Digital Signature签署XML而不引入模糊的安全漏洞是非常困难的。

JSON解析器在大多数编程语言中都很常见,因为它们直接映射到对象。相反,XML没有自然的文档到对象的映射。与SAML断言相比,这使使用JWT更加容易。

关于用法,JWT是在Internet规模上使用的。这突显了在多个平台(尤其是移动平台)上对JSON Web令牌进行客户端处理的简便性。

提示

以上内容全部来自jwt官网介绍

go-zero中怎么使用jwt

jwt鉴权一般在api层使用,我们这次演示工程中分别在user api登录时生成jwt token,在search api查询图书时验证用户jwt token两步来实现。

user api生成jwt token

接着业务编码章节的内容,我们完善上一节遗留的getJwtToken方法,即生成jwt token逻辑

添加配置定义和yaml配置项
$ vim service/user/api/internal/config/config.go
type Config struct {
rest.RestConf
Mysql struct{
DataSource string
}
CacheRedis cache.CacheConf
Auth struct {
AccessSecret string
AccessExpire int64
}
}
$ vim service/user/api/etc/user-api.yaml
Name: user-api
Host: 0.0.0.0
Port: 8888
Mysql:
DataSource: $user:$password@tcp($url)/$db?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
CacheRedis:
- Host: $host
Pass: $pass
Type: node
Auth:
AccessSecret: $AccessSecret
AccessExpire: $AccessExpire
提示

$AccessSecret:生成jwt token的密钥,最简单的方式可以使用一个uuid值。

$AccessExpire:jwt token有效期,单位:秒

$ vim service/user/api/internal/logic/loginlogic.go
func (l *LoginLogic) getJwtToken(secretKey string, iat, seconds, userId int64) (string, error) {
claims := make(jwt.MapClaims)
claims["exp"] = iat + seconds
claims["iat"] = iat
claims["userId"] = userId
token := jwt.New(jwt.SigningMethodHS256)
token.Claims = claims
return token.SignedString([]byte(secretKey))
}

search api使用jwt token鉴权

编写search.api文件
$ vim service/search/api/search.api
type (
SearchReq {
// 图书名称
Name string `form:"name"`
}

SearchReply {
Name string `json:"name"`
Count int `json:"count"`
}
)

@server(
jwt: Auth
)
service search-api {
@handler search
get /search/do (SearchReq) returns (SearchReply)
}

service search-api {
@handler ping
get /search/ping
}
提示

jwt: Auth:开启jwt鉴权

如果路由需要jwt鉴权,则需要在service上方声明此语法标志,如上文中的 /search/do

不需要jwt鉴权的路由就无需声明,如上文中/search/ping

生成代码

前面已经描述过有三种方式去生成代码,这里就不赘述了。

添加yaml配置项
$ vim service/search/api/etc/search-api.yaml
Name: search-api
Host: 0.0.0.0
Port: 8889
Auth:
AccessSecret: $AccessSecret
AccessExpire: $AccessExpire

提示

$AccessSecret:这个值必须要和user api中声明的一致。

$AccessExpire: 有效期

这里修改一下端口,避免和user api端口8888冲突

验证 jwt token

  • 启动user api服务,登录

    $ cd service/user/api
    $ go run user.go -f etc/user-api.yaml
    Starting server at 0.0.0.0:8888...
    $ curl -i -X POST \
    http://127.0.0.1:8888/user/login \
    -H 'content-type: application/json' \
    -d '{
    "username":"666",
    "password":"123456"
    }'
    HTTP/1.1 200 OK
    Content-Type: application/json
    Date: Mon, 08 Feb 2021 10:37:54 GMT
    Content-Length: 251

    {"id":1,"name":"小明","gender":"男","accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MTI4NjcwNzQsImlhdCI6MTYxMjc4MDY3NCwidXNlcklkIjoxfQ.JKa83g9BlEW84IiCXFGwP2aSd0xF3tMnxrOzVebbt80","accessExpire":1612867074,"refreshAfter":1612823874}
  • 启动search api服务,调用/search/do验证jwt鉴权是否通过

    $ go run search.go -f etc/search-api.yaml
    Starting server at 0.0.0.0:8889...

    我们先不传jwt token,看看结果

    $ curl -i -X GET \
    'http://127.0.0.1:8889/search/do?name=%E8%A5%BF%E6%B8%B8%E8%AE%B0'
    HTTP/1.1 401 Unauthorized
    Date: Mon, 08 Feb 2021 10:41:57 GMT
    Content-Length: 0

    很明显,jwt鉴权失败了,返回401的statusCode,接下来我们带一下jwt token(即用户登录返回的accessToken

    $ curl -i -X GET \
    'http://127.0.0.1:8889/search/do?name=%E8%A5%BF%E6%B8%B8%E8%AE%B0' \
    -H 'authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MTI4NjcwNzQsImlhdCI6MTYxMjc4MDY3NCwidXNlcklkIjoxfQ.JKa83g9BlEW84IiCXFGwP2aSd0xF3tMnxrOzVebbt80'
    HTTP/1.1 200 OK
    Content-Type: application/json
    Date: Mon, 08 Feb 2021 10:44:45 GMT
    Content-Length: 21

    {"name":"","count":0}

至此,jwt从生成到使用就演示完成了,jwt token的鉴权是go-zero内部已经封装了,你只需在api文件中定义服务时简单的声明一下即可。

获取jwt token中携带的信息

go-zero从jwt token解析后会将用户生成token时传入的kv原封不动的放在http.Request的Context中,因此我们可以通过Context就可以拿到你想要的值

$ vim /service/search/api/internal/logic/searchlogic.go

添加一个log来输出从jwt解析出来的userId。

func (l *SearchLogic) Search(req types.SearchReq) (*types.SearchReply, error) {
logx.Infof("userId: %v",l.ctx.Value("userId"))// 这里的key和生成jwt token时传入的key一致
return &types.SearchReply{}, nil
}

运行结果

{"@timestamp":"2021-02-09T10:29:09.399+08","level":"info","content":"userId: 1"}

中间件使用

在上一节,我们演示了怎么使用jwt鉴权,相信你已经掌握了对jwt的基本使用,本节我们来看一下api服务中间件怎么使用。

中间件分类

在go-zero中,中间件可以分为路由中间件和全局中间件,路由中间件是指某一些特定路由需要实现中间件逻辑,其和jwt类似,没有放在jwt:xxx下的路由不会使用中间件功能, 而全局中间件的服务范围则是整个服务。

中间件使用

这里以search服务为例来演示中间件的使用

路由中间件

  • 重新编写search.api文件,添加middleware声明

    $ cd service/search/api
    $ vim search.api
    type SearchReq struct {}

    type SearchReply struct {}

    @server(
    jwt: Auth
    middleware: Example // 路由中间件声明
    )
    service search-api {
    @handler search
    get /search/do (SearchReq) returns (SearchReply)
    }
  • 重新生成api代码

    $ goctl api go -api search.api -dir . 
    etc/search-api.yaml exists, ignored generation
    internal/config/config.go exists, ignored generation
    search.go exists, ignored generation
    internal/svc/servicecontext.go exists, ignored generation
    internal/handler/searchhandler.go exists, ignored generation
    internal/handler/pinghandler.go exists, ignored generation
    internal/logic/searchlogic.go exists, ignored generation
    internal/logic/pinglogic.go exists, ignored generation
    Done.

    生成完后会在internal目录下多一个middleware的目录,这里即中间件文件,后续中间件的实现逻辑也在这里编写。

  • 完善资源依赖ServiceContext

    $ vim service/search/api/internal/svc/servicecontext.go
    type ServiceContext struct {
    Config config.Config
    Example rest.Middleware
    }

    func NewServiceContext(c config.Config) *ServiceContext {
    return &ServiceContext{
    Config: c,
    Example: middleware.NewExampleMiddleware().Handle,
    }
    }
  • 编写中间件逻辑 这里仅添加一行日志,内容example middle,如果服务运行输出example middle则代表中间件使用起来了。

    $ vim service/search/api/internal/middleware/examplemiddleware.go
    package middleware

    import "net/http"

    type ExampleMiddleware struct {
    }

    func NewExampleMiddleware() *ExampleMiddleware {
    return &ExampleMiddleware{}
    }

    func (m *ExampleMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
    // TODO generate middleware implement function, delete after code implementation

    // Passthrough to next handler if need
    next(w, r)
    }
    }
  • 启动服务验证

    {"@timestamp":"2021-02-09T11:32:57.931+08","level":"info","content":"example middle"}

全局中间件

通过rest.Server提供的Use方法即可

func main() {
flag.Parse()

var c config.Config
conf.MustLoad(*configFile, &c)

ctx := svc.NewServiceContext(c)
server := rest.MustNewServer(c.RestConf)
defer server.Stop()

// 全局中间件
server.Use(func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
logx.Info("global middleware")
next(w, r)
}
})
handler.RegisterHandlers(server, ctx)

fmt.Printf("Starting server at %s:%d...\n", c.Host, c.Port)
server.Start()
}
{"@timestamp":"2021-02-09T11:50:15.388+08","level":"info","content":"global middleware"}

在中间件里调用其它服务

通过闭包的方式把其它服务传递给中间件,示例如下:

// 模拟的其它服务
type AnotherService struct{}

func (s *AnotherService) GetToken() string {
return stringx.Rand()
}

// 常规中间件
func middleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("X-Middleware", "static-middleware")
next(w, r)
}
}

// 调用其它服务的中间件
func middlewareWithAnotherService(s *AnotherService) rest.Middleware {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("X-Middleware", s.GetToken())
next(w, r)
}
}
}

完整代码参考:https://github.com/zeromicro/zero-examples/tree/main/http/middleware

rpc编写与调用

在一个大的系统中,多个子系统(服务)间必然存在数据传递,有数据传递就需要通信方式,你可以选择最简单的http进行通信,也可以选择rpc服务进行通信, 在go-zero,我们使用zrpc来进行服务间的通信,zrpc是基于grpc。

场景

在前面我们完善了对用户进行登录,用户查询图书等接口协议,但是用户在查询图书时没有做任何用户校验,如果当前用户是一个不存在的用户则我们不允许其查阅图书信息, 从上文信息我们可以得知,需要user服务提供一个方法来获取用户信息供search服务使用,因此我们就需要创建一个user rpc服务,并提供一个getUser方法。

rpc服务编写

  • 编译proto文件
$ vim service/user/rpc/user.proto
syntax = "proto3";

package user;

option go_package = "user";

message IdReq{
int64 id = 1;
}

message UserInfoReply{
int64 id = 1;
string name = 2;
string number = 3;
string gender = 4;
}

service user {
rpc getUser(IdReq) returns(UserInfoReply);
}
  • 生成rpc服务代码
$ cd service/user/rpc
$ goctl rpc proto -src user.proto -dir .
提示

如果安装的 protoc-gen-go 版大于1.4.0, proto文件建议加上go_package

  • 添加配置及完善yaml配置项
$ vim service/user/rpc/internal/config/config.go
type Config struct {
zrpc.RpcServerConf
Mysql struct {
DataSource string
}
CacheRedis cache.CacheConf
}
$ vim /service/user/rpc/etc/user.yaml
Name: user.rpc
ListenOn: 127.0.0.1:8080
Etcd:
Hosts:
- $etcdHost
Key: user.rpc
Mysql:
DataSource: $user:$password@tcp($url)/$db?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
CacheRedis:
- Host: $host
Pass: $pass
Type: node
提示

$user: mysql数据库user

$password: mysql数据库密码

$url: mysql数据库连接地址

$db: mysql数据库db名称,即user表所在database

$host: redis连接地址 格式:ip:port,如:127.0.0.1:6379

$pass: redis密码

$etcdHost: etcd连接地址,格式:ip:port,如: 127.0.0.1:2379

  • 添加资源依赖
    $ vim service/user/rpc/internal/svc/servicecontext.go  
    type ServiceContext struct {
    Config config.Config
    UserModel model.UserModel
    }

    func NewServiceContext(c config.Config) *ServiceContext {
    conn := sqlx.NewMysql(c.Mysql.DataSource)
    return &ServiceContext{
    Config: c,
    UserModel: model.NewUserModel(conn, c.CacheRedis),
    }
    }
  • 添加rpc逻辑
    $ service/user/rpc/internal/logic/getuserlogic.go
    func (l *GetUserLogic) GetUser(in *user.IdReq) (*user.UserInfoReply, error) {
    one, err := l.svcCtx.UserModel.FindOne(in.Id)
    if err != nil {
    return nil, err
    }

    return &user.UserInfoReply{
    Id: one.Id,
    Name: one.Name,
    Number: one.Number,
    Gender: one.Gender,
    }, nil
    }

使用rpc

接下来我们在search服务中调用user rpc

  • 添加UserRpc配置及yaml配置项
    $ vim service/search/api/internal/config/config.go
    type Config struct {
    rest.RestConf
    Auth struct {
    AccessSecret string
    AccessExpire int64
    }
    UserRpc zrpc.RpcClientConf
    }
    $ vim service/search/api/etc/search-api.yaml
    Name: search-api
    Host: 0.0.0.0
    Port: 8889
    Auth:
    AccessSecret: $AccessSecret
    AccessExpire: $AccessExpire
    UserRpc:
    Etcd:
    Hosts:
    - $etcdHost
    Key: user.rpc
    :::tip $AccessSecret:这个值必须要和user api中声明的一致。 $AccessExpire: 有效期 $etcdHost: etcd连接地址 etcd中的Key必须要和user rpc服务配置中Key一致 :::
  • 添加依赖
    $ vim service/search/api/internal/svc/servicecontext.go
    type ServiceContext struct {
    Config config.Config
    Example rest.Middleware
    UserRpc userclient.User
    }

    func NewServiceContext(c config.Config) *ServiceContext {
    return &ServiceContext{
    Config: c,
    Example: middleware.NewExampleMiddleware().Handle,
    UserRpc: userclient.NewUser(zrpc.MustNewClient(c.UserRpc)),
    }
    }
  • 补充逻辑
    $ vim /service/search/api/internal/logic/searchlogic.go
    func (l *SearchLogic) Search(req types.SearchReq) (*types.SearchReply, error) {
    userIdNumber := json.Number(fmt.Sprintf("%v", l.ctx.Value("userId")))
    logx.Infof("userId: %s", userIdNumber)
    userId, err := userIdNumber.Int64()
    if err != nil {
    return nil, err
    }

    // 使用user rpc
    _, err = l.svcCtx.UserRpc.GetUser(l.ctx, &userclient.IdReq{
    Id: userId,
    })
    if err != nil {
    return nil, err
    }

    return &types.SearchReply{
    Name: req.Name,
    Count: 100,
    }, nil
    }

启动并验证服务

  • 启动etcd、redis、mysql
  • 启动user rpc
    $ cd /service/user/rpc
    $ go run user.go -f etc/user.yaml
    Starting rpc server at 127.0.0.1:8080...
  • 启动search api
$ cd service/search/api
$ go run search.go -f etc/search-api.yaml
  • 验证服务
    $ curl -i -X GET \
    'http://127.0.0.1:8889/search/do?name=%E8%A5%BF%E6%B8%B8%E8%AE%B0' \
    -H 'authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MTI4NjcwNzQsImlhdCI6MTYxMjc4MDY3NCwidXNlcklkIjoxfQ.JKa83g9BlEW84IiCXFGwP2aSd0xF3tMnxrOzVebbt80'
    HTTP/1.1 200 OK
    Content
    -Type: application/json
    Date: Tue, 09 Feb 2021 06:05:52 GMT
    Content-Length: 32

    {"name":"西游记","count":100}

错误处理

错误的处理是一个服务必不可缺的环节。在平时的业务开发中,我们可以认为http状态码不为2xx系列的,都可以认为是http请求错误, 并伴随响应的错误信息,但这些错误信息都是以plain text形式返回的。除此之外,我在业务中还会定义一些业务性错误,常用做法都是通过 codemsg 两个字段来进行业务处理结果描述,并且希望能够以json响应体来进行响应。

业务错误响应格式

  • 业务处理正常

    {
    "code": 0,
    "msg": "successful",
    "data": {
    ....
    }
    }
  • 业务处理异常

    {
    "code": 10001,
    "msg": "参数错误"
    }

user api之login

在之前,我们在登录逻辑中处理用户名不存在时,直接返回来一个error。我们来登录并传递一个不存在的用户名看看效果。

curl -X POST \
http://127.0.0.1:8888/user/login \
-H 'content-type: application/json' \
-d '{
"username":"1",
"password":"123456"
}'
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Tue, 09 Feb 2021 06:38:42 GMT
Content-Length: 19

用户名不存在

接下来我们将其以json格式进行返回

自定义错误

  • 首先在common中添加一个baseerror.go文件,并填入代码

    $ cd common
    $ mkdir errorx&&cd errorx
    $ vim baseerror.go
    package errorx

    const defaultCode = 1001

    type CodeError struct {
    Code int `json:"code"`
    Msg string `json:"msg"`
    }

    type CodeErrorResponse struct {
    Code int `json:"code"`
    Msg string `json:"msg"`
    }

    func NewCodeError(code int, msg string) error {
    return &CodeError{Code: code, Msg: msg}
    }

    func NewDefaultError(msg string) error {
    return NewCodeError(defaultCode, msg)
    }

    func (e *CodeError) Error() string {
    return e.Msg
    }

    func (e *CodeError) Data() *CodeErrorResponse {
    return &CodeErrorResponse{
    Code: e.Code,
    Msg: e.Msg,
    }
    }

  • 将登录逻辑中错误用CodeError自定义错误替换

    if len(strings.TrimSpace(req.Username)) == 0 || len(strings.TrimSpace(req.Password)) == 0 {
    return nil, errorx.NewDefaultError("参数错误")
    }

    userInfo, err := l.svcCtx.UserModel.FindOneByNumber(req.Username)
    switch err {
    case nil:
    case model.ErrNotFound:
    return nil, errorx.NewDefaultError("用户名不存在")
    default:
    return nil, err
    }

    if userInfo.Password != req.Password {
    return nil, errorx.NewDefaultError("用户密码不正确")
    }

    now := time.Now().Unix()
    accessExpire := l.svcCtx.Config.Auth.AccessExpire
    jwtToken, err := l.getJwtToken(l.svcCtx.Config.Auth.AccessSecret, now, l.svcCtx.Config.Auth.AccessExpire, userInfo.Id)
    if err != nil {
    return nil, err
    }

    return &types.LoginReply{
    Id: userInfo.Id,
    Name: userInfo.Name,
    Gender: userInfo.Gender,
    AccessToken: jwtToken,
    AccessExpire: now + accessExpire,
    RefreshAfter: now + accessExpire/2,
    }, nil
  • 开启自定义错误

    $ vim service/user/api/user.go
    func main() {
    flag.Parse()

    var c config.Config
    conf.MustLoad(*configFile, &c)

    ctx := svc.NewServiceContext(c)
    server := rest.MustNewServer(c.RestConf)
    defer server.Stop()

    handler.RegisterHandlers(server, ctx)

    // 自定义错误
    httpx.SetErrorHandler(func(err error) (int, interface{}) {
    switch e := err.(type) {
    case *errorx.CodeError:
    return http.StatusOK, e.Data()
    default:
    return http.StatusInternalServerError, nil
    }
    })

    fmt.Printf("Starting server at %s:%d...\n", c.Host, c.Port)
    server.Start()
    }
  • 重启服务验证

    $ curl -i -X POST \
    http://127.0.0.1:8888/user/login \
    -H 'content-type: application/json' \
    -d '{
    "username":"1",
    "password":"123456"
    }'
    HTTP/1.1 200 OK
    Content-Type: application/json
    Date: Tue, 09 Feb 2021 06:47:29 GMT
    Content-Length: 40

    {"code":1001,"msg":"用户名不存在"}