发帖功能实现
需求分析
用例
通过使用用例来展示某个需求的具体使用场景
- 创作者
- 增删改查
- 读者
- 查
内容的可见性
创作者创作了一篇文章,那么这篇文章什么情况下可以被读者看到呢?
根据生活经验,可以很容易想到:一篇文章只有在发表(通过审核)并且处于公开状态下才能被用户所看到。如此,一篇帖子就有了发表和未发表两个状态,我们可以很轻松地得出一篇帖子的状态转换图
想象这么一个场景,用户正在浏览文章,与此同时作者也在修改文章,那么用户会实时看到作者的修改吗?
这当然不会,在创作者发布之前,这些修改对读者都是不可见的
一种很显然的解决办法就是分开存储,即一个制作表,一个线上表
用户看到的是线上表中的数据,而作者修改的都是制作表中的数据,这样作者的修改对于用户而言都是不可知的,只有当线上表更新后,用户才能看到修改
整理上述分析,可以得到更详细的状态图:
流程分析
我们将两个表(制作表、线上表)的状态,分别用一位二进制表示,可以得出3中起始状态(有一种状态不可能存在)
- 0 0:制作表和线上表都没有数据
- 1 0:只有制作表中有数据
- 1 1:制作表和线上表都有数据
分析每一种起始状态可能的后续状态
- 0 0
- 1 0:作者写了一半,木有发表
- 1 1:作者写好并发表了文章
- 1 0
- 1 1:作者写好并发表了文章
- 1 1
- 1 1:作者修改文章
TDD与编辑接口
TDD:测试驱动开发,先写测试,再写实现
- 通过撰写测试,理清楚接口该如何定义,站在用户的角度看是否合适
- 通过撰写测试用例,理清楚整个功能要考虑的主流程、异常流程
核心循环
- 根据对需求的理解,初步定义接口,无所谓接口合不合适,反正还得改
- 根据接口定义测试
- 执行核心循环
- 增加测试用例
- 提供/修改实现
- 执行测试用例
suite测试组织套件
有的时候在测试之前需要连接db、redis、rpc之类的东西,然后再测试结束后将这些连接关闭。也就是说,我们的单元(集成)测试具有生命周期。如果是手动编写连接、关闭连接的代码会使得测试部分显得异常臃肿,好在有suite
可以帮助我们有效地管理测试的生命周期
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)
// 定义测试结构体
type ExampleTestSuite struct {
suite.Suite
msg string
}
// 测试前的预备
func (suite *ExampleTestSuite) SetupTest() {
suite.msg = "iu is my wife"
db, _ = sql.Open("xxx")
}
// 测试
func (suite *ExampleTestSuite) TestExample() {
m := &obj{
Msg: suite.msg
}
err := insert(m)
assert.Nil(suite.T(), err)
assert.Equal(suite.T(), suite.msg, m.Msg)
}
// 测试后的清理
func (suite *ExampleTestSuite) TearDownTest() {
db.Close()
}
// 入口
func TestExampleTestSuite(t *testing.T) {
suite.Run(t, new(ExampleTestSuite))
}
五个步骤
- 定义测试结构体
- 测试结构体中应包含所需要的第三方字段,例如
suite.Suite
,gorm.DB
,redis.Cmdable
等
- 测试结构体中应包含所需要的第三方字段,例如
- 入口函数
func TestExampleTestSuite(t *testing.T)
- 函数体中仅有
suite.Run(t, new(xxx))
一行代码
- 测试前的准备工作
func (suite *ExampleTestSuite) SetupTest()
- 测试
func (suite *ExampleTestSuite) TestExample()
- 测试后的清理
func (suite *ExampleTestSuite) TearDownTest()
TDD集成测试实践(一)
// 测试套件
type ArticleSuite struct {
suite.Suite
server *gin.Engine
db *gorm.DB
l *zap.Logger
}
// SetupTest 都用startup里的初始化方法,当然使用wire生成啦
func (s *ArticleSuite) SetupTest() {
var err error
s.l, err = zap.NewDevelopment()
if err != nil {
panic(err)
}
s.db = startup.InitDB(s.l)
gin.SetMode(gin.ReleaseMode)
s.server = gin.Default()
// 在这里放好userClaims,表示登录状态
s.server.Use(func(ctx *gin.Context) {
ctx.Set("userClaims", jwt.UserClaims{
UserId: 123,
})
})
artHdl := startup.InitArticleHandler()
artHdl.RegisterRoutes(s.server)
}
func (s *ArticleSuite) TestEdit() {
t := s.T()
testCases := []struct {
name string
// 准备数据
before func(t *testing.T)
// 校验数据
after func(t *testing.T)
article Article
// 预期数据
expectCode int
expectRes Result[int]
}{
{
name: "新建帖子——保存成功",
before: func(t *testing.T) {
},
after: func(t *testing.T) {
// 对每一列数据都要进行检验
var article dao.Article
err := s.db.Where("id=?", 1).First(&article).Error
require.NoError(t, err)
assert.True(t, article.Ctime > 0)
assert.True(t, article.Utime > 0)
article.Ctime = 0
article.Utime = 0
assert.Equal(t, dao.Article{
Id: 1,
Title: "这是标题",
Content: "这是内容",
AuthorId: 123,
}, article)
},
article: Article{
Title: "这是标题",
Content: "这是内容",
}
expectCode: http.StatusOK,
expectRes: Result[int]{
Data: 1,
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
//tc.before(t)
// 以json格式输入
articleJson, err := json.Marshal(tc.article)
require.NoError(t, err)
req, err := http.NewRequest(http.MethodPost, "/article/edit", bytes.NewBuffer(articleJson))
req.Header.Set("Content-Type", "application/json")
require.NoError(t, err)
resp := httptest.NewRecorder()
s.server.ServeHTTP(resp, req)
var res Result[int]
assert.Equal(t, tc.expectCode, resp.Code)
err = json.NewDecoder(resp.Body).Decode(&res)
assert.Equal(t, tc.expectRes, res)
//tc.after(t)
})
}
}
// 测试后所作的动作
func (s *ArticleSuite) TearDownTest() {
s.db.Exec("truncate table articles")
}
func TestArticleSuite(t *testing.T) {
suite.Run(t, new(ArticleSuite))
}
type Article struct {
Title string `json:"title"`
Content string `json:"content"`
}
type Result[T any] struct {
Msg string `json:"msg"`
Code int `json:"code"`
Data T `json:"data"`
}
DDD侧重于设计架构、高层次战略
TDD专注于某个功能的实现
在开发过程中,可以使用如下技巧,确保类型实现了特定接口接口
例如,确保ArticleHandler
类型实现了handler
接口
TDD集成测试实践(二)
在实现完创建文章功能后,接着就要实现修改文章功能
依据TDD核心循环,需要再添加测试用例
由于我们将要实现的是修改文章功能,也就是说文章是事先存在的,前端请求将会带上文章的Id,那么在测试时需要在before
函数中做好数据的准备工作
before: func(t *testing.T) {
err := s.db.Create(&dao.Article{
Id: 2,
Title: "旧的标题",
Content: "旧的标题",
// 和时间有关的测试,最好不用time.Now(), 因为每一次都不一样,不方便进行断言
AuthorId: 123,
Ctime: 677,
Utime: 677,
}).Error
require.NoError(t, err)
},
更新文章涉及到更新数据库,有两个值得注意的点
修改
utime
这个跟时间有关的字段,而在进行和时间有关的测试时,尽量不要使用time.Now()
函数,因为每一次运行time.Now()
结果都不相同,不便于后续的断言操作两种更新操作写法
- go
// 利用了gorm忽略零值的特性,根据主键(锁定要更新的行)进行更新操作 // 可读性差 // 忽略零值是指如果某个字段是原本是零值,那么就不更新该字段 err := dao.db.WithContext(ctx).Updates(&article).Error
- go
// 可读性较强 err := dao.db.WithContext(ctx).Model(&article). Where("id=?", article.Id).Updates(map[string]any{ "title": article.Title, "content": article.Content, "utime": article.Utime,
忽略零值可参考 更新 | GORM
DDD:实体 —— 值对象
下图所示就是一个非常经典的领域设计驱动中的领域对象中实体和值对象的一个体现
- 实体:独立存在的
- 值对象:依赖于实体而存在,通常作为实体的属性出现
可以结合实例理解一下:在用户领域,一个人是一个实体,他是独立存在的;而在帖子领域,一个人是依赖于帖子才能成为作者,是一个值对象
在DDD里面,一个领域的实体,在另一个领域中通常作为值对象、另一个实体的属性出现
数据同步
职责划分
由之前的分析可知,我们需要一个制作表和线上表,那么该有哪一个部分来同步两个表的数据呢?
理论上,项目是单体应用还是微服务应用是选择时需要参考的因素
- web/聚合服务
- 调用不同的service来同步数据,可以理解为作者和读者各有各的服务
- service
- 调用不同的repository来保存数据,从存储的角度看来,作者看到的帖子和读者看到的帖子是不一样的
- repository
- 操作不同的数据源
业务简单就一个repository,复杂就用多个repository
维护事务
同步制作库和线上库要么同时成功,要么同时失败,因此将数据同步操作做成一个事务再合适不过了
可以在两个层面上维护事务,一个是repository
,另一个是dao
。但是repository
在DDD中应该是操作数据库和缓存的,如果在这一层面维护事务,结果就是跨层操作并强耦合于GORM(而且写起来不优雅🤪)。另一种方案是在dao
层中维护事务(这就方便多啦😊),具体实现如下
func (dao *BasicArticleDAO) Sync(ctx context.Context, article Article) (int, error) {
id := article.Id
// 利用GORM控制事务的生命周期
err := dao.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var err error
if id > 0 {
err = dao.UpdateById(ctx, article)
} else {
id, err = dao.Insert(ctx, article)
}
if err != nil {
return err
}
return dao.Upsert(ctx, PublishArticle{
Article: article,
})
})
return id, err
}
⚠️:GORM的transaction
方法在底层实现时就已经帮我们维护了事务的生命周期
重点是如何在GORM下实现Upsert函数
func (dao *BasicArticleDAO) Upsert(ctx context.Context, article PublishArticle) error {
now := time.Now().UnixMilli()
article.Utime = now
article.Ctime = now
// Upsert函数在GORM下的实现
return dao.db.WithContext(ctx).Clauses(clause.OnConflict{
DoUpdates: clause.Assignments(map[string]interface{}{
"title": article.Title,
"content": article.Content,
"utime": article.Utime,
}),
}).Create(&article).Error
}
向Clauses
函数中传递OnConflict
结构体,表示如果发生冲突的话应该做什么事情
OnConfilt
中有一些比较有用的字段
// DoUpdate ---> 做更新操作
// DoNothing ---> 什么也不做
// Where ---> 数据冲突了并且符合where子句,就执行更新操作
🌟:在一个事务中,一直都是同一个数据库连接
维护状态
在先前的分析中,我们知道需要有线上库和制作库两个数据存储的地方,这两个地方存储的数据本质上是一样的,因此不必创建新的领域对象来维护,使用衍生类型更加方便
状态常量定义
利用常量表示一篇帖子的不同状态,这些常量可以直接利用iota
在domain
中定义。一般定义常量,最好不要把零值做成有意义的值,否则难以区分是用户没有输入还是前端传递默认数据
使用MongoDB存储
类似于大文本之类的东西,除了可以使用关系型数据库,也可以考虑使用其他存储应用,例如MongoDB
MongoDB属于NoSQL,NoSQL即Not Only SQL,不仅仅是SQL
NoSQL数据库的产生是为了解决大规模数据集合、多种数据种类带来的挑战,尤其是大数据应用难题
MongoDB简介
MongoDB的基本特性
- 面向集合存储:MongoDB集合中可以存储多个文档
- 类比于MySQL,MongoDB中的一个集合就像是一张表,集合中的一个文档就相当于表中的一条记录
- 模式自由:MongoDB采用无结构模式存储数据,也就是说,在储存数据之前不需要定义数据的结构
- 就像当于在MySQL中不需要预先建立表结构就能插入数据
- 支持分片:MongoDB支持分片,并且MongoDB自动解决了分片的各种问题,包括自动化扩容
选择MongoDB的两个理由:
灵活的文档模型:于MySQL不同,不需要事前在MongoDB中定义文档模型,并且可以灵活修改
易于横向扩展:可以通过增加MongoDB实例来应付流量和数据增长
初始化客户端并插入文档
需要使用MongoDB官网的go-driver
查找文档
在MongoDB中有两种构造查询条件的方式:
- bson
- 结构体
// 使用bson构造查询条件
filter := bson.D{bson.E{Key: "id", Value: 123}}
var art Article
err = col.FindOne(ctx, filter).Decode(&art)
assert.NoError(t, err)
fmt.Printf("find article %v\n", art)
// 使用结构体构造查询条件
err = col.FindOne(ctx, Article{Id: 123}).Decode(&art)
if errors.Is(err, mongo.ErrNoDocuments) {
fmt.Printf("find none")
}
⚠️:使用结构体构造查询条件时,mongodb-go-driver默认使用结构体的全部字段作为匹配条件,需要注意某些字段默认零值。当然,可以使用bson:"field_name,omitempty"
标签来告知mongodb-go-driver忽略字段零值
🌟:可以使用mongo.ErrNoDocuments
错误值检验是否有查询到文档
更新文档
- 构造更新条件,同构造查询条件
- 构造更新字段
- bson
- 结构体
⚠️:同样的,需要注意底层是否会采用字段零值
sets := bson.D{bson.E{Key: "$set",
Value: bson.E{Key: "content", Value: "serious content"}}}
res, err := col.UpdateOne(ctx, filter, sets)
// 使用结构体构造更新字段
res, err = col.UpdateOne(ctx, filter, bson.D{bson.E{Key: "$set", Value: Article{
Title: "new title",
}}})
创建索引
类似于MySQL,一般是根据业务中常用的查询条件,来决定在什么列上构建索引
indexRes, err := col.Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.M{"id": 1},
Options: options.Index().SetUnique(true),
})
fmt.Println(indexRes)
MongoDB存储数据
由于MongoDB中的_id
字段不是自增并且是一个12字节的切片,因此不能直接使用这个字段作为主键
主键的可选方案:
- 将整个id修改为string类型
- 使用ID生成策略,例如雪花算法
- 1比特保留位
- 41比特时间戳
- 10比特机器位,一般叫做
Worker ID
- 12比特自增序号
- 使用GUID
- 定义一个新的接口,但这样就失去了DAO层的统一接口
⚠️:在MongoDB中,尽量使用衍生类型而不是嵌套结构体。因为嵌套结构体中子结构体的字段标签在操作过程中无法正常生效
利用OSS来存储数据
利用OSS存储线上数据,而后直接通过CDN加速访问
OSS(Object Storage Service)是指兑现存储,被大量用于存储文件、流媒体、图片等
OSS中的两个核心概念:
- Bucket:逻辑上的分组关系,例如某个业务使用一个桶,另一个业务使用另外一个桶
- Object:对象,也就是需要存储的东西
ACL(Access Control List):权限控制方案
OSS通常和CDN结合在一起使用,即在OSS上存储的数据,可以通过CDN来访问
一种很常见的性能和可用性优化手段,就是使用OSS和CDN来存储网站的静态资源,此时OSS被当作CDN的一个回源站点
查询接口与缓存
在实现查询文章功能过程中,需要从不同用户角度出发进行思考
- 对于创作者来说,需要查询自己的文章列表以及某一篇文章的详细内容,分别对应着两个接口
- 列表接口
- 详情接口
- 对于读者,他能够搜索某篇文章、浏览推荐文章列表、阅读文章的具体内容。由此可见,需要提供以下接口
- 搜索接口
- 推荐接口
- 阅读文章接口
目前仅实现创作者的列表接口和详情接口,以及读者的阅读文章接口
列表接口
需求分析
参考掘金的列表展示:
可以发现,在列表中展示的文章对象有如下特征:
- 标题
- 创建时间
- 简介
- ...
根据这些特征我们就能定义出与前端相对应的ArticleVO
另一个值得注意的点是,掘金在列表中展示的文章只有一小部分,当页面下滑时才会加载更多的文章,这就相当于一个分页查询操作
分页形态
分页的核心目标就是避免一次操作太多数据,引发性能问题。况且,列表页在展示的时候,也不许那么多的内容😏
分页接口一般有三种定义形式:
- 直接定义
Offset
和Limit
字段 - 直接定义一个
Page
字段,并约定好Page的大小 - 直接定义一个
Cursor
字段
缓存设计
缓存第一页分页
大部分情况下,不会对分页结果进行缓存,因为如果数据的筛选条件、排序条件、分页条件、分页的偏移量和数据量中的任何一个发生了变化,缓存就很难使用了
但可以只缓存第一页,毕竟大部分用户只看列表的第一页
有了缓存之后,每次进行更新操作都要将缓存删除,防止缓存不一致问题
业务相关的预加载
预加载本质上就是预判用户将要读取的内容,提前将内容存入缓存中
而且,因为是预测性质的,所以过期时间设置得很短
不缓存大文档(调优😂)
可以考虑在Redis内存消耗和缓存性能之间做一个权衡
也就是说,并不是所有的值都需要缓存,有一些很占用内存空间的文章就不必缓存
缓存过期时间
缓存的过期时间和缓存命中率有关,一般情况下,过期时间越长,命中率越高,但数据一致性会变差;过期时间越短,命中率越低,但数据一致性会更好
- 业务相关的缓存预加载,过期时间要短
- 根据用户身份按权给予不同的缓存时间
淘汰策略
淘汰策略是指在缓存内存不够用的情况下,淘汰某一部分数据时的计划
常规方案就是LRU和LFU
还有一些方案:
- 优先淘汰普通创作者的数据,留下大v的数据
- 优先淘汰大对象,释放出更多的内存空间
- 优先淘汰小对象,相比于大对象,小对象在从数据库(或OSS)中获取更加迅速
注意事项
- 使用
wrapbodyandtoken
统一处理Body和Token中的数据 - 考虑到中文字符由多个字节组成,在处理包含中文的字符串时,应实现将其转为
[]rune
再进行len()
操作 - 需要将存储对象转换为
json
格式后才能存入Redis中,否则会报错