美文网首页Go
Go RESTful API 项目模板介绍

Go RESTful API 项目模板介绍

作者: 0xE8551CCB | 来源:发表于2020-04-03 01:51 被阅读0次

    0x00 引言

    疫情期间学的东西比较杂(比如学习了如何在市场行情不好的时候还盲目加仓 🙂),没什么干货值得分享。不过考虑到很久都没有更新了,还是要强迫自己写一点东西,不然容易变懒。总之,多思考,多实践还是很重要。

    今天主要是想把三个月前就放到 GitHub 上的 go-rest-api-starter 项目介绍下,目的有两个:

    1. 分享下我们目前的工程实践;
    2. portal 增加点曝光度 😊。虽然我在 Go 语言中如何以优雅的姿势实现对象序列化 做过介绍,不过一直没有给过我们在真实环境中的具体用法,今天提供的示例也刚好可以帮助感兴趣的同学了解下它的用法。

    0x01 为什么?

    好的工程规范是在项目实践中不断踩坑总结出来的,期间我们也遇到各种写起来不够优雅的地方,自然就需要想办法解决这些问题。经过若干项目的实践,结合遇到的问题,也造了些轮子便于提升开发幸福度。沉淀和提炼的结果,便是一套可以沿袭的项目脚手架,集成了一些我们认为的「最佳实践」。

    笔者相信不同的团队有不同的想法,但本质上都是更好的服务于业务。我们希望能够以一致清晰的方式来编写代码,并且保证项目结构不被随意破坏,项目的可维护性大于一切。另外,对于业务框架,也不应该一味地排斥。合理地使用业务框架,既有利于简化业务代码编写,又有利于理解和维护。

    0x02 是什么?

    go-rest-api-starter 是一个完整的 RESTful API 项目(商品管理后台+前台接口,源自 aizoo 管理后台),演示了我们目前的工程实践是什么样的。当然,团队内部版本使用了一些非开源框架,不过同样的理念换成别的框架依然可行。所以,在该项目中,笔者将一些内部框架换成了开源框架,方便大家参考。

    从这个项目中可以了解到什么?

    1. 整体的项目结构,分层情况;
    2. Model 层怎么编写,关联资源如何以属性方式暴露;
    3. Schema 层怎么编写,表单校验逻辑放在什么位置;
    4. 复杂业务逻辑如何在 Controller 层实现;
    5. 如何保证 Handler 层整洁清晰;
    6. portal 所扮演的角色,如何简化字段格式化逻辑。

    首先来看下项目结构:

    ├── .env(环境变量配置,资源连接串等)
    ├── .env_unittest(跑单元测试使用的测试资源配置)
    ├── LICENSE
    ├── Makefile
    ├── README.md
    ├── bin
    │   ├── starter-admin(面向管理后台的 RESTful API 服务器)
    │   └── starter-web(面向客户端、PC 等前台的 RESTful API 服务器)
    ├── cmd(各个服务启动的入口)
    │   ├── admin(管理后台)
    │   │   └── main.go
    │   ├── bee(离线异步任务)
    │   │   └── main.go
    │   ├── service(RPC 服务)
    │   │   └── main.go
    │   └── web(客户端、PC、小程序等前台)
    │       └── main.go
    ├── go.mod(依赖包 go modules)
    ├── pkg
    │   ├── admin(管理后台服务)
    │   │   ├── handler
    │   │   ├── router.go
    │   │   ├── schema(和返回的 JSON 数据关联的资源结构体定义)
    │   │   └── validator(通用的校验代码)
    │   ├── config(资源配置)
    │   │   ├── fixture.go
    │   │   ├── init.go
    │   │   └── mysql.go
    │   ├── constant(常量、枚举定义)
    │   │   ├── enum.go
    │   │   ├── gen_enum.go
    │   │   └── macro.go
    │   ├── controller(复杂业务逻辑)
    │   │   ├── company.go
    │   │   ├── company_test.go
    │   │   └── product.go
    │   ├── job(异步离线任务业务逻辑)
    │   │   └── after_product_created.go
    │   ├── middleware(可复用的中间件)
    │   │   └── cors.go
    │   ├── model(Models,可能还会聚合来自 RPC 等数据源,数据模型抽象)
    │   │   ├── company.go
    │   │   ├── doc.go
    │   │   ├── init.go
    │   │   └── product.go
    │   ├── util(工具集)
    │   │   ├── orderby.go
    │   │   ├── pic
    │   │   ├── rest
    │   │   ├── seqgen
    │   │   └── toolkit
    │   └── web(前台服务)
    │       ├── handler
    │       ├── router.go
    │       └── schema
    ├── script(一些脚本文件)
    │   └── 20200101
    └── testdata(单元测试有关测试数据、表结构定义)
        ├── fixtures
        │   ├── company.yml
        │   ├── doc.yml
        │   └── product.yml
        └── schema.sql
    

    0x03 说 Handler

    我们希望 Handler 层的逻辑不要太过复杂,曾经在看某后台项目时,某 Handler 足足近百行代码,糅杂了大量的字段校验逻辑、业务逻辑、数据格式化逻辑等,极度啰嗦,难以修改和维护,这个是我们不太想要的。

    理想情况下,Handler 层应该保证足够轻量,它就应该像是胶水,负责将 Model 层、Controller 层以及 Schema 层粘在一起。我们来看看下面的示例:

    func GetProducts(c *rest.Context) (rest.Response, error) {
        // 1. 复杂逻辑下沉到 Controller 层实现
        products, total, err := controller.GetProducts(
            c.Query("title"),
            c.QueryWithFallback("order_by", "-created_at"),
            c.Offset(),
            c.Limit(),
        )
    
        // 2. Model -> Schema 渲染
        var schemas []schema.OutputProductSchema
        // 借助 portal.Only,可以在请求的时候指定只吐出需要的字段值
         err = portal.Dump(&schemas, products, portal.Only(strings.Split(c.QueryWithFallback("only", ""), ",")...))
        if err != nil {
            return nil, err
        }
    
        // 3. 返回结果
        return rest.NewPage(c, schemas, total), nil
    }
    
    func CreateProduct(c *rest.Context) (rest.Response, error) {
        // 1. 表单校验和处理
        var input schema.InputProductSchema
        err := c.BindJSON(&input)
        if err != nil {
            return nil, err
        }
    
        var product model.ProductModel
        err = portal.Dump(&product, input)
        if err != nil {
            return nil, err
        }
    
        // 2. 业务逻辑处理
        prodID, err := controller.CreateProduct(&product, &input)
        if err != nil {
            return nil, err
        }
    
        // 3. 返回处理结果
        return gin.H{"success": true, "product_id": cast.ToString(prodID)}, nil
    }
    

    总结来看,Handler 层无非执行如下几个步骤(Keep It Simple, Stupid):

    1. 输入参数校验和处理(实际逻辑要么封装在 Schema 层,要么在框架层解决);
    2. 业务逻辑处理(Controller 层负责);
    3. 结果返回。

    0x04 讲 Schema 与 Model

    这两个适合放在一起将,因为 Schema 的字段映射的正是对应的 Model 中相关字段。也许这里会有疑惑,为什么我们不直接在 Model 层,给某个 Field 加个 Tag json: field_name,直接序列化返回出去呢?为何非要生硬地写一个 Schema 结构体再做映射呢?

    接下来将结合具体的场景来回答这个问题:

    1. API 要求返回的字段类型和 Model 中实际类型不一致(如 model.ID 为 int 类型,但 API 要求返回 string 类型);
    2. 某些字段要求是可选返回字段(这时可以轻松地修改 Schema 中的字段为 *type 即可)。

    总之,Model 层作为 Source of Truth,保持最原始的格式最好。Schema 层则根据具体的业务场景进行变动,对于不适配的场景,自定义 format 方法完成转换即可。这样可以将改动只聚焦在较小的范围,避免到处修改类型,想想也心累。

    最后要提的一点是 Model 的关联资源,我们推荐的写法如下:

    type ProductModel struct {
        ID        int64
        CompanyID int64
        // ... other fields ignored
    }
    
    // Company 返回商品关联的公司(来源于别的表)
    func (pd *ProductModel) Company() (*CompanyModel, error) {
        var company CompanyModel
        err := DB.Where("id = ?", pd.CompanyID).Find(&company).Error
        if err != nil {
            if gorm.IsRecordNotFoundError(err) {
                return nil, nil
            }
            return nil, errors.WithStack(err)
        }
        return &company, nil
    }
    
    // Rate 返回商品关联的评分(来源于 RPC 调用)
    func (pd *ProductModel) Rate() (float64, error) {
        return rpc.GetProductRate(pd.ID)
    }
    

    这样在 Schema 层,我们只需要声明需要映射的字段,在经过 portal.Dump 时,框架会自动完成相关字段映射,并将返回的值填充到对应的 Schema 字段中:

    type OutputCompanySchema struct {
        ID        string           `json:"id"`
        // ...
    }
    
    type OutputProductSchema struct {
        ID            *string               `json:"id,omitempty"`
        // ...
        Company       *OutputCompanySchema  `json:"company,omitempty" portal:"nested;async"`
        CreatedAt     *field.Timestamp      `json:"created_at,omitempty"`
    }
    
    var output OutputProductSchema
    portal.Dump(&output, product)
    
    // 此时将 output json 序列化,就是想要得到的结果
    

    0x05 碎碎念

    最近几天在尝试重构某个项目的某个巨长的函数(接近 250 行,代码就不贴了,怕被打 🐶),每次修改它的时候心态都要崩。但说起来,它也没有多么复杂的业务逻辑,只是产品线类型较多,糅杂了资源获取逻辑、字段格式化逻辑等等。严格来说,完全可以使用上述的 Model & Schema 分层思路进行重构,但是该函数做了些优化:

    1. 使用 batch_get_resource 接口替代 get_resource 接口;
    2. 并发获取多个产品线的章节信息等。

    因为这种优化的引入,会导致上述写法上存在一些不太优雅的地方。接下来举个栗子🌰能够更好地说明现在遇到的问题:

    先看看常规的 StudentModel & StudentSchema 定义:

    type StudentModel struct {
        MemberID int64
    }
    
    // Member 返回关联的账号详情
    func (s *StudentModel) Member(ctx context.Member) *rpc.Member {
        // 注意,这里使用的是单次调用,而非批量调用
        return rpc.GetMemberByID(s.MemberID)
    }
    
    type MemberSchema struct { /*...*/ }
    
    type StudentSchema struct {
        Member *MemberSchema `portal: nested`
    }
    

    假设一页需要获取 20 个学生信息,portal 序列化时,会默认启动 20 个 goroutine 分别处理 20 个 StudentSchema 的渲染。这样的话,具体到 StudentModel.Member 获取时,就会产生 20 个并发的 GetMemberByID 请求,相对串行执行,这种方式自然可以提高速度。但是代价也很明显,产生的请求较多。对于一些后台项目这样做还好,但是对于 C 端接口,如果请求量较高,那请求放大会比较严重。

    students := DBGetStudents(20)
    
    var output []StudentSchema
    portal.Dump(&output, students)
    

    假设上游为我们提供了类似 func BatchGetMemberByID(memberIDs []int64) map[int64]*Member 方法,那么我么可以通过批量调用的方式解决上面提到的问题。不过,此时我们需要同时修改原有的 Model 层和 Schema 层如下:

    type StudentModel struct {
        MemberID int64
    }
    
    type StudentSchema struct {
        Member *MemberSchema `portal: nested`
    }
    
    func (s *StudentSchema) GetMember(ctx context.Context, student *StudentModel) *rpc.Member {
        // 假设外层批量调用结果放在 ctx 中传入
        v := ctx.Value("members").(map[int64]*rpc.Member)
        return v[student.MemberID]
    }
    

    然后在 Handler 层,我们需要手动调用 BatchGetMemberByID 接口:

    students := DBGetStudents(20)
    
    // 收集 member_ids
    memberIDs := make([]int64, len(students))
    for i, s := range students {
        memberIDs[i] = s.MemberID
    }
    
    // 批量调用一次
    members := rpc.BatchGetMemberByID(memberIDs)
    ctx = context.WithValue("members", members)
    
    var output []StudentSchema
    portal.DumpWithContext(ctx, &output, students)
    

    嗯,似乎看起来并没有多么繁琐嘛。不过这样的法会容易导致 Handler 层膨胀,试想再加点别的关联资源获取呢?那各种 BatchGetShit 就怼进去了。

    所以,我们究竟怎么才能做到原有的 Model 层依然保持简单的 rpc.GetResourceByID 这种简单的调用方式;Schema 层也不用做侵入式修改;Handler 层更不用忍受可能导致的代码膨胀问题呢?也许我们可以对 rpc.GetResourceByID 做点包装,在底层框架上,自动支持请求合并;而对于上层调用方无感知。

    熟悉 HTTP/2 的同学应该也会了解到其中一个特色是多路复用,避免每次新的请求都要进行 TCP+TLS 握手。那么如果我们能够做到在底层将上层的 rpc.GetResourceByID 自动合并为 rpc.BatchGetResourceByID,也能很大程度上提高请求效率,减少上游服务的请求压力。虽然相对于并发 20 个 rpc.GetResourceByID 请求,自动合并技术可能因为优化不到位或者策略上的问题,响应时间可能稍长,但是相对于串行调用,速度理论上会有很大提升。

    image

    关于这个请求合并的策略如何实现呢?可以想象下 rpc.BatchGetResourceByID 就像一辆往返于两地的公共汽车,假设它有两个关键属性:

    1. 车上乘客一旦坐满立刻出发(不许加塞,咱们要合法经营);
    2. 到达一定超时时间立刻出发;
    3. 到达目的地后,还要在返程时将同一批乘客尽可能全部带回来(假设乘客们要么买到了想要的礼物 result 或者像笔者一样比较穷就什么也没买 error)。

    基于这样的假设,尝试使用 Go 语言实现了一个简单的 Demo,感兴趣的同学可以前往 rpcx 查看。尝试引入了一个 Proxy 层,由 Proxy 层收集业务方的调用参数,并批量发起调用,最后将结果分派给业务方。当然,目前 Proxy 是以一个单独的 goroutine 部署;理想情况下,如果能用 sidecar 方式部署,甚至可以做到语言无关。

    func getMembersAutoAgg(ctx context.Context, max int) {
        var wg sync.WaitGroup
        for i := 0; i <= max; i++ {
            wg.Add(1)
            // 底层自动将并发单次调用转换成批量调用
             go func(n int) {
                r, err := rsdk.GetMember(ctx, int64(n)+1)
                _, _ = r, err
                wg.Done()
            }(i)
        }
        wg.Wait()
    }
    

    当然,想要在生产环境搞事情,还有很长的路要走。比如 Proxy 监控怎么做?是否存在单点问题?是否会成为接口调用瓶颈?如何与公司现有的 RPC 框架结合?收益是否真的达到预期?

    0x06 总结

    以上分享了一些关于工程实践方面的思考,欢迎指正,有什么好的想法也欢迎留言交流~

    相关文章

      网友评论

        本文标题:Go RESTful API 项目模板介绍

        本文链接:https://www.haomeiwen.com/subject/shoqphtx.html