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有着以下两点考虑:
- 不想引入session
- 如果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 + guardfunc 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 + recoverfunc 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年后的今天,还是没有提供更多的数据结构。