登陆服务 | 三、初步实现

2021 10 31, Sun

前面大概讲了考虑到的问题。这篇简单记录我选择的工具

技术选型

既然是自己写一个服务,那当然是面临很多技术选型的问题。

部署方案 / 运维架构

考虑初期主要是我自己使用,没有大规模集群,没有CDN,我可能需要多考虑一点单二进制部署的方法,加上systemd来维持服务的状态。入口用一个nginx或者envoy做入口gateway,卸掉SSL以后代理到服务就行。

后期可能会考虑用K8S之类的工具。

持久化

持久化主要是关系型数据的持久化、K/V或者文档类数据的持久化和大块blob。

考虑到现在是简单的登陆服务,只要有简单的关系型数据库和NoSQL就够了。资源文件都是静态的,暂时不会允许用户添加资源,所以没有CDN的需求。

开发语言

我自己的主力语言中,可以写后端服务的主要是Python、Go、Rust这三个,且刚刚好这三个都有自己的异步生态。

对比过后,比较结果大约是这样:

  • Go和Rust/Tokio本身的资源占用和性能会略优于Python/Tornado。
  • Go和Python/Tornado编码中的负担远小于Rust/Tokio
  • Go在1.16后可以embed资源,非常方便打包
  • 数据库方面
    • Python有databases作为异步的ORM,但是数据库迁移
    • Python的aioredis维护状态不是很好
    • Golang的GORM在版本迁移上有点弱
    • Rust中,如果要同时使用diesel和tokio,就需要tokio的线程池
  • Go的默认值优于其他两个语言
  • 需要调优时Rust可以更准确的调整tokio的runtime,甚至可以自己写一个,当然Python野性,只是绕不开GIL的话还是很麻烦

平衡以后我选择

  • Go,时刻保持版本最新。保持版本最新就可以用上Go最新的调度策略和GC,默认值下只依赖升级就有非常不错的性能提升
  • github.com/gorilla/mux & …/session
  • github.com/gorilla/sessions & redisstore & go-redis
  • 标准库/database/sql + mysql + sqlite + go-migrate/migrate
  • 标准库/html/template,在服务器端构造HTML
  • 手工实现一个简单的限流器

外部依赖主要是

  • ory/hydra,提供OIDC
  • mailgun,发信

关于Rust

未来重构可能会用Rust,但是正如之前在上海的时候第一次写Go时自己给自己上的一课,写完以后项目结构难以维护。我现在写Rust还没没有太多积累,没有必要同时解决登陆和写Rust的两个问题。

项目实现

项目实现放在了我的Git托管中。一部分这篇博客提到的内容没有实现。

应用配置

配置读取

根据12 Factors,配置都从环境变量和参数读取。

参数解析用到了cobra。环境变量则是写了个非常简单的函数来检查环境变量并且提供一个默认值:

func EnvString(key, defaultVal string) string {
    if val, ok := os.LookupEnv(key); ok {
        return val
    }
    return defaultVal
}

这样可以在本地运行的时候用direnv来指定本地的配置,临时修改某项的话,用命令行参数就好。服务器上可以给K8S或者systemd单独配置环境。

配置代码组织

组织应用的时候有一个很麻烦的问题就是组织应用的配置和一部分状态。用不好了项目就会显得很乱。

以包为作用域的配置

在工作的时候经常可以看到同事把大量的配置放到包里面,用init函数来配置。个人觉得这是一个一没有那么方便,二不容易组织代码的形式。

用配置和上下文作为Function receiver

另一种方法是用函数的接受器作为上下文,我现在暂时是这样写的应用,但是不够好。类似于这样

type Application struct {
    // ...
}

func (app Application) HandleXXXView(rw http.ResponseWriter, r* http.Request) {
    // ...
}

func (app Application) Router() http.Handler {
    router := mux.NewRouter()

    router.Path("/xxx").Methods(http.MethodGet).HandlerFunc(app.HandleXXXView)

    return router
}

这种方法可以直接在receiver中取得应用上下文和配置。但是最大的劣势还是无法方便的组织代码,所有应用的代码都需要处在同一个包里面,这就使得比较大的应用用这种方法会受到非常大的局限性。

给闭包函数传递参数

最近读代码学到的一种形式是这样:

// package Module A
func XXXViewHandler(app Application, ...) (...) {
    // ...
}

// package App
func makeHandler(app Application, fn func(app Application, ...) (...)) http.Handler {
    return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request){
        result := fn(app, ...)
        // ...
    })
}

func Router() http.Handler {
    router := mux.NewRouter()

    router.Path("/xxx").Methods(http.MethodGet).Handle(makeHandler(XXXViewHandler))

    return router
}

利用闭包函数就可以在构建Router的时候把应用配置传递给具体的Handler。

独到这种写法的时候已经在写服务了,下次一定下次一定

数据库

数据库的部分我比较了GORM、sqlx和标准库sql,最终选择了sql。

GORM

我最开始用Go处理关系型数据时用的是GORM,简单,反射解决一切烦恼。

但是随着要求越来越高,GORM开始显得吃力:

  • 构造复杂Query时,GORM比SQL语句更复杂
  • 迁移版本时需要额外写程序来处理

所以后来就用的越来越少了。

sqlx vs 标准库sql

后来也比较过sqlx这类工具,但是对我来说sqlx的主要功能都对我无关紧要:

  • 映射到结构体(甚至我觉得这个是不应该使用的)
  • Rebind

database/sql + embed

标准库的函数和类型足够我使用数据库来CRUD。构建Query只需要我针对数据库写不同pattern作为路径的SQL语句就好了

部署

单二进制

我自己比较偏向单独的二进制部署,这样无论是打包容器还是传送到服务器都比较有优势。

上面提到的cobra包利用同一个作者自己写的pflags提供了参数解析的能力,cobra本身提供了处理子命令的能力。我只要构建cobra的command的树形结构就可以在单个二进制内提供需要的所有功能。

对于资源文件,比如说web服务用到的模板、数据库用到的SQL语句,都可以用embed包打包进二进制内。

服务运行

容器和编排

由于刚才提到的所有的配置项都可以用环境变量和参数传递给应用,我们可以就正常用buildah或者docker构建一个简单的镜像:

FROM golang:1.17-alpine
COPY . /src
WORKDIR /src
RUN go generate ./...; go build -o /app .

FROM alpine
RUN apk update && apk add ca-certificates
COPY --from=0 /app /bin/app
ENTRYPOINT ["/bin/app"]

systemd

对于systemd而言,Go的应用没有fork之类的操作,直接作为simple类型的service注意。最小配置类似于这样:

[Service]
ExecStart=/path/to/binary
Restart=always

[Install]
WantedBy=multi-user.target