Golang重构博客心得和使用感受

博主使用Golang重构了原本用SpringBoot编写的博客系统,以解决内存占用问题。技术选型包括Gin框架、gorm ORM和ccache缓存。项目改进了JWT认证和CSRF防御,提升了安全性和性能。Golang的简洁语法和高并发支持(如goroutine和defer)使其在开发中表现出色,但也存在错误处理和泛型使用上的不便。总体而言,Golang适合云原生等高性能场景,语言设计简洁高效。

文章目录

前言

在22年初,博主用SpringBoot编写了本站的博客系统。此后的日子里,博客虽稳定运行,但有时也会遇到需要开MC服与好友齐乐乐的场景,这时服务器那可怜的内存便所剩无几了,加上SpringBoot应用占用的几百兆内存,有时出现爆内存的情况。所以,一般在开服时会关掉博客系统,保证服务器的正常运行。

这里并不打算论证Java内存消耗大小之类的问题,总之,为了博客服务的稳定运行,也为了多掌握一门感兴趣的语言(主要原因),我们不妨尝试用Golang重构一遍博客后端。

技术选型

Web框架

基于Go的Web框架有不少,这里博主选择了老牌框架Gin,Gin设计上秉承Go大道至简的特点,使用起来心智负担较小,当然功能也相对简单,不过中间件的设计能够让你方便地添加其他功能。

ORM

选择了gorm,和大多数ORM框架类似,我们需要声明模型结构、关联信息,然后就可以方便地进行crud,某些场景下还可以利用ORM的特性少写很多代码和SQL语句(如多级嵌套、自嵌套)。

本地缓存

Go的缓存框架并不少,各有各自的特点,这里博主选择了一个支持泛型的缓存框架ccache,ccache的底层储存实现较为简单,为哈希表,表中桶的实现则是一个加了读写锁的map。

请求流程及开发规范

用户的请求一开始会经过各层中间件,中间件主要分为安全组件、日志记录、错误处理三种。经过中间件后进入路由,路由负责处理请求参数的转换和响应体的包装,而实际业务部分会在service中处理,这一层引入了缓存以提高查询服务的效率。最后,和数据库有关的操作都会在DAO层处理,而文件上传这类操作则单独抽出一个Upload接口,方便不同储存方式的实现(本地储存、云储存等)。

请求流程

为了提高代码质量,方便调试,项目中也规定了一些开发规范,比如DAO应该将结果和错误一并返回,而不是panic;service遇到错误应直接panic;路由不应处理错误,应该由兜底的recovery中间件统一处理等等…

项目改进

此次重构也对项目做出了一些改进,主要体现在认证和安全上。

JWT认证

Gin没有实现session机制,在官方仓库中有相关session中间件实现,但项目并不强依赖于session机制。经过一番思考,我决定替换成JWT的认证方式,JWT中的载荷除了通用的时间戳信息外,仅存用户id。每次调用接口时,从JWT中获取用户id,并通过service查询用户信息(service有缓存机制),并放在请求上下文中。这样做的好处是可以及时感知到用户信息的变更,比如管理员在后端ban了一个用户,这个用户下次请求时鉴权中间件就可以获取用户最新状态,从而阻止请求。

认证流程

这种设计还有一种好处,假设用户在公共环境上登录后忘了退出,需要有一键下线该用户所有在线设备的功能。我们可以在用户表中添加字段——一键下线的时间,这样当用户点击一键下线所有设备时,该字段就更新为当前时间,通过JWT中的签发时间和用户信息中的下线时间就可以来判断token是否过时。

其实关于登录态的设计一直有一个矛盾,就是性能和安全的选择。无状态的JWT拥有更好的性能,但如果对账号安全和一致性有严格要求的话,又不得不引入其他有状态的机制(纯JWT直接从载荷中获取用户信息即可,但一旦用户在这段时间内修改了信息,则必须等JWT过期后才能获取该用户最新信息)。这里我引入了service来查询实时用户信息,所以也是有状态的,使用JWT有着以下两点考虑:

  1. 不想引入session
  2. 如果session用来存放用户信息的话,那么也会有无法实时获取最新用户信息的缺点。如果每次都去service请求用户信息,那session只承担着储存会话用户id的功能,有种高射炮打蚊子的感觉。

CSRF防御

根据CSRF攻击的原理,攻击请求并不能添加自定义header。所以秉承专事专干原则,仅需要约定好一个header,请求进入后端时中间件检查有无对应header即可,而无需其他token生成、校验的步骤。

Golang使用感受

如果有使用过Go和Java这两种语言的话,对Go的印象大抵就是四个字——大道至简。语言级的高并发支持、使用组合而不是继承、弱化的指针,并有一些有意思且实用的设计,下面将谈谈博主在实际使用中感受到的Go语言的优缺点。

大道至简

由于Go的语法足够简单,设计之初便遵循“少即是多”的原则,没有繁多的关键字、不用关心内存管理等特性使得开发人员可以快速上手Go。博主学习Go语言的过程中,遇到较难理解的仅有channel和接口零值判断的部分。

goroutine

goroutine作为Go语言天生高并发支持的大杀器,相比于内核线程具有创建开销小、调度性能好等优势,特别适合IO密集型任务。并且由于Go语言级的支持,使得goroutine的创建和使用都十分简单,还可以通过channel进行goroutine间的通信。

博主在项目中便使用goroutine实现异步邮件的发送。

func postComment(comment Comment) uint64 {
// 添加评论记录
dao.Comment.Insert(comment)
// 异步发送通知邮件
switch comment.Status {
case PUBLISHED:
go util.SendReplyMail(comment.Aid, comment.Nickname) // 发送回复通知邮件
case REVIEW:
go util.SendReviewMail(comment.Nickname) // 发送审核通知邮件
}
// 返回评论id
return comment.Id
}

defer语句

defer关键字声明的语句会在当前函数执行完毕(return或panic)后执行,十分适用于资源释放的场景。博主在service中使用缓存时便使用了defer,将缓存的相关代码聚集在一处,减少对业务代码的侵入。

func (articleService) FindById(id uint64) (res dto.ArticleDto) {
// cache
cacheKey := fmt.Sprintf("article:%d", id)
articleCache := cache.Article.Get(cacheKey)
if articleCache != nil && !articleCache.Expired() {
return articleCache.Value()
}
// defer语句将在函数返回或panic后执行,比其他语言的finally代码块更优雅
// 该defer函数作用是在函数返回后对结果进行缓存,除非recover捕获到了异常
defer cache.DeferredSetWithRecover(cache.Article, cacheKey, &res)()
article, daoErr := dao.Article.FindById(id)
panicNotFoundErrIfNotNil(daoErr, "未找到该文章")
return dto.ToArticleDto(article)
}

当然,Go语言也有一些缺点,让人使用起来并不满意。

别扭的写法

Go为了提高编译速度,衍生了一些很别扭的写法,以函数链式调用为例,换行时需要把.写在上一行,下一行再写函数调用:

// gorm链式调用示例
func chainExample(page int, size int, order string) (data Data, err error) {
err = db.Model(&models.Article{}).Count(&data.Total).
Limit(size).Offset(page * size).
Order(order + " desc").
Find(data).Error
return
}

错误处理

Go语言中的错误处理有panic-recover和return-guard两种方式。

其中,panic抛出的错误只能在defer函数中通过revocer捕获并处理,但这种方式并不是官方所提倡的,这种方式更多用于恢复不可预料的异常。

而借助多返回值的特性,将错误放在返回值中是更常见的方式,Go标准库中也大量使用这种方式来处理错误,但这也导致需要用大量卫语句来判断并处理错误,具体选择哪种方式就见仁见智了。

// return + guard
func guardExample() {
a, err := f1()
if err != nil {
fmt.Println("oops! %v", err)
return
}
b, err := f2(a)
if err != nil {
fmt.Println("oops! %v", err)
return
}
err := f3(b)
if err != nil {
fmt.Println("oops! %v", err)
return
}
}
// panic + recover
func recoverExample() {
defer func() {
if err := recover(); err != nil {
fmt.Println("oops! %v", err)
}
}()
a := f1()
b := f2(a)
f3(b)
}

泛型

不用尖括号表示泛型的都是异端(暴论)!方括号作为数组取下标、map访问元素的语法的一部分,本身使用频率并不低,加上泛型声明后承担的工作未免多了点,在我看来可读性并不好。而官方的解释为了防止多返回值二义性(比如a,b := f<c,d>(e)),感觉Go的语法为了保证编译速度做出的让步有点多…

稀少的数据结构

在数据结构上,Go原生提供了array、slice、map这几种基础类型,并保证类型安全。除此之外就只剩container包下的堆、链表和环了,甚至在添加泛型特性2年后的今天,还是没有提供更多的数据结构。

结论

这期文章并不想将Go与其他语言做一个比较,正所谓术业有专攻,Go的语言特性和生态注定了其在云原生等领域的大放光彩。学习得越多,站得越高,越能感受到语言只是一种工具,特性多、设计复杂不一定好用,Go语言大道至简的设计便是最好的诠释。谨记,There is no silver bullet!