本文是 从 Flask 到 Gin 系列的一部分。


如果写过 C++ 或者 Java,你会觉得在 Golang 中处理 JSON 比前两者要简单很多。但作为习惯了在 Python 中偷懒的我来说,Golang 中的 JSON 用法还是挺难受的。

MJP 是一个 RESTful API 服务,绝大多数 API 返回的都是 JSON 格式。由于 Python 的灵活性和 Flask 的良好封装,编写 MJP 服务时,我在 Flask 中使用 JSON 没有遇到什么困难。MJP 中有一个名为 responseto 的封装,我使用它来统一输出 JSON Response。本文介绍将这个方法移植到 Gin 的过程。

responseto 的 Python 实现

responseto 是一个 Python 中的模块方法,它的定义如下:

from flask import jsonify
from mjp.app import db

def responseto(message=None, error=None, code=None, data=None, replaceobj=None, replaceobj_key_only=False, **kwargs):
    """
    封装 json 响应
    :param message: 错误消息,若提供则默认 error 为 True
    :param error: 是否包含错误
    :param code: 错误代码,若不提供则值可能为 200 error=False/444 error=True
    :param data: 若提供了 data,则 data 中应该包含 error/message/code
    :param replaceobj: 替换响应中的键名。 {'被替换': '替换值'}
    :param kwargs: 要加入响应的其他对象,可以是 model 也可以是 dict
    :return: 一个 Response 对象
    """

    # 如果提供了 data,那么不理任何其他参数,直接响应 data
    if not data:
        data = kwargs
        for k, v in kwargs.items():
            # 不处理空对象
            if not v:
                continue
            data[k] = db.to_response_data(v, replaceobj, replaceobj_key_only)
        data['error'] = error
        data['code'] = code
        if message:
            # 除非显示提供 error 的值,否则默认为 True
            # 意思是提供了 message 就代表有 error
            data['message'] = message
            if error is None:
                data['error'] = True
            if not data.get('code'):
                data['code'] = 444
        else:
            # 除非显示提供 error 的值,否则默认为 False
            # 意思是没有提供 message 就代表没有 error
            if error is None:
                data['error'] = False
            if not data.get('code'):
                data['code'] = 200
    if not isinstance(data, dict):
        # 444 为不合法操作
        data = {'error': True, 'code': 444, 'message': 'data 必须是一个 dict!'}
    if not data.get('code'):
        if data.get('error'):
            data['code'] = 444
        else:
            data['code'] = 200
    resp = jsonify(data)
    return resp

上面的源码注释很清晰,不用多言。jsonify 是 Flask 提供的一个 JSON Response 封装,它会返回一个 JSON 响应。

有必要多说一句的是 db.to_response_data。这个方法把 SQLAlchemy 的 Model 对象(一般是数据库查询的结果)转换成为一个 dict,接着使用 jsonify 将其转换成为 JSON Response。因为与本文关系不大,这里就不贴 db.to_response_data 的源码了。

responseto 方法在路由中使用。下面是一个获取奖品信息的路由方法示例:

@reward.route('/get/', methods=['GET'])
@login_token_checker()
def reward_status_get(r, loginobj):
    """ 获取邀请码对应的奖励信息
    :param invitecode: 奖励邀请码
    """
    invitecode = parse_int(request.args.get('invitecode'))
    if invitecode is None:
        return responseto('需要 invitecode!', code=401)

    # 保存奖励的 Table
    Reward = get_reward_table(r)
    reward_result = Reward.query.filter(Reward.invitecode == invitecode).all()
    if reward_result is None:
        reward_result = []
    else:
        rewards = []
        # item 是 Reward Table 中的一行数据
        for item in reward_result:
            # rvalue 是一个保存在数据库中的 JSON 字符串
            result = json.loads(item.rvalue)
            # 当前奖励行的状态
            result['status'] = item.status
            rewards.append(result)
        reward_result = rewards

    return responseto(result=reward_result)

在上面的路由方法中,当没有提供 invitecode 导致请求失败的时候,responseto 返回的内容为:

{
    "error": true,
    "code": 401,
    "message": "需要 invitecode!"
}

当请求成功的时候,JSON 内容可能为:

{
    "error": false,
    "code": 200,
    "result": [
        {
            "gold": 10,
            "status": 1
        },
        {
            "money": 3000,
            "status": 2
        }
    ]
}

从上面的例子可以看出,得益于 Pyhon 方法的 kwargs 参数机制,responseto 的使用可以非常灵活。使用中既可以省略许多参数,也可以动态调整返回的 JSON 内容的键名。

这些灵活的用法,在 Golang 中会遇到挑战。

responseto 在 Golang 中的挑战

Golang/gin 中实现 responseto 方法,至少会碰到 3 个问题。

  1. JSON 对应的 Struct 问题。
  2. Golang 不支持关键字参数的问题。
  3. 动态 JSON 结构的问题。

让我们来解决这些问题。

gin.H 和 Context.JSON

查看 gin 的源码,可以找到一些良好的封装。gin.H(utils.go) 提供了一个类似于 Python dict 的结构。Context.JSON(context.go) 提供了类似于 Flask jsonify 的方法。

// utils.go
// H is a shortcut for map[string]interface{}
type H map[string]interface{}

// context.go
// JSON serializes the given struct as JSON into the response body.
// It also sets the Content-Type as "application/json".
func (c *Context) JSON(code int, obj interface{}) {
	c.Render(code, render.JSON{Data: obj})
}

Responseto/ResponsetoWithData

Golang 不提供方法重载和关键字参数,因此我创建了 3 个方法来替代 Flask 版本的 responseto

package re2q

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

// Responseto 使用 gin.Context 创建一个响应,提供 message/code
func Responseto(c *gin.Context, message string, code int) {
	ResponsetoWithAllData(c, gin.H{
		"error":   code != 200,
		"code":    code,
		"message": message,
	})
}

// ResponsetoWithData 使用 gin.Context 创建一个响应,自动填充 error/code/message
func ResponsetoWithData(c *gin.Context, data gin.H) {
	message := data["message"]
	if message != nil {
		data["message"] = message
	}
	code, ok := data["code"]
	if !ok {
		if message == nil {
			code = 200
		} else {
			code = 444
		}
	}
	if err, ok := data["error"]; ok {
		data["error"] = err
	} else {
		data["error"] = code != 200
	}
	data["code"] = code
	ResponsetoWithAllData(c, data)
}

// ResponsetoWithAllData 使用 gin.Context 创建一个响应,直接使用包含了 message/code/error 的 data
func ResponsetoWithAllData(c *gin.Context, data gin.H) {
	c.JSON(http.StatusOK, data)
}

上面的三个方法层层递进,提供了 Flask 版本 responseto 能提供的 大部分 功能。想完整实现 Flask 版本 responseto 的功能,需要定义更多的方法,或者采用 Golang 中的变长参数。这样会让方法变得更加负责,让方法的使用者产生困扰。

在跨语言移植功能的时候,”保持绝对完全一致“ 是没有必要的。每个语言都有自己独特的特性,我们作为架构设计者,需要在不同语言中进行折衷。我认为上面 3 个方法既保证了简洁的抽象,也保持了一定的使用灵活性。这是一个合理的移植决策。

下面是 ResponsetoResponsetoWithData 在路由中使用的例子:

// AudibleActive return active users list
func AudibleActive(c *gin.Context) {
	regionals, defaultDBQry := registerOrActive(c)
	if regionals != nil {
		// 查找活跃数据
		actives := []models.ActiveModel{}
		findError := defaultDBQry.Find(&actives).Error
		if findError != nil {
			re2q.Responseto(c, findError.Error(), 503)
			return
		}
		activesSerializer := models.ActivesSerializer{c, actives}
		re2q.ResponsetoWithData(c, gin.H{
			"registers": activesSerializer.Response(),
			"regionals": regionals,
			"code":      200,
		})
	}
}

和上面 Flask 路由的例子类似,第一个 Responseto 方法提供了一个包含 error/code/message 键名的 JSON 对象,第二个 ResponsetoWithData 方法提供了一个带有具体数据返回的 JSON 对象。

看吧,完美!

序列化

然鹅并没有那么完美。

也许你注意到了上面代码中的 models.ActiveSerializer 这个名称,是的,序列化必须自己动手。

由于 Golang 的语言特色(Public 必须大写字母开头),在定义数据库字段的时候,你必须要进行一些映射。另外,你也不一定希望所有的数据库字段都返回给客户端,因此需要手动序列化。

package models

import (
	"github.com/gin-gonic/gin"
)

// ActiveModel is a table for active users
type ActiveModel struct {
	Gid     int `gorm:"PRIMARY_KEY,AUTO_INCREMENT"`
	Date    int `gorm:"PRIMARY_KEY,INDEX"`
	Channel int `gorm:"PRIMARY_KEY,INDEX"`
	Num     int `gorm:"NOT NULL"`
}

// ActiveResponse is a JSON config for response
type ActiveResponse struct {
	Gid     int `json:"gid"`
	Date    int `json:"date"`
	Channel int `json:"channel"`
	Num     int `json:"num"`
}

// ActiveSerializer is a secializer for JSON object
type ActiveSerializer struct {
	C *gin.Context
	ActiveModel
}

// ActivesSerializer is a secializer for JSON list
type ActivesSerializer struct {
	C       *gin.Context
	Actives []ActiveModel
}

// Response is for JSON response
func (s *ActiveSerializer) Response() ActiveResponse {
	response := ActiveResponse{
		Gid:     s.Gid,
		Date:    s.Date,
		Channel: s.Channel,
		Num:     s.Num,
	}
	return response
}

// Response is for JSON response
func (s *ActivesSerializer) Response() []ActiveResponse {
	response := []ActiveResponse{}
	for _, active := range s.Actives {
		serializer := ActiveSerializer{s.C, active}
		response = append(response, serializer.Response())
	}
	return response
}

在上面的代码中,ActiveModel 是一个数据表定义,其中的 gorm 我会在后面的系列文章中介绍。ActiveResponse 用来完成数据库字段与 JSON 响应之间的映射。

需要注意 ActiveSerializerActivesSerializer 的区别(后者是复数形式)。它们分别用来返回 一个 ActiveModel 对象和 一组 ActiveModel 对象。在 Python 这类动态语言中,我们可以将它们放在同一个方法中,在 Golang 中则必须分开处理。

如果不希望这么麻烦,只想简单把 JSON 转换成 Struct 定义,可以使用这个网站: JSON-to-Go

Go by Example: JSON 详细介绍了在 Golang 中使用 JSON 的一些特性,适合初学者阅读。

全文完