Go语言ORM框架XORM上手实战及全自动事务托管实现(类似Spring)

前言

前几天开始寻找适合自己的ORM框架, 因为自己是本命Java, Mybatis用了十年了, 就尝试搜索XMLMqpper风格的XML, 找到了GoMybatis, 经过两天的学习, 发现GoMybatis在事务方面的表现确实不尽人意, 所有事务都必须纯手工操作并且不支持嵌套事务, 这导致在数据操作层的功能代码将变得非常冗长且难以阅读, 所以我不得不重新寻找, 终于我找到了xorm plus, 这是一个基于老外开发ORM框架xorm, 国人自己魔改的独立开源ORM, 作者在简洁中解释了, 因为老外比较轴, 坚持零依赖做xorm的开发, 便导致作者只能起一个项目, 不过作者也提到会跟随xorm更新, Issues的处理也挺及时. 不过遗憾的是xorm plus也不支持类似Spring那样的全自动事务托管, 让我们可以专注于业务, 同时让代码变得简洁, 不过不要紧, 生命不止, 折腾不息, 大不了自己实现嘛.

Ps: 使用过程中发现两处Bug, 已更正.

  • DBEngine struct里的隐式变量xorm.Engine没有使用指针, 导致高并发时被GC回收.
  • cmd.Exec里会新开Session的事务类型开启的Session缺失关闭操作.

开始正题

第一步 了解功能

原版xorm传送门
原版xorm功能并不复杂, 标准的类hibernameORM, 就是用代码自动构造SQL的那种.
加强版xorm传送门

特性

  • 支持Struct和数据库表之间的灵活映射, 并支持自动同步
  • 事务支持, 支持嵌套事务(支持类JAVA Spring的事务传播机制)
  • 同时支持原始SQL语句和ORM操作的混合执行
  • 使用连写来简化调用
  • 支持使用Id, In, Where, Limit, Join, Having, Table, Sql, Cols等函数和结构体等方式作为条件
  • 支持级联加载Struct
  • 支持类ibatis方式配置SQL语句(支持xml配置文件、json配置文件、xsql配置文件, 支持pongo2、jet、html/template模板和自定义实现配置多种方式)
  • 支持动态SQL功能
  • 支持一次批量混合执行多个CRUD操作, 并返回多个结果集
  • 支持数据库查询结果直接返回Json字符串和xml字符串
  • 支持SqlMap配置文件和SqlTemplate模板密文存储和解析
  • 支持缓存
  • 支持主从数据库(Master/Slave)数据库读写分离
  • 支持根据数据库自动生成xorm的结构体
  • 支持记录版本(即乐观锁)
  • 支持查询结果集导出csv、tsv、xml、json、xlsx、yaml、html功能
  • 支持SQL Builder github.com/go-xorm/builder

我最关注的是SQL外置、嵌套事务以及级联加载, 而xorm全部支持, 那么就可以安装开始写点测试了.

第二步 安装

官网传送门
GitHub传送门

命令行安装方式:

1
2
go get -u github.com/xormplus/xorm
go get github.com/go-sql-driver/mysql

第二句是安装Go的Mysql Driver

第三步 数据库准备

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) COLLATE utf8mb4_bin NOT NULL,
`sex` int(1) NOT NULL,
`create_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00' ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(20) COLLATE utf8mb4_bin NOT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=DYNAMIC;

这里的设计仅供测试使用, 不求需求上多么严谨, 假定用户和角色是一对多关系.

第四步 编写Struct

这里就可以用到特性里提到的struct生成工具了, 自己写多麻烦, 能偷懒就必须偷懒, HiaHiaHia~.
照例先给传送门

1
go get github.com/go-xorm/cmd/xorm

首先要进入到这个工具的目录下, 主要是后面的命令最后一个参数中要用到存放在该目录下的templates/goxorm目录.

1
cd $GOPATH/src/github.com/go-xorm/cmd/xorm

sqlite: xorm reverse sqite3 test.db templates/goxorm

mysql: xorm reverse mysql root:@/xorm_test?charset=utf8 templates/goxorm

mymysql: xorm reverse mymysql xorm_test2/root/ templates/goxorm

postgres: xorm reverse postgres "dbname=xorm_test sslmode=disable" templates/goxorm

运行之后, 生成的go文件在./model目录下, 自行剪切到自己的项目中.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package model

import (
"time"
)

type SysUser struct {
Id int `xorm:"not null pk autoincr INT(11)"`
Name string `xorm:"not null VARCHAR(50)"`
Sex int `xorm:"not null INT(1)"`
CreateDate time.Time `xorm:"not null default 'CURRENT_TIMESTAMP' TIMESTAMP <-"`
UpdateTime time.Time `xorm:"null default null TIMESTAMP <-"`
Role SysRole `xorm:"-"`
}

type UserInterface interface {
New() error
Save(conditions SysUser) error
}

可以看到13行, 这里被我加了一个字段Role, 是用于存放用户的角色, 在使用级联加载是可自动填充.
还有CreateDate UpdateTime俩字段的Tag最后也都被我加入了<-, 意思是这俩字段只读取不写入, 这样在CUD操作中及时这两个字段不为nil, 也不会被写入数据库.
另外加入了一个接口UserInterface, 定义用户的行为.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package impl

import (
"xorm/model"
)

type UserImpl struct {
model.SysUser
}

func (impl UserImpl) New() error{
panic("implement me")
}

func (impl UserImpl) Save(conditions model.SysUser) error {
panic("implement me")
}

SysUser.go所在目录再创建一个文件夹, 放入UserInterface接口的实现.
为什么直接实现在SysUser.go里面呢, 或者为什么不跟SysUser.go放在同级目录里呢?

这里就要讲到一个Go挺让人抓狂的设定import cycle not allowed.
也就是说出现以下情况是, 编译就会报出import cycle not allowed错误

1
2
A depends on B 
B depends on A

通俗来讲, 就是不允许在不同的两个package中的两个文件里进行相互import.
拿我们的struct举例, 我们把接口实现就写在接口定义下面, 或者同一个package中, 当不在同一个package中的业务函数使用这个struct或者调用接口实现方法时, 势必需要import这个package, 而这个接口实现里面如果也需要调用该业务函数所在package中的其他业务函数时, 同样也需要import, 这样的话形成了循环引入, 无法编译通过.
所以我们只能将接口实现独立出来放入子目录中, 也就是package impl.

SysRole如上操作.

1
2
3
4
5
6
7
8
9
10
11
12
13
package model

type SysRole struct {
Id int `xorm:"not null pk autoincr INT(11)"`
Name string `xorm:"not null VARCHAR(20)"`

Users []SysUser `xorm:"-"`
}

type RoleInterface interface {
New() error
Save(conditions SysRole) error
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package impl

import (
"xorm/model"
)

type RoleImpl struct{
model.SysRole
}

func (impl RoleImpl) New() error {
panic("implement me")
}

func (impl RoleImpl) Save(conditions model.SysRole) error {
panic("implement me")
}

接口实现方法先不急着填充内容, 既然Struct已经准备好了, 那就先尝尝鲜.

第五步 尝个鲜

首先准备数据库引擎, 使用sync.Once保证初始化部分代码只被执行一次.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package common

import (
"fmt"
"github.com/xormplus/xorm"
"sync"
"time"
)

type DBEngine struct {
*xorm.Engine //这里之前忘记加*号, 并发测试值会被GC回收.
}

var engine *DBEngine

var once sync.Once
func GetDBEngine() *DBEngine{

once.Do(func() {
var tmp, err = xorm.NewEngine("mysql", "root:xxxxx@tcp(localhost:3306)/gotest?charset=utf8&parseTime=True")

if err != nil{
panic(err)
}
engine = &DBEngine{
Engine: tmp,
}

engine.DatabaseTZ = time.Local
engine.TZLocation = time.Local
engine.ShowSQL(true)
engine.ShowExecTime(true)
engine.SetMaxOpenConns(1500)
engine.SetMaxIdleConns(1200)
fmt.Println("******************************************************")
})
return engine
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package xorm

import (
"encoding/json"
"fmt"
"testing"
"time"
"xorm/common"
"xorm/model"
)

func Test_orm(t *testing.T){
time.LoadLocation("Asia/Shanghai")
engine := common.GetDBEngine()

session := engine.NewSession()
defer session.Close()

tx, e := session.BeginTrans()
if e != nil {
panic(e)
}


user := &model.SysUser{
Name: "Victor",
Sex: 1,
}


txsession := tx.Session()

_, err := txsession.InsertOne(user)

if err != nil {
panic(err)
}

bool, err := txsession.ID(user.Id).Get(user)
if !bool {
panic(err)
}
fmt.Println(user)
j, err := json.Marshal(user)
if err != nil {
panic(err)
}
fmt.Println(string(j))

res2, err := txsession.Update(&model.SysUser{
Name: "hhh",
}, &model.SysUser{
Sex: 2,
})
if err != nil {
panic(err)
txsession.Rollback()
}
txsession.Commit()
fmt.Println(res2)
}

因为SysUser中使用的是自增Id, 所以调用Insert或者InsertOne方法时, 会自动获取自增后的Id反向注入传入的user指针中.
这里需要特别注意的是, 无论你是否传入的是指针, 都会执行成功. 但如果传入的user不是指针则xorm无法完成自增Id的反向注入.
同理, 进行调用查询相关API时, 例如上面的Get方法, 也必须传入指针, 否则xorm也无法完成结果集的自动装箱.

到此我们大致明白了xorm的基础用法, 那么可以开始放大招了, 开始准备实现全自动事务托管.

第六步 为什么需要全自动事务托管?

首先先来看一段没有使用全自动事务托管的代码.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package user

import (
"github.com/xormplus/xorm"
"xorm/cmd"
"xorm/common"
"xorm/model"
)

type NewUserCMD struct {
User model.SysUser
Session *xorm.Session
}

func (this NewUserCMD) Execute() (interface{}, error) {

var engine *common.DBEngine
var session *xorm.Session

if this.Session == nil {
engine = common.GetDBEngine()
session = engine.NewSession()
defer session.Close()
} else {
session = this.Session
}

tx1, err := session.BeginTrans()
if err != nil {
return nil, err
}

_, err1 := session.InsertOne(this.User)
if err1 != nil {
tx1.RollbackTrans()
return nil, err
}

res, err2 := cmd.Exec(&NewRoleCmd{
Role: this.User.Role,
Session: session,
})
if err2 != nil {
tx1.RollbackTrans()
return nil, err2
}

tx1.CommitTrans()
return res, nil
}

这是一段使用简化的命名模式实现的Struct, 这个命令需要两个参数, 一个User一个一个Session, 从命令名称就能看懂, 这是用来新增用户的, 对命令模式不熟的同学就把这个单纯当成一个新增用户的函数来理解就好.
在通常业务中, 新增用户的函数, 可能是一段事务里的第一个函数,例如注册. 也可能是一段事务里中间的函数, 例如有外键关联的用户, 需要先创建外键数据拿到Id后才创建用户那种.

那么按照上诉可能性, 就需要先判断Session是否为空, 如果是做为第一个被调用的函数那么Session就是空的, 中间被调用的话则会被传入.

得到Session后, 开启事务完成新增, 接着调用新增角色的命令, 把Session继续往下传. 新增角色函数返回后, 再根据是否返回异常决定是回滚还是提交.

在这么长一段代码中, 其实我们真正干的业务就只有两个, 新增用户, 新增角色. 本来仅仅只需要两句代码, 但是因为事务的关系, 无关业务的代码量是业务代码的数倍, 既浪费时间又大大增加发生Bug的可能性, 而事务上的Bug又具有比较高的隐蔽性和高危性, 不影响编译运行, 一旦触发直接导致脏数据.

这就是为什么我们需要全自动事务托管.

第七步 实现全自动事务托管

我的实现思路是和Spring的全自动事务托管差不多的, 但是因为Go不支持动态代理, 所以需要绕一点弯子.

根据第五步中讲到的内容, 用最通俗的话来讲, 我们需要解决的问题就是把事务相关的代码从函数中统统拿掉.

所以我们需要一个代理来帮我们做这件事, 而我选择用简化版命令模式来干这件事.

1
2
3
4
5
package cmd

type Command interface {
Execute() (interface{}, error)
}

定义一个接口, 所有的业务命令实现这个接口. 例如刚刚讲到的NewUserCMD命令.

然后编写一个命令执行器函数.

1
2
3
4
5
6
7
8
package cmd

func Exec(cmd Command) (interface{}, error) {
...
res, err := cmd.Execute()
...
return
}

因为我们的命令都实现了Command接口, 所以这里我们可以直接接收Command类型的参数.
然后像这样完成命令的调用执行

1
2
3
cmd.Exec(&XXXCMD{
...
})

这样我们就实现了对命令的环绕代理, 可以任意在其前后添加执行代码.

到这里, 我们就是可以在res, err := cmd.Execute()之前, 完成获取Session和开启事务的操作, 在之后根据返回值err判断是Commit还是Rollback.

但是问题来了, 不是所有命令都需要开启事务, 如果是纯查询的命令, 自然是不需要事务的, 那么需要在命令的Session参数后增加一个Tag配置, trans:"0", 然后在命令执行器中通过反射获取这个Tag, 从而判断是否需要开启事务以及开启事务的类型.
(xorm plus支持8种事务类型, 详见传送门)
既然传入的命令需要开启事务, 那么说明该命令里需要进行数据库操作, 那么肯定需要Session, 所以我们需要获取一个Session通过反射注入该命令的Session字段中.

那么假设跟第四步中的例子一样, 这个命令中又调用另一个命令呢? 在那个例子中我们是在调用时手动传入的Session, 如果这里还需要我们手动传入, 还算哪门子的全自动?
也就是说需要当这个命令里再次通过cmd.Exec()函数调用其它命令而再次进入这个函数时, 我们需要判断在它之前时候已经开启过事务, 如果开启过事务则获取该事务然后.Session(), 从而获取到加入这个事务的Session并反射注入给这个“其它命令”.

那么问题又来了, 因为cmd.Exec()会被并发访问, 所以必然不能使用全局变量来存储事务, 那样肯定会导致线程安全问题, 那么我们的事务该怎么保存呢?

整理下需求, 我们需要一个内存空间, 这个内存空间只能被同一个线程上的代码访问, 用来临时存放事务, 当嵌套命令调用完毕逐层返回到最顶层时, 清空这个空间以便GC回收.

理清需求发现我们需要的就是Java里ThreadLocal这样的东西, 不过很可惜, Go里又木有😭, 认命, 自己实现.

首先我们需要编写一个获取线程Id的功能, 为了偷懒, 我从http2这个开源项目里直接扣了一个现成的出来😂.

安全起见, 我做了几十次5000线的并发测试, 没出现一例遗漏或者重复的情况, 可放心食用.
这个文件中func curGoroutineID()函数就是获取线程Id的, 自行将其改成公有函数.
有了线程Id之后, 我们还需要一个线程安全的Map, 用线程Id当Key, 来临时存放事务, 还好这个有, sync.Map就是官方提供的线程安全Map.

这样我们就可以在同一个线程中共享事务, 每次cmd.Exec()被调用, 先从这个我们自己实现的ThreadLocal中获取事务, 获取不到说明是第一次被调用, 获取Session开启事务存入ThreadLocal中, 获取到就直接使用这个事务调用.Session()得到加入这个事务后的Session再注入给需要被执行的命令.

上代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
package cmd

import (
"github.com/xormplus/xorm"
"reflect"
"sync"
"xorm/common"
"xorm/utils"
)

var m sync.Map

func Exec(cmd Command) (interface{}, error) {
//获取Session字段
v := reflect.ValueOf(cmd)

t, ok := reflect.Indirect(v).Type().FieldByName("Session")

//如果没有获取到Session字段则说明无DB操作,直接执行。
if !ok {
return cmd.Execute()
}

var session *xorm.Session

//用来包裹放入sync.Map的Transaction,因为sync.Map.Load函数无法返回指针,
//所以需要用Map来做一层包裹 使用了CurGoroutineID来做为sync.Map的Key,
//因此每一个transMap只能被创建它的线程获取到,所以不存在线程安全问题。
var transMap map[string]*xorm.Transaction
var tx *xorm.Transaction
//从Session字段的Tag中获取需要开启事务的类型。
var transactionDefinition int
routineId := utils.CurGoroutineID()
txv, ok := m.Load(routineId)
//如果从根据当前线程ID从缓存中获取到了事务,则使用该事务初始化Session。
//否则则获取一个新的Session
if ok {
transMap = txv.(interface{}).(map[string]*xorm.Transaction)
tx = transMap["tx"]
session = tx.Session()
} else {
engine := common.GetDBEngine()
session = engine.NewSession()
defer clean(session, routineId)
}


tagString := t.Tag.Get("trans")
//如果获取不到则说明不需要事务,默认为PROPAGATION_NOT_SUPPORTED。
//该情况多数应用在写日志等不影响主业务的命令。
if tagString == "" {
transactionDefinition = xorm.PROPAGATION_NOT_SUPPORTED
} else {
//如果transType配置的不是数字,或者不在合法范围则回滚并抛出异常。
err := utils.StrToInt(tagString, &transactionDefinition)
if err != nil && transactionDefinition >= 0 && transactionDefinition <= 7 {
session.Rollback()
panic("unrecognized transaction type, value range 0-7.")
}
}

transMap = make(map[string]*xorm.Transaction, 1)

//开启嵌套事务
tx, err := session.BeginTrans(transactionDefinition)
if err != nil {
panic(err)
}
transMap["tx"] = tx

//这里也需要更新
//如果事务类型是PROPAGATION_NOT_SUPPORTED 或 PROPAGATION_REQUIRES_NEW 则会开启新的Session, 故需要关闭Session
if transactionDefinition == xorm.PROPAGATION_NOT_SUPPORTED || transactionDefinition == xorm.PROPAGATION_REQUIRES_NEW {
defer tx.Session().Close()
}

//通过反射将Session注入CMD
//因为部分事务类型会开启新的Session, 所以这里需要通过tx.Session()重新获取。
fv := v.Elem().FieldByName("Session")
fv.Set(reflect.ValueOf(tx.Session()))

if !ok {
m.Store(utils.CurGoroutineID(), transMap)
}

res, err := cmd.Execute()

if err != nil {
tx.RollbackTrans()
} else {
tx.CommitTrans()
}
return res, err
}

//关闭Session和根据进程ID清理缓存,以便GC回收。
func clean(session *xorm.Session, routineId uint64){
session.Close()
m.Delete(routineId)
}

说起来逻辑挺绕挺高大上的, 但实际代码量并不多, 就这么点, 跟着注释来看, 应该能明白具体逻辑.
接着来看使用全自动事务托管之后的命令时什么样子.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package autotx

import (
"github.com/xormplus/xorm"
"xorm/cmd"
"xorm/model"
)

type NewUserCMD struct {
User model.SysUser
Session *xorm.Session `trans:"0"`
}

func (this NewUserCMD) Execute() (res interface{}, err error) {

res, err = this.Session.InsertOne(this.User)

if err != nil {
return
}

res, err = cmd.Exec(&NewRoleCmd{
Role: this.User.Role,
})

return
}

做的事和第四步中那个命令一样, 一个用了50代码, 一个只用了27行, 效果立竿见影, 清爽易懂.
(可以点击左边目录进行章节跳跃哦, 什么? 你用手机在看? 我敬你是条汉子…)

第八步 编写Test

先把第三步中两个Struct接口实现的坑填上方便测试.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package impl

import (
"xorm/cmd"
"xorm/cmd/user/autotx"
"xorm/model"
)

type UserImpl struct {
model.SysUser
}

func (impl UserImpl) New() error{
_, err := cmd.Exec(&autotx.NewUserCMD{
User: impl.SysUser,
})
if err != nil {
return err
}
//fmt.Println(res)
return nil
}

func (impl UserImpl) Save(conditions model.SysUser) error {

return nil
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package impl

import (
"xorm/cmd"
"xorm/cmd/user/autotx"
"xorm/model"
)

type RoleImpl struct{
model.SysRole
}

func (impl RoleImpl) New() error {
_, err := cmd.Exec(&autotx.NewRoleCmd{Role: impl.SysRole})
if err != nil {
return err
}
//fmt.Println(res)
return nil
}

func (impl RoleImpl) Save(conditions model.SysRole) error {
panic("implement me")
}

testing编写测试函数.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func TestUser(t *testing.T) {
defer waitgroup.Done()
user := impl.UserImpl{
SysUser: model.SysUser{
Name: "Victor2",
Sex: 1,
Role: model.SysRole{
Name: "role",
},
},
}

var err error

err = user.New()
if err != nil {
panic(err)
}
}

执行通过, 完结撒花.
经过测试, 刨开xorm plus自身用时后得到的cmd.Exec()函数用时不到1ms, 另外经过1000线并发测试, 在我这个15年的Macbook air上, 1秒完成.

总结

拉了下滚动条, 特么又写了这么多, 也不知道会不会有人看到, 权当自己巩固记忆吧, xorm plus确实是目前我了解到最适合国内业务的ORM框架, 简单的SQL可以直接调用API自动构造SQL执行, 复杂的SQL也可以外置存放到模版文件中, 类似Mybaits的XMLMapper, 通过模版来动态构造复杂的SQL, 支持8种事务类型, 基本和Spring一致, 再使用本文中的方式, 实现全自动事务托管, 可降低人才要求和Bug率同时提升编码效率和可阅读性. Go语言很不错很强大, 但在这一切搞定之前, 还真不敢将其运用到生产环境中, 现在终于可以浪起来了, Happy.

本文中的源码之后有空可能会整理发布到Github, 当然也可能被我忘记了, 如果真有幸被你看到而我确实忘记发布而你又确实非常需要, 可以留言给我, 我会收到邮件通知的.

至此,又一篇超长的博文诞生!Thanks for your reading。

文章目录
  1. 1. 前言
  2. 2. 开始正题
    1. 2.1. 第一步 了解功能
    2. 2.2. 第二步 安装
    3. 2.3. 第三步 数据库准备
    4. 2.4. 第四步 编写Struct
    5. 2.5. 第五步 尝个鲜
    6. 2.6. 第六步 为什么需要全自动事务托管?
    7. 2.7. 第七步 实现全自动事务托管
    8. 2.8. 第八步 编写Test
  3. 3. 总结
,