Frodo-v2.0 没有添加新功能,而是将后端最重要的部分,后台 API 使用 golang 重构,python 现在只负责前台模板的渲染。这样原本的单服务应用就成了多服务。本文将简介 v2.0 的调整思路和 golang 异步的特性,新版本的部署文档请参看项目地址

主要重构的模块为:

  • 博文、用户、标签等的后台 CRUD 接口
  • 缓存清理模块
  • JWT 认证模块

why golang?

golang 是年轻的语言,新世纪的静态语言。设计理念很好地平衡了C++javascript/python等动态语言的优劣,独具特色的goroutine设计范式旨在告别多线程式的并发。而 web 后台和微服务是 go 语言用的的最多的领域,故我将后台纯 api 部分拿 go 来重写。

我是 2019 年开始使用kubernetes时开始接触的 go 语言,当时项目有需求想要扩展一个 k8s 的 api, 官方给的意见是如果想真正的 contributor, 最好使用 golang 开发。go 语言的代表一定是dockerkubernetes这一最主流的容器与容器编排工具。

其次,使用 go 语言对我并不痛苦,他的风格介于静态和动态语言,因此你要使用指针来避免冗余的对象复制,同时你也能使用便捷的如 python 里的动态数据结构。比类 C 的好处在于他不是那么地接近底层,只需考虑必要的指针操作和类型问题。

而 python 也越来越多地提倡使用显示类型,在 v1.0 中就已经使用了类型检查,在 python 中虽然不能带来性能的提升,但有利于调试和对接静态语言。因此 golang 的语并不会带来困难。

带类型的 python 与 golang 风格十分相似:

async def get_user_by_id(id: int) -> User:
    user: User = await User.async_first(id)
    return user

在 golang 中类型是强制的:

func GetUsers(page int) (users []User) {
    DB.Where(...).First(&user)
    return
}

再来看 C++, 明显的不同时类型的位置不一样:

User* getUserById(int id) {
    user = User{id}
    User::first(&user)
    return user
}

最后,最重要的是 golang 的圈子如何,跟 python-web 比,golang 可选择的余地并不是很多,但也足够用。这次选择的框架是gingorm都是轻量且简单的框架。

Challenge

golang 的轮子

写习惯动态原因的人 (尤其是 python/js) 会感觉 golang 数据结构的麻烦:

  • map/struct 不能动态添加新属性
  • 没有 in 这一经常使用的特性
  • 任意类型 interface {} 到其他类型的转换并没有那么简单
  • structmap, json 之间的转换并不是很自然
  • 值传递和地址传递时刻要注意
  • 没有方便的集合运算,如交并差,如排序,如格式化生成等。
  • ...

庆幸的是,go 语言的开源社区做的很不错,可以直接饮用 github 的他人完成的包,很多轮子都有现成的实现,首先可以去https://godoc.org/去搜索官方支持的轮子,这些一般是稳定的,受官方认可的,同时可以方便地查看他们的文档。如集合运算我就使用了goset这个库。如果没有在官方找到,可以直接寻求 github,直接引用仓库地址即可。(感觉 golang 包模块很方便吗?目前看来是的,但其实坑也不少...

多服务网络结构部署

没想到 V2.0 版本麻烦最多是在部署上...

这样我们的博客系统就有两个服务了,golang 和 uvicorn 分别占两个端口,静态文件中做相应的调整,但因为我的部署只能暴露一个端口(因为域名问题,见下图),这样只能借助nginx来转发了。

上图结构有几个配置上的难点:

  • 静态资源寻址、路由配置。v1.0 但语言版本时比较好配置直接都映射本地地址即可。现在需要明确地分服务在 nginx 配置转发。同时静态资源上,也要将原先的本地地址更换为域名地址。

  • golang 部分功能还要调用 python 的服务,如「动态」的 api 的还是保留在 python 里,post 的 api 在 golang, 而创建「文章」后需要创建动态,这时 golang 需要调用 python 的服务。(这其实很正常,很大的项目也避免不了互相通信的需要。)好在在一台机器上此问题容易解决的多。

  • 等等,缓存会冲突吗? 在「数据篇」中讲到 Frodo 是有缓存机制的,现在发现 python 的前台和 golang 的后台都依赖缓存,这点需要严格的 key 的统一来保证两个缓存数据的一致性。

Golang 的异步与并发

既然将原来 python 的服务换为 golang, 前面提到的异步特性 golang 能满足吗?其实思想是一致的,只是从 asycio 和可等待对象变为了 goroutine, 拿「博文」创建接口举例:

func CreatePost(data map[string]interface{}) {
    post := new(Post)
    post.Title = data["title"].(string)
    post.Summary = data["summary"].(string)
    post.Type = data["type"].(int)
    post.CanComment = data["can_comment"].(int)
    post.AuthorID = data["author_id"].(int)
    post.Status = data["status"].(int)

    tags := data["tags"].([]string)
    content := data["content"]
    DB.Create(&post)

    fmt.Println(post)

    go post.SetProps("content", content) // go设置内容
    go post.UpdateTags(tags) // go 更新标签
    go post.Flush() // go 清除缓存
    go CreateActivity(post) // go 创建动态
}

可以看到连续使用了 4 个go分发不能阻塞的任务,这些都是goroutine, 配套的有对他们管理的通信工具和同步原语,每个goroutine也可以继续分发协程,如其中的更新标签:

func (post *Post) UpdateTags(tagNames []string) {
    var originTags []Posttag
    var originTagNames []string

    DB.Where("post_id = ?", post.ID).Find(&originTags)
    for _, item := range originTags {
        var tag Tag
        DB.Select("name").Where("id = ?", item.TagID).First(&tag)
        originTagNames = append(originTagNames, tag.Name)
    }
    _, _, deleteTagNames, addTagNames := goset.Difference(originTagNames, tagNames)
    for _, tag := range addTagNames.([]string) {
        go CreateTags(tag)
        go CreatePostTags(post.ID, tag)
    }
    for _, tag := range deleteTagNames.([]string) {
        go DeletePostTags(post.ID, tag)
    }
}

golang 没有类似asyncio.gather(*coros)式的分发,采用 for 循环是一样的实现。

目前我已经把简单的系统拆成了两个不同技术类型的服务,可以见到部署难题渐显,接下来的更新就是虚拟化解决环境依赖难题和自动化部署了~