0%

GoFrame框架Web开发教程

GoFrame是一款模块化、高性能、企业级的Go基础开发框架。GoFrame是一款通用性的基础开发框架,是Golang标准库的一个增强扩展级,包含通用核心的基础开发组件,优点是实战化、模块化、文档全面、模块丰富、易用性高、通用性强、面向团队。GoFrame既可用于开发完整的工程化项目,由于框架基础采用模块化解耦设计,因此也可以作为工具库使用。

官方文档:https://goframe.org/pages/viewpage.action?pageId=1114119

视频教程:https://www.bilibili.com/video/BV1Uu4y1u7kX

学习笔记:https://gitee.com/unlimited13/code

准备工作

前置条件

  1. 已安装Go语言开发环境,已配置好GOROOTGOPATH环境变量
  2. 熟悉Go语言语法与基本使用

安装框架

下载地址:https://github.com/gogf/gf/releases

下载对应操作系统的包并安装,推荐安装到GOROOT的bin目录中

使用gf -v命令查看是否安装成功

项目初始化

先修改Go语言开发环境:一次性配置,如果已经设置过可以跳过

1
2
go env -w GO111MODULE=on
go env -w GOPROXY=https://goproxy.cn
初始化项目:-u参数表示获取最新版本的框架依赖,如果之前创建过项目,可省略
1
gf init hellogf -u

项目启动

进入项目中main.go文件的所在目录运行该命令启动项目

1
gf run main.go
成功启动后,在浏览器中输入http://127.0.0.1:8000/hello查看结果

项目目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/
├── api 请求接口输入/输出数据结构定义
├── hack 项目开发工具、脚本
├── internal 业务逻辑存放目录,核心代码
│ ├── cmd 入口指令与其他命令工具目录
│ ├── consts 常量定义目录
│ ├── controller 控制器目录,接收/解析用户请求
│ ├── dao 数据访问对象目录,用于和底层数据库交互
│ ├── logic 核心业务逻辑代码目录
│ ├── model 数据结构管理模块,管理数据实体对象,以及输入与输出数据结构定义
│ | ├── do 数据操作中业务模型与实例模型转换,由工具维护,不能手动修改
│ │ └── entity 数据模型是模型与数据集合的一对一关系,由工具维护,不用手动修改。
│ └── service 业务接口定义层。具体的接口实现在logic中进行注入。
├── manifest 包含程序编译、部署、运行、配置的文件
├── resource 静态资源文件
├── utility
├── go.mod
└── main.go 程序入口文件

路由管理

路由管理是HTTP Web Server开发的基础 ### 路由规则 最基础的路由绑定方法是BindHandler方法,我们来看一下之前一直使用的BindHandler的原型:

1
func (s *Server) BindHandler(pattern string, handler interface{})
- 其中pattern参数用于指定路由注册规则的字符串,参数格式如下:
1
[HTTPMethod:]路由规则[@域名]
HTTPMethod:@域名为可选参数,如果指定了HTTPMethod,那么路由规则仅会在该请求方式下有效。@域名可以指定生效的域名名称,那么该路由规则仅会在该域名下生效。比如:

1
2
// 该路由规则仅会在GET请求及localhost域名下有效
GET:/order/info/{order_id}@localhost

BindHandler是最原生的路由注册方法,在大部分场景中,我们通常使用分组路由方式来管理理由,后续会介绍

  • 其中handler参数用于指定路由函数,这个路由函数只需要满足以下要求,即只要能接收请求对象*ghttp.Request即可
    1
    2
    3
    func(r *ghttp.Request) {
    // ...
    }
    让我们先关注internal/cmd目录下的cmd.go文件,cmd层负责引导程序启动,显著的工作是初始化逻辑、注册路由对象、启动server监听、阻塞运行程序直至server退出

了解该文件作用后,我们在cmd.go中为一个函数注册路由:

1
2
3
4
5
6
7
8
9
10
Main = gcmd.Command{
// ...
Func: func(ctx context.Context, parser *gcmd.Parser) (err error) {
s := g.Server()
s.BindHandler("POST:/hello", func(r *ghttp.Request){
r.Response.WriteJson(r.Router)
})
// ...
},
}
运行项目后,在API测试工具(如Postman、ApiFox等)中输入http://127.0.0.1:8000/hello并以POST方法进行访问测试,结果应当是该函数返回的r.Router,即当前匹配的路由规则信息

  • 路由规则分为精准匹配规则动态匹配规则

精准匹配规则是指已确定名称的规则,比如/user/article这种。在大多数场景下,我们会同时使用两种匹配规则,比如user/:userIduser/id={id}

动态匹配规则分为命名匹配规则模糊匹配规则字段匹配规则,详细内容在官方文档中写得相当清楚,这里便不再具体展开

官方文档:https://goframe.org/pages/viewpage.action?pageId=1114257

路由注册

函数注册

函数注册在上文中的例子中已经出现过了,其中handler不单单可以是一个简单函数,它还可以是包方法,也可以是实例化对象的方法,只要这些函数接收*ghttp.Request类型的参数就能作为handler

官方文档:https://goframe.org/pages/viewpage.action?pageId=1114240

对象注册

对象注册是通过一个实例化的对象来执行路由注册,以后每一个请求都交给该对象(同一对象)处理,该对象常驻内存不释放

相关方法:

1
2
3
4
5
func (s *Server) BindObject(pattern string, object interface{}, [methods ...string]) error

func (s *Server) BindObjectMethod(pattern string, object interface{}, method string) error

func (s *Server) BindObjectRest(pattern string, object interface{}) error
前置约定:需要进行路由注册的方法必须为公开方法(即方法名首字母大写),并且方法需接收*ghttp.Request类型的参数

绑定对象公开方法

通过BindObject方法对实例化对象中的所有方法或指定方法完成注册

先在/internal/controller/user中准备一个user控制类

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
package user

import "github.com/gogf/gf/v2/net/ghttp"

type Controller struct{}

func New() *Controller {
return &Controller{}
}

func (c *Controller) AddUser(r *ghttp.Request) {
r.Response.Writeln("添加用户")
}

func (c *Controller) UpdateUser(r *ghttp.Request) {
r.Response.Writeln("更新用户")
}

func (c *Controller) DeleteUser(r *ghttp.Request) {
r.Response.Writeln("删除用户")
}

func (c *Controller) ListUser(r *ghttp.Request) {
r.Response.Writeln("用户列表")
}

func (c *Controller) GetUser(r *ghttp.Request) {
r.Response.Writeln("查询一个用户")
}

func (c *Controller) Post(r *ghttp.Request) {
r.Response.Writeln("添加用户")
}

func (c *Controller) Put(r *ghttp.Request) {
r.Response.Writeln("更新用户")
}

func (c *Controller) Delete(r *ghttp.Request) {
r.Response.Writeln("删除用户")
}

func (c *Controller) Get(r *ghttp.Request) {
r.Response.Writeln("查询一个用户")
}
我们暂时不提供成员属性,先了解如何绑定成员方法。然后在cmd.go中实例化对象,并绑定该对象的所有公共方法:
1
2
3
4
5
6
7
8
9
10
import (
// ...
hellogf/internal/controller/user
)

// ...

usercontroller := user.New()
// 绑定user控制器中所有公共方法
s.BindObject("/user", usercontroller)
如果在BindObject方法提供了第三个参数,即指定对象里的方法名,表示只注册该对象中的该方法
1
s.BindObject("/user", userController, "AddUser, UpdateUser")

绑定对象指定方法

通过BindObjectMethod方法和上文的BindObject方法中提供第三个参数效果是类似的

1
2
3
4
5
usercontroller := user.New()
// 绑定user控制器中多个方法
s.BindObject("/user", usercontroller, "AddUser,UpdateUser")
// 绑定单个方法
s.BindObjectMethod("/deluser", usercontroller, "DeleteUser")

以RESTFul风格绑定对象方法

通过BindObjectRest方法对对象中的以GetPostDelete等命名的方法做绑定

1
2
usercontroller := user.New()
s.BindObjectRest("/user", usercontroller)
最终路由表的结果是:以/user为路径的路由有四个,但请求方法都不同,而且其他如AddUser命名的方法没有被注册

分组路由

前面我们都是使用类似BindHandler方法的方式进行路由注册,而分组路由才是业务项目中主要使用的路由注册方式

我们可以给分组路由指定一个prefix前缀(也可以直接给定/前缀,表示注册在根路由下),在该分组下的所有路由注册都将注册在该路由前缀下

官方文档:https://goframe.org/pages/viewpage.action?pageId=1114517

比如我们将实例化后的userController绑定到以/user为前缀的路由组里

1
2
3
4
5
6
7
8
9
10
11
12
usercontroller := user.New()
s.Group("/user", func(group *ghttp.RouterGroup) {
group.Middleware(ghttp.MiddlewareHandlerResponse)
group.Bind(
usercontroller, // 绑定到控制器对象
)

// 可以用GET POST PUT等定义路由
group.GET("/get", func(r *ghttp.Request) {
r.Response.Writeln("/user/get")
})
})

规范路由

规范路由是为了解决一些规范化和自动化管理接口的场景,更适用于团队多人协作使用

规范路由需要使用YAML配置文件:manifest/config/config.yaml

1
2
3
4
server:
address: ":8000"
openapiPath: "/api.json"
swaggerPath: "/swagger"

在定义规范路由时使用固定的格式:它不再像前文中只需要接收*ghttp.Request类型参数的函数

1
func Handler(ctx context.Context, req *Request) (res *Response, err error)

其中形参和返回值各为两个,并且都是必须的一个都不能少:

参数 说明 注意事项
ctx context.Context 上下文 Server组件会自动从请求中获取并传递给接口方法
req *Request 请求对象 就算没有接收参数也要定义,因为请求结构体中不仅仅包含请求参数的定义,也包含了接口的请求定义。
res *Response 返回对象 就算没有返回参数也要定义,因为返回结构体中不仅仅包含返回参数的定义,也可以包含接口返回定义。
err error 错误对象 Server通过该参数判断接口执行成功或失败。

请求 / 返回结构体

在规范化路由注册中,非常重要的是请求 / 返回结构体的定义,在该结构体不仅仅包含了输入参数的定义,也包含了接口的定义,特别是路由地址、请求方法、接口描述等信息。为保证命名规范化,输入数据结构以XxxReq方式命名,输出数据结构以XxxRes方式命名。即便输入或者输出参数为空,也需要定义相应的数据结构,这样的目的一个是便于后续扩展,另一个是便于接口信息的管理

这个Xxx是控制类的方法名,个人感觉定义结构体类似我们在自定义Spring框架中的注解,比如@PostMapping之类的(当然这个注解是框架提供的,如果看到后文,其实有点@PostMapping@PathVariable@RequestParam等等注解混合使用的感觉)

其写法参照上图,需要将请求 / 返回结构体封装到/api/xxx目录下的xxx.go文件中

在请求结构体中一定有g.meta元数据,通过后面打tag的方式定义路由地址、请求方法、接口描述等信息,而结构体其他具体写什么内容会在后文中具体展开

在控制类中导入请求 / 返回结构体,比如导入user(通常可以自动导入)

1
import hellogf/api/user"

然后实现控制器方法,比如实现添加用户的方法,注意请求 / 返回结构体的使用

1
2
3
4
5
6
7
8
9
10
11
func (c *Controller) AddUser(ctx context.Context, req *user.AddReq) (res *user.AddRes, err error) {
res = &user.AddRes{
Name: "张三",
Age: 18,
}

// 自定义返回数据
// g.RequestFromCtx(ctx).Response.Writeln("Hello")

return
}

规范路由注册

推荐使用实例化对象的方式来管理所有路由方法,并通过分组路由的Bind方法执行统一注册

我们知道,在规范化路由方式下,路由地址以及请求方式将由请求结构体在g.Meta中定义,但我们依然可以通过分组路由定义分组下的所有路由前缀(很像Spring框架中的@RequestMapping注解)

比如注册userhello控制类的对象(/internal/cmd/cmd.go

1
2
3
4
5
6
7
8
9
userController := user.New()
helloController := hello.NewHello()
s.Group("/", func(group *ghttp.RouterGroup) {
group.Middleware(ghttp.MiddlewareHandlerResponse)
group.Bind(
userController,
helloController,
)
})

😕 为什么推荐使用规范路由? 从我对Java语言的Spring框架的理解来对官方文档中所描述的特性进行展开阐述:

👍 请求 / 返回结构体很类似Java中注解的使用,这在上文中也提到过; - 规范化API按照结构化编程设计 - 规范化API接口方法参数风格定义

👍 我们通过实例化控制器对象,注册对象中的全部公有方法,另外可以通过分组路由,类似@RequestMapping类注解添加路由前缀; - 更加简化的路由注册与维护

👍 返回数据直接是通用返回结果类的形式了,因此不需要再额外定义这个类了; - 统一接口返回数据格式设计

👍 不再需要我们手动整合Swagger组件(需要在配置文件中进行配置,但通常初始化项目的时候已经自动配置); - 保障代码与接口文档同步维护 - 自动生成基于标准OpenAPIv3协议的接口文档 - 自动生成SwaggerUI页面

👍 通过打tag的方式实现类似Spring Validation参数校验框架的效果,这个后文会介绍 - 自动的API参数对象化接收与校验

请求与返回

请求输入

基本使用

如何获取请求参数有很多种API,比如获取Query数据的GetQueryForm数据的GetForm等等,但这里先仅介绍GetRequest,这个API可以获取客户端提交的所有参数,按照参数优先级进行覆盖,不区分提交方式。(这些API是*ghttp.Request对象的,但如果我们使用规范路由,其实也都不常用)

如果想知道其他API的用法,参照官方文档:https://goframe.org/pages/viewpage.action?pageId=1114483

首先,需要知道在Goframe框架中,有以下几种提交类型:

提交类型 描述
Router 路由参数。来源于路由规则匹配,比如/article/id={id}
Query Query参数。URL中的Query String参数解析,如:http://127.0.0.1/index?id=1&name=john中的id=1&name=john
Form 表单参数。最常见的提交方式,提交的Content-Type往往为:application/x-www-form-urlencodedmultipart/form-datamultipart/mixed
Body 内容参数。从Body中获取并解析得到的参数,JSON/XML请求往往使用这种方式提交。
Custom 自定义参数,往往在服务端的中间件、服务函数中通过SetParam/GetParam方法管理。

如果我们使用GetRequest方法获取请求参数,那么客户端提交什么参数,我们都可以获取到

另外Get方法是GetRequest的别名,GetRequest方法需要传参,用于指定获取哪个参数,比如GetRequest("username"),而GetRequestMap不需要传参,用于获取全部请求参数,其别名是GetMap

我们通过下面的例子来了解GetRequest的使用: 1. 准备helloparams方法的请求 / 返回结构体

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

import "github.com/gogf/gf/v2/frame/g"

type ParamsReq struct {
g.Meta `path:"/params/age={age}" method:"all"`

UserName string
Password string
Age int
}

type ParamsRes struct {

}

  1. hello控制器中实现Params方法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    func (c *Hello) Params(ctx context.Context, req *hello.ParamsReq) (res *hello.ParamsRes, err error) {
    r := g.RequestFromCtx(ctx)

    data := r.GetMap()
    r.Response.Writeln(data)
    r.Response.Writeln(req)

    return
    }

要知道GetRequest这个方法是*ghttp.Request的API,如果我们使用规范路由,不能直接获取*ghttp.Request对象了,因此我们需要通过g.RequestFromCtx(ctx)来获取,然后调GetMap(或者GetRequestMap)获取客户端的所有请求参数

对象处理

对象转换在请求处理中非常常见。我们推荐将输入和输出定义为struct结构体对象,以便于结构化的参数输入输出维护

客户端提交的所有参数(上文提到的任何提交类型)都能转换为指定的struct结构体,并且支持提交参数与struct属性的映射关系维护

简单来说,客户端无论通过表单还是URL?后面跟参数等等提交请求参数的形式,只要XxxReq结构体中定义了对应的属性,都能被映射并且维护

参数映射

客户端提交的参数如果需要映射到服务端定义的struct属性上,可以采用默认的映射关系,这一点非常方便。默认的转换规则如下: 1. struct中需要匹配的属性必须为公开属性(首字母大写) 2. 参数名称会自动按照不区分大小写忽略-_空格符号的形式与struct属性进行匹配 3. 如果匹配成功,那么将键值赋值给属性,如果无法匹配,那么忽略该键值

以下是匹配的示例:

1
2
3
4
5
6
7
8
9
10
11
12
map键名    struct属性     是否匹配
name Name match
Email Email match
nickname NickName match
NICKNAME NickName match
Nick-Name NickName match
nick_name NickName match
nick name NickName match
NickName Nick_Name match
Nick-name Nick_Name match
nick_name Nick_Name match
nick name Nick_Name match

另外,除了默认规则,我们还可以通过p标签使请求参数映射为指定的struct属性,例如:

1
2
3
4
5
6
7
8
type ParamsReq struct {
g.Meta `path:"/params" method:"all"`

Id string
UserName string
Password string `p:"mima"`
Age int `p:"nianling"`
}
可以看到我们定义了四个属性:IdUsernamePasswordAge,其中PasswordAge追加了自定义规则外,四个属性都遵循默认规则的匹配

我们在控制器中实现Params方法:结果是输出客户端的全部请求参数和对象映射后的请求参数req

1
2
3
4
5
6
7
8
9
func (c *Hello) Params(ctx context.Context, req *hello.ParamsReq) (res *hello.ParamsRes, err error) {
r := g.RequestFromCtx(ctx)
data := r.GetMap()

r.Response.Writeln(data)
r.Response.Writeln(req)

return
}

最后在 Postman 中测试接口:

我们通过query的方式请求idsex两个参数,并且通过表单的方式请求usernamemimaa_ge三个参数

通过r.Response.Writeln(data)输出的内容来看,服务端能够收到所有请求参数(无论什么请求类型)

但通过r.Response.Writeln(req)输出的内容来看,所有请求参数中除了sex参数,其他参数都能通过默认规则或者自定义规则实现映射关系

默认值绑定

支持使用d标签(或者default标签)为属性绑定默认值,例如:

1
2
3
4
5
6
7
8
type ParamsReq struct {
g.Meta `path:"/params" method:"all"`

Id string
UserName string `d:"Anonymous"`
Password string `p:"mima" d:"123456"`
Age int `p:"nianling"`
}

最后使用 Postman 测试接口:

参数校验

在使用中,经常需要验证前端提交过来的数据是否符合规则,比如非空、长度限制、是否为数字等一系列验证。在GoFrame中,基本上都不用手动写验证规则,框架里已经提供了很多内置的验证规则可以用来验证数据,类似Spring Validation

https://goframe.org/pages/viewpage.action?pageId=1114367

  • 指定单个规则 在规则路由中使用v标签为每个字段指定校验规则

    1
    2
    3
    4
    5
    6
    type ValidReq struct {
    g.Meta `url:"/valid" method:"all"`
    UserName string `p:"username" v:"required"`
    Password string `p:"password"`
    Age int `p:"age"`
    }

  • 指定校验失败后的错误提示信息 在规则后面使用#符号添加错误提示信息

    1
    2
    3
    4
    5
    6
    type ValidReq struct {
    g.Meta `url:"/valid" method:"all"`
    UserName string `p:"username" v:"required#username不能为空"`
    Password string `p:"password"`
    Age int `p:"age"`
    }

  • 指定多个规则 使用|符号区分每个校验规则和错误提示信息,注意校验规则和错误提示信息的顺序要一一对应

    1
    2
    3
    4
    5
    6
    type ValidReq struct {
    g.Meta `url:"/valid" method:"all"`
    UserName string `p:"username" v:"required#username不能为空"`
    Password string `p:"password"`
    Age int `p:"age" v:"required|integer|min:0#age不能为空|age必须是整数|age不能小于0"`
    }

除了校验规则,还有修饰规则,比如ci可以为某些需要比较值的校验规则(samedifferentin等等)指定不区分大小写的规则

1
2
3
4
5
6
type ValidReq struct {
g.Meta `url:"/valid" method:"all"`
Account string `v:"required"`
Password string `v:"required|same:Password2"`
Password2 string `v:"required"`
}
此时,即使PasswordPassword2仅大小写不同也不会报错

响应输出

文本数据返回

除了返回文本外,还能返回HTML模板。但现在基本上都是前后端分离了,大多数场景下返回的是通用返回结果类,因此这里只简单展示如何返回文本数据

1
2
3
4
5
6
func (c *Hello) Respons(ctx context.Context, req *hello.ParamsReq) (res *hello.ParamsRes, err error) {
r := g.RequestFromCtx(ctx)
r.Response.Writeln("你好 GoFrame !")

return
}

API数据返回

在实际的前后端分离开发中,返回的JSON数据通常为以下结构(通用返回结果类)

1
2
3
4
5
{
"code":0, // 自定义编码,用来表示请求成功与失败
"msg":"请求成功", // 提示信息,如果请求出错则为错误信息
"data":{} // 请求返回数据,请求出错时一般为null
}

GoFrame为前后端分离的API开发提供了很好的支持,只需要借助api模块就可以方便完成类似的返回结构,不需要自行定义

  1. /api中定义请求 / 响应结构体,比如在/api/hello/hello.go中定义hellorespons方法的请求 / 响应结构体

    1
    2
    3
    4
    5
    6
    7
    8
    9
    type ResponsReq struct {
    g.Meta `method:"all"`
    }

    type ResponsRes struct {
    UserName string
    Password string
    List g.Array
    }
    如果没有定义路径,那么就默认以方法名做路径

  2. 在控制器中定义Respons方法,同时实例化数据并返回

    1
    2
    3
    4
    5
    6
    7
    8
    9
    func (c *Hello) Respons(ctx context.Context, req *hello.ResponsReq) (res *hello.ResponsRes, err error) {
    res = &hello.ResponsRes{
    UserName: "zhangsan",
    Password: "123456",
    List: g.Array{1, 2, 3, 4},
    }

    return
    }

  3. 使用 Postman 测试接口

如果有错误,定义错误信息并直接返回

1
2
3
4
5
6
7
8
9
10
func (c *Hello) Respons(ctx context.Context, req *hello.ResponsReq) (res *hello.ResponsRes, err error) {
// res = &hello.ResponsRes{
// UserName: "zhangsan",
// Password: "123456",
// List: g.Array{1, 2, 3, 4},
// }

err = gerror.New("服务器开小差了")
return
}

使用 Postman 测试接口

数据库ORM

准备数据库

本教程将以MySQL为例,使用命令行工具或者任意可视化工具创建名为goframe的数据库,字符集为utf8,排序规则为utf8mb4_general_ci

创建完成后,运行以下SQL脚本,创建测试数据表:

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
101
102
103
104
105
106
107
108
109
110
111
112
113
114
USE `goframe`;

/*Table structure for table `book` */

DROP TABLE IF EXISTS `book`;

CREATE TABLE `book` (
`id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID',
`name` VARCHAR(50) NOT NULL COMMENT '书名',
`author` VARCHAR(30) NOT NULL COMMENT '作者',
`price` DOUBLE NOT NULL COMMENT '价格',
`publish_time` DATE COMMENT '出版时间',
PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8;

/*Data for the table `book` */

INSERT INTO `book`(`id`,`name`,`author`,`price`) VALUES
(1,'MySQL数据库从入门到精通','王飞飞',59.8),
(2,'设计模式','刘伟',45),
(3,'数据库原理及应用','刘亮',33),
(4,'Linux驱动开发入门与实践','郑强',69),
(5,'Linux驱动开发入门与实践','郑强',69),
(6,'Linux驱动开发入门与实践','郑强',69);

/*Table structure for table `dept` */

DROP TABLE IF EXISTS `dept`;

CREATE TABLE `dept` (
`id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID',
`pid` INT(10) UNSIGNED DEFAULT NULL COMMENT '上级部门ID',
`name` VARCHAR(30) DEFAULT NULL COMMENT '部门名称',
`leader` VARCHAR(20) DEFAULT NULL COMMENT '部门领导',
`phone` VARCHAR(11) DEFAULT NULL COMMENT '联系电话',
PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=108 DEFAULT CHARSET=utf8;

/*Data for the table `dept` */

INSERT INTO `dept`(`id`,`pid`,`name`,`leader`,`phone`) VALUES
(100,0,'哪都通','赵方旭','10000000000'),
(101,100,'华北大区','徐四','10000000001'),
(102,100,'东北大区','高廉','10000000002'),
(103,100,'华东大区','窦乐','10000000003'),
(104,100,'华中大区','任菲','10000000004'),
(105,100,'华南大区',NULL,NULL),
(106,100,'西北大区','华风','10000000005'),
(107,100,'西南大区','郝意','10000000006');

/*Table structure for table `emp` */

DROP TABLE IF EXISTS `emp`;

CREATE TABLE `emp` (
`id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID',
`dept_id` INT(10) UNSIGNED NOT NULL COMMENT '所属部门',
`name` VARCHAR(30) NOT NULL COMMENT '姓名',
`gender` TINYINT(1) DEFAULT NULL COMMENT '性别: 0=男 1=女',
`phone` VARCHAR(11) DEFAULT NULL COMMENT '联系电话',
`email` VARCHAR(50) DEFAULT NULL COMMENT '邮箱',
`avatar` VARCHAR(100) DEFAULT NULL COMMENT '照片',
PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8;

/*Data for the table `emp` */

INSERT INTO `emp`(`id`,`dept_id`,`name`,`gender`,`phone`,`email`) VALUES
(1,100,'赵方旭',0,'10000000000','zhaofx@nadoutong.com'),
(2,100,'毕游龙',0,'10000000007','biyoulong@nadoutong.com'),
(3,100,'黄伯仁',0,'10000000008','huangboren@nadoutong.com'),
(4,101,'徐四',0,'10000000001','xusi@nadoutong.com'),
(5,101,'徐三',0,'10000000009','xusan@nadoutong.com'),
(6,101,'冯宝宝',1,'10000000010','fengbaobao@nadoutong.com'),
(7,101,'张楚岚',0,'10000000011','zhangchulan@nadoutong.com'),
(8,102,'高廉',0,'10000000002','gaolian@nadoutong.com'),
(9,102,'高二壮',1,'10000000012','gaoerzhuang@nadoutong.com'),
(10,103,'窦乐',0,'10000000003','doule@nadoutong.com'),
(11,103,'肖自在',0,'10000000013','xiaozizai@nadoutong.com'),
(12,104,'任菲',0,'10000000004','renfei@nadoutong.com'),
(13,106,'华风',0,'10000000005','huafeng@nadoutong.com'),
(14,107,'郝意',0,'10000000006','huafeng@nadoutong.com');

DROP TABLE IF EXISTS `hobby`;

CREATE TABLE `hobby` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID',
`emp_id` INT UNSIGNED NOT NULL COMMENT 'EmpID',
`hobby` VARCHAR(50) COMMENT '爱好',
PRIMARY KEY (`id`)
) ENGINE=INNODB CHARSET=utf8 COLLATE=utf8_general_ci;

INSERT INTO `hobby` (`id`, `emp_id`, `hobby`) VALUES
(1, 6, '埋人'),
(2, 4, '看美女'),
(3, 7, '月下遛鸟');

DROP TABLE IF EXISTS `user`;

CREATE TABLE `user` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID',
`username` VARCHAR(20) NOT NULL COMMENT '用户名',
`nickname` VARCHAR(30) COMMENT '昵称',
`password` VARCHAR(32) COMMENT '密码',
`avatar` VARCHAR(100) COMMENT '头像',
`created_at` DATETIME COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=INNODB CHARSET=utf8 COLLATE=utf8_general_ci;

INSERT INTO
`user` (`id`, `username`, `nickname`, `password`, `avatar`, `created_at`) VALUES
(1, 'libai', '李白', '123456', '', '2023-10-08 16:57:24'),
(2, 'dufu', '杜甫', '123456', '', '2023-10-08 16:57:24'),
(3, 'baijuyi', '白居易', '123456', '', '2023-10-08 16:57:24');

数据库配置

数据库内容完毕后,在配置文件/manifest/config/config.yaml中添加数据库的配置:

1
2
3
4
5
6
7
8
9
database:
type: "mysql"
host: "127.0.0.1"
port: "3306"
user: "root"
pass: "root"
name: "goframe"
timezone: "Asia/Shanghai"
debug: true

各配置项的内容: - type:数据库类型,MySQL、SQLite、SQL Server等等 - host:数据库主机 - port:数据库端口 - user:数据库连接用户名 - pass:数据库连接密码 - name:需要连接的数据库名 - timezone:时区,设置为Asis/Shanghai或者Local - debug:是否开启调试,学习开发阶段可以打开,用于查看数据库操作相关信息输出

其他配置项参考官方文档:https://goframe.org/pages/viewpage.action?pageId=1114245

驱动添加与导入

  1. main.go中进行MySQL驱动的初始化导入

    1
    2
    3
    import (
    _ "github.com/gogf/gf/contrib/drivers/mysql/v2"
    )

  2. go.mod中添加驱动库与版本

    1
    2
    3
    4
    require (
    github.com/gogf/gf/contrib/drivers/mysql/v2 v2.5.3
    github.com/gogf/gf/v2 v2.5.3
    )

  3. 在命令行中进行依赖更新

    1
    go mod tidy

最后等待依赖的下载更新完成

数据库基本操作

github.com/gogf/gf/v2/frame/g包里的Model函数将返回gdb.Model对象,提供了一系列用于操作数据库的API。Model函数接收一个参数:数据表名

1
md := g.Model("book")

基础查询

查询单个数据One
1
2
3
4
5
6
7
md := g.Model("book")
book, err := md.One()
if err == nil {
req.Response.WriteJson(book)
}
// 生成的SQL语句
// SELECT * FROM `book` LIMIT 1

该结果将返回数据表中的第一条数据,查询成功返回的数据为map[string]*gvar.Var类型,所以可以访问该数据的每一个字段

1
req.Response.WriteJson(book["name"])  // 返回结果中"name"字段
我们也可以通过*gvar.Var提供的API对字符进行类型转换(*gvar.Var是框架提供的泛型):

1
2
3
4
5
6
7
8
9
10
11
12
book["name"].String() // 转为string类型
book["price"].Float32() // 转为float32类型
````

##### 查询指定字段`Fields`
个人感觉这个方法是生成了只有指定字段的新数据表,因此后面还需要链式调用其他API
```go
book, err := md.Fields("name, price").One() //只查询name price两个字段
// 也可以写为
book, err := md.Fields("name", "price").One()
// 生成的SQL语句
// SELECT `name`,`price` FROM `book` LIMIT 1

另外,可以查询除了指定字段的其他字段

1
2
3
book, err := md.FieldsEx("name, price").One() //查询除了name price两个字段的其他字段
// 也可以写为
book, err := md.FieldsEx("name", "price").One()

查询所有数据All
1
2
3
4
md := g.Model("book")
bookList, err := md.All()
// 生成的SQL语句
// SELECT * FROM `book`

该方法以切片返回数据表中的所有数据,我们可以通过range关键字来循环操作每一条数据

1
2
3
for _, v := range bookList {
req.Response.Writeln(v)
}

查询单个数据的指定字段值Value
1
2
3
4
md := g.Model("book")
name, err := md.Value("name")
// 生成的SQL语句
// SELECT `name` FROM `book` LIMIT 1
查询所有数据的指定字段值Array
1
2
3
4
md := g.Model("book")
name, err := md.Array("name")
// 生成的SQL语句
// SELECT `name` FROM `book`
聚合函数

注意,一个Model只能使用一次查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
min, err := g.Model("book").Min("price")
max, err := g.Model("book").Max("price")
avg, err := g.Model("book").Avg("price")
count, err := g.Model("book").Count()

if err == nil {
r.Response.WriteJson(g.Map{
"min": min,
"max": max,
"avg": avg,
"count": count,
})
}
// 生成的SQL语句
// SELECT MIN(`price`) FROM `book` LIMIT 1
// SELECT MAX(`price`) FROM `book` LIMIT 1
// SELECT AVG(`price`) FROM `book` LIMIT 1
// SELECT COUNT(1) FROM `book`

条件查询

查询数据时可以通过Where方法指定条件,如果调用了多个Where,那么多个条件之间会用AND连接

  1. 等于

默认情况下条件会用=连接

1
2
3
4
md := g.Model("book")
book, err := md.Where("id", 3).All()
// 生成的SQL语句
// SELECT * FROM `book` WHERE `id`=3

  1. 不等 如果是不等关系,则需要在字段后面加上不等符号
    1
    2
    3
    4
    md := g.Model("book")
    book, err := md.Where("id>", 3).All()
    // 生成的SQL语句
    // SELECT * FROM `book` WHERE id>3

另一种写法是加上?占位符,效果是一样的

1
2
md := g.Model("book")
book, err := md.Where("id>?", 3).All()

  1. 多个条件叠加 当存在多个条件时可以多个Where函数进行链式调用,条件之间会用AND连接
    1
    2
    3
    4
    md := g.Model("book")
    books, err := md.Where("id>=?", 2).Where("id<?", 6).All()
    // 生成的SQL语句
    // SELECT * FROM `book` WHERE (id>=2) AND (id<6)

以下是Where系列方法:

方法 生成的SQL条件表达式
WhereLT(column, value) column < value
WhereLTE(column, value) column <= value
WhereGT(column, value) column > value
WhereGTE(column, value) column >= value
WhereBetween(column, min, max) column BETWEEN min AND max
WhereNotBetween(column, min, max) column NOT BETWEEN min AND max
WhereLike(column, like) column LIKE like
WhereIn(column, in) column IN (in)
WhereNotIn(column, in) column NOT IN (in)
WhereNot(column, value) column != value
WhereNull(columns1, columns2... ) columns1 IS NULL AND columns2 IS NULL...
WhereNotNull(columns1, columns2... ) columns1 IS NOT NULL AND columns2 IS NOT NULL ...

使用示例:

1
2
3
4
5
md := g.Model("book")
books, err := md.WhereIn("id", g.Array{1, 2, 3}).WhereLike("name", "%数据%").All()

// 生成的SQL语句
// SELECT * FROM `book` WHERE (`id` IN (1,2,3)) AND (`name` LIKE '%数据%')

以上方法如果链式调用会生成以AND连接的条件,如果需要生成以OR连接的条件,则需要用到以下方法: |方法|生成的SQL条件表达式| |:--:|:--:| |WhereOrLT(column, value)|OR (column < value)| |WhereOrLTE(column, value)|OR (column <= value)| |WhereOrGT(column, value)|OR (column > value)| |WhereOrGTE(column, value)|OR (column >= value)| |WhereOrBetween(column, min, max)|OR (column BETWEEN min AND max)| |WhereOrNotBetween(column, min, max)|OR (column NOT BETWEEN min AND max)| |WhereOrLike(column, like)|OR (column LIKE like)| |WhereOrIn(column, in)|OR (column IN (in))| |WhereOrNotIn(column, in)|OR (column NOT IN (in))| |WhereOrNot(column, value)|OR (column != value)| |WhereOrNull(columns1, columns2... )|OR (columns1 IS NULL AND columns2 IS NULL...)| |WhereOrNotNull(columns1, columns2... )|OR (columns1 IS NOT NULL AND columns2 IS NOT NULL ...)| |WhereOr(column, value)|OR (column = value)|

使用示例:

1
2
3
4
5
md := g.Model("book")
books, err := md.WhereIn("id", g.Array{1, 2, 3}).WhereOrLike("name", "%数据%").All()

// 生成的SQL语句
// SELECT * FROM `book` WHERE (`id` IN (1,2,3)) OR (`name` LIKE '%数据%')

排序与分组

按字段排序:

1
2
3
4
5
6
7
8
9
10
11
md := g.Model("book")

books, err := md.Order("price", "DESC").All()

// 多字段排序
books, err := md.Order("price", "DESC").Order("id", "ASC").All()
// 生成的SQL语句
// SELECT * FROM `book` ORDER BY `price` DESC,`id` ASC

// 排序封装方法
books, err := md.OrderDesc("price").OrderAsc("id").All()

按字段分组:

1
2
3
4
md := g.Model("book")
books, err := md.Group("name").Count() // 4
// 生成的SQL语句
// SELECT COUNT(1) FROM (SELECT COUNT(1) FROM `book` GROUP BY `name`)

分页

使用Limit方法用来限制查询条数或者自定义起始位置与数据限制

1
2
3
4
5
6
7
8
9
10
md := g.Model("book")
// 限制条数
books, err := md.Limit(5).All()
// 生成的SQL语句
// SELECT * FROM `book` LIMIT 5

// 指定起始位置与限制条数:此处指第三条数据开始后面五条数据(本身不算)
books, err := md.Limit(3, 5).All()
// 生成的SQL语句
// SELECT * FROM `book` LIMIT 3,5

GoFrame 提供了Page方法可以很方便实现分页查询,只需提供两个参数:页数和每页数据数量

1
2
3
4
md := g.Model("book")
books, err := md.Page(2, 3).All()
// 生成的SQL语句
// SELECT * FROM `book` LIMIT 3,3

将查询结果转为结构体

在上文中提到过OneAll方法返回的数据是Map或者Map切片,在实际使用中查询到的数据可能需要转换为特定的数据结构方便使用。

Scan方法可以将查询到的数据转为自定义结构体或结构体数组,下面是推荐写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type Book struct {
Id uint
Name string
Author string
Price float64
PublishTime *gtime.Time
}

var book *Book

md := g.Model("book")
error := md.Where("id", 10).Scan(&book)

if error == nil {
r.Response.WriteJson(&book)
} else {
r.Response.WriteJson(error.Error())
}

Scan会将数据库字段下划线命名对应到结构体中相应的驼峰命名上,如果对应不上,则该成员为nil或者零值。如果结构体中成员名称与数据表中字段不对应,可以用orm:标签来指定对应字段,比如:

1
2
3
4
5
6
7
8
9
10
11
12
type Book struct {
BookId uint `orm:"id" `
BookName string `orm:"name"`
BookAuthor string `orm:"author"`
BookPrice float64 `orm:"price"`
PubTime *gtime.Time `orm:"publish_time"`
}

var book *Book

md := g.Model("book")
error := md.Scan(&book)

Scan方法可以如上查询单独结构体,也可以查询一个结构体数组,只需要将结构体指针改为结构体切片传入即可

1
2
3
4
5
6
7
8
9
10
11
12
type Book struct {
Id uint
Name string
Author string
Price float64
PublishTime *gtime.Time
}

var book []Book

md := g.Model("book")
error := md.Scan(&book)
查询结果为一个由Book组成的结构体数组,存放多条数据。

查询结果为空判断
  • All

    1
    2
    3
    4
    5
    6
    7
    8
    9
    md := g.Model("book")
    books, _ := md.All()
    if len(books) == 0 {
    r.Response.Writeln("结果为空")
    }
    // 或者
    if books.IsEmpty() {
    r.Response.Writeln("结果为空")
    }

  • One

    1
    2
    3
    4
    5
    6
    7
    8
    9
    md := g.Model("book")
    book, _ := md.Where("id", 100).One()
    if len(book) == 0 {
    r.Response.Writeln("结果为空")
    }
    // 或者
    if book.IsEmpty() {
    r.Response.Writeln("结果为空")
    }

  • Value

    1
    2
    3
    4
    5
    md := g.Model("book")
    name, _ := md.Where("id", 10).Value("name")
    if name.IsEmpty() {
    r.Response.Writeln("结果为空")
    }

  • Array

    1
    2
    3
    4
    5
    md := g.Model("book")
    names, _ := md.WhereLT("id", 10).Array("name")
    if len(names) == 0{
    r.Response.Writeln("结果为空")
    }

  • Scan结构体对象

    1
    2
    3
    4
    5
    6
    7
    var book *Book
    md := g.Model("book")
    md.Scan(&book)
    if book == nil {
    r.Response.Writeln("结果为空")
    }
    md.Save(data)

  • Scan结构体数组

    1
    2
    3
    4
    5
    6
    var books []Book
    md := g.Model("book")
    md.Scan(&books)
    if len(books) == 0 {
    r.Response.Writeln("结果为空")
    }

插入数据

有三个方法可以插入数据,分别是InsertReplaceSave,区别在于当主键字段(或唯一索引)冲突时,处理的方式不同: |方法|主键(或唯一索引)冲突时| |:--:|:--:| |Insert|报错:主键(或唯一索引)冲突| |Replace|用新数据替换已存在的同主键(或唯一索引)的数据| |Save|用新数据更新已存在的同主键(或唯一索引)的数据|

写入单条数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
md := g.Model("book")
data := g.Map{
"id": 8,
"name": "Linux驱动开发入门与实践",
"author": "郑强",
"price": 69,
"publish_time": "2023-10-10",
}
// Insert
result, err := md.Insert(data)
// Replace
result, err := md.Replace(data)
// Save
result, err := md.Save(data)

result返回结果将会返回影响行数,另外以上方法也可配合Data方法使用:

1
2
3
4
5
6
// Insert
result, err := md.Data(data).Insert()
// Replace
result, err := md.Data(data).Replace()
// Save
result, err := md.Data(data).Save()

除了使用Map类型之外,还可以用结构体。结构体成员名称与数据表字段名称不对应时,用orm标签指定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type Book struct {
Id uint
Name string
Author string
Price float64
PubTime *gtime.Time `orm:"publish_time"`
}


md := g.Model("book")
data := Book{
Id: 8,
Name: "Linux驱动开发入门与实践",
Author: "郑强",
Price: 69.3,
PubTime: gtime.New("2023-10-10"),
}
result, err := md.Data(data).Save()

批量写入数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
data := g.List{
g.Map{
"name": "Linux驱动开发入门与实践",
"author": "郑强",
"price": 69.3,
"publish_time": gtime.New("2023-10-10"),
},
g.Map{
"name": "Linux驱动开发入门与实践",
"author": "郑强",
"price": 69.3,
"publish_time": gtime.New("2023-10-10"),
},
g.Map{
"name": "Linux驱动开发入门与实践",
"author": "郑强",
"price": 69.3,
"publish_time": gtime.New("2023-10-10"),
},
}

result, err := md.Data(data).Save()

如果使用的是结构体,将g.List改为g.Array或者g.Slice

InsertAndGetId

插入数据并返回ID

1
2
3
4
5
6
7
8
data := g.Map{
"name": "Linux驱动开发入门与实践",
"author": "郑强",
"price": 69.3,
"publish_time": gtime.New("2023-10-10"),
}

result, err := md.Data(data).InsertAndGetId()

gdb.Raw

对于某些字段,可能需要调用SQL里面的操作来获得结果,例如,publish_time字段可以用SQL中的CURRENT_DATE()来获取当前日期,这时就需要用到gdb.Raw

1
2
3
4
5
6
7
8
data := g.Map{
"name": "Linux驱动开发入门与实践",
"author": "郑强",
"price": 69.3,
"publish_time": gdb.Raw("CURRENT_DATE()"),
}

result, err := md.Data(data).InsertAndGetId()

更新数据

使用Update方法进行更新数据操作

1
2
3
4
5
6
data := g.Map{
"author": "郑强强",
"price": 69.333,
}

result, err := md.Where("author", "郑强").Update(data)

也可以配合Data使用

1
2
3
4
5
6
data := g.Map{
"author": "郑强强",
"price": 69.333,
}

result, err := md.Where("author", "郑强").Data(data).Update()

使用IncrementDecrement用来给指定字段增加或减少指定值

1
2
result, err := md.WhereBetween("id", 7, 10).Increment("price", 2.5)
result, err := md.WhereBetween("id", 7, 10).Decrement("price", 1.5)

删除数据

使用Delete方法进行删除数据操作

1
result, err := md.WhereGT("id", 10).Delete()

时间维护与逻辑删除

在实际应用中,数据表通常会有三个时间字段:创建时间、更新时间、删除时间。GoFrame 支持这三个时间字段的自动填充,这三个字段支持的类型为DATEDATETIMETIMESTAMP

比如我们在book表中添加created_atupdated_atdeleted_at三个字段

我们使用Insert方法添加数据,注意我们并没有给定创建时间和更新时间的值

1
2
3
4
5
6
7
8
data := Book{
Id: 11,
Name: "Linux驱动开发入门与实践",
Author: "郑强",
Price: 69.3,
PubTime: gtime.New("2023-10-10"),
}
result, err := md.Data(data).Insert()

得到的结果是:

我们发现 GoFrame 框架为我们自动生成了创建时间和更新时间

Update方法也是同理

删除时间涉及到逻辑删除的概念,我们使用Delete方法删除数据:

1
result, err := md.Where("id", 10).Data(data).Delete()

得到的结果是:

只要数据的deleted_at字段非空,那么就表示该数据被逻辑删除了,如果这时使用比如All方法查询数据是查不到该数据的。因为查询数据时,会自动加上WHERE deleted_at IS NULL的条件,过滤已被逻辑删除的数据

如果还是要查询所有数据,需要使用Unscoped方法

1
ls, _ := md.Unscoped().All()

事务

1
2
3
4
g.DB().Transaction(context.TODO(), func(ctx context.Context, tx gdb.TX) error {
_, err := tx.Model("book").Ctx(ctx).Save(data)
return err
})

原生SQL

Model提供的方法能组合出绝大多数使用场景所需要的数据操作,但如果需要的操作过于复杂,可能就没法通过已有的方法组合出来,就需要使用写SQL来实现

首先获取ORM对象

1
2
3
4
5
// 获取默认配置的数据库对象
db := g.DB()

// 或获取配置分组名称为"user-center"的数据库对象
db := g.DB("user-center")

  • 查询数据列表:

    1
    2
    3
    list, err := db.GetAll(ctx, "select * from user limit 2")
    list, err := db.GetAll(ctx, "select * from user where age > ? and name like ?", g.Slice{18, "%john%"})
    list, err := db.GetAll(ctx, "select * from user where status= ?", g.Slice{1})

  • 查询单条数据

    1
    2
    3
    one, err := db.GetOne(ctx, "select * from user where uid=1000")
    one, err := db.GetOne(ctx, "select * from user where uid=?", 1000)
    one, err := db.GetOne(ctx, "SELECT * FROM `book` WHERE `id` > ? AND `id` < ?", g.Array{3, 7})

  • 增删改数据

    1
    2
    3
    sql := "INSERT INTO `book` (`name`, `author`, `price`) VALUES (?, ?, ?)"
    data := g.Array{"Go语言从入门到精通", "Go语言研讨组", 99.98}
    result, err := db.Exec(ctx, sql, data)

1
2
3
sql := "DELETE FROM `book` WHERE `id` = ?"
data := g.Array{"11"}
result, err := db.Exec(ctx, sql, data)
1
2
3
sql := "UPDATE `book` SET author = ? WHERE id = ?"
data := g.Array{"郑强强", "12"}
result, err := db.Exec(ctx, sql, data)

但是注意,当我们使用原生SQL语句增删改数据时,上文中的三个时间字段(创建时间、更新时间、删除时间)不会被自动填充

DAO自动生成与使用

数据库相关的操作与数据结构放在daomodel中,在GoFrame中,daomodel的内容可以自动生成。生成步骤如下: 1. 在hack/config.yaml中配置dao

1
2
3
4
5
gfcli:
gen:
dao:
link: "mysql:root:2002@tcp(localhost:3306)/goframe?loc=Local&parseTime=True"
tables: "book,user,emp,dept,hobby"
其中,link表示数据库连接的URL;tables表示需要生成的daomodel的数据表,多个表用逗号隔开

  1. 在命令行中执行该命令生成daomodel的代码
    1
    gf gen dao

生成成功后,使用各个表对应的Model对象时,不再用g.Model获取,而是使用下面的方式:

1
2
md := dao.Book.Ctx(ctx)
books, err := md.All()
注意,该Model对象可以通过下面的方式多次叠加方法
1
2
3
4
5
6
7
md := dao.Book.Ctx(ctx)
md = md.WhereGT("id", 3)
md = md.WhereLT("id", 6)
books, err := md.All()

// 以上代码相当于
// books, err := dao.Book.Ctx(ctx).WhereGT("id", 3).WhereLT("id", 6).All()

字段过滤

使用结构体数据更新数据的时候,有些字段可能不需要更新,因此对应的字段就不进行赋值,例如以下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Book struct {
Id uint
Name string
Author string
Price float64
PubTime *gtime.Time `orm:"publish_time"`
}

data := Book{
Name: "Linux驱动开发入门与实践",
PubTime: gtime.New("2023-10-11"),
}

_, err = dao.Book.Ctx(ctx).Where("id", 13).Data(data).Update()
如果这样更新,那么idauthorprice也会被对应类型的零值更新,分别被更新为0""nil

如果要解决这样的问题,有以下几种解决方案: - 用Fields指定需要更新的字段

1
dao.Book.Ctx(ctx).Fields("name", "publish_time").Where("id", 13).Data(data).Update()

  • FieldsEx排除不需要更新的字段

    1
    dao.Book.Ctx(ctx).FieldsEx("id,author,price").Where("id", 13).Data(data).Update()

  • OmitEmpty过滤空值

    1
    2
    3
    4
    5
    6
    data := Book{
    Name: "Linux驱动开发入门与实践",
    Price: 0,
    PubTime: nil,
    }
    dao.Book.Ctx(ctx).Where("id", 13).OmitEmpty().Data(data).Update()
    如果用OmitEmpty方法,即使数据中有零值,也无法更新对应字段的值

  • do对象进行字段过滤 使用gf gen dao会为每个表生成对应的do对象,使用do对象作为参数传递,将会自动过滤空值

    1
    2
    3
    4
    5
    6
    data := do.Book{
    Name: "Linux驱动开发入门与实践",
    Price: 0,
    PublishTime: nil,
    }
    dao.Book.Ctx(ctx).Where("id", 13).Data(data).Update()
    如果使用这种方法,nil的零值都可以更新

do对象也可以用于传递查询条件,也会自动过滤空值

1
2
3
4
5
6
7
8
9
where := do.Book{
Author: "郑强",
Id: 13,
PublishTime: nil,
}

books, err := dao.Book.Ctx(ctx).Where(where).All()
// 相当于
books, err := dao.Book.Ctx(ctx).Where("id", 13).Where("author", "郑强").All()

关联查询

多表数据联查时可以用连接,但是数据量大时连接效率不高,GoFrame中提供了模型关联查询,可以简化一些多表联查操作

deptemphobby三个表为例,每个部门可以有多个员工,每个员工只有一个部门,每个员工对应一条爱好

  • 查询所有员工,并关联查询出其所在部门
  1. internal/model/entity/emp.go中修改entity.Emp,加入关联信息

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    type Emp struct {
    Id uint `json:"id" ` // ID
    DeptId uint `json:"dept_id" ` // 所属部门
    Name string `json:"name" ` // 姓名
    Gender int `json:"gender" ` // 性别: 0=男 1=女
    Phone string `json:"phone" ` // 联系电话
    Email string `json:"email" ` // 邮箱
    Avatar string `json:"avatar" ` // 照片

    Dept *Dept `json:"dept" orm:"with:id=dept_id"`
    }
    注意,在with:id=dept_id中前面的字段id必须是Emp表中的字段id,而后面的字段dept_id必须是Dept被关联表中的字段dept_id

  2. 使用With方法指定关联模型查询

    1
    2
    var emps []*entity.Emp
    err = dao.Emp.Ctx(ctx).With(entity.Dept{}).Scan(&emps)

  • 查询所有员工,并关联查询出其所在部门和爱好
  1. internal/model/entity/emp.go中修改entity.Emp,加入关联信息

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    type Emp struct {
    Id uint `json:"id" ` // ID
    DeptId uint `json:"dept_id" ` // 所属部门
    Name string `json:"name" ` // 姓名
    Gender int `json:"gender" ` // 性别: 0=男 1=女
    Phone string `json:"phone" ` // 联系电话
    Email string `json:"email" ` // 邮箱
    Avatar string `json:"avatar" ` // 照片

    Dept *Dept `json:"dept" orm:"with:id=dept_id" `
    Hobby *Hobby `json:"hobby" orm:"with:emp_id=id"`
    }

  2. 使用With指定需要关联的内容

    1
    2
    var emps []*entity.Emp
    err = dao.Emp.Ctx(ctx).With(entity.Dept{}, entity.Hobby{}).Where("dept_id", 101).Scan(&emps)
    也可以使用WithAll关联所有
    1
    2
    var emps []*entity.Emp
    err = dao.Emp.Ctx(ctx).WithAll().Where("dept_id", 101).Scan(&emps)

  • 查询部门,关联查询每个部门的员工
  1. internal/model/entity/dept.go修改entity.Dept,加入关联信息

    1
    2
    3
    4
    5
    6
    7
    8
    9
    type Dept struct {
    Id uint `json:"id" ` // ID
    Pid uint `json:"pid" ` // 上级部门ID
    Name string `json:"name" ` // 部门名称
    Leader string `json:"leader" ` // 部门领导
    Phone string `json:"phone" ` // 联系电话

    Emps []*Emp `json:"emps" orm:"with:dept_id=id"`
    }

  2. 使用With指定需要关联的内容

    1
    2
    var depts []*entity.Dept
    err = dao.Dept.Ctx(ctx).With(entity.Emp{}).Scan(&depts)

如果不想查询关联表(或被关联表的)所有字段,可以自己重新定义结构体,只需要保留需要查询的字段(用于关联的字段必须存在)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type MyDept struct {
g.Meta `orm:"table:dept"`
Id uint `json:"id" ` // ID
Name string `json:"name" ` // 部门名称
Leader string `json:"leader" ` // 部门领导
Phone string `json:"phone" ` // 联系电话
}

type MyEmp struct {
g.Meta `orm:"table:emp"`
Id uint `json:"id" ` // ID
DeptId uint `json:"dept_id" ` // 所属部门
Name string `json:"name" ` // 姓名
Phone string `json:"phone" ` // 联系电话

Dept *MyDept `json:"dept" orm:"with:id=dept_id" `
}

var emps []*MyEmp
err = dao.Emp.Ctx(ctx).With(MyDept{}).Scan(&emps)
注意,在自定义结构体时,需要用g.Metaorm标签指定对应的数据表

业务层

业务层的接口定义层,具体的接口实现在logic中进行注入,类似Spring项目中的service目录 logic业务封装,用于业务逻辑封装管理,特定的业务逻辑实现和封装。往往是项目中最复杂的部分,类似Spring项目中的serviceImpl目录

数据模型与业务模型

因此,综合上文提到的全部内容,所有数据模型和业务模型的关系可以总结为下图

  1. 数据模型 数据模型又叫做实体模型,主要是来自于底层持久化数据库的数据结构,例如:MySQLRedis等等。这部分数据模型的代码位于/internal/model/entity目录下。开发者应当在数据库设计并建立完成后,使用gf gen dao生成数据模型的代码

  1. 业务模型 业务模型主要包括:接口输入/输出模型业务输入/输出模型

接口输入/输出模型用于系统/服务间的接口交互,定义在api接口层中,供工程项目所有的层级调用,例如controller, logic, model中均可以调用api层的输入输出模型,但是api层仅用于与外部服务的接口交互,该模型中不能调用或者引用内部的模型如model模型。在GoFrame框架规范中,这部分输出输出模型名称以XxxReqXxxRes格式命名。

业务输入/输出模型用于服务内部模块/组件之间的方法调用交互,特别是controller->service或者service->service之间的调用。这部分模型定义在model模型层中。在GoFrame框架规范中,这部分输入输出模型名称通常以XxxInputXxxOutput格式命名。

还有一种特殊的业务模型DO,介于业务模型与数据模型之间,主要用于结合框架强大的ORM组件大大简便DAO数据访问操作。

业务接口

使用业务接口需要按照以下四条步骤: 1. 定义接口,并给定形参和返回值 2. 定义接口变量,用于存储接口实现的实例 3. 定义获取接口实例的函数,用于返回接口变量存储的接口实例 4. 定义接口实现的注册方法,用于将接口实现的实例注册到接口变量中

book类为例,在internal/service中创建book.go以实现book逻辑层的功能:

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
package service

import (
"context"
"hellogf/internal/model/do"
"hellogf/internal/model/entity"
)

// 1. 定义接口
type IBook interface {
GetList(ctx context.Context) (books []entity.Book, err error)
Add(ctx context.Context, book do.Book) (err error)
Edit(ctx context.Context, book do.Book) (err error)
Del(ctx context.Context) (err error)
}

// 2. 定义接口变量
var localBook IBook

// 3. 定义获取接口实例的函数
func Book() IBook {
if localBook == nil {
panic("implement not found for interface IBook, forgot register?")
}
return localBook
}

// 4. 定义接口实现的注册方法
func RegisterBook(i IBook) {
localBook = i
}

个人认为可以在VSCode中注册一个代码片段,方便使用,比如:

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
"service": {
"prefix": "XxxService",
"body": [
"package service",
"",
"import (",
"\t\"context\"",
"",
")",
"",
"// 1. 定义接口",
"type IXxx$0 interface {",
"\tGetXxxList(ctx context.Context) (xxxList []entity.Xxx, err error)",
"\tAddXxx(ctx context.Context, xxx do.Xxx) (err error)",
"\tEditXxx(ctx context.Context, xxx do.Xxx) (err error)",
"\tDeleteXxx(ctx context.Context) (err error)",
"}",
"",
"// 2. 定义接口变量",
"var localXxx IXxx",
"",
"// 3. 定义获取接口实例的函数",
"func Xxx() IXxx {",
"\tif localXxx == nil {",
"\t\tpanic(\"implement not found for interface IBook, forgot register?\")",
"\t}",
"\treturn localXxx",
"}",
"",
"// 4. 定义接口实现的注册方法",
"func RegisterXxx(i IXxx) {",
"\tlocalXxx = i",
"}",
"",
],
"description": "业务接口代码片段"
},

业务封装

业务封装用于实现业务接口的全部方法

通常,我们在internal/logic中还需要创建目录,以book为例,就创建book目录,并在该目录下再创建book.go用于实现接口方法

  1. 定义方法结构体

    1
    type sBook struct{}

  2. 将方法结构体传给注册方法

    1
    2
    3
    func init() {
    service.RegisterBook(&sBook{})
    }

  3. 鼠标悬停并点击&sBook{},会出现“快速修复”功能,点击后自动生成接口中每个方法的实现 以GetList方法为例,实现该方法的具体功能

    1
    2
    3
    4
    5
    // GetList implements service.IBook.
    func (*sBook) GetList(ctx context.Context) (books []entity.Book, err error) {
    err = dao.Book.Ctx(ctx).Scan(&books)
    return
    }

以下是book业务封装的完整代码

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
package book

import (
"context"
"hellogf/internal/dao"
"hellogf/internal/model/do"
"hellogf/internal/model/entity"
"hellogf/internal/service"
)

func init() {
service.RegisterBook(&sBook{})
}

type sBook struct{}

// Add implements service.IBook.
func (*sBook) Add(ctx context.Context, book do.Book) (err error) {
panic("unimplemented")
}

// Del implements service.IBook.
func (*sBook) Del(ctx context.Context) (err error) {
panic("unimplemented")
}

// Edit implements service.IBook.
func (*sBook) Edit(ctx context.Context, book do.Book) (err error) {
panic("unimplemented")
}

// GetList implements service.IBook.
func (*sBook) GetList(ctx context.Context) (books []entity.Book, err error) {
err = dao.Book.Ctx(ctx).Scan(&books)
return
}

但此时还需要在internal/logic中创建logic.go用于导入业务封装代码的目录

1
2
3
4
5
package logic

import (
_ "hellogf/internal/logic/book"
)
如果有其他业务封装代码目录也需要导入,此处仅以book为例

最后在main.go中导入logic目录

1
2
3
4
import (
_ "hellogf/internal/logic"
...
)

文件上传下载

文件上传

单文件上传

  1. 准备前端页面的表单结构

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>上传文件</title>
    </head>
    <body>

    <form action="/upload" method="post" enctype="multipart/form-data">
    <input type="file" name="ufile"> <br>
    <input type="file" name="ufiles" multiple> <br>
    <input type="submit" value="上传">
    </form>
    </body>
    </html>

  2. 在控制类中实现单文件上传的功能代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    func (c *Controller) Upload(req *ghttp.Request) {
    file := req.GetUploadFile("ufile")
    if file != nil {
    file.Filename = "20231001.png" // 可以根据需要给文件重命名
    name, err := file.Save("./upload")
    if err == nil {
    req.Response.Writeln(name)
    }
    }
    }
    注意,GetUploadFile的方法参数需要和表单中file输入框的name属性名保持一致

Save方法用于指定文件的存放路径

多文件上传

1
2
3
4
5
6
7
8
9
func (c *Controller) Upload(req *ghttp.Request) {
files := req.GetUploadFiles("ufiles")
if files != nil {
names, err := files.Save("./upload")
if err == nil {
req.Response.Writeln(names)
}
}
}

如果使用用api规范路由,还可以用如下方式获取上传文件:

1
2
3
4
5
type UploadReq struct {
g.Meta `path:"/upload" method:"post"`
Ufile ghttp.UploadFile `json:"ufile"`
UFiles ghttp.UploadFiles `json:"ufiles"`
}

1
2
3
4
type UploadRes struct {
Name string `json:"name"`
Url string `json:"url"`
}

使用这种方式,如果文件允许为空, 则可能会发生转换错误。

文件下载

展示

ServeFile向客户端返回一个文件内容,如果是文本或者图片,将会直接展示,不能直接在浏览器中展示的将进行下载

1
2
3
func (c *Controller) Download(req *ghttp.Request) {
req.Response.ServeFile("upload/1.png")
}
#### 下载 ServeFileDownload直接引导客户端进行下载,并且可以给下载文件重命名
1
2
3
func (c *Controller) Download(req *ghttp.Request) {
req.Response.ServeFileDownload("upload/1.png", "download.png")
}

上传限制

如果需要限制单次上传文件大小,可以用clientMaxBodySize配置(config.yaml)。如果完全不需要限制,直接设为0即可

1
2
server:
clientMaxBodySize: "0"

Cookie/Session

Cookie是保存在浏览器的一些数据,在请求的时候会放在请求头当中一同发送,通常用来保存sessionidtoken等一些数据。

1
2
3
4
5
6
7
8
9
func (c *Controller) Cookie(req *ghttp.Request) {
req.Cookie.Set("id", "kslfjojklcjkldjfsie")
req.Cookie.Set("user_name", "诸葛青")

name := req.Cookie.Get("user_name")
req.Response.Writeln("name from cookie: " + name.String())

req.Cookie.Remove("id")
}

Session

Session机制用于判断请求由哪一用户发起,Session数据保存在服务器。

以前常用于保存登录数据,进行登录验证,不过现在只是有些比较小的,前后端不分离的项目还在使用。

1
2
3
4
5
6
7
8
9
10
func (c *Controller) Session(req *ghttp.Request) {
op := req.GetQuery("op").String()
if op == "set" {
req.Session.Set("user", g.Map{"name": "张三", "id": 18})
} else if op == "get" {
req.Response.Writeln(req.Session.Get("user"))
} else if op == "rm" {
req.Session.Remove("user")
}
}

登录验证

前后端分离的项目更常用的登录验证是JWT(JSON Web Token)。GoFrame中没有提供相关生成与验证,需要添加第三方库,例如golang-jwt

  1. 添加库

    1
    go get -u github.com/golang-jwt/jwt/v5

  2. 导入库

    1
    import "github.com/golang-jwt/jwt/v5"

  3. 生成token