test.tpl content, vars: map[k:v]
```
## 目录配置方法
`GoFrame` 框架的模板引擎支持非常灵活的多目录自动搜索功能,通过 `SetPath` 可以修改模板目录为唯一的一个目录地址,同时,我们可以通过 `AddPath` 方法添加多个搜索目录,模板引擎底层将会按照添加目录的顺序作为优先级进行自动检索。直到检索到一个匹配的文件路径为止,如果在所有搜索目录下查找不到模板文件,那么会返回失败。
**默认目录配置**:
`gview` 视图对象初始化时,默认会自动添加以下模板文件搜索目录:
1. **当前工作目录及其下的 `template` 目录**:例如当前的工作目录为 `/home/www` 时,将会添加 `/home/www` 及 `/home/www/template`;
2. **当前可执行文件所在目录及其下的 `template` 目录**:例如二进制文件所在目录为 `/tmp` 时,将会添加 `/tmp` 及 `/tmp/template`;
3. **当前 `main` 源代码包所在目录及其下的 `template` 目录**(仅对源码开发环境有效):例如 `main` 包所在目录为 `/home/john/workspace/gf-app` 时,将会添加 `/home/john/workspace/gf-app` 及 `/home/john/workspace/gf-app/template`;
## 修改模板目录
我们可以通过以下方式修改视图对象的模板文件搜索目录,视图对象将会只在该指定目录执行配置文件检索:
1. (推荐)单例模式获取全局View对象,通过 `SetPath` 方法手动修改;
2. 修改命令行启动参数 - `gf.gview.path`;
3. 修改指定的环境变量 - `GF_GVIEW_PATH`;
例如,我们的执行程序文件为 `main`,那么可以通过以下方式修改模板引擎的模板目录(Linux下):
1. (推荐)通过单例模式
```go
g.View().SetPath("/opt/template")
```
2. 通过命令行参数
```shell
./main --gf.gview.path=/opt/template/
```
3. 通过环境变量
- 启动时修改环境变量:
```shell
GF_GVIEW_PATH=/opt/config/; ./main
```
- 使用 `genv` 模块来修改环境变量:
```go
genv.Set("GF_GVIEW_PATH", "/opt/template")
```
## 自动检测更新
模板引擎使用了精心设计的 **缓存机制**,当模板文件第一次被读取后会被缓存到内存,下一次读取时将会直接从缓存中获取,以提高执行效率。并且,模板引擎提供了对模板文件的 **自动检测更新机制**,当模板文件在外部被修改后,模板引擎能够即时地监控到并刷新模板文件的缓存内容。
# 模板引擎-模板配置
视图组件是`GoFrame`框架核心的组件之一,当然也支持非常方便的配置管理功能。
## 配置对象
配置对象定义: [https://pkg.go.dev/github.com/gogf/gf/v2/os/gview#Config](https://pkg.go.dev/github.com/gogf/gf/v2/os/gview#Config)
## 配置文件
视图组件支持配置文件,当使用`g.View(单例名称)`获取`View`单例对象时,将会自动通过默认的配置管理对象获取对应的`View`配置。默认情况下会读取`viewer.单例名称`配置项,当该配置项不存在时,将会读取`viewer`配置项。
完整配置文件配置项及说明如下,其中配置项名称不区分大小写:
```
[viewer]
Paths = ["/var/www/template"] # 模板文件搜索目录路径,建议使用绝对路径。默认为当前程序工作路径
DefaultFile = "index.html" # 默认解析的模板引擎文件。默认为"index.html"
Delimiters = ["${", "}"] # 模板引擎变量分隔符号。默认为 ["{{", "}}"]
AutoEncode = false # 是否默认对变量内容进行XSS编码。默认为false
[viewer.Data] # 自定义的全局Key-Value键值对,将在模板解析中可被直接使用到
Key1 = "Value1"
Key2 = "Value2"
```
### 示例1,默认配置项
```
[viewer]
paths = ["template", "/var/www/template"]
defaultFile = "index.html"
delimiters = ["${", "}"]
[viewer.data]
name = "gf"
version = "1.10.0"
```
随后可以使用 `g.View()` 获取默认的单例对象时自动获取并设置该配置。
### 示例2,多个配置项
多个 `View` 对象的配置示例:
```
[viewer]
paths = ["template", "/var/www/template"]
defaultFile = "index.html"
delimiters = ["${", "}"]
[viewer.data]
name = "gf"
version = "1.10.0"
[viewer.view1]
defaultFile = "layout.html"
delimiters = ["${", "}"]
[viewer.view2]
defaultFile = "main.html"
delimiters = ["#{", "}"]
```
我们可以通过单例对象名称获取对应配置的 `View` 单例对象:
```go
// 对应 viewer.view1 配置项
v1 := g.View("view1")
// 对应 viewer.view2 配置项
v2 := g.View("view2")
// 对应默认配置项 viewer
v3 := g.View("none")
// 对应默认配置项 viewer
v4 := g.View()
```
### 配置方法
方法列表: [https://pkg.go.dev/github.com/gogf/gf/v2/os/gview](https://pkg.go.dev/github.com/gogf/gf/v2/os/gview)
简要说明:
1. 可以通过 `SetConfig` 及 `SetConfigWithMap` 来设置。
2. 也可以使用 `View` 对象的 `Set*` 方法进行特定配置的设置。
3. 主要注意的是,配置项在 `View` 对象执行视图解析之前设置,避免并发安全问题。
### `SetConfigWithMap` 方法
我们可以使用 `SetConfigWithMap` 方法通过 `Key-Value` 键值对来设置/修改 `View` 的特定配置,其余的配置使用默认配置即可。其中 `Key` 的名称即是 `Config` 这个 `struct` 中的属性名称,并且不区分大小写,单词间也支持使用 `-`/ `_`/ `空格` 符号连接,具体可参考 [类型转换-Struct转换](./gconv-struct.md) 章节转换规则。
简单示例:
```go
package main
import (
"context"
"fmt"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gview"
)
func main() {
view := gview.New()
view.SetConfigWithMap(g.Map{
"Paths": []string{"template"},
"DefaultFile": "index.html",
"Delimiters": []string{"${", "}"},
"Data": g.Map{
"name": "gf",
"version": "1.10.0",
},
})
result, err := view.ParseContent(context.TODO(), "hello ${.name}, version: ${.version}")
if err != nil {
panic(err)
}
fmt.Println(result)
}
```
其中 `DefaultFile` 表示默认解析的模板文件,键名也可以使用 `defaultFile`, `default-File`, `default_file`, `default file`,其他配置属性以此类推。
## 注意事项
经常有小伙伴问,为神马我的模板解析没有生效?为神马页面上直接将我写的标签原木原样显示出来了?
这个时候请检查的配置文件中是否有设置模板标签,常见的是 `delimiters` 设置为了 `["${", "}"]`,但是模板中使用的是 `["{{", "}}"]`。
# 模板引擎-模板标签
模板引擎默认使用了`{{`和`}}`作为左右闭合标签,开发者可通过`gview.SetDelimiters`方法设置自定义的模板闭合标签。
使用`.`来访问当前对象的值(模板局部变量)。
使用`$`来引用当前模板根级的上下文(模板全局变量)。
使用`$var`来访问特定的模板变量。
**模板中支持的`go`语言符号**
```
{{"string"}} // 一般 string
{{`raw string`}} // 原始 string
{{'c'}} // byte
{{print nil}} // nil 也被支持
```
**模板中的 `pipeline`**
可以是上下文的变量输出,也可以是函数通过管道传递的返回值
```
{{. | FuncA | FuncB | FuncC}}
```
当 `pipeline` 的值等于:
- `false` 或 `0`
- `nil的指针` 或 `interface`
- 长度为 `0` 的 `array`, `slice`, `map`, `string`
那么这个 `pipeline` 被认为是空。
**注意**
需要注意:在 `gf` 模板引擎中,当模板中展示的指定变量不存在时,将会显示为空(标准库模板引擎会展示 ``)。
## `if … else … end`
```
{{if pipeline}}...{{end}}
```
`if` 判断时, `pipeline` 为空时,相当于判断为 `false`。
支持嵌套的循环
```
{{if .condition}}
...
{{else}}
{{if .condition2}}
...
{{end}}
{{end}}
```
也可以使用 `else if` 进行
```
{{if .condition}}
...
{{else if .condition2}}
...
{{else}}
...
{{end}}
```
## `range … end`
```
{{range pipeline}} {{.}} {{end}}
```
`pipeline` 支持的类型为 `slice`, `map`, `channel`。
注意:在 `range` 循环内部, `.` 符号会被覆盖为以上类型的子元素(局部变量)。如果想在循环中访问外部变量(全局变量),请加上 `$` 符号,如: `{{$.Session.Name}}`
此外,对应的值长度为 `0` 时, `range` 不会执行, `.` 不会改变。
例如,遍历 `map`:
```
{{range $key, $value := .MapContent}}
{{$key}}:{{$value}}
{{end}}
```
例如,遍历 `slice`:
```
{{range $index, $elem := .SliceContent}}
{{range $key, $value := $elem}}
{{$key}}:{{$value}}
{{end}}
{{end}}
```
## `with … end`
```
{{with pipeline}}...{{end}}
```
`with` 用于重定向 `pipeline`
```
{{with .Field.NestField.SubField}}
{{.Var}}
{{end}}
```
## `define`
`define` 可以用来 **自定义模板内容块**(给一段模板内容定义一个模板名称),可用于模块定义和模板嵌套(使用在 `template` 标签中)。
```
{{define "loop"}}
{{.Name}}
{{end}}
```
其中 `loop` 为该模板内容块的名称,随后可使用 `template` 标签调用模板:
```
{{range .Items}}
{{template "loop" .}}
{{end}}
```
**注意**
`define` 标签需要结合 `template` 标签一起使用,并且支持跨模板使用(在同一模板目录/子目录下有效,原理是使用的 `ParseFiles` 方法解析模板文件)。
## `template`
```
{{template "模板名称" pipeline}}
```
将对应的上下文 `pipeline` 传给模板,才可以在模板中调用。
注意: `template` 标签参数为 `模板名称`,而不是模板文件路径, `template` 标签不支持模板文件路径。
**注意**
`template` 标签需要结合 `define` 标签一起使用,并且支持跨模板使用(在同一模板目录/子目录下有效,原理是使用的 `ParseFiles` 方法解析模板文件)。
## `include`
**该标签为 `gf` 框架模板引擎新增标签**
```
{{include "模板文件名(需要带完整文件名后缀)" pipeline}}
```
在模板中可以使用 `include` 标签载入其他模板(任意路径),模板文件名支持 **相对路径** 以及文件的系统 **绝对路径**。如果想要把当前模板的模板变量传递给子模板(嵌套模板),可以这样:
```
{{include "模板文件名(需要带完整文件名后缀)" .}}
```
与 `template` 标签的区别是: `include` 仅支持 **文件路径**,不支持 **模板名称**;而 `tempalte` 标签仅支持 **模板名称**,不支持 **文件路径**。
## 注释
允许多行文本注释,不允许嵌套。
```
{{/*
comment content
support new line
*/}}
```
## 删除空白符号
删除移除空格换行符
```
# 使用{{-语法去除模板内容左侧的所有空白符号, 使用-}}去除模板内容右侧的所有空白符号。
# 注意:-要紧挨{{和}},同时与模板值之间需要使用空格分隔。
{{- .Name -}}
{{- range $key, $value := .list}}
"{{$value}}"
{{- end}}
```
## 更多使用说明
[template package - html/template - Go Packages](https://pkg.go.dev/html/template)
[template package - text/template - Go Packages](https://pkg.go.dev/text/template)
# 模板引擎-模板变量
## 内置变量
`WebServer`内置变量请参考[数据返回-模板解析](../web/response-template.md)章节。
## 变量对象
我们可以在模板中使用自定义的对象,并可在模板中访问对象的属性及调用其方法。
示例:
```go
package main
import (
"context"
"github.com/gogf/gf/v2/frame/g"
)
type T struct {
Name string
}
func (t *T) Hello(name string) string {
return "Hello " + name
}
func (t *T) Test() string {
return "This is test"
}
func main() {
t := &T{"John"}
v := g.View()
content := `{{.t.Hello "there"}}, my name's {{.t.Name}}. {{.t.Test}}.`
if r, err := v.ParseContent(context.TODO(), content, g.Map{"t": t}); err != nil {
g.Dump(err)
} else {
g.Dump(r)
}
}
```
其中,赋值给模板的变量既可以是`对象指针`也可以是`对象变量`。但是注意定义的对象方法,如果为对象指针那么只能调用方法接收器为对象指针的方法;如果为对象变量,那么只能调用方法接收器为对象的方法。
执行后,输出结果为:
```
Hello there, my name's John. This is test.
```
# 模板引擎-模板布局
`gview`模板引擎支持两种`layout`模板布局方式:
1. `define` + `template`方式
2. `include`模板嵌入方式
这两种方式均支持对模板变量的传入。
### `define` + `template`
由于`gview`底层采用了`ParseFiles`方式批量解析模板文件,因此可以使用`define`标签定义模板内容块,通过`template`标签在其他任意的模板文件中引入指定的模板内容块。`template`标签支持跨模板引用,也就是说`define`标签定义的模板内容块可能是在其他模板文件中,`template`也可以随意引入。
**注意**
注意:
- 为嵌套的子模板传递模板变量时,应当使用: `{{template "xxx" .}}` 的语法。
- 模板文件的后缀名,要跟 `define template` 文件的后缀名保持一致。
使用示例:
1. `layout.html`
```html
GoFrame Layout
{{template "header" .}}
{{template "container" .}}
```
2. `header.html`
```html
{{define "header"}}
{{.header}}
{{end}}
```
1. `container.html`
```html
{{define "container"}}
{{.container}}
{{end}}
```
1. `footer.html`
```html
{{define "footer"}}
{{.footer}}
{{end}}
```
1. `main.go`
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/", func(r *ghttp.Request) {
r.Response.WriteTpl("layout.html", g.Map{
"header": "This is header",
"container": "This is container",
"footer": "This is footer",
})
})
s.SetPort(8199)
s.Run()
}
```
执行后,访问 [http://127.0.0.1:8199](http://127.0.0.1:8199/) 结果如下:
### `include`模板嵌入
当然我们也可以使用`include`标签来实现页面布局。
**注意**
注意,为嵌套的子模板传递模板变量时,应当使用: `{{include "xxx" .}}` 的语法。
使用示例:
1. `layout.html`
```html
{{include "header.html" .}}
{{include .mainTpl .}}
{{include "footer.html" .}}
```
2. `header.html`
```html
HEADER
```
3. `footer.html`
```html
FOOTER
```
4. `main1.html`
```html
MAIN1
```
1. `main2.html`
```html
MAIN2
```
1. `main.go`
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/main1", func(r *ghttp.Request) {
r.Response.WriteTpl("layout.html", g.Map{
"mainTpl": "main/main1.html",
})
})
s.BindHandler("/main2", func(r *ghttp.Request) {
r.Response.WriteTpl("layout.html", g.Map{
"mainTpl": "main/main2.html",
})
})
s.SetPort(8199)
s.Run()
}
```
执行后,访问不同的路由地址,将会看到不同的结果:
1. [http://127.0.0.1:8199/main1](http://127.0.0.1:8199/main1)
2. [http://127.0.0.1:8199/main2](http://127.0.0.1:8199/main2)
# 模板引擎-XSS处理
默认情况下,模板引擎对所有的变量输出并没有使用 `HTML` 转码处理,也就是说,如果开发者处理不好,可能会存在XSS漏洞。
不用担心, `GoFrame` 框架当然已经充分考虑到这点,并且为开发者提供了比较灵活的配置参数来控制是否默认转义变量输出的 `HTML` 内容。该特性可以通过 `AutoEncode` 配置项,或者 `SetAutoEncode` 方法来开启/关闭。
**提示**
需要注意的是,该特性并不会影响 `include` 模板的内置函数。
使用示例:
1、配置文件
```toml
[viewer]
delimiters = ["${", "}"]
autoencode = true
```
2、示例代码
```go
package main
import (
"context"
"fmt"
"github.com/gogf/gf/v2/frame/g"
)
func main() {
result, _ := g.View().ParseContent(context.TODO(), "姓名: ${.name}", g.Map{
"name": "",
})
fmt.Println(result)
}
```
3、执行输出
```html
姓名: <script>alert('john');</script>
```
# 模板引擎-其他使用
## `I18N`支持
模板引擎支持`i18n`特性,可以通过给上下文注入特定的`i18n`语言来实现不同的请求/页面使用不同的`i18n`语言渲染。例如:
```go
package main
import (
"context"
"fmt"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/i18n/gi18n"
)
func main() {
var (
ctxCN = gi18n.WithLanguage(context.TODO(), "zh-CN")
ctxJa = gi18n.WithLanguage(context.TODO(), "ja")
content = `{{.name}} says "{#hello}{#world}!"`
)
result1, _ := g.View().ParseContent(ctxCN, content, g.Map{
"name": "john",
})
fmt.Println(result1)
result2, _ := g.View().ParseContent(ctxJa, content, g.Map{
"name": "john",
})
fmt.Println(result2)
}
```
执行后,输出结果为:(保证当前运行目录带有 `i18n` 转译配置文件)
```html
john says "你好世界!"
john says "こんにちは世界!"
```
## HTTP对象视图
`GoFrame`框架的`WebServer`的返回对象中提供了基础的模板解析方法,如下:
```go
func (r *Response) WriteTpl(tpl string, params map[string]interface{}, funcMap ...map[string]interface{}) error
func (r *Response) WriteTplContent(content string, params map[string]interface{}, funcMap ...map[string]interface{}) error
```
其中`WriteTpl`用于输出模板文件,`WriteTplContent`用于直接解析输出模板内容。
使用示例:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/", func(r *ghttp.Request) {
r.Cookie.Set("theme", "default")
r.Session.Set("name", "john")
r.Response.WriteTplContent(`Cookie:{{.Cookie.theme}}, Session:{{.Session.name}}`, nil)
})
s.SetPort(8199)
s.Run()
}
```
执行后,输出结果为:
```
Cookie:default, Session:john
```
## 控制器视图管理
`GoFrame`为路由控制器注册方式提供了良好的模板引擎支持,由`gmvc.View`视图对象进行管理,提供了良好的数据`隔离性`。控制器视图是并发安全设计的,允许在多线程中异步操作。
**注意**
控制器注册方式类似于PHP执行流程,相对来说性能比较低效,因此未来不再推荐使用控制器注册方式。
```go
func (view *View) Assign(key string, value interface{})
func (view *View) Assigns(data gview.Params)
func (view *View) Parse(file string) ([]byte, error)
func (view *View) ParseContent(content string) ([]byte, error)
func (view *View) Display(files ...string) error
func (view *View) DisplayContent(content string) error
func (view *View) LockFunc(f func(vars map[string]interface{}))
func (view *View) RLockFunc(f func(vars map[string]interface{}))
```
使用示例1:
```go
package main
import (
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/frame/gmvc"
)
type ControllerTemplate struct {
gmvc.Controller
}
func (c *ControllerTemplate) Info() {
c.View.Assign("name", "john")
c.View.Assigns(map[string]interface{}{
"age" : 18,
"score" : 100,
})
c.View.Display("index.tpl")
}
func main() {
s := ghttp.GetServer()
s.BindController("/template", new(ControllerTemplate))
s.SetPort(8199)
s.Run()
}
```
其中 `index.tpl` 的模板内容如下:
```
gf template engine
Name: {{.name}}
Age: {{.age}}
Score:{{.score}}
```
执行后,访问 [http://127.0.0.1:8199/template/info](http://127.0.0.1:8199/template/info) 可以看到模板被解析并展示到页面上。如果页面报错找不到模板文件,没有关系,因为这里并没有对模板目录做设置,默认是当前可行文件的执行目录( `Linux&Mac` 下是 `/tmp` 目录, `Windows` 下是 `C:\Documents and Settings\用户名\Local Settings\Temp`)。
其中,给定的模板文件file参数是需要带完整的文件名后缀,例如: `index.tpl`, `index.html` 等等,模板引擎对模板文件后缀名没有要求,用户可完全自定义。此外,模板文件参数也支持文件的绝对路径(完整的文件路径)。
当然,我们也可以直接解析模板内容,例如:
```go
package main
import (
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/frame/gmvc"
)
type ControllerTemplate struct {
gmvc.Controller
}
func (c *ControllerTemplate) Info() {
c.View.Assign("name", "john")
c.View.Assigns(map[string]interface{}{
"age" : 18,
"score" : 100,
})
c.View.DisplayContent(`
gf template engine
Name: {{.name}}
Age: {{.age}}
Score:{{.score}}
`)
}
func main() {
s := ghttp.GetServer()
s.BindController("/template", new(ControllerTemplate{}))
s.SetPort(8199)
s.Run()
}
```
执行后,访问 [http://127.0.0.1:8199/template/info](http://127.0.0.1:8199/template/info) 可以看到解析后的内容如下:
```
gf template engine
Name: john
Age: 18
Score:100
```
# 模板函数-基础函数
**提示**
以下为`Golang`标准库的一些基础语法和基础函数,`GoFrame`框架对部分基础函数做了必要的改进。
变量可以使用符号 `|` 在函数间传递
```
{{.value | Func1 | Func2}}
```
使用括号
```
{{printf "nums is %s %d" (printf "%d %d" 1 2) 3}}
```
## `and`
```
{{and .X .Y .Z}}
```
`and` 会逐一判断每个参数,将返回第一个为空的参数,否则就返回最后一个非空参数
## `call`
```
{{call .Field.Func .Arg1 .Arg2}}
```
`call` 可以调用函数,并传入参数
调用的函数需要返回 1 个值 或者 2 个值,返回两个值时,第二个值用于返回 `error` 类型的错误。返回的错误不等于 `nil` 时,执行将终止。
## `index`
`index` 支持 `map`, `slice`, `array`, `string`,读取指定类型对应下标的值。
```
{{index .Maps "name"}}
```
## `len`
```
{{printf "The content length is %d" (.Content|len)}}
```
返回对应类型的长度,支持类型: `map`, `slice`, `array`, `string`, `chan`。
## `not`
`not` 返回输入参数的否定值。
例如,判断是否变量是否为空:
```
{{if not .Var}}
// 执行为空操作(.Var为空, 如: nil, 0, "", 长度为0的slice/map)
{{else}}
// 执行非空操作(.Var不为空)
{{end}}
```
## `or`
```
{{or .X .Y .Z}}
```
`or` 会逐一判断每个参数,将返回第一个非空的参数,否则就返回最后一个参数。
## `print`
同 `fmt.Sprint`。
## `printf`
同 `fmt.Sprintf`。
## `println`
同 `fmt.Sprintln`。
## `urlquery`
```
{{urlquery "http://johng.cn"}}
```
将返回
```
http%3A%2F%2Fjohng.cn
```
## `eq / ne / lt / le / gt / ge`
这类函数一般配合在 `if` 中使用
```
`eq`: arg1 == arg2
`ne`: arg1 != arg2
`lt`: arg1 < arg2
`le`: arg1 <= arg2
`gt`: arg1 > arg2
`ge`: arg1 >= arg2
```
`eq` 和其他函数不一样的地方是,支持多个参数。
```
{{eq arg1 arg2 arg3 arg4}}
```
和下面的逻辑判断相同:
```
arg1==arg2 || arg1==arg3 || arg1==arg4 ...
```
与 `if` 一起使用
```
{{if eq true .Var1 .Var2 .Var3}}...{{end}}
```
```
{{if lt 100 200}}...{{end}}
```
例如,判断变量不为空时执行:
```
{{if .Var}}
// 执行非空操作(.Var不为空)
{{else}}
// 执行为空操作(.Var为空, 如: nil, 0, "", 长度为0的slice/map)
{{end}}
```
### 对比函数改进
`GoFrame` 框架模板引擎对标准库自带的对比模板函数 `eq/ne/lt/le/gt/ge` 做了必要的改进,以便支持任意数据类型的比较。例如,在标准库模板中的以下比较:
```
{{eq 1 "1"}}
```
将会引发错误:
```
panic: template: at : error calling eq: incompatible types for comparison
```
由于比较的两个参数不是同一数据类型,因此引发了 `panic` 错误。
在 `GoFrame` 框架的模板引擎中,将会自动将两个参数进行数据转换,转换为同一类型后再进行比较,这样的开发体验更好、灵活性更高。如果两个参数均为整型变量(或者整型字符串),那么将会转换为整型进行比较,否则转换为字符串变量进行比较(区分大小写)。
### 改进运行示例
我们来看一个 `GoFrame` 框架的模板引擎中的对比模板函数运行示例。
```go
package main
import (
"context"
"fmt"
"github.com/gogf/gf/v2/frame/g"
)
func main() {
tplContent := `
eq:
eq "a" "a": {{eq "a" "a"}}
eq "1" "1": {{eq "1" "1"}}
eq 1 "1": {{eq 1 "1"}}
ne:
ne 1 "1": {{ne 1 "1"}}
ne "a" "a": {{ne "a" "a"}}
ne "a" "b": {{ne "a" "b"}}
lt:
lt 1 "2": {{lt 1 "2"}}
lt 2 2 : {{lt 2 2 }}
lt "a" "b": {{lt "a" "b"}}
le:
le 1 "2": {{le 1 "2"}}
le 2 1 : {{le 2 1 }}
le "a" "a": {{le "a" "a"}}
gt:
gt 1 "2": {{gt 1 "2"}}
gt 2 1 : {{gt 2 1 }}
gt "a" "a": {{gt "a" "a"}}
ge:
ge 1 "2": {{ge 1 "2"}}
ge 2 1 : {{ge 2 1 }}
ge "a" "a": {{ge "a" "a"}}
`
content, err := g.View().ParseContent(context.TODO(), tplContent, nil)
if err != nil {
panic(err)
}
fmt.Println(content)
}
```
运行后,输出结果为:
```
eq:
eq "a" "a": true
eq "1" "1": true
eq 1 "1": true
ne:
ne 1 "1": false
ne "a" "a": false
ne "a" "b": true
lt:
lt 1 "2": true
lt 2 2 : false
lt "a" "b": true
le:
le 1 "2": true
le 2 1 : false
le "a" "a": true
gt:
gt 1 "2": false
gt 2 1 : true
gt "a" "a": false
ge:
ge 1 "2": false
ge 2 1 : true
ge "a" "a": true
```
# 模板函数-内置函数
## `plus/minus/times/divide`
| 函数 | 说明 | 格式 | 示例 | 备注 |
| --- | --- | --- | --- | --- |
| `plus` | **加** | `{{.value1 \| plus .value2}}` | `{{2 \| plus 3}} => 5` | `3+2` |
| `minus` | **减** | `{{.value1 \| minus .value2}}` | `{{2 \| minus 3}} => 1` | `3-2` |
| `times` | **乘** | `{{.value1 \| times .value2}}` | `{{2 \| times 3}} => 6` | `3*2` |
| `divide` | **除** | `{{.value1 \| divide .value2}}` | `{{2 \| divide 3}} => 1.5` | `3/2` |
## `text`
```
{{.value | text}}
```
将`value`变量值去掉HTML标签,仅显示文字内容(并且去掉`script`标签)。示例:
```
{{"测试
"|text}}
// 输出: 测试
```
## `htmlencode/encode/html`
```
{{.value | htmlencode}}
{{.value | encode}}
{{.value | html}}
```
将`value`变量值进行html转义。示例:
```
{{"测试
"|html}}
// 输出: <div>测试</div>
```
## `htmldecode/decode`
```
{{.value | htmldecode}}
{{.value | decode}}
```
将 `value` 变量值进行html反转义。 示例:
```
{{"<div>测试</div>" | htmldecode}}
// 输出: 测试
```
## `urlencode/url`
```
{{.url | url}}
```
将 `url` 变量值进行 `url` 转义。 示例:
```
{{"https://goframe.org" | url}}
// 输出: https%3A%2F%2Fgoframe.org
```
## `urldecode`
```
{{.url | urldecode}}
```
将 `url` 变量值进行 `url` 反转义。 示例:
```
{{"https%3A%2F%2Fgoframe.org"|urldecode}}
// 输出: https://goframe.org
```
## `date`
```
{{.timestamp | date .format}}
{{date .format .timestamp}}
{{date .format}}
```
将`timestamp`时间戳变量进行时间日期格式化,类似PHP的`date`方法,`format`参数支持[PHP date](http://php.net/manual/en/function.date.php)方法格式,亦可参考[时间管理-gtime](../components/os-gtime.md)。
当`timestamp`变量为`空`(或者`0`)时,表示以当前时间作为时间戳参数执行打印。
示例:
```
{{1540822968 | date "Y-m-d"}}
{{"1540822968" | date "Y-m-d H:i:s"}}
{{date "Y-m-d H:i:s"}}
// 输出:
// 2018-10-29
// 2018-10-29 22:22:48
// 2018-12-05 10:22:00
```
## `compare`
```
{{compare .str1 .str2}}
{{.str2 | compare .str1}}
```
将`str1`和`str2`进行字符串比较,返回值:
- 0 : `str1` == `str2`
- 1 : `str1` > `str2`
- -1 : `str1` < `str2`
示例:
```
{{compare "A" "B"}}
{{compare "1" "2"}}
{{compare 2 1}}
{{compare 1 1}}
// 输出:
// -1
// -1
// 1
// 0
```
## `replace`
```
{{.str | replace .search .replace}}
{{replace .search .replace .str}}
```
将`str`中的`search`替换为`replace`。示例:
```
{{"I'm中国人" | replace "I'm" "我是"}}
// 输出:
// 我是中国人
```
## `substr`
```
{{.str | substr .start .length}}
{{substr .start .length .str}}
```
将`str`从`start`索引位置(索引从0开始)进行字符串截取`length`,支持中文,类似PHP的`substr`函数。示例:
```
{{"我是中国人" | substr 2 -1}}
{{"我是中国人" | substr 2 2}}
// 输出:
// 中国人
// 中国
```
## `strlimit`
```
{{.str | strlimit .length .suffix}}
```
将`str`字符串截取`length`长度,支持中文,超过长度则追加`suffix`字符串到末尾。示例:
```
{{"我是中国人" | strlimit 2 "..."}}
// 输出:
// 我是...
```
## `concat`
```
{{concat .str1 .str2 .str3...}}
```
拼接字符串。 示例:
```
{{concat "我" "是" "中" "国" "人"}}
// 输出:
// 我是中国人
```
## `hidestr`
```
{{.str | hidestr .percent .hide}}
```
将`str`字符串按照`percent`百分比从字符串中间向两边隐藏字符(主要用于姓名、手机号、邮箱地址、身份证号等的隐藏),隐藏字符由`hide`变量定义。支持中文,支持email格式。示例:
```
{{"热爱GF热爱生活" | hidestr 20 "*"}}
{{"热爱GF热爱生活" | hidestr 50 "*"}}
// 输出:
// 热爱GF*爱生活
// 热爱****生活
```
## `highlight`
```
{{.str | highlight .key .color}}
```
将 `str` 字符串中的关键字 `key` 按照定义的颜色 `color` 进行前置颜色高亮。 示例:
```
{{"热爱GF热爱生活" | highlight "GF" "red"}}
// 输出:
// 热爱GF热爱生活
```
## `toupper/tolower`
```
{{.str | toupper}}
{{.str | tolower}}
```
将 `str` 字符串进行大小写转换。 示例:
```
{{"gf" | toupper}}
{{"GF" | tolower}}
// 输出:
// GF
// gf
```
## `nl2br`
```
{{.str | nl2br}}
```
将 `str` 字符串中的 `\n/\r` 替换为html中的 `
` 标签。 示例:
```
{{"Go\nFrame" | nl2br}}
// 输出:
// Go
Frame
```
## `dump`
```
{{dump .var}}
```
格式化打印变量,功能类似于 `g.Dump` 方法,常用于开发调试。 示例:
```
gview.Assign("var", g.Map{
"name" : "john",
})
```
```
{{dump .var}}
// 输出:
//
```
## `map`
```
{{map .var}}
```
将模板变量转换为 `map[string]interface{}` 类型,常用于 `range...end` 遍历。
## `maps`
```
{{maps .var}}
```
将模板变量转换为 `[]map[string]interface{}` 类型,常用于 `range...end` 遍历。
## `json/xml/ini/yaml/yamli/toml`
| 函数 | 说明 | 格式 |
| --- | --- | --- |
| `json` | 将模板变量转换为 `JSON` 格式字符串。 | `{{json .var}}` |
| `xml` | 将模板变量转换为 `XML` 格式字符串。 | `{{xml .var}}` |
| `ini` | 将模板变量转换为 `INI` 格式字符串。 | `{{ini .var}}` |
| `yaml` | 将模板变量转换为 `YAML` 格式字符串。 | `{{yaml .var}}` |
| `yamli` | 将模板变量转换为带有自定义缩进的 `YAML` 格式字符串。 | `{{yamli .var .indent}}` |
| `toml` | 将模板变量转换为 `TOML` 格式字符串。 | `{{toml .var}}` |
# 模板函数-自定义函数
## 基本介绍
开发者可以自定义模板函数,全局绑定模板函数到指定的视图对象中。
**提示**
也可以将自定义的对象赋值给模板,随后通过对象来调用其封装的方法。
## 使用示例
```go
package main
import (
"context"
"fmt"
"github.com/gogf/gf/v2/frame/g"
)
// 用于测试的带参数的内置函数
func funcHello(name string) string {
return fmt.Sprintf(`Hello %s`, name)
}
func main() {
// 绑定全局的模板函数
g.View().BindFunc("hello", funcHello)
// 普通方式传参
parsed1, err := g.View().ParseContent(context.TODO(), `{{hello "GoFrame"}}`, nil)
if err != nil {
panic(err)
}
fmt.Println(string(parsed1))
// 通过管道传参
parsed2, err := g.View().ParseContent(context.TODO(), `{{"GoFrame" | hello}}`, nil)
if err != nil {
panic(err)
}
fmt.Println(string(parsed2))
}
```
执行后,输出结果为:
```
Hello GoFrame
Hello GoFrame
```
# 数据库ORM
## 驱动引入
为了将数据库驱动与框架主库解耦,从 `v2.1` 版本开始,所有的数据库驱动都需要通过社区包手动引入。
数据库驱动的安装和引入请参考: [https://github.com/gogf/gf/tree/master/contrib/drivers](https://github.com/gogf/gf/tree/master/contrib/drivers)
## 基本介绍
`GoFrame` 框架的 `ORM` 功能由 `gdb` 模块实现,用于常用关系型数据库的 `ORM` 操作。
**提示**
`gdb` 数据库引擎底层采用了 **链接池设计**,当链接不再使用时会自动关闭,因此链接对象不用的时候不需要显式使用 `Close` 方法关闭数据库连接。
**备注**
注意:为提高数据库操作安全性,在 `ORM` 操作中不建议直接将参数拼接成 `SQL` 字符串执行,建议使用预处理的方式(充分使用 `?` 占位符)来传递 `SQL` 参数。 `gdb` 的底层实现中均采用的是预处理的方式处理开发者传递的参数,以充分保证数据库操作安全性。
**接口文档:**
[https://pkg.go.dev/github.com/gogf/gf/v2/database/gdb](https://pkg.go.dev/github.com/gogf/gf/v2/database/gdb)
## 组件特性
`GoFrame ORM` 组件具有以下显著特点:
1. 全自动化支持嵌套事务。
2. 面向接口化设计、易使用易扩展。
3. 内置支持主流数据库类型驱动,并易于扩展。
4. 强大的配置管理,使用框架统一的配置组件。
5. 支持单例模式获取配置同一分组数据库对象。
6. 支持原生SQL方法操作、ORM链式操作两种方式。
7. 支持 `OpenTelemetry` 可观测性:链路跟踪、日志记录、指标上报。
8. 通过 `Scan` 方法自动识别 `Map/Struct` 接收查询结果,自动化查询结果初始化、结构体类型转换。
9. 通过返回结果 `nil` 识别为空,无需 `sql.ErrNoRows` 识别查询数据为空的情况。
10. 全自动化的结构体属性-字段映射,无需显示定义结构体标签维护属性-字段映射关系。
11. 自动化的给定 `Map/Struct/Slice` 参数类型中的字段识别、过滤,大大提高查询条件输入、结果接收。
12. 完美支持 `GoFrame` 框架层面的 `DAO` 设计,全自动化 `Model/DAO` 代码生成,极大提高开发效率。
13. 支持调试模式、日志输出、 `DryRun`、自定义 `Handler`、自动类型类型转换、自定义接口转换等等高级特性。
14. 支持查询缓存、软删除、自动化时间更新、模型关联、数据库集群配置(软件主从模式)等等实用特性。
## 组件关联
`GoFrame ORM Dependencies`
## `g.DB` 与 `gdb.New`、 `gdb.Instance`
获取数据库操作对象有三种方式,一种是使用 `g.DB` 方法(推荐),一种是使用原生 `gdb.New` 方法,还有一种是使用包原生单例方法 `gdb.Instance`,而第一种是推荐的使用方式。这三种方式的区别如下:
1. `g.DB` 对象管理方式获取的是单例对象,整合了配置文件的管理功能,支持配置文件热更新。
2. `gdb.New` 是根据给定的数据库节点配置创建一个新的数据库对象(非单例),无法使用配置文件。
3. `gdb.Instance` 是包原生单例管理方法,需要结合配置方法一起使用,通过分组名称(非必需)获取对应配置的数据库单例对象。
**提示**
有这么多对象获取方式原因在于 `GoFrame` 是一个模块化设计的框架,每个模块皆可单独使用。
### `New` 创建数据库对象
```go
db, err := gdb.New(gdb.ConfigNode{
Link: "mysql:root:12345678@tcp(127.0.0.1:3306)/test",
})
```
### 获取数据库对象单例
```go
// 获取默认配置的数据库对象(配置名称为"default")
db := g.DB()
// 获取配置分组名称为"user"的数据库对象
db := g.DB("user")
// 使用原生单例管理方法获取数据库对象单例
db, err := gdb.Instance()
db, err := gdb.Instance("user")
```
# ORM方法操作(原生)
## 方法操作
方法操作用于原生 `SQL` 执行,相对链式操作更偏底层操作一些,在 `ORM` 链式操作执行不了太过于复杂的 `SQL` 操作时,可以交给方法操作来处理。
**接口文档:**
[https://pkg.go.dev/github.com/gogf/gf/v2/database/gdb#DB](https://pkg.go.dev/github.com/gogf/gf/v2/database/gdb#DB)
**常用方法:**
本文档的方法列表可能滞后于于代码,详细的方法列表请查看接口文档,以下方法仅供参考。
```go
// SQL操作方法,返回原生的标准库sql对象
Query(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
Exec(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
Prepare(ctx context.Context, query string) (*sql.Stmt, error)
// 数据表记录查询:
// 查询单条记录、查询多条记录、获取记录对象、查询单个字段值(链式操作同理)
GetAll(ctx context.Context, sql string, args ...interface{}) (Result, error)
GetOne(ctx context.Context, sql string, args ...interface{}) (Record, error)
GetValue(ctx context.Context, sql string, args ...interface{}) (Value, error)
GetArray(ctx context.Context, sql string, args ...interface{}) ([]Value, error)
GetCount(ctx context.Context, sql string, args ...interface{}) (int, error)
GetScan(ctx context.Context, objPointer interface{}, sql string, args ...interface{}) error
// 数据单条操作
Insert(ctx context.Context, table string, data interface{}, batch...int) (sql.Result, error)
Replace(ctx context.Context, table string, data interface{}, batch...int) (sql.Result, error)
Save(ctx context.Context, table string, data interface{}, batch...int) (sql.Result, error)
// 数据修改/删除
Update(ctx context.Context, table string, data interface{}, condition interface{}, args ...interface{}) (sql.Result, error)
Delete(ctx context.Context, table string, condition interface{}, args ...interface{}) (sql.Result, error)
```
**简要说明:**
1. `Query` 是原始的数据查询方法,返回的是原生的标准库的结果集对象,需要自行解析。推荐使用 `Get*` 方法,会对结果自动做解析。
2. `Exec` 方法用于写入/更新的 `SQL` 的操作。
3. 在执行数据查询时推荐使用 `Get*` 系列查询方法。
4. `Insert`/ `Replace`/ `Save` 方法中的 `data` 参数支持的数据类型为: `string/map/slice/struct/*struct`,当传递为 `slice` 类型时,自动识别为批量操作,此时 `batch` 参数有效。
## 操作示例
### 1\. `ORM` 对象
```go
// 获取默认配置的数据库对象(配置名称为"default")
db := g.DB()
// 获取配置分组名称为"user-center"的数据库对象
db := g.DB("user-center")
// 使用原生单例管理方法获取数据库对象单例
db, err := gdb.Instance()
db, err := gdb.Instance("user-center")
// 注意不用的时候不需要使用Close方法关闭数据库连接(并且gdb也没有提供Close方法),
// 数据库引擎底层采用了链接池设计,当链接不再使用时会自动关闭
```
### 2\. 数据写入
```go
r, err := db.Insert(ctx, "user", gdb.Map {
"name": "john",
})
```
### 3\. 数据查询(列表)
```go
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})
```
### 4\. 数据查询(单条)
```go
one, err := db.GetOne(ctx, "select * from user limit 2")
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 user where uid=?", g.Slice{1000})
```
### 5\. 数据保存
```go
r, err := db.Save(ctx, "user", gdb.Map {
"uid" : 1,
"name" : "john",
})
```
### 6\. 批量操作
其中 `batch` 参数用于指定批量操作中分批写入条数数量(默认是 `10`)。
```go
_, err := db.Insert(ctx, "user", gdb.List {
{"name": "john_1"},
{"name": "john_2"},
{"name": "john_3"},
{"name": "john_4"},
}, 10)
```
### 7\. 数据更新/删除
```go
// db.Update/db.Delete 同理
// UPDATE `user` SET `name`='john' WHERE `uid`=10000
r, err := db.Update(ctx, "user", gdb.Map {"name": "john"}, "uid=?", 10000)
// UPDATE `user` SET `name`='john' WHERE `uid`=10000
r, err := db.Update(ctx, "user", "name='john'", "uid=10000")
// UPDATE `user` SET `name`='john' WHERE `uid`=10000
r, err := db.Update(ctx, "user", "name=?", "uid=?", "john", 10000)
```
注意,参数域支持并建议使用预处理模式(使用 `?` 占位符)进行输入,避免 `SQL` 注入风险。
# ORM时区处理
## 基本介绍
这个问题由于大家问得比较多,因此单独开了一个章节详细介绍一下 `ORM` 中的时区处理是怎么一回事。我们这里以 `MySQL` 数据库为例来介绍时区转换的事情,本地时区我们设定为 `+8` 时区,数据库时区也是 `+8` 时区。
`MySQL` 数据库驱动用得最多的是这个第三方包: [https://github.com/go-sql-driver/mysql](https://github.com/go-sql-driver/mysql),在这个第三方包中有这么一个参数:
大概的意思是,当提交的时间参数为 `time.Time` 时,该参数用来转换参数时区的。当在连接数据库时,该参数传递 `loc=Local`,那么该 `driver` 将会自动将提交的 `time.Time` 参数转换为本地程序设置的时区,没有手动设置时,那么该时区为 `UTC` 时区。那么我们来看两个例子。
## 转换示例
### 示例1,设置 `loc=Local`
**配置文件**
```yaml
database:
link: "mysql:root:12345678@tcp(127.0.0.1:3306)/test?loc=Local"
```
**代码示例**
```go
t1, _ := time.Parse("2006-01-02 15:04:05", "2020-10-27 10:00:00")
t2, _ := time.Parse("2006-01-02 15:04:05", "2020-10-27 11:00:00")
db.Model("user").Ctx(ctx).Where("create_time>? and create_time", t1, t2).One()
// SELECT * FROM `user` WHERE create_time>'2020-10-27 18:00:00' AND create_time<'2020-10-27 19:00:00'
```
这里由于通过 `time.Parse` 创建的 `time.Time` 时间对象是 `UTC` 时区,那么提交到数据库执行时将会被底层的 `driver` 修改为 `+8` 时区。
```go
t1, _ := time.ParseInLocation("2006-01-02 15:04:05", "2020-10-27 10:00:00", time.Local)
t2, _ := time.ParseInLocation("2006-01-02 15:04:05", "2020-10-27 11:00:00", time.Local)
db.Model("user").Ctx(ctx).Where("create_time>? and create_time", t1, t2).One()
// SELECT * FROM `user` WHERE create_time>'2020-10-27 10:00:00' AND create_time<'2020-10-27 11:00:00'
```
这里由于通过 `time.ParseInLocation` 创建的 `time.Time` 时间对象是 `+8` 时区,和 `loc=Local` 的时区一致,那么提交到数据库执行时不会被底层的 `driver` 修改。
**注意**
注意在写入数据中包含 `time.Time` 参数时,也需要注意时区转换的问题。
### 示例2,不设置 `loc` 参数
**配置文件**
```yaml
database:
link: "mysql:root:12345678@tcp(127.0.0.1:3306)/test"
```
**代码示例**
```go
t1, _ := time.Parse("2006-01-02 15:04:05", "2020-10-27 10:00:00")
t2, _ := time.Parse("2006-01-02 15:04:05", "2020-10-27 11:00:00")
db.Model("user").Ctx(ctx).Where("create_time>? and create_time", t1, t2).One()
// SELECT * FROM `user` WHERE create_time>'2020-10-27 10:00:00' AND create_time<'2020-10-27 11:00:00'
```
这里由于通过 `time.Parse` 创建的 `time.Time` 时间对象是 `UTC` 时区,那么提交到数据库执行时将不会被底层的 `driver` 修改。
```go
t1, _ := time.ParseInLocation("2006-01-02 15:04:05", "2020-10-27 10:00:00", time.Local)
t2, _ := time.ParseInLocation("2006-01-02 15:04:05", "2020-10-27 11:00:00", time.Local)
db.Model("user").Ctx(ctx).Where("create_time>? and create_time", t1, t2).One()
// SELECT * FROM `user` WHERE create_time>'2020-10-27 02:00:00' AND create_time<'2020-10-27 03:00:00'
```
这里由于通过 `time.ParseInLocation` 创建的 `time.Time` 时间对象是 `+8` 时区,那么提交到数据库执行时会被底层的 `driver` 修改为 `UTC` 时区。
**注意**
注意在写入数据中包含 `time.Time` 参数时,也需要注意时区转换的问题。
## 改进建议
建议在配置中统一加上 `locl` 配置,例如(MySQL): `loc=Local&parseTime=true`。以下是一个可供参考的配置:
```yaml
database:
logger:
level: "all"
stdout: true
default:
link: "mysql:root:12345678@tcp(192.168.1.10:3306)/mydb?loc=Local&parseTime=true"
debug: true
order:
link: "mysql:root:12345678@tcp(192.168.1.20:3306)/order?loc=Local&parseTime=true"
debug: true
```
# ORM模型生成
## 模型自动生成
`GoFrame` 框架支持非常便捷的数据表模型生成功能,通过 `gf gen dao/model` 工具链命令实现,具体请参考 [开发工具](../cli.md) 章节。
# ORM上下文变量
`ORM` 支持传递自定义的 `context` 上下文变量,用于异步 `IO` 控制、上下文信息传递(特别是链路跟踪信息的传递)、以及嵌套事务支持。
我们可以通过 `Ctx` 方法传递自定义的上下文变量给 `ORM` 对象, `Ctx` 方法其实是一个链式操作方法,该上下文传递进去后仅对当前 `DB` 接口对象有效,方法定义如下:
```go
func Ctx(ctx context.Context) DB
```
## 请求超时控制
我们来看一个通过上下文变量控制请求超时时间的示例。
```go
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_, err := db.Ctx(ctx).Query("SELECT SLEEP(10)")
fmt.Println(err)
```
该示例中执行会 `sleep 10` 秒中,因此必定会引发请求的超时。执行后,输出结果为:
```html
context deadline exceeded, SELECT SLEEP(10)
```
## 链路跟踪信息
上下文变量也可以传递链路跟踪信息,并且可以和日志组件结合,将链路信息打印到日志中(仅当 `ORM` 日志开启时),具体请参考链路跟踪专题介绍章节: [服务链路跟踪](../obs/tracing.md)
## 模型上下文操作
模型对象也支持上下文变量的传递,同样也是通过 `Ctx` 方法。我们来看一个简单的示例,我们将示例2的例子通过模型操作调整一下。
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
)
func main() {
_, err := g.DB().Model("user").Ctx(gctx.New()).All()
if err != nil {
panic(err)
}
}
```
执行后,终端输出为:
```html
2020-12-28 23:46:56.349 [DEBU] {38d45cbf2743db16f1062074f7473e5c} [ 5 ms] [default] [rows:0 ] SHOW FULL COLUMNS FROM `user`
2020-12-28 23:46:56.354 [DEBU] {38d45cbf2743db16f1062074f7473e5c} [ 5 ms] [default] [rows:100] SELECT * FROM `user`
```
**提示**
其中 ``SHOW FULL COLUMNS FROM `user` `` 为 `ORM` 组件的数据表字段获取查询,每个表在执行操作之前仅会查询一次并缓存结果到内存中。
## 嵌套事务支持
嵌套事务的支持依赖 `Context` 上下文变量的层级传递,具体请参考章节: [ORM事务处理](./gdb-transaction.md)
# ORM常见问题
## `ORM`是否可以直接执行SQL文件
1. 首先,`ORM`底层依赖的数据库`driver`从安全性考虑,通常默认不支持同时执行多条`SQL`语句。可以读取`SQL`文件内容,将内容拆分为单条`SQL`语句(通过`;`分隔符号分隔多条`SQL`语句),然后调用`ORM`的`Exec`方法来执行。
2. 其次,如果想要允许底层`driver`一次性执行多条`SQL`语句,可以参考底层`driver`的配置,比如`mysql`的配置,可以设置`multiStatements=true`(参考[mysql-driver配置](https://github.com/go-sql-driver/mysql?tab=readme-ov-file#multistatements)),这样`ORM`的`Exec`方法就可以执行多条`SQL`语句了。配置项示例:
```yaml
database:
default:
link: "mysql:root:123456@tcp(127.0.0.1:3306)/test?charset=utf8mb4&parseTime=True&loc=Local&multiStatements=true"
```
## `driver: bad connection`
如果数据库执行出现该错误,可能是由于本地数据库连接池的连接已经过期,可以检查一下客户端配置的 `MaxLifeTime` 配置是否超过数据库服务端设置的连接最大超时时间。更多客户端配置请参考章节: [ORM使用配置](./gdb-config.md)
## `update/insert` 操作不生效
使用 `orm` 时,配置文件中:
```toml
dryRun = "(可选)ORM空跑(只读不写)"
```
这行配置一定要删掉或者设置为0
否则出现 `update insert` 操作不生效的现象。
## `cannot find database driver for specified database type "xxx", did you misspell type name "xxx" or forget importing the database driver?`
程序代码没有引入依赖的数据库驱动,需要注意从 `GoFrame v2.1` 版本开始,需要手动引入社区驱动,请参考:
- [https://github.com/gogf/gf/tree/master/contrib/drivers](https://github.com/gogf/gf/tree/master/contrib/drivers)
## 数据库打开 `DEBUG` 日志后,查询的 `SQL` 语句中发现出现 `WHERE 0=1` 的语句
出现 `WHERE 0=1` 的情况是由于查询条件中存在数组条件,并且数组的长度为 `0`。这种情况 `ORM` 无法自动过滤这种空数组条件(这种条件过滤可能会引起业务异常),需要开发者根据业务场景,显示调用 `OmitEmpty` 或者 `OmitEmptyWhere` 来告诉 `ORM` 可以过滤这些空数组的条件。
## MYSQL中的表情,用SQL查询后,乱码问题
解决办法:
`config.toml` 文件 数据库配置的 `charset` 设置为 `utf8mb4` 默认是 `utf8`
`MySQL` 存储表情时注意:
- 数据库编码 `utf8mb4`
- 表的编码是 `utf8mb4`
- 表中内容字段是 `utf8mb4`
## 怎么让有UUID类型的数据库支持UUID处理
由于ORM类型转换在底层是使用的gconv实现的,所以我们可以自己添加新的converter就可以了。如以下示例,在程序初始化时调用就可以了。
```golang
gconv.RegisterTypeConverterFunc(func(src uuid.UUID) (val *string, err error) {
v := src.String()
return &v, nil
})
gconv.RegisterTypeConverterFunc(func(src uuid.UUIDs) (val *[]string, err error) {
v := src.Strings()
return &v, nil
})
gconv.RegisterTypeConverterFunc(func(src string) (val *uuid.UUID, err error) {
var v uuid.UUID
val = &v
src = gstr.Trim(src)
if src == "" {
return
}
v, err = uuid.Parse(src)
return
})
```
# ORM使用配置-配置文件
我们推荐使用配置组件来管理数据库配置,并使用 `g` 对象管理模块中的 `g.DB("数据库分组名称")` 方法获取数据库操作对象,数据库对象将会自动读取配置组件中的相应配置项,并自动初始化该数据库操作的单例对象。数据库配置管理功能使用的是配置管理组件实现(配置组件采用接口化设计默认使用文件系统实现),同样支持多种数据格式如: `toml/yaml/json/xml/ini/properties`。默认并且推荐的配置文件数据格式为 `yaml`。
## 简单配置
**提示**
从 `v2.2.0` 版本开始,使用 `link` 进行数据库配置时,数据库组件统一了不同数据库类型的配置格式,以简化配置管理。
简化配置通过配置项 `link` 指定,格式如下:
```text
type:username:password@protocol(address)[/dbname][?param1=value1&...¶mN=valueN]
```
即:
```text
类型:账号:密码@协议(地址)/数据库名称?特性配置
```
其中:
- **数据库名称** 及 **特性配置** 为非必须参数,其他参数为必须参数。
- **协议** 可选配置为: `tcp/udp/unix/file`,常见配置为 `tcp`
- **特性配置** 根据不同的数据库类型,由其底层实现的第三方驱动定义,具体需要参考第三方驱动官网。例如,针对 `mysql` 驱动而言,使用的第三方驱动为: [https://github.com/go-sql-driver/mysql](https://github.com/go-sql-driver/mysql) 支持的特性配置如 `multiStatements` 和 `loc` 等。
示例:
```yaml
database:
default:
link: "mysql:root:12345678@tcp(127.0.0.1:3306)/test"
user:
link: "sqlite::@file(/var/data/db.sqlite3)"
local:
link: "mysql:username:password@unix(/tmp/mysql.sock)/dbname"
```
不同数据类型对应的 `link` 示例如下:
| 类型 | link示例 | extra参数 |
| --- | --- | --- |
| `mysql` | ```mysql:root:12345678@tcp(127.0.0.1:3306)/test?loc=Local&parseTime=true``` | [mysql](https://github.com/go-sql-driver/mysql) |
| `mariadb` | ```mariadb:root:12345678@tcp(127.0.0.1:3306)/test?loc=Local&parseTime=true``` | [mysql](https://github.com/go-sql-driver/mysql) |
| `tidb` | ```tidb:root:12345678@tcp(127.0.0.1:3306)/test?loc=Local&parseTime=true``` | [mysql](https://github.com/go-sql-driver/mysql) |
| `pgsql` | ```pgsql:root:12345678@tcp(127.0.0.1:5432)/test``` | [pq](https://github.com/lib/pq) |
| `mssql` | ```mssql:root:12345678@tcp(127.0.0.1:1433)/test?encrypt=disable``` | [go-mssqldb](https://github.com/microsoft/go-mssqldb) |
| `sqlite` | ```sqlite::@file(/var/data/db.sqlite3)``` | pure go: [go-sqlite](https://github.com/glebarez/go-sqlite)
32bit-cgo: [go-sqlite3](https://github.com/mattn/go-sqlite3) |
| `oracle` | ```oracle:root:12345678@tcp(127.0.0.1:5432)/test``` | [go-ora](https://github.com/sijms/go-ora) |
| `clickhouse` | ```clickhouse:root:12345678@tcp(127.0.0.1:9000)/test``` | [clickhouse-go](https://github.com/ClickHouse/clickhouse-go) |
| `dm` | ```dm:root:12345678@tcp(127.0.0.1:5236)/test``` | [dm](https://gitee.com/chunanyong/dm) |
| `gaussdb` | ```gaussdb:root:12345678@tcp(127.0.0.1:5432)/test``` (`v2.10.0+`) | [openGauss-connector-go-pq](https://gitee.com/opengauss/openGauss-connector-go-pq) |
**提示**
更多框架支持的数据库类型请参考: [https://github.com/gogf/gf/tree/master/contrib/drivers](https://github.com/gogf/gf/tree/master/contrib/drivers)
## 完整配置
完整的 `config.yaml` 数据库配置项的数据格式形如下:
```yaml
database:
default: # 分组名称,可自定义,默认为default
host: "127.0.0.1" # 地址
port: "3306" # 端口
user: "root" # 账号
pass: "your_password" # 密码
name: "your_database" # 数据库名称
type: "mysql" # 数据库类型(如:mariadb/tidb/mysql/pgsql/mssql/sqlite/oracle/clickhouse/dm)
link: "" # (可选)自定义数据库链接信息,当该字段被设置值时,以上链接字段(Host,Port,User,Pass,Name,Type)将失效
extra: "" # (可选)不同数据库的额外特性配置,由底层数据库driver定义,具体有哪些配置请查看具体的数据库driver介绍
role: "master" # (可选)数据库主从角色(master/slave),默认为master。如果不使用应用主从机制请不配置或留空即可。
debug: false # (可选)开启调试模式
prefix: "gf_" # (可选)表名前缀
dryRun: false # (可选)ORM空跑(只读不写)
charset: "utf8" # (可选)数据库编码(如: utf8mb4/utf8/gbk/gb2312),一般设置为utf8mb4。默认为utf8。
protocol: "tcp" # (可选)数据库连接协议,默认为TCP
weight: 100 # (可选)负载均衡权重,用于负载均衡控制,不使用应用层的负载均衡机制请置空
timezone: "Local" # (可选)时区配置,例如:Local
namespace: "" # (可选)用以支持个别数据库服务Catalog&Schema区分的问题,原有的Schema代表数据库名称,而NameSpace代表个别数据库服务的Schema
maxIdle: 10 # (可选)连接池最大闲置的连接数(默认10)
maxOpen: 100 # (可选)连接池最大打开的连接数(默认无限制)
maxLifetime: "30s" # (可选)连接对象可重复使用的时间长度(默认30秒)
maxIdleConnTime: "30s" # (可选,v2.10新增)连接池中空闲连接的最大生存时间(默认30秒)。可以通过配置文件或SetConnMaxIdleTime方法设置,避免长时间空闲连接占用资源。
queryTimeout: "0" # (可选)查询语句超时时长(默认无限制,同时受ctx超时时间影响)。值为time.Parse支持的格式,如30s, 1m。
execTimeout: "0" # (可选)写入语句超时时长(默认无限制,同时受ctx超时时间影响)。值为time.Parse支持的格式,如30s, 1m。
tranTimeout: "0" # (可选)事务处理超时时长(默认无限制,同时受ctx超时时间影响)。值为time.Parse支持的格式,如30s, 1m。
prepareTimeout: "0" # (可选)预准备SQL语句执行超时时长(默认无限制,同时受ctx超时时间影响)。值为time.Parse支持的格式,如30s, 1m。
createdAt: "created_at" # (可选)自动创建时间字段名称
updatedAt: "updated_at" # (可选)自动更新时间字段名称
deletedAt: "deleted_at" # (可选)软删除时间字段名称
timeMaintainDisabled: false # (可选)是否完全关闭时间更新特性,为true时CreatedAt/UpdatedAt/DeletedAt都将失效
```
**备注**
使用该配置方式时, **为保证数据库安全,默认底层不支持多行 `SQL` 语句执行**。为了得到更多配置项控制,请参考推荐的`link`简化配置,并了解清楚简化配置项中每个连接参数的功能作用,以及对应驱动的官方额外配置参数。
配置示例:
```yaml
database:
default:
link: "mysql:root:12345678@tcp(127.0.0.1:3306)/test"
extra: "charset=utf8mb4&parseTime=True&loc=Local"
mysql:
type: "mysql"
host: "127.0.0.1"
port: "3306"
user: "root"
pass: "12345678"
name: "test"
charset: "utf8mb4"
timezone: "Local"
maxIdle: "10"
maxOpen: "100"
maxLifetime: "30s"
extra: "parseTime=True"
```
## 集群模式
`gdb` 的配置支持集群模式,数据库配置中每一项分组配置均可以是多个节点,支持负载均衡权重策略,例如:
```yaml
database:
default:
- link: "mysql:root:12345678@tcp(127.0.0.1:3306)/test"
role: "master"
- link: "mysql:root:12345678@tcp(127.0.0.1:3306)/test"
role: "slave"
user:
- link: "mysql:root:12345678@tcp(127.0.0.1:3306)/user"
role: "master"
- link: "mysql:root:12345678@tcp(127.0.0.1:3306)/user"
role: "slave"
- link: "mysql:root:12345678@tcp(127.0.0.1:3306)/user"
role: "slave"
```
以上数据库配置示例中包含两个数据库分组 `default` 和 `user`,其中 `default` 分组包含一主一从, `user` 分组包含一主两从。在代码中可以通过 `g.DB()` 和 `g.DB("user")` 获取对应的数据库连接对象。
## 日志配置
`gdb` 支持日志输出,内部使用的是 `glog.Logger` 对象实现日志管理,并且可以通过配置文件对日志对象进行配置。默认情况下 `gdb` 关闭了 `DEBUG` 日志输出,如果需要打开 `DEBUG` 信息需要将数据库的 `debug` 参数设置为 `true`。以下是为一个配置文件示例:
```yaml
database:
logger:
path: "/var/log/gf-app/sql"
level: "all"
stdout: true
default:
link: "mysql:root:12345678@tcp(127.0.0.1:3306)/user_center"
debug: true
```
其中 `database.logger` 即为 `gdb` 的日志配置,这是一项特殊的配置项,当该配置不存在时,将会使用日志组件的默认配置,
具体请参考 [日志组件-配置管理](./glog-config.md) 章节。
**注意**
需要注意哦:由于 `ORM` 底层都是采用安全的预处理执行方式,提交到底层的 `SQL` 与参数其实是分开的,因此日志中记录的完整 `SQL` 仅作参考方便人工阅读,并不是真正提交到底层的 `SQL` 语句。
# ORM使用配置-配置方法
**提示**
以下为数据库底层管理配置介绍,如果对数据库的底层配置管理比较感兴趣,可继续阅读后续章节。
## 数据结构
`gdb` 数据库管理模块的内部配置管理数据结构如下:
`ConfigNode` 用于存储一个数据库节点信息; `ConfigGroup` 用于管理多个数据库节点组成的配置分组(一般一个分组对应一个业务数据库集群); `Config` 用于管理多个 `ConfigGroup` 配置分组。
**配置管理特点:**
1. 支持多节点数据库集群管理;
2. 每个节点可以单独配置连接属性;
3. 采用单例模式管理数据库实例化对象;
4. 支持对数据库集群分组管理,按照分组名称获取实例化的数据库操作对象;
5. 支持多种关系型数据库管理,可通过 `ConfigNode.Type` 属性进行配置;
6. 支持 `Master-Slave` 读写分离,可通过 `ConfigNode.Role` 属性进行配置;
7. 支持客户端的负载均衡管理,可通过 `ConfigNode.Weight` 属性进行配置,值越大,优先级越高;
```go
type Config map[string]ConfigGroup // 数据库配置对象
type ConfigGroup []ConfigNode // 数据库分组配置
// 数据库配置项(一个分组配置对应多个配置项)
type ConfigNode struct {
Host string // 地址
Port string // 端口
User string // 账号
Pass string // 密码
Name string // 数据库名称
Type string // 数据库类型:mysql, sqlite, mssql, pgsql, oracle
Link string // (可选)自定义链接信息,当该字段被设置值时,以上链接字段(Host,Port,User,Pass,Name)将失效(该字段是一个扩展功能)
Extra string // (可选)不同数据库的额外特性配置,由底层数据库driver定义
Role string // (可选,默认为master)数据库的角色,用于主从操作分离,至少需要有一个master,参数值:master, slave
Debug bool // (可选)开启调试模式
Charset string // (可选,默认为 utf8)编码,默认为 utf8
Prefix string // (可选)表名前缀
Weight int // (可选)用于负载均衡的权重计算,当集群中只有一个节点时,权重没有任何意义
MaxIdleConnCount int // (可选)连接池最大闲置的连接数
MaxOpenConnCount int // (可选)连接池最大打开的连接数
MaxConnLifetime time.Duration // (可选,单位秒)连接对象可重复使用的时间长度
}
```
特别说明, `gdb` 的配置管理最大的 **特点** 是,(同一进程中)所有的数据库集群信息都使用同一个配置管理模块进行统一维护, **不同业务的数据库集群配置使用不同的分组名称** 进行配置和获取。
## 配置方法
这是原生调用 `gdb` 模块来配置管理数据库。如果开发者想要自行控制数据库配置管理可以参考以下方法。若无需要可忽略该章节。
接口文档: [https://pkg.go.dev/github.com/gogf/gf/v2/database/gdb](https://pkg.go.dev/github.com/gogf/gf/v2/database/gdb)
```go
// 添加一个数据库节点到指定的分组中
func AddConfigNode(group string, node ConfigNode)
// 添加一个配置分组到数据库配置管理中(同名覆盖)
func AddConfigGroup(group string, nodes ConfigGroup)
// 添加一个数据库节点到默认的分组中(默认为default,可修改)
func AddDefaultConfigNode(node ConfigNode)
// 添加一个配置分组到数据库配置管理中(默认分组为default,可修改)
func AddDefaultConfigGroup(nodes ConfigGroup)
// 设置默认的分组名称,获取默认数据库对象时将会自动读取该分组配置
func SetDefaultGroup(groupName string)
// 设置数据库配置为定义的配置信息,会将原有配置覆盖
func SetConfig(c Config)
// 获取所有数据库配置信息(从版本v2.10.0开始支持)
func GetAllConfig() Config
```
默认分组表示,如果获取数据库对象时不指定配置分组名称,那么 `gdb` 默认读取的配置分组。例如: `gdb.NewByGroup()` 可获取一个默认分组的数据库对象。简单的做法,我们可以通过 `gdb` 包的 `SetConfig` 配置管理方法进行自定义的数据库全局配置,例如:
```go
gdb.SetConfig(gdb.Config {
"default" : gdb.ConfigGroup {
gdb.ConfigNode {
Host : "192.168.1.100",
Port : "3306",
User : "root",
Pass : "123456",
Name : "test",
Type : "mysql",
Role : "master",
Weight : 100,
},
gdb.ConfigNode {
Host : "192.168.1.101",
Port : "3306",
User : "root",
Pass : "123456",
Name : "test",
Type : "mysql",
Role : "slave",
Weight : 100,
},
},
"user-center" : gdb.ConfigGroup {
gdb.ConfigNode {
Host : "192.168.1.110",
Port : "3306",
User : "root",
Pass : "123456",
Name : "test",
Type : "mysql",
Role : "master",
Weight : 100,
},
},
})
```
随后,我们可以使用 `gdb.NewByGroup("数据库分组名称")` 来获取一个数据库操作对象。该对象用于后续的数据库一系列方法/链式操作。
## 获取所有配置
从版本`v2.10.0`开始,`gdb`提供了`GetAllConfig`方法用于获取所有数据库配置信息,方便进行配置验证、配置导出等业务操作。
### 方法定义
```go
func GetAllConfig() Config
```
该方法返回类型为`Config`,即`map[string]ConfigGroup`,包含了所有已注册的数据库分组配置。
### 使用示例
```go
package main
import (
"fmt"
"github.com/gogf/gf/v2/database/gdb"
)
func main() {
// 获取所有数据库配置
allConfig := gdb.GetAllConfig()
// 遍历所有配置分组
for groupName, configGroup := range allConfig {
fmt.Printf("数据库分组: %s\n", groupName)
// 遍历分组中的所有节点
for i, node := range configGroup {
fmt.Printf(" 节点 %d:\n", i+1)
fmt.Printf(" Host: %s\n", node.Host)
fmt.Printf(" Port: %s\n", node.Port)
fmt.Printf(" Name: %s\n", node.Name)
fmt.Printf(" Type: %s\n", node.Type)
fmt.Printf(" Role: %s\n", node.Role)
}
}
}
```
### 应用场景
`GetAllConfig`方法主要用于以下场景:
1. **配置验证**:在应用启动时验证所有数据库配置是否正确。
2. **配置导出**:将当前数据库配置导出到文件或其他存储介质。
3. **监控和诊断**:在运行时检查数据库配置,用于故障排查。
4. **配置管理界面**:为管理后台提供配置查看和编辑功能。
# ORM使用配置-常见问题
## 如何实现数据库账号密码在配置文件中加密
在某些场景下,数据库的账号密码无法明文配置到配置文件中,需要进行一定的加密。在连接数据库的时候,
再对配置文件中加密的字段进行解密处理。这种需求可以通过自定义 `Driver` 来实现(关于 `Driver` 的详细介绍请参考章节:
[ORM接口开发](./gdb-interface.md))。以 `mysql` 为例,我们可以自己编写一个 `Driver`,包裹框架社区组件中的 `mysql driver`,并且覆盖它的 `Open` 方法即可。
这里有一个完整的可运行示例代码:https://goframe.org/examples/database/encoded-pass
# ORM链式操作
## 基本介绍
`gdb` 链式操作使用方式简单灵活,是 `GoFrame` 框架官方推荐的数据库操作方式。链式操作可以通过数据库对象的 `db.Model` 方法或者事务对象的 `tx.Model` 方法,基于指定的数据表返回一个链式操作对象 `*Model`,该对象可以执行以下方法。当前方法列表可能滞后于源代码,详细的方法列表请参考接口文档: [https://pkg.go.dev/github.com/gogf/gf/v2/database/gdb#Model](https://pkg.go.dev/github.com/gogf/gf/v2/database/gdb#Model)
## 部分不支持的操作
以下是最新版本的支持情况
| 类型 | Replace | Save | InsertIgnore | InsertGetId | LastInsertId | Transaction | RowsAffected |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `mysql` | 支持 | 支持 | 支持 | 支持 | 支持 | 支持 | 支持 |
| `mariadb` | 支持 | 支持 | 支持 | 支持 | 支持 | 支持 | 支持 |
| `tidb` | 支持 | 支持 | 支持 | 支持 | 支持 | 支持 | 支持 |
| `pgsql` | 不支持 | 支持 | 不支持 | 支持 | 支持 | 支持 | 支持 |
| `mssql` | 不支持 | 支持 | 支持 | 支持 | 不支持 | 支持 | 支持 |
| `sqlite` | 不支持 | 支持 | 支持 | 支持 | 支持 | 支持 | 支持 |
| `oracle` | 不支持 | 支持 | 支持 | 支持 | 不支持 | 支持 | 支持 |
| `dm` | 不支持 | 支持 | 支持 | 支持 | 支持 | 支持 | 支持 |
| `clickhouse` | 不支持 | 不支持 | 不支持 | 不支持 | 支持 | 不支持 | 不支持 |
# ORM链式操作-模型创建
## 模型创建
### `Model`
`Model` 方法用于创建基于数据表的 `Model` 对象。常见的,也可以使用 `g` 对象管理模块中的 `Model` 方法在默认的数据库配置上创建 `Model` 对象。
使用示例:
```go
g.Model("user")
// 或者
g.DB().Model("user")
```
此外,在某些场景下,我们也可以通过 `DB` 方法切换当前模型的数据库对象,例如:
```go
m := g.Model("user")
m = m.DB(g.DB("order"))
```
其效果与以下操作是一样的:
```go
m := g.DB("order").Model("user")
```
### `Raw`
`Raw` 方法用于创建一个基于原始 `SQL` 语句的 `Model` 对象。也可以使用 `g` 对象管理模块中的 `ModelRaw` 方法通过给定的 `SQL` 语句在默认的数据库配置上创建 `Model` 对象。
```go
s := "SELECT * FROM `user`"
m, _ := g.ModelRaw(s).WhereLT("age", 18).Limit(10).OrderAsc("id").All()
// SELECT * FROM `user` WHERE `age`<18 ORDER BY `id` ASC LIMIT 10
```
```go
s := "SELECT * FROM `user` WHERE `status` IN(?)"
m, _ := g.ModelRaw(s, g.Slice{1,2,3}).WhereLT("age", 18).Limit(10).OrderAsc("id").All()
// SELECT * FROM `user` WHERE `status` IN(1,2,3) AND `age`<18 ORDER BY `id` ASC LIMIT 10
```
## 链式安全
`链式安全` 只是模型操作的两种方式区别:一种会修改当前 `model` 对象(不安全,默认),一种不会(安全)但是模型属性修改/条件叠加需要使用赋值操作,仅此而已。
### 默认情况
在默认情况下, `gdb` 是 `非链式安全` 的,也就是说链式操作的每一个方法都将对当前操作的 `Model` 属性进行修改,因此该 `Model` 对象 **不可以重复使用**。例如,当存在多个分开查询的条件时,我们可以这么来使用 `Model` 对象:
```go
user := g.Model("user")
user.Where("status", g.Slice{1,2,3})
if vip {
// 查询条件自动叠加,修改当前模型对象
user.Where("money>=?", 1000000)
} else {
// 查询条件自动叠加,修改当前模型对象
user.Where("money", 1000000)
}
// vip: SELECT * FROM user WHERE status IN(1,2,3) AND money >= 1000000
// !vip: SELECT * FROM user WHERE status IN(1,2,3) AND money < 1000000
r, err := user.All()
// vip: SELECT COUNT(1) FROM user WHERE status IN(1,2,3) AND money >= 1000000
// !vip: SELECT COUNT(1) FROM user WHERE status IN(1,2,3) AND money < 1000000
n, err := user.Count()
```
可以看到,如果是分开执行链式操作,链式的每一个操作都会修改已有的 `Model` 对象,查询条件会自动叠加,因此 `user` 对象不可重复使用,否则条件会不停叠加。并且在这种使用方式中,每次我们需要操作 `user` 用户表,都得使用 `g.DB().Model("user")` 这样的语法创建一个新的 `user` 模型对象,相对来说会比较繁琐。
默认情况下,基于性能以及GC优化考虑,模型对象为 `非链式安全`,防止产生过多的临时模型对象。
**提示**
不过需要注意的是,如果使用的是cli工具 `gen dao` 生成的 `dao`,如 `user := dao.User.Ctx(ctx)`,此时获取到的 `user` `Model` 对象默认是链式安全的(已自动调用过 `.Safe()`)。
### `Clone` 方法
此外,我们也可以手动调动 `Clone` 方法克隆当前模型,创建一个新的模型来实现链式安全,由于是新的模型对象,因此并不担心会修改已有的模型对象的问题。例如:
```go
// 定义一个用户模型单例
user := g.Model("user")
```
```go
// 克隆一个新的用户模型
m := user.Clone()
m.Where("status", g.Slice{1,2,3})
if vip {
m.Where("money>=?", 1000000)
} else {
m.Where("money", 1000000)
}
// vip: SELECT * FROM user WHERE status IN(1,2,3) AND money >= 1000000
// !vip: SELECT * FROM user WHERE status IN(1,2,3) AND money < 1000000
r, err := m.All()
// vip: SELECT COUNT(1) FROM user WHERE status IN(1,2,3) AND money >= 1000000
// !vip: SELECT COUNT(1) FROM user WHERE status IN(1,2,3) AND money < 1000000
n, err := m.Count()
```
### `Safe` 方法
当然,我们可以通过 `Safe` 方法设置当前模型为 `链式安全` 的对象,后续的每一个链式操作都将返回一个新的 `Model` 对象,该 `Model` 对象可重复使用。但需要特别注意的是,模型属性的修改,或者操作条件的叠加,需要通过变量赋值的方式( `m = m.xxx`)覆盖原有的模型对象来实现。例如:
```go
// 定义一个用户模型单例
user := g.Model("user").Safe()
```
```go
m := user.Where("status", g.Slice{1,2,3})
if vip {
// 查询条件通过赋值叠加
m = m.Where("money>=?", 1000000)
} else {
// 查询条件通过赋值叠加
m = m.Where("money", 1000000)
}
// vip: SELECT * FROM user WHERE status IN(1,2,3) AND money >= 1000000
// !vip: SELECT * FROM user WHERE status IN(1,2,3) AND money < 1000000
r, err := m.All()
// vip: SELECT COUNT(1) FROM user WHERE status IN(1,2,3) AND money >= 1000000
// !vip: SELECT COUNT(1) FROM user WHERE status IN(1,2,3) AND money < 1000000
n, err := m.Count()
```
可以看到,示例中的用户模型单例对象 `user` 可以重复使用,而不用担心被「污染」的问题。在这种链式安全的方式下,我们可以创建一个用户单例对象 `user`,并且可以重复使用到后续的各种查询中。但是存在多个查询条件时,条件的叠加需要通过模型赋值操作( `m = m.xxx`)来实现。
**提示**
使用 `Safe` 方法标记之后,每一个链式操作都将会创建一个新的临时模型对象(内部自动使用 `Clone` 实现模型克隆),从而实现链式安全。这种使用方式在模型操作中比较常见。
# ORM链式操作-写入保存
## 常用方法
### `Insert/Replace/Save`
这几个链式操作方法用于数据的写入,并且支持自动的单条或者批量的数据写入,区别如下:
1. `Insert`
使用 `INSERT INTO` 语句进行数据库写入,如果写入的数据中存在主键或者唯一索引时,返回失败,否则写入一条新数据。
2. `Replace`
使用 `REPLACE INTO` 语句进行数据库写入,如果写入的数据中存在主键或者唯一索引时,会删除原有的记录,必定会写入一条新记录。
3. `Save`
使用 `INSERT INTO` 语句进行数据库写入,如果写入的数据中存在主键或者唯一索引时,更新原有数据,否则写入一条新数据。对于部分数据库,例如 `PgSQL, SQL server, Oracle` 可以使用 `OnConflict` 方法指定冲突键。
```go
db.Model(table).Data(g.Map{
"id": 1,
"passport": "p1",
"password": "pw1",
}).OnConflict("id").Save()
```
> 在部分数据库类型中,并不支持 `Replace/Save` 方法,具体请参考 [链式操作](./gdb-chaining.md) 介绍章节。
这几个方法需要结合 `Data` 方法使用,该方法用于传递数据参数,用于数据写入/更新等写操作。
### `InsertIgnore`
用于写入数据时如果写入的数据中存在主键或者唯一索引时,忽略错误继续执行写入。该方法定义如下:
```go
func (m *Model) InsertIgnore(data ...interface{}) (result sql.Result, err error)
```
### `InsertAndGetId`
用于写入数据时并直接返回自增字段的 `ID`。该方法定义如下:
```go
func (m *Model) InsertAndGetId(data ...interface{}) (lastInsertId int64, err error)
```
### `OnDuplicate/OnDuplicateEx`
`OnDuplicate/OnDuplicateEx` 方法需要结合 `Save` 方法一起使用,用于指定 `Save` 方法的更新/不更新字段,参数为字符串、字符串数组、 `Map`。例如:
```go
OnDuplicate("nickname, age")
OnDuplicate("nickname", "age")
OnDuplicate(g.Map{
"nickname": gdb.Raw("CONCAT('name_', VALUES(`nickname`))"),
})
OnDuplicate(g.Map{
"nickname": "passport",
})
```
其中 `OnDuplicateEx` 用于排除指定忽略更新的字段,排除的字段需要在写入的数据集合中。
#### 使用 `gdb.Counter` 实现字段自增
在使用 `OnDuplicate` 方法时,可以结合 `gdb.Counter` 类型来实现字段的自增操作。`gdb.Counter` 类型包含两个属性:`Field` 和 `Value`,分别表示要自增的字段名和自增的值。
```go
_, err := db.Model("user").OnConflict("id").OnDuplicate(g.Map{
"count": gdb.Counter{Field: "count", Value: 1}, // count = count + 1
"amount": gdb.Counter{Field: "amount", Value: 9.99}, // amount = amount + 9.99
}).Data(g.Map{
"id": 1,
"name": "john",
"count": 0,
"amount": 0,
}).Save()
```
上述示例中,当插入数据时遇到 `id` 冲突,会将现有记录的 `count` 字段值增加 `1`,将 `amount` 字段值增加 `9.99`。这在需要统计计数或累加金额等场景中非常有用。
## 使用示例
### 示例1,基本使用
数据写入/保存方法需要结合 `Data` 方法使用,方法的参数类型可以为 `Map/Struct/Slice`:
```go
// INSERT INTO `user`(`name`) VALUES('john')
g.Model("user").Data(g.Map{"name": "john"}).Insert()
// INSERT IGNORE INTO `user`(`uid`,`name`) VALUES(10000,'john')
g.Model("user").Data(g.Map{"uid": 10000, "name": "john"}).InsertIgnore()
// REPLACE INTO `user`(`uid`,`name`) VALUES(10000,'john')
g.Model("user").Data(g.Map{"uid": 10000, "name": "john"}).Replace()
// INSERT INTO `user`(`uid`,`name`) VALUES(10001,'john') ON DUPLICATE KEY UPDATE `uid`=VALUES(`uid`),`name`=VALUES(`name`)
g.Model("user").Data(g.Map{"uid": 10001, "name": "john"}).Save()
```
也可以不使用 `Data` 方法,而给写入/保存方法直接传递数据参数:
```go
g.Model("user").Insert(g.Map{"name": "john"})
g.Model("user").Replace(g.Map{"uid": 10000, "name": "john"})
g.Model("user").Save(g.Map{"uid": 10001, "name": "john"})
```
数据参数也常用 `struct` 类型,例如当表字段为 `uid/name/site` 时:
```go
type User struct {
Uid int `orm:"uid"`
Name string `orm:"name"`
Site string `orm:"site"`
}
user := &User{
Uid: 1,
Name: "john",
Site: "https://goframe.org",
}
// INSERT INTO `user`(`uid`,`name`,`site`) VALUES(1,'john','https://goframe.org')
g.Model("user").Data(user).Insert()
```
### 示例2,数据批量写入
通过给 `Data` 方法输入 `Slice` 数组类型的参数,用以实现批量写入。数组元素需要为 `Map` 或者 `Struct` 类型,以便于数据库组件自动获取字段信息并生成批量操作 `SQL`。
```go
// INSERT INTO `user`(`name`) VALUES('john_1'),('john_2'),('john_3')
g.Model("user").Data(g.List{
{"name": "john_1"},
{"name": "john_2"},
{"name": "john_3"},
}).Insert()
```
可以通过 `Batch` 方法指定批量操作中分批写入条数数量(默认是 `10`),以下示例将会被拆分为两条写入请求:
```go
// INSERT INTO `user`(`name`) VALUES('john_1'),('john_2')
// INSERT INTO `user`(`name`) VALUES('john_3')
g.Model("user").Data(g.List{
{"name": "john_1"},
{"name": "john_2"},
{"name": "john_3"},
}).Batch(2).Insert()
```
### 示例3,数据批量保存
批量保存操作与单条保存操作原理是一样的,当写入的数据中存在主键或者唯一索引时将会更新原有记录值,否则新写入一条记录。
> `oracle, dm, mssql` 不支持批量保存。
```go
// INSERT INTO `user`(`uid`,`name`) VALUES(10000,'john_1'),(10001,'john_2'),(10002,'john_3')
// ON DUPLICATE KEY UPDATE `uid`=VALUES(`uid`),`name`=VALUES(`name`)
g.Model("user").Data(g.List{
{"uid":10000, "name": "john_1"},
{"uid":10001, "name": "john_2"},
{"uid":10002, "name": "john_3"},
}).Save()
```
## `RawSQL` 语句嵌入
`gdb.Raw` 是字符串类型,该类型的参数将会直接作为 `SQL` 片段嵌入到提交到底层的 `SQL` 语句中,不会被自动转换为字符串参数类型、也不会被当做预处理参数。更详细的介绍请参考章节: [ORM高级特性-RawSQL](./gdb-senior-raw-sql.md)。例如:
```go
// INSERT INTO `user`(`id`,`passport`,`password`,`nickname`,`create_time`) VALUES('id+2','john','123456','now()')
g.Model("user").Data(g.Map{
"id": "id+2",
"passport": "john",
"password": "123456",
"nickname": "JohnGuo",
"create_time": "now()",
}).Insert()
// 执行报错:Error Code: 1136. Column count doesn't match value count at row 1
```
使用 `gdb.Raw` 改造后:
```go
// INSERT INTO `user`(`id`,`passport`,`password`,`nickname`,`create_time`) VALUES(id+2,'john','123456',now())
g.Model("user").Data(g.Map{
"id": gdb.Raw("id+2"),
"passport": "john",
"password": "123456",
"nickname": "JohnGuo",
"create_time": gdb.Raw("now()"),
}).Insert()
```
# ORM链式操作-更新删除
**注意**
为安全性保证、防止误操作, `Update` 及 `Delete` 方法必须带有 `Where` 条件才能提交执行,否则将会错误返回,错误信息如: `there should be WHERE condition statement for XXX operation`。 `goframe` 是一款用于企业生产级别的框架,各个模块设计严谨,工程实践的细节处理得比较好。
## `Update` 更新方法
`Update` 用于数据的更新,往往需要结合 `Data` 及 `Where` 方法共同使用。 `Data` 方法用于指定需要更新的数据, `Where` 方法用于指定更新的条件范围。同时, `Update` 方法也支持直接给定数据和条件参数。
使用示例:
```go
// UPDATE `user` SET `name`='john guo' WHERE name='john'
g.Model("user").Data(g.Map{"name" : "john guo"}).Where("name", "john").Update()
g.Model("user").Data("name='john guo'").Where("name", "john").Update()
// UPDATE `user` SET `status`=1 WHERE `status`=0 ORDER BY `login_time` asc LIMIT 10
g.Model("user").Data("status", 1).Order("login_time asc").Where("status", 0).Limit(10).Update()
// UPDATE `user` SET `status`=1 WHERE 1
g.Model("user").Data("status=1").Where(1).Update()
g.Model("user").Data("status", 1).Where(1).Update()
g.Model("user").Data(g.Map{"status" : 1}).Where(1).Update()
```
也可以直接给 `Update` 方法传递 `data` 及 `where` 参数:
```go
// UPDATE `user` SET `name`='john guo' WHERE name='john'
g.Model("user").Update(g.Map{"name" : "john guo"}, "name", "john")
g.Model("user").Update("name='john guo'", "name", "john")
// UPDATE `user` SET `status`=1 WHERE 1
g.Model("user").Update("status=1", 1)
g.Model("user").Update(g.Map{"status" : 1}, 1)
```
## `Counter` 更新特性
可以使用 `Counter` 类型参数对特定的字段进行数值操作,例如:增加、减少操作。
`Counter` 数据结构定义:
```go
// Counter is the type for update count.
type Counter struct {
Field string
Value float64
}
```
`Counter` 使用示例,字段自增:
```go
updateData := g.Map{
"views": &gdb.Counter{
Field: "views",
Value: 1,
},
}
// UPDATE `article` SET `views`=`views`+1 WHERE `id`=1
result, err := db.Update("article", updateData, "id", 1)
```
`Counter` 也可以实现非自身字段的自增,例如:
```go
updateData := g.Map{
"views": &gdb.Counter{
Field: "clicks",
Value: 1,
},
}
// UPDATE `article` SET `views`=`clicks`+1 WHERE `id`=1
result, err := db.Update("article", updateData, "id", 1)
```
## `Increment/Decrement` 自增/减
我们可以通过 `Increment` 和 `Decrement` 方法实现对指定字段的自增/自减常用操作。两个方法的定义如下:
```go
// Increment increments a column's value by a given amount.
func (m *Model) Increment(column string, amount float64) (sql.Result, error)
// Decrement decrements a column's value by a given amount.
func (m *Model) Decrement(column string, amount float64) (sql.Result, error)
```
使用示例:
```go
// UPDATE `article` SET `views`=`views`+10000 WHERE `id`=1
g.Model("article").Where("id", 1).Increment("views", 10000)
// UPDATE `article` SET `views`=`views`-10000 WHERE `id`=1
g.Model("article").Where("id", 1).Decrement("views", 10000)
```
## `RawSQL` 语句嵌入
`gdb.Raw` 是字符串类型,该类型的参数将会直接作为 `SQL` 片段嵌入到提交到底层的 `SQL` 语句中,不会被自动转换为字符串参数类型、也不会被当做预处理参数。更详细的介绍请参考章节: [ORM高级特性-RawSQL](./gdb-senior-raw-sql.md)。例如:
```go
// UPDATE `user` SET login_count='login_count+1',update_time='now()' WHERE id=1
g.Model("user").Data(g.Map{
"login_count": "login_count+1",
"update_time": "now()",
}).Where("id", 1).Update()
// 执行报错:Error Code: 1136. Column count doesn't match value count at row 1
```
使用 `gdb.Raw` 改造后:
```go
// UPDATE `user` SET login_count=login_count+1,update_time=now() WHERE id=1
g.Model("user").Data(g.Map{
"login_count": gdb.Raw("login_count+1"),
"update_time": gdb.Raw("now()"),
}).Where("id", 1).Update()
```
## `Delete` 删除方法
`Delete` 方法用于数据的删除。
使用示例:
```go
// DELETE FROM `user` WHERE uid=10
g.Model("user").Where("uid", 10).Delete()
// DELETE FROM `user` ORDER BY `login_time` asc LIMIT 10
g.Model("user").Order("login_time asc").Limit(10).Delete()
```
也可以直接给 `Delete` 方法传递 `where` 参数:
```go
// DELETE FROM `user` WHERE `uid`=10
g.Model("user").Delete("uid", 10)
// DELETE FROM `user` WHERE `score`<60
g.Model("user").Delete("score < ", 60)
```
## 软删除特性
软删除特性请查看章节: [ORM链式操作-时间维护](./gdb-chaining-soft-time.md)
# ORM链式操作-对象输入
`Data/Where/WherePri/And/Or` 方法支持任意的 `string/map/slice/struct/*struct` 数据类型参数,该特性为 `gdb` 提供了很高的灵活性。当使用 `struct`/ `*struct` 对象作为输入参数时,将会被自动解析为 `map` 类型,只有 `struct` 的 **公开属性** 能够被转换,并且支持 `orm`/ `gconv`/ `json` 标签,用于定义转换后的键名,即与表字段的映射关系。
例如:
```go
type User struct {
Uid int `orm:"user_id"`
Name string `orm:"user_name"`
NickName string `orm:"nick_name"`
}
// 或者
type User struct {
Uid int `gconv:"user_id"`
Name string `gconv:"user_name"`
NickName string `gconv:"nick_name"`
}
// 或者
type User struct {
Uid int `json:"user_id"`
Name string `json:"user_name"`
NickName string `json:"nick_name"`
}
```
其中, `struct` 的属性应该是公开属性(首字母大写), `orm` 标签对应的是数据表的字段名称。表字段的对应关系标签既可以使用 `orm`,也可以用 `gconv`,还可以使用传统的 `json` 标签,但是当三种标签都存在时, `orm` 标签的优先级更高。为避免将 `struct` 对象转换为 `JSON` 数据格式返回时与 `JSON` 编码标签冲突,推荐使用 `orm` 标签来实现数据库 `ORM` 的映射关系。更详细的转换规则请查看 [类型转换-Map转换](./gconv-map.md) 章节。
# ORM链式操作-字段过滤
## `Fields/FieldsEx` 字段过滤
1. `Fields` 用于指定需要操作的表字段,包括查询字段、写入字段、更新字段等过滤;
2. `FieldsEx` 用于例外的字段指定,可用于查询字段、写入字段、更新字段等过滤;
### `Fields` 示例
1. 假如 `user` 表有4个字段 `uid`, `nickname`, `passport`, `password`。
2. 查询字段过滤
```go
// SELECT `uid`,`nickname` FROM `user` ORDER BY `uid` asc
g.Model("user").Fields("uid, nickname").Order("uid asc").All()
```
3. 写入字段过滤
```go
m := g.Map{
"uid" : 10000,
"nickname" : "John Guo",
"passport" : "john",
"password" : "123456",
}
g.Model(table).Fields("nickname,passport,password").Data(m).Insert()
// INSERT INTO `user`(`nickname`,`passport`,`password`) VALUES('John Guo','john','123456')
```
4. 支持`gdb.Raw`输入
```go
// SELECT 1 FROM `user` WHERE `id`=10
g.Model("user").Fields(gdb.Raw("1")).Where("id", 10).Value()
```
### `FieldsEx` 示例
1. 假如 `user` 表有4个字段 `uid`, `nickname`, `passport`, `password`。
2. 查询字段排除
```go
// SELECT `uid`,`nickname` FROM `user`
g.Model("user").FieldsEx("passport, password").All()
```
1. 写入字段排除
```go
m := g.Map{
"uid" : 10000,
"nickname" : "John Guo",
"passport" : "john",
"password" : "123456",
}
g.Model(table).FieldsEx("uid").Data(m).Insert()
// INSERT INTO `user`(`nickname`,`passport`,`password`) VALUES('John Guo','john','123456')
```
## `OmitEmpty` 空值过滤
当 `map`/ `struct` 中存在空值如 `nil`, `""`, `0` 时,默认情况下, `gdb` 将会将其当做正常的输入参数,因此这些参数也会被更新到数据表。 `OmitEmpty` 特性可以在将数据写入到数据库之前过滤空值数据的字段。
相关方法:
```go
func (m *Model) OmitEmpty() *Model
func (m *Model) OmitEmptyWhere() *Model
func (m *Model) OmitEmptyData() *Model
```
`OmitEmpty` 方法会同时过滤 `Where` 及 `Data` 中的空值数据,而通过 `OmitEmptyWhere/OmitEmptyData` 方法可以执行特定的字段过滤。
### 写入/更新操作
空值会影响于写入/更新操作方法,如 `Insert`, `Replace`, `Update`, `Save` 操作。如以下操作(以 `map` 为例, `struct` 同理):
```go
// UPDATE `user` SET `name`='john',update_time=null WHERE `id`=1
g.Model("user").Data(g.Map{
"name" : "john",
"update_time" : nil,
}).Where("id", 1).Update()
```
针对空值情况,我们可以通过 `OmitEmpty` 方法来过滤掉这些空值。例如,以上示例可以修改为:
```go
// UPDATE `user` SET `name`='john' WHERE `id`=1
g.Model("user").OmitEmpty().Data(g.Map{
"name" : "john",
"update_time" : nil,
}).Where("id", 1).Update()
```
对于 `struct` 数据参数,我们也可以进行空值过滤。操作示例:
```go
type User struct {
Id int `orm:"id"`
Passport string `orm:"passport"`
Password string `orm:"password"`
NickName string `orm:"nickname"`
CreateTime string `orm:"create_time"`
UpdateTime string `orm:"update_time"`
}
user := User{
Id : 1,
NickName : "john",
UpdateTime: gtime.Now().String(),
}
g.Model("user").OmitEmpty().Data(user).Insert()
// INSERT INTO `user`(`id`,`nickname`,`update_time`) VALUES(1,'john','2019-10-01 12:00:00')
```
**注意**
注意哟,批量写入/更新操作中 `OmitEmpty` 方法将会失效,因为在批量操作中,必须保证每个写入记录的字段是统一的。
关于 `omitempty` 标签与 `OmitEmpty` 方法:
1. 针对于 `struct` 的空值过滤大家会想到 `omitempty` 的标签。该标签常用于 `json` 转换的空值过滤,也在某一些第三方的 `ORM` 库中用作 `struct` 到数据表字段的空值过滤,即当属性为空值时不做转换。
2. `omitempty` 标签与 `OmitEmpty` 方法所达到的效果是一样的。在 `ORM` 操作中,我们不建议对 `struct` 使用 `omitempty` 的标签来控制字段的空值过滤,而建议使用 `OmitEmpty` 方法来做控制。因为该标签一旦加上之后便绑定到了 `struct` 上,没有办法做灵活控制;而通过 `OmitEmpty` 方法使得开发者可以选择性地、根据业务场景对 `struct` 做空值过滤,操作更加灵活。
### 数据查询操作
空值也会影响数据查询操作,主要是影响 `where` 条件参数。我们可以通过 `OmitEmpty` 方法过滤条件参数中的空值。
使用示例:
```go
// SELECT * FROM `user` WHERE `passport`='john' LIMIT 1
r, err := g.Model("user").Where(g.Map{
"nickname" : "",
"passport" : "john",
}).OmitEmpty().One()
```
```go
type User struct {
Id int `orm:"id"`
Passport string `orm:"passport"`
Password string `orm:"password"`
NickName string `orm:"nickname"`
CreateTime string `orm:"create_time"`
UpdateTime string `orm:"update_time"`
}
user := User{
Passport : "john",
}
r, err := g.Model("user").OmitEmpty().Where(user).One()
// SELECT * FROM `user` WHERE `passport`='john' LIMIT 1
```
## `OmitNil` 空值过滤
### 基本介绍
当 `map`/ `struct` 中存在空值如 `nil` 时,默认情况下, `gdb` 将会将其当做正常的输入参数,因此这些参数也会被更新到数据表。 `OmitNil` 特性可以在将数据写入到数据库之前过滤空值数据的字段。与 `OmitEmpty` 特性的区别在于, `OmitNil` 只会过滤值为 `nil` 的空值字段,其他空值如 `""`, `0` 并不会被过滤。
相关方法:
```go
func (m *Model) OmitNil() *Model
func (m *Model) OmitNilWhere() *Model
func (m *Model) OmitNilData() *Model
```
`OmitEmpty` 方法会同时过滤 `Where` 及 `Data` 中的空值数据,而通过 `OmitEmptyWhere/OmitEmptyData` 方法可以执行特定的字段过滤。
### 使用 `do` 对象进行字段过滤
如果使用 `GoFrame` 工程目录,通过 `gf gen dao` 或者 `make dao` 指令会自动根据配置的数据库生成对应的数据表 `dao/entity/do` 文件,如果在数据库操作中使用 `do` 对象,那么将会自动过滤未赋值的字段。例如:
生成的 `do` 对象结构体定义
```go
// User is the golang structure of table user for DAO operations like Where/Data.
type User struct {
g.Meta `orm:"table:user, do:true"`
Id interface{} // User ID
Passport interface{} // User Passport
Password interface{} // User Password
Nickname interface{} // User Nickname
CreateAt *gtime.Time // Created Time
UpdateAt *gtime.Time // Updated Time
}
```
数据写入:
```go
dao.User.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
_, err = dao.User.Ctx(ctx).Data(do.User{
Passport: in.Passport,
Password: in.Password,
Nickname: in.Nickname,
}).Insert()
return err
})
```
数据查询:
```go
var user *entity.User
err = dao.User.Ctx(ctx).Where(do.User{
Passport: in.Passport,
Password: in.Password,
}).Scan(&user)
```
## `Filter` 字段过滤(已内置)
~~`gdb` 可以自动同步 **数据表结构** 到程序缓存中(缓存不过期,直至程序重启/重新部署),并且可以过滤提交参数中不符合表结构的数据项,该特性可以使用 `Filter` 方法实现。常用于新增/删除操作中输入 `map/struct/[]map/[]string` 参数类型的场景。~~
~~使用示例,假如 `user` 表有4个字段 `uid`, `nickname`, `passport`, `password`:~~
```go
r, err := g.Model("user").Filter().Data(g.Map{
"id" : 1,
"uid" : 1,
"passport" : "john",
"password" : "123456",
}).Insert()
// INSERT INTO user(uid,passport,password) VALUES(1, "john", "123456")
```
~~其中 `id` 为不存在的字段,在写入数据时将会被过滤掉,不至于被构造成写入SQL中产生执行错误。~~
**提示**
~~数据库没有设计为 `Data` 方法做自动过滤,而是需要开发者调用 `Filter` 方法来手动指定过滤,目的是友好地提醒开发者可能误写/传递错误了字段名称。如果强制性的自动过滤可能会引起难以预料的业务逻辑异常,例如,由于字段名称拼写错误导致自动过滤了本来需要输入的字段,导致写入数据库的数据不完整。~~
**注意**
从 `GoFrame v1.15.7` 版本开始,根据社区整体反馈,为提高组件易用性, `filter` 特性被设置为默认开启,不再需要显示调用, `Filter` 方法已被标记废弃。
# ORM链式操作-字段获取
## `GetFieldsStr/GetFieldsExStr` 字段获取
1. `GetFieldsStr` 用于获取指定表的字段,并可给定字段前缀,字段之间使用" `,`"符号连接成字符串返回;
2. `GetFieldsExStr` 用于获取指定表中例外的字段,并可给定字段前缀,字段之间使用" `,`"符号连接成字符串返回;
### `GetFieldsStr` 示例
1. 假如 `user` 表有4个字段 `uid`, `nickname`, `passport`, `password`。
2. 查询字段
```go
// uid,nickname,passport,password
g.Model("user").GetFieldsStr()
```
3. 查询字段给指定前缀
```go
// gf_uid,gf_nickname,gf_passport,gf_password
g.Model("user").GetFieldsStr("gf_")
```
### `GetFieldsExStr` 示例
1. 假如 `user` 表有4个字段 `uid`, `nickname`, `passport`, `password`。
2. 查询字段排除
```go
// uid,nickname
g.Model("user").GetFieldsExStr("passport, password")
```
3. 查询字段排除并给定前缀
```go
// gf_uid,gf_nickname
g.Model("user").GetFieldsExStr("passport, password", "gf_")
```
# ORM链式操作-事务处理
`Model` 对象也可以通过 `TX` 事务接口创建,通过事务对象创建的 `Model` 对象与通过 `DB` 数据库对象创建的 `Model` 对象功能是一样的,只不过前者的所有操作都是基于事务,而当事务提交或者回滚后,对应的 `Model` 对象不能被继续使用,否则会返回错误。因为该 `TX` 接口不能被继续使用,一个事务对象仅对应于一个事务流程, `Commit`/ `Rollback` 后即结束。
本章节仅对链式操作涉及到的事务处理方法做简单介绍,更详细的介绍请参考 [ORM事务处理](./gdb-transaction.md) 章节。
## 示例1,通过 `Transaction`
为方便事务操作, `gdb` 提供了事务的闭包操作,通过 `Transaction` 方法实现,该方法定义如下:
```go
func (db DB) Transaction(ctx context.Context, f func(ctx context.Context, tx TX) error) (err error)
```
当给定的闭包方法返回的 `error` 为 `nil` 时,那么闭包执行结束后当前事务自动执行 `Commit` 提交操作;否则自动执行 `Rollback` 回滚操作。
**提示**
如果闭包内部操作产生 `panic` 中断,该事务也将进行回滚。
```go
func Register() error {
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
var (
result sql.Result
err error
)
// 写入用户基础数据
result, err = tx.Table("user").Insert(g.Map{
"name": "john",
"score": 100,
//...
})
if err != nil {
return err
}
// 写入用户详情数据,需要用到上一次写入得到的用户uid
result, err = tx.Table("user_detail").Insert(g.Map{
"uid": result.LastInsertId(),
"phone": "18010576258",
//...
})
return err
})
}
```
## 示例2,通过 `TX` 链式操作
我们也可以在链式操作中通过 `TX` 方法切换绑定的事务对象。多次链式操作可以绑定同一个事务对象,在该事务对象中执行对应的链式操作。
```go
func Register() error {
var (
uid int64
err error
)
tx, err := g.DB().Begin()
if err != nil {
return err
}
// 方法退出时检验返回值,
// 如果结果成功则执行tx.Commit()提交,
// 否则执行tx.Rollback()回滚操作。
defer func() {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}()
// 写入用户基础数据
uid, err = AddUserInfo(tx, g.Map{
"name": "john",
"score": 100,
//...
})
if err != nil {
return err
}
// 写入用户详情数据,需要用到上一次写入得到的用户uid
err = AddUserDetail(tx, g.Map{
"uid": uid,
"phone": "18010576259",
//...
})
return err
}
func AddUserInfo(tx gdb.TX, data g.Map) (int64, error) {
result, err := g.Model("user").TX(tx).Data(data).Insert()
if err != nil {
return 0, err
}
uid, err := result.LastInsertId()
if err != nil {
return 0, err
}
return uid, nil
}
func AddUserDetail(tx gdb.TX, data g.Map) error {
_, err := g.Model("user_detail").TX(tx).Data(data).Insert()
return err
}
```
# ORM链式操作-主从切换
从之前的介绍我们知道 `gdb` 支持基于应用层的主从配置以及读写分离,并且所有的特性仅需要通过简单的配置即可实现, `gdb` 内部将会对SQL请求自动地进行主从切换。以下是一个简单的主从配置,包含一主一从:
```yaml
database:
default:
- type: "mysql"
link: "root:12345678@tcp(192.168.1.1:3306)/test"
role: "master"
- type: "mysql"
link: "root:12345678@tcp(192.168.1.2:3306)/test"
role: "slave"
```
在大部分的场景中,我们的写入请求是到 `Master` 主节点,而读取请求是到 `Slave` 从节点,这样的好处是能够对数据库的请求进行压力分摊,并提高数据库的可用性。但在某些场景中,我们期望读取操作在 `Master` 节点上执行,特别是一些对于即时性要求比较高的场景(因为主从节点之间的数据同步是有延迟的)。
开发者可以通过 `Master` 和 `Slave` 方法自定义决定当前链式操作执行在哪个节点上。
我们来一个简单的示例。我们有一个订单系统,每天的流量比较大,因此数据库在主从同步时往往会存在 `1-500ms` 时间的延迟。在业务需求中,创建订单后需要立即展示订单列表页面。可以预料到如果该订单列表页面默认往从节点读取数据的话,很有可能用户在创建订单后在订单列表页面看不到最新创建的订单(因为数据库主从同步延迟)。这个问题,我们可以在订单列表页面设置为往主节点读取数据即可解决。
1. 在订单创建的时候,没有必要指定操作的节点,因为写入操作默认是在主节点上执行的。为简化示例,我们这里仅展示关键的代码:
```go
g.Model("order").Data(g.Map{
"uid" : 1000,
"price" : 99.99,
// ...
}).Insert()
```
2. 在订单列表页面查询时,我们需要使用 `Master` 方法指定查询操作是在主节点上进行,以避免读取延迟。
```go
g.Model("order").Master().Where("uid", 1000).All()
```
`
`
# ORM链式操作-查询缓存
## 查询缓存
`gdb` 支持对查询结果的缓存处理,常用于多读少写的查询缓存场景,并支持手动的缓存清理。需要注意的是,查询缓存仅支持链式操作,且在事务操作下不可用。
相关方法:
```go
type CacheOption struct {
// Duration is the TTL for the cache.
// If the parameter `Duration` < 0, which means it clear the cache with given `Name`.
// If the parameter `Duration` = 0, which means it never expires.
// If the parameter `Duration` > 0, which means it expires after `Duration`.
Duration time.Duration
// Name is an optional unique name for the cache.
// The Name is used to bind a name to the cache, which means you can later control the cache
// like changing the `duration` or clearing the cache with specified Name.
Name string
// Force caches the query result whatever the result is nil or not.
// It is used to avoid Cache Penetration.
Force bool
}
// Cache sets the cache feature for the model. It caches the result of the sql, which means
// if there's another same sql request, it just reads and returns the result from cache, it
// but not committed and executed into the database.
//
// Note that, the cache feature is disabled if the model is performing select statement
// on a transaction.
func (m *Model) Cache(option CacheOption) *Model
```
## 缓存管理
### 缓存对象
`ORM` 对象默认情况下提供了缓存管理对象,该缓存对象类型为 `*gcache.Cache`,也就是说同时也支持 `*gcache.Cache` 的所有特性。可以通过 `GetCache() *gcache.Cache` 接口方法获得该缓存对象,并通过返回的对象实现自定义的各种缓存操作,例如: `g.DB().GetCache().Keys()`。
### 缓存适配( `Redis` 缓存)
默认情况下 `ORM` 的 `*gcache.Cache` 缓存对象提供的是单进程内存缓存,虽然性能非常高效,但是只能在单进程内使用。如果服务如果采用多节点部署,多节点之间的缓存可能会产生数据不一致的情况,因此大多数场景下我们都是通过 `Redis` 服务器来实现对数据库查询数据的缓存。 `*gcache.Cache` 对象采用了适配器设计模式,可以轻松实现从单进程内存缓存切换为分布式的 `Redis` 缓存。使用示例:
```go
redisCache := gcache.NewAdapterRedis(g.Redis())
g.DB().GetCache().SetAdapter(redisCache)
```
更多介绍请参考: [缓存管理-Redis缓存](./gcache-redis.md)
### 管理方法
为简化数据库的查询缓存管理,从 `v2.2.0` 版本开始,提供了两个缓存管理方法:
```go
// ClearCache removes cached sql result of certain table.
func (c *Core) ClearCache(ctx context.Context, table string) (err error)
// ClearCacheAll removes all cached sql result from cache
func (c *Core) ClearCacheAll(ctx context.Context) (err error)
```
方法介绍如注释。可以看到这两个方法是挂载 `Core` 对象上的,而底层的 `Core` 对象已经通过 `DB` 接口暴露,因此我们这么来获取 `Core` 对象:
```go
g.DB().GetCore()
```
## 使用示例
### 数据表结构
```sql
CREATE TABLE `user` (
`uid` int(10) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(30) NOT NULL DEFAULT '' COMMENT '昵称',
`site` varchar(255) NOT NULL DEFAULT '' COMMENT '主页',
PRIMARY KEY (`uid`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
```
### 示例代码
```go
package main
import (
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
)
func main() {
var (
db = g.DB()
ctx = gctx.New()
)
// 开启调试模式,以便于记录所有执行的SQL
db.SetDebug(true)
// 写入测试数据
_, err := g.Model("user").Ctx(ctx).Data(g.Map{
"name": "john",
"site": "https://goframe.org",
}).Insert()
// 执行2次查询并将查询结果缓存1小时,并可执行缓存名称(可选)
for i := 0; i < 2; i++ {
r, _ := g.Model("user").Ctx(ctx).Cache(gdb.CacheOption{
Duration: time.Hour,
Name: "vip-user",
Force: false,
}).Where("uid", 1).One()
g.Log().Debug(ctx, r.Map())
}
// 执行更新操作,并清理指定名称的查询缓存
_, err = g.Model("user").Ctx(ctx).Cache(gdb.CacheOption{
Duration: -1,
Name: "vip-user",
Force: false,
}).Data(gdb.Map{"name": "smith"}).Where("uid", 1).Update()
if err != nil {
g.Log().Fatal(ctx, err)
}
// 再次执行查询,启用查询缓存特性
r, _ := g.Model("user").Ctx(ctx).Cache(gdb.CacheOption{
Duration: time.Hour,
Name: "vip-user",
Force: false,
}).Where("uid", 1).One()
g.Log().Debug(ctx, r.Map())
}
```
执行后输出结果为(测试表数据结构仅供示例参考):
```html
2022-02-08 17:36:19.817 [DEBU] {c0424c75f1c5d116d0df0f7197379412} {"name":"john","site":"https://goframe.org","uid":1}
2022-02-08 17:36:19.817 [DEBU] {c0424c75f1c5d116d0df0f7197379412} {"name":"john","site":"https://goframe.org","uid":1}
2022-02-08 17:36:19.817 [DEBU] {c0424c75f1c5d116d0df0f7197379412} [ 0 ms] [default] [rows:1 ] UPDATE `user` SET `name`='smith' WHERE `uid`=1
2022-02-08 17:36:19.818 [DEBU] {c0424c75f1c5d116d0df0f7197379412} [ 1 ms] [default] [rows:1 ] SELECT * FROM `user` WHERE `uid`=1 LIMIT 1
2022-02-08 17:36:19.818 [DEBU] {c0424c75f1c5d116d0df0f7197379412} {"name":"smith","site":"https://goframe.org","uid":1}
```
可以看到:
1. 为了方便展示缓存效果,这里开启了数据 `debug` 特性,当有任何的SQL操作时将会输出到终端。
2. 执行两次 `One` 方法数据查询,第一次走了SQL查询,第二次直接使用到了缓存,SQL没有提交到数据库执行,因此这里只打印了一条查询SQL,并且两次查询的结果也是一致的。
3. 注意这里为该查询的缓存设置了一个自定义的名称 `vip-user`,以便于后续清空更新缓存。如果缓存不需要清理,那么可以不用设置缓存名称。
4. 当执行 `Update` 更新操作时,同时根据名称清空指定的缓存。
5. 随后再执行 `One` 方法数据查询,这时重新缓存新的数据。
# ORM链式操作-数据库切换
我们知道数据库的配置中有支持对默认数据库的配置,因此 `DB` 对象及 `Model` 对象在初始化的时候已经绑定到了特定的数据库上。运行时切换数据库有几种方案(假如我们的数据库有 `user` 用户数据库和 `order` 订单数据库):
1. 通过不同的配置分组来实现。这需要在配置文件中配置不同的分组配置,随后在程序中可以通过 `g.DB("分组名称")` 来获取特定数据库的单例对象。
2. 通过运行时 `DB.SetSchema` 方法切换单例对象的数据库,需要注意的是由于修改的是单例对象的数据库配置,因此影响是全局的:
```go
g.DB().SetSchema("user-schema")
g.DB().SetSchema("order-schema")
```
3. 通过链式操作 `Schema` 方法创建 `Schema` 数据库对象,并通过该数据库对象创建模型对象并执行后续链式操作:
```go
g.DB().Schema("user-schema").Model("user").All()
g.DB().Schema("order-schema").Model("order").All()
```
4. 也可以通过链式操作 `Model.Schema` 方法设置当前链式操作对应的数据库,没有设置的情况下使用的是其 `DB` 或者 `TX` 默认连接的数据库:
```go
g.Model("user").Schema("user-schema").All()
g.Model("order").Schema("order-schema").All()
```
**提示**
注意两种使用方式的差别,前一种方式来自于 `Schema` 对象创建 `Model` 对象后执行操作;后一种方式是通过修改当前 `Model` 对象操作的数据库名称达到切换数据库的目的。
5. 此外,假如当前数据库操作配置的用户有权限,那么可以直接通过表名中带数据库名称实现跨域操作,甚至跨域关联查询:
```go
// SELECT * FROM `order`.`order` o LEFT JOIN `user`.`user` u ON (o.uid=u.id) WHERE u.id=1 LIMIT 1
g.Model("order.order o").LeftJoin("user.user u", "o.uid=u.id").Where("u.id", 1).One()
```
# ORM链式操作-Hook特性
`Hook` 特性允许我们对特性的 `Model` 绑定 `CRUD` 钩子处理。
## 相关定义
相关 `Hook` 函数:
```go
type (
HookFuncSelect func(ctx context.Context, in *HookSelectInput) (result Result, err error)
HookFuncInsert func(ctx context.Context, in *HookInsertInput) (result sql.Result, err error)
HookFuncUpdate func(ctx context.Context, in *HookUpdateInput) (result sql.Result, err error)
HookFuncDelete func(ctx context.Context, in *HookDeleteInput) (result sql.Result, err error)
)
// HookHandler manages all supported hook functions for Model.
type HookHandler struct {
Select HookFuncSelect
Insert HookFuncInsert
Update HookFuncUpdate
Delete HookFuncDelete
}
```
`Hook` 注册方法:
```go
// Hook sets the hook functions for current model.
func (m *Model) Hook(hook HookHandler) *Model
```
## 使用示例
查询 `birth_day` 字段时,同时计算出当前用户的年龄:
```go
// Hook function definition.
var hook = gdb.HookHandler{
Select: func(ctx context.Context, in *gdb.HookSelectInput) (result gdb.Result, err error) {
result, err = in.Next(ctx)
if err != nil {
return
}
for i, record := range result {
if !record["birth_day"].IsEmpty() {
age := gtime.Now().Sub(record["birth_day"].GTime()).Hours() / 24 / 365
record["age"] = gvar.New(age)
}
result[i] = record
}
return
},
}
// It registers hook function, creates and returns a new `model`.
model := g.Model("user").Hook(hook)
// The hook function takes effect on each ORM operation when using the `model`.
all, err := model.Where("status", "online").OrderAsc(`id`).All()
```
# ORM链式操作-Handler特性
`Handler` 特性允许轻松地复用常见的逻辑。
## 示例1,查询
```go
func AmountGreaterThan1000(m *gdb.Model) *gdb.Model {
return m.WhereGT("amount", 1000)
}
func PaidWithCreditCard(m *gdb.Model) *gdb.Model {
return m.Where("pay_mode_sign", "credit_card")
}
func PaidWithCod(m *gdb.Model) *gdb.Model {
return m.Where("pay_mode_sign", "cod")
}
func OrderStatus(statuses []string) func(m *gdb.Model) *gdb.Model {
return func(m *gdb.Model) *gdb.Model {
return m.Where("status", statuses)
}
}
var (
m = g.Model("product_order")
)
m.Handler(AmountGreaterThan1000, PaidWithCreditCard).Scan(&orders)
// SELECT * FROM `product_order` WHERE `amount`>1000 AND `pay_mode_sign`='credit_card'
// 查找所有金额大于 1000 的信用卡订单
m.Handler(AmountGreaterThan1000, PaidWithCod).Scan(&orders)
// SELECT * FROM `product_order` WHERE `amount`>1000 AND `pay_mode_sign`='cod'
// 查找所有金额大于 1000 的 COD 订单
m.Handler(AmountGreaterThan1000, OrderStatus([]string{"paid", "shipped"})).Scan(&orders)
// SELECT * FROM `product_order` WHERE `amount`>1000 AND `status` IN('paid','shipped')
// 查找所有金额大于1000 的已付款或已发货订单
```
## 示例2,分页
```go
func Paginate(r *ghttp.Request) func(m *gdb.Model) *gdb.Model {
return func(m *gdb.Model) *gdb.Model {
type Pagination struct {
Page int
Size int
}
var pagination Pagination
_ = r.Parse(&pagination)
switch {
case pagination.Size > 100:
pagination.Size = 100
case pagination.Size <= 0:
pagination.Size = 10
}
return m.Page(pagination.Page, pagination.Size)
}
}
m.Handler(Paginate(r)).Scan(&users)
m.Handler(Paginate(r)).Scan(&articles)
```
# ORM链式操作-悲观锁 & 乐观锁
`悲观锁(Pessimistic Lock)`,顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁、表锁、读锁、写锁等,都是在做操作之前先上锁。
`乐观锁(Optimistic Lock)`,顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量。
### 悲观锁使用
相关方法:
```go
func (m *Model) LockUpdate() *Model
func (m *Model) LockShared() *Model
func (m *Model) LockUpdateSkipLocked() *Model
```
`gdb` 模块的链式操作提供了两个方法帮助在 `SQL` 语句中实现「悲观锁」。可以在查询中使用 `LockShared` 方法从而在运行语句时带一把」共享锁「。共享锁可以避免被选择的行被修改直到事务提交:
```go
g.Model("users").Ctx(ctx).Where("votes>?", 100).LockShared().All();
```
上面这个查询等价于下面这条 SQL 语句:
```sql
SELECT * FROM `users` WHERE `votes` > 100 LOCK IN SHARE MODE
```
此外还可以使用 `LockUpdate` 方法。该方法用于创建 `FOR UPDATE` 锁,避免选择行被其它共享锁修改或删除:
```go
g.Model("users").Ctx(ctx).Where("votes>?", 100).LockUpdate().All();
```
上面这个查询等价于下面这条 SQL 语句:
```sql
SELECT * FROM `users` WHERE `votes` > 100 FOR UPDATE
```
#### 跳过已锁定的行(Skip Locked)
**提示**
版本要求:`v2.10.0`
从 `v2.10.0` 版本开始,新增了 `LockUpdateSkipLocked` 方法,支持在高并发场景下跳过已被锁定的行,避免等待,提升系统吞吐量。
**使用方法:**
```go
g.Model("tasks").Ctx(ctx).Where("status", "pending").Limit(10).LockUpdateSkipLocked().All()
```
上面这个查询等价于下面这条`SQL`语句:
```sql
SELECT * FROM `tasks` WHERE `status` = 'pending' LIMIT 10 FOR UPDATE SKIP LOCKED
```
**应用场景:**
`SKIP LOCKED` 特别适用于任务队列、工单分配等高并发场景。当多个工作进程同时争抢任务时:
- 使用 `LockUpdate()`:所有进程会排队等待锁释放,导致性能下降
- 使用 `LockUpdateSkipLocked()`:每个进程跳过已被其他进程锁定的行,立即获取可用的任务,提升并发处理能力
**示例:任务队列处理**
```go
package main
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
)
func main() {
ctx := gctx.New()
// 开启事务
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// 获取未被锁定的待处理任务
records, err := g.Model("tasks").Ctx(ctx).
Where("status", "pending").
Limit(10).
LockUpdateSkipLocked(). // 跳过已被其他进程锁定的任务
All()
if err != nil {
return err
}
// 处理任务...
for _, record := range records {
// 更新任务状态为处理中
_, err = g.Model("tasks").Ctx(ctx).
Data(g.Map{"status": "processing"}).
Where("id", record["id"]).
Update()
if err != nil {
return err
}
}
return nil
})
if err != nil {
g.Log().Error(ctx, err)
}
}
```
**数据库支持:**
`SKIP LOCKED` 功能由以下数据库支持:
- `PostgreSQL 9.5+`
- `Oracle`
- `MySQL 8.0+`
- `MariaDB 10.6+`
**注意**
使用 `LockUpdateSkipLocked()` 前请确认的数据库版本支持该特性,否则会导致`SQL`执行错误。
**性能对比:**
在高并发任务分配场景下:
- 使用 `LockUpdate()`:`10`个工作进程处理`100`个任务可能需要`10`秒(排队等待)
- 使用 `LockUpdateSkipLocked()`:`10`个工作进程可以同时各自获取`10`个任务并行处理,时间缩短为`1`秒
#### 锁机制对比
`FOR UPDATE` 与 `LOCK IN SHARE MODE` 都是用于确保被选中的记录值不能被其它事务更新(上锁),两者的区别在于 `LOCK IN SHARE MODE` 不会阻塞其它事务读取被锁定行记录的值,而 `FOR UPDATE` 会阻塞其他锁定性读对锁定行的读取(非锁定性读仍然可以读取这些记录, `LOCK IN SHARE MODE` 和 `FOR UPDATE` 都是锁定性读)。
这么说比较抽象,我们举个计数器的例子:在一条语句中读取一个值,然后在另一条语句中更新这个值。使用 `LOCK IN SHARE MODE` 的话可以允许两个事务读取相同的初始化值,所以执行两个事务之后最终计数器的值 `+1`;而如果使用 `FOR UPDATE` 的话,会锁定第二个事务对记录值的读取直到第一个事务执行完成,这样计数器的最终结果就是 `+2` 了。
### 乐观锁使用
乐观锁,大多是基于数据版本 ( `Version`)记录机制实现。何谓数据版本?即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 " `version`" 字段来实现。
读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。
### 锁机制总结
两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下,即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果经常产生冲突,上层应用会不断的进行重试,这样反倒是降低了性能,所以这种情况下用悲观锁就比较合适。
# ORM查询-Where条件
`ORM` 组件提供了一些常用的条件查询方法,并且条件方法支持多种数据类型输入。`Where`条件方法比较多,本章节方法更新可能会不及时,完整的接口列表请参考接口文档:https://pkg.go.dev/github.com/gogf/gf/v2/database/gdb#Model
```go
func (m *Model) Where(where interface{}, args...interface{}) *Model
func (m *Model) Wheref(format string, args ...interface{}) *Model
func (m *Model) WherePri(where interface{}, args ...interface{}) *Model
func (m *Model) WhereBetween(column string, min, max interface{}) *Model
func (m *Model) WhereLike(column string, like interface{}) *Model
func (m *Model) WhereIn(column string, in interface{}) *Model
func (m *Model) WhereNull(columns ...string) *Model
func (m *Model) WhereLT(column string, value interface{}) *Model
func (m *Model) WhereLTE(column string, value interface{}) *Model
func (m *Model) WhereGT(column string, value interface{}) *Model
func (m *Model) WhereGTE(column string, value interface{}) *Model
func (m *Model) WhereNotBetween(column string, min, max interface{}) *Model
func (m *Model) WhereNotLike(column string, like interface{}) *Model
func (m *Model) WhereNotIn(column string, in interface{}) *Model
func (m *Model) WhereNotNull(columns ...string) *Model
func (m *Model) WhereOr(where interface{}, args ...interface{}) *Model
func (m *Model) WhereOrBetween(column string, min, max interface{}) *Model
func (m *Model) WhereOrLike(column string, like interface{}) *Model
func (m *Model) WhereOrIn(column string, in interface{}) *Model
func (m *Model) WhereOrNull(columns ...string) *Model
func (m *Model) WhereOrLT(column string, value interface{}) *Model
func (m *Model) WhereOrLTE(column string, value interface{}) *Model
func (m *Model) WhereOrGT(column string, value interface{}) *Model
func (m *Model) WhereOrGTE(column string, value interface{}) *Model
func (m *Model) WhereOrNotBetween(column string, min, max interface{}) *Model
func (m *Model) WhereOrNotLike(column string, like interface{}) *Model
func (m *Model) WhereOrNotIn(column string, in interface{}) *Model
func (m *Model) WhereOrNotNull(columns ...string) *Model
func (m *Model) WhereExists(subQuery *Model) *Model
func (m *Model) WhereNotExists(subQuery *Model) *Model
```
下面我们对其中的几个常用方法做简单介绍,其他条件查询方法用法类似。
## `Where/WhereOr` 查询条件
### 基本介绍
这两个方法用于传递查询条件参数,支持的参数为任意的 `string/map/slice/struct/*struct` 类型。
`Where` 条件参数推荐使用字符串的参数传递方式(并使用 `?` 占位符预处理),因为 `map`/ `struct` 类型作为查询参数无法保证顺序性,且在部分情况下(数据库有时会帮助自动进行查询索引优化),数据库的索引和传递的查询条件顺序有一定关系。
当使用多个 `Where` 方法连接查询条件时,多个条件之间使用 `And` 进行连接。 此外,当存在多个查询条件时, `gdb` 会默认将多个条件分别使用 `()` 符号进行包含,这种设计可以非常友好地支持查询条件分组。
使用示例:
```go
// WHERE `uid`=1
Where("uid=1")
Where("uid", 1)
Where("uid=?", 1)
Where(g.Map{"uid" : 1})
// WHERE `uid` <= 1000 AND `age` >= 18
Where(g.Map{
"uid <=" : 1000,
"age >=" : 18,
})
// WHERE (`uid` <= 1000) AND (`age` >= 18)
Where("uid <=?", 1000).Where("age >=?", 18)
// WHERE `level`=1 OR `money`>=1000000
Where("level=? OR money >=?", 1, 1000000)
// WHERE (`level`=1) OR (`money`>=1000000)
Where("level", 1).WhereOr("money >=", 1000000)
// WHERE `uid` IN(1,2,3)
Where("uid IN(?)", g.Slice{1,2,3})
```
使用 `struct` 参数的示例,其中 `orm` 的 `tag` 用于指定 `struct` 属性与表字段的映射关系:
```go
type Condition struct{
Sex int `orm:"sex"`
Age int `orm:"age"`
}
Where(Condition{1, 18})
// WHERE `sex`=1 AND `age`=18
```
### 使用示例
`Where + string`,条件参数使用字符串和预处理。
```go
// 查询多条记录并使用Limit分页
// SELECT * FROM user WHERE uid>1 LIMIT 0,10
g.Model("user").Where("uid > ?", 1).Limit(0, 10).All()
// 使用Fields方法查询指定字段
// 未使用Fields方法指定查询字段时,默认查询为*
// SELECT uid,name FROM user WHERE uid>1 LIMIT 0,10
g.Model("user").Fields("uid,name").Where("uid > ?", 1).Limit(0, 10).All()
// 支持多种Where条件参数类型
// SELECT * FROM user WHERE uid=1 LIMIT 1
g.Model("user").Where("uid=1").One()
g.Model("user").Where("uid", 1).One()
g.Model("user").Where("uid=?", 1).One()
// SELECT * FROM user WHERE (uid=1) AND (name='john') LIMIT 1
g.Model("user").Where("uid", 1).Where("name", "john").One()
g.Model("user").Where("uid=?", 1).Where("name=?", "john").One()
// SELECT * FROM user WHERE (uid=1) OR (name='john') LIMIT 1
g.Model("user").Where("uid=?", 1).WhereOr("name=?", "john").One()
```
`Where + slice`,预处理参数可直接通过 `slice` 参数给定。
```go
// SELECT * FROM user WHERE age>18 AND name like '%john%'
g.Model("user").Where("age>? AND name like ?", g.Slice{18, "%john%"}).All()
// SELECT * FROM user WHERE status=1
g.Model("user").Where("status=?", g.Slice{1}).All()
```
`Where + map`,条件参数使用任意 `map` 类型传递。
```go
// SELECT * FROM user WHERE uid=1 AND name='john' LIMIT 1
g.Model("user").Where(g.Map{"uid" : 1, "name" : "john"}).One()
// SELECT * FROM user WHERE uid=1 AND age>18 LIMIT 1
g.Model("user").Where(g.Map{"uid" : 1, "age>" : 18}).One()
```
`Where + struct/*struct`, `struct` 标签支持 `orm/json`,映射属性到字段名称关系。
```go
type User struct {
Id int `json:"uid"`
UserName string `orm:"name"`
}
// SELECT * FROM user WHERE uid =1 AND name='john' LIMIT 1
g.Model("user").Where(User{ Id : 1, UserName : "john"}).One()
// SELECT * FROM user WHERE uid =1 LIMIT 1
g.Model("user").Where(&User{ Id : 1}).One()
```
以上的查询条件相对比较简单,我们来看一个比较复杂的查询示例。
```go
condition := g.Map{
"title like ?" : "%九寨%",
"online" : 1,
"hits between ? and ?" : g.Slice{1, 10},
"exp > 0" : nil,
"category" : g.Slice{100, 200},
}
// SELECT * FROM article WHERE title like '%九寨%' AND online=1 AND hits between 1 and 10 AND exp > 0 AND category IN(100,200)
g.Model("article").Where(condition).All()
```
## `Wheref` 格式化条件字符串
在某些场景中,在输入带有字符串的条件语句时,往往需要结合 `fmt.Sprintf` 来格式化条件(当然,注意在字符串中使用占位符代替变量的输入而不是直接将变量格式化),因此我们提供了 `Where+fmt.Sprintf` 结合的便捷方法 `Wheref`。使用示例:
```go
// WHERE score > 100 and status in('succeeded','completed')
Wheref(`score > ? and status in (?)`, 100, g.Slice{"succeeded", "completed"})
```
## `WherePri` 支持主键的查询条件
`WherePri` 方法的功能同 `Where`,但提供了对表主键的智能识别,常用于根据主键的便捷数据查询。假如 `user` 表的主键为 `uid`,我们来看一下 `Where` 与 `WherePri` 的区别:
```go
// WHERE `uid`=1
Where("uid", 1)
WherePri(1)
// WHERE `uid` IN(1,2,3)
Where("uid", g.Slice{1,2,3})
WherePri(g.Slice{1,2,3})
```
可以看到,当使用 `WherePri` 方法且给定参数为单一的参数基本类型或者 `slice` 类型时,将会被识别为主键的查询条件值。
## `WhereBuilder` 复杂条件组合
`WhereBuilder` 用以组合生成复杂的 `Where` 条件。
### 对象创建
我们可以使用 `Model` 的 `Builder` 方法生成 `WhereBuilder` 对象。该方法定义如下:
```go
// Builder creates and returns a WhereBuilder.
func (m *Model) Builder() *WhereBuilder
```
### 使用示例
```go
// SELECT * FROM `user` WHERE `id`=1 AND `address`="USA" AND (`status`="active" OR `status`="pending")
m := g.Model("user")
all, err := m.Where("id", 1).Where("address", "USA").Where(
m.Builder().Where("status", "active").WhereOr("status", "pending"),
).All()
```
## `WhereExists/WhereNotExists` 子查询条件
### 基本介绍
`WhereExists` 和 `WhereNotExists` 方法用于创建 `EXISTS` 和 `NOT EXISTS` 子查询条件,这是 SQL 中常用的高级查询技术。这两个方法接受一个 `*Model` 类型的参数作为子查询。
```go
func (b *WhereBuilder) WhereExists(subQuery *Model) *WhereBuilder
func (b *WhereBuilder) WhereNotExists(subQuery *Model) *WhereBuilder
```
`EXISTS` 子查询用于检查子查询是否返回任何行,如果子查询返回至少一行,则 `EXISTS` 条件为真;如果子查询不返回任何行,则 `EXISTS` 条件为假。`NOT EXISTS` 则相反,当子查询不返回任何行时条件为真。
这两个方法特别适合于以下场景:
1. 需要检查一个表中的记录是否在另一个表中存在关联记录
2. 需要查找满足或不满足某些复杂条件的记录
3. 替代复杂的 JOIN 查询,提高查询性能
### 使用示例
#### 基本用法
```go
// 查询存在关联订单的用户
// SELECT * FROM user WHERE EXISTS (SELECT 1 FROM order WHERE order.user_id = user.id)
subQuery := g.Model("order").Fields("1").Where("order.user_id = user.id")
users, err := g.Model("user").WhereExists(subQuery).All()
// SELECT * FROM user WHERE EXISTS (SELECT 1 FROM order WHERE order.user_id = user.id)
```
#### 使用表别名
```go
// 查询没有任何订单的用户
subQuery := g.Model("order").Fields("id").Where("user_id = u.id")
users, err := g.Model("user u").WhereNotExists(subQuery).All()
// SELECT * FROM user u WHERE NOT EXISTS (SELECT id FROM order WHERE user_id = u.id)
```
#### 与其他条件组合
```go
// 查询活跃状态且有订单的用户
subQuery := g.Model("order").Fields("id").Where("user_id = user.id")
users, err := g.Model("user").Where("status", "active").WhereExists(subQuery).All()
// SELECT * FROM user WHERE (status='active') AND EXISTS (SELECT id FROM order WHERE user_id = user.id)
```
#### 复杂子查询
```go
// 查询有未完成订单的VIP用户
subQuery := g.Model("order").Fields("id").Where("user_id = u.id").Where("status", "pending")
users, err := g.Model("user u").Where("vip", 1).WhereExists(subQuery).All()
// SELECT * FROM user u WHERE (vip=1) AND EXISTS (SELECT id FROM order WHERE user_id = u.id AND status='pending')
```
#### 使用原始SQL引用
在子查询中引用外部查询的字段时,可以使用 `db.Raw()` 方法:
```go
// 查询有相同名称产品的类别
subQuery := g.Model("product").Fields("id").Where("name = ?", db.Raw("category.name"))
categories, err := g.Model("category").WhereExists(subQuery).All()
// SELECT * FROM category WHERE EXISTS (SELECT id FROM product WHERE name = category.name)
```
## 注意事项:空数组条件引发的 `0=1` 条件
我们来看例子:
`SQL1`:
```go
m := g.Model("auth")
m.Where("status", g.Slice{"permitted", "inherited"}).Where("uid", 1).All()
// SELECT * FROM `auth` WHERE (`status` IN('permitted','inherited')) AND (`uid`=1)
```
`SQL2`:
```go
m := g.Model("auth")
m.Where("status", g.Slice{}).Where("uid", 1).All()
// SELECT * FROM `auth` WHERE (0=1) AND (`uid`=1)
```
可以看到,当给定的数组条件为空数组时,生成的 `SQL` 出现了 `0=1` 的无效条件,这是为什么呢?
在开发者没有显示声明可以过滤空数组条件时, `ORM` 不会自动过滤空数组条件,以避免程序逻辑绕过 `SQL` 限制条件,引发不可预知的业务问题。如果开发者确定 `SQL` 限制条件是可以过滤的,那么可以显示调用 `OmitEmpty/OmitEmptyWhere` 方法来执行空条件过滤,如下:
```go
m := g.Model("auth")
m.Where("status", g.Slice{}).Where("uid", 1).OmitEmpty().All()
// SELECT * FROM `auth` WHERE `uid`=1
```
# ORM查询-常用查询方法
## 基本介绍
数据查询比较常用的几个方法:
```go
func (m *Model) All(where ...interface{} (Result, error)
func (m *Model) One(where ...interface{}) (Record, error)
func (m *Model) Array(fieldsAndWhere ...interface{}) ([]Value, error)
func (m *Model) Value(fieldsAndWhere ...interface{}) (Value, error)
func (m *Model) Count(where ...interface{}) (int, error)
func (m *Model) CountColumn(column string) (int, error)
```
简要说明:
1. `All` 用于查询并返回多条记录的列表/数组。
2. `One` 用于查询并返回单条记录。
3. `Array` 用于查询指定字段列的数据,返回数组。
4. `Value` 用于查询并返回一个字段值,往往需要结合 `Fields` 方法使用。
5. `Count` 用于查询并返回记录数。
此外,也可以看得到这五个方法定义中也支持条件参数的直接输入,参数类型与 `Where` 方法一致。但需要注意,其中 `Array` 和 `Value` 方法的参数中至少应该输入字段参数。
## 使用示例
```go
// SELECT * FROM `user` WHERE `score`>60
Model("user").Where("score>?", 60).All()
// SELECT * FROM `user` WHERE `score`>60 LIMIT 1
Model("user").Where("score>?", 60).One()
// SELECT `name` FROM `user` WHERE `score`>60
Model("user").Fields("name").Where("score>?", 60).Array()
// SELECT `name` FROM `user` WHERE `uid`=1 LIMIT 1
Model("user").Fields("name").Where("uid", 1).Value()
// SELECT COUNT(1) FROM `user` WHERE `status` IN(1,2,3)
Model("user").Where("status", g.Slice{1,2,3}).Count()
```
# ORM查询-AllAndCount
## 基本介绍
该方法从 `v2.5.0` 版本开始提供,用于同时查询数据记录列表及总数量,一般用于分页查询场景中,简化分页查询逻辑。
方法定义:
```go
// AllAndCount retrieves all records and the total count of records from the model.
// If useFieldForCount is true, it will use the fields specified in the model for counting;
// otherwise, it will use a constant value of 1 for counting.
// It returns the result as a slice of records, the total count of records, and an error if any.
// The where parameter is an optional list of conditions to use when retrieving records.
//
// Example:
//
// var model Model
// var result Result
// var count int
// where := []interface{}{"name = ?", "John"}
// result, count, err := model.AllAndCount(true)
// if err != nil {
// // Handle error.
// }
// fmt.Println(result, count)
func (m *Model) AllAndCount(useFieldForCount bool) (result Result, totalCount int, err error)
```
在方法内部查询总数量时,将会忽略查询中的 `Limit/Page` 操作。
## 使用示例
```go
// SELECT `uid`,`name` FROM `user` WHERE `status`='deleted' LIMIT 0,10
// SELECT COUNT(`uid`,`name`) FROM `user` WHERE `status`='deleted'
all, count, err := Model("user").Fields("uid", "name").Where("status", "deleted").Limit(0, 10).AllAndCount(true)
// SELECT `uid`,`name` FROM `user` WHERE `status`='deleted' LIMIT 0,10
// SELECT COUNT(1) FROM `user` WHERE `status`='deleted'
all, count, err := Model("user").Fields("uid", "name").Where("status", "deleted").Limit(0, 10).AllAndCount(false)
```
# ORM查询-Exist
`Exist`方法可以更高效地检索所给的`Where`条件数据是否存在,而不是查询完整的数据结果后返回。
方法定义:
```go
func (m *Model) Exist(where ...interface{}) (bool, error)
```
## 示例SQL
这是后续示例代码中用到的`MySQL`表结构。
```sql
CREATE TABLE `user` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(45) NOT NULL
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
```
## 使用示例
查询完整数据:
```go
// SELECT * FROM `user` WHERE (id > 1) AND `deleted_at`=0
g.Model("user").Where("id > ?", 1).All()
```
使用`Exist`方法:
```go
// SELECT 1 FROM `user` WHERE (id > 1) AND `deleted_at`=0 LIMIT 1
g.Model("user").Where("id > ?", 1).Exist()
```
可以看到底层是使用`SELECT 1`来查询结果,即如果结果存在则返回`1`,否则什么也不返回。
# ORM查询-Scan
`Scan` 方法支持将查询结果转换为结构体或者结构体数组, `Scan` 方法将会根据给定的参数类型自动识别执行的转换类型。
## `struct` 对象
`Scan` 支持将查询结果转换为一个 `struct` 对象,查询结果应当是特定的一条记录,并且 `pointer` 参数应当为 `struct` 对象的指针地址( `*struct` 或者 `**struct`),使用方式例如:
```go
type User struct {
Id int
Passport string
Password string
NickName string
CreateTime *gtime.Time
}
user := User{}
g.Model("user").Where("id", 1).Scan(&user)
```
或者
```go
var user = User{}
g.Model("user").Where("id", 1).Scan(&user)
```
前两种方式都是预先初始化对象(提前分配内存),推荐的方式:
```go
var user *User
g.Model("user").Where("id", 1).Scan(&user)
```
这种方式只有在查询到数据的时候才会执行初始化及内存分配。注意在用法上的区别,特别是传递参数类型的差别(前两种方式传递的参数类型是 `*User`,这里传递的参数类型其实是 `**User`)。
## `struct` 数组
`Scan` 支持将多条查询结果集转换为一个 `[]struct/[]*struct` 数组,查询结果应当是多条记录组成的结果集,并且 `pointer` 应当为数组的指针地址,使用方式例如:
```go
var users []User
g.Model("user").Scan(&users)
```
或者
```go
var users []*User
g.Model("user").Scan(&users)
```
# ORM查询-ScanAndCount
## 基本介绍
在分页查询场景中,我们往往需要先调用 `Scan` 方法结合 `Limit/Page` 链式操作方法查询列表,随后再去掉 `Limit/Page` 链式操作方法查询总数量。这一过程较为繁琐,因此从 `v2.5.0` 版本开始,框架提供了 `ScanAndCount` 方法,用于简化分页查询的场景。
## 使用示例
**提示**
示例代码来源于业务项目案例,仅供参考理解,无法独立运行。
使用传统的分页查询逻辑代码:
```go
// GetList 获取实例的用户列表.
func (s sUserInfo) GetList(ctx context.Context, in model.UserInfoGetListInput) (items []entity.UserInfo, total int, err error) {
items = make([]entity.UserInfo, 0)
orm := dao.UserInfo.Ctx(ctx).Where(do.UserInfo{
ResourceId: in.ResourceId,
Status: in.Statuses,
})
err = orm.Order(in.OrderBy, in.OrderDirection).Limit(in.Offset, in.Limit).Scan(&items)
if err != nil {
return
}
total, err = orm.Count()
return
}
```
使用 `ScanAndCount` 方法实现分页查询:
```go
// GetList 获取实例的用户列表.
func (s sUserInfo) GetList(ctx context.Context, in model.UserInfoGetListInput) (items []entity.UserInfo, total int, err error) {
items = make([]entity.UserInfo, 0)
err = dao.UserInfo.Ctx(ctx).Where(do.UserInfo{
ResourceId: in.ResourceId,
Status: in.Statuses,
}).
Order(in.OrderBy, in.OrderDirection).
Limit(in.Offset, in.Limit).
ScanAndCount(&items, &total, false)
return
}
```
## 注意事项
- 仅用于需要同时查询数据和总数量的场景,一般为分页场景。
- `ScanAndCount` 的第 `3` 个参数 `useFieldForCount` 表示是否在执行 `Count` 操作的时候将 `Fields` 作为 `Count` 参数,一般为 `false` 即可,表示执行 `COUNT(1)` 查询总数量。传递 `true` 表示执行使用查询的字段作为 `COUNT` 方法的参数。
# ORM查询-Join查询
## `*Join`系列方法
1. `LeftJoin` 左关联查询。
2. `RightJoin` 右关联查询。
3. `InnerJoin` 内关联查询。
**备注**
其实我们并不推荐使用 `Join` 进行联表查询,特别是在数据量比较大、并发请求量比较高的场景中,容易产生性能问题,也容易提高维护的复杂度。建议在确定有此必要的场景下使用。
此外,也可以参考
[ORM链式操作-模型关联](./gdb-chaining-relation-scan-list.md)
章节,数据库只负责存储数据和简单的单表操作,通过 `ORM` 提供的功能在代码层面实现数据聚合。
使用示例:
```go
// 查询符合条件的单条记录(第一条)
// SELECT u.*,ud.site FROM user u LEFT JOIN user_detail ud ON u.uid=ud.uid WHERE u.uid=1 LIMIT 1
g.Model("user u").LeftJoin("user_detail ud", "u.uid=ud.uid").Fields("u.*,ud.site").Where("u.uid", 1).One()
// 查询指定字段值
// SELECT ud.site FROM user u RIGHT JOIN user_detail ud ON u.uid=ud.uid WHERE u.uid=1 LIMIT 1
g.Model("user u").RightJoin("user_detail ud", "u.uid=ud.uid").Fields("ud.site").Where("u.uid", 1).Value()
// 分组及排序
// SELECT u.*,ud.city FROM user u INNER JOIN user_detail ud ON u.uid=ud.uid GROUP BY city ORDER BY register_time asc
g.Model("user u").InnerJoin("user_detail ud", "u.uid=ud.uid").Fields("u.*,ud.city").Group("city").Order("register_time asc").All()
// 不使用join的联表查询
// SELECT u.*,ud.city FROM user u,user_detail ud WHERE u.uid=ud.uid
g.Model("user u,user_detail ud").Where("u.uid=ud.uid").Fields("u.*,ud.city").All()
```
## 自定义数据表别名
```go
// SELECT * FROM `user` AS u LEFT JOIN `user_detail` as ud ON(ud.id=u.id) WHERE u.id=1 LIMIT 1
g.Model("user", "u").LeftJoin("user_detail", "ud", "ud.id=u.id").Where("u.id", 1).One()
g.Model("user").As("u").LeftJoin("user_detail", "ud", "ud.id=u.id").Where("u.id", 1).One()
```
## `*JoinOnFields`系列方法
`LeftJoinOnFields/RightJoinOnFields/InnerJoinOnFields`这三个方法可以指定字段和操作符进行`join`查询,使用示例:
```go
// 查询符合条件的单条记录(第一条)
// SELECT user.*,user_detail.address FROM user LEFT JOIN user_detail ON (user.id = user_detail.uid) WHERE user.id=1 LIMIT 1
g.Model("user").LeftJoinOnFields("user_detail", "id", "=", "uid").Fields("user.*,user_detail.address").Where("id", 1).One()
// 查询多条记录
// SELECT user.*,user_detail.address FROM user RIGHT JOIN user_detail ON (user.id = user_detail.uid)
g.Model("user").RightJoinOnFields("user_detail", "id", "=", "uid").Fields("user.*,user_detail.address").All()
```
## 结合 `dao` 使用示例
```go
// SELECT resource_task_schedule.id,...,time_window.time_window
// FROM `resource_task_schedule`
// LEFT JOIN `time_window` ON (`resource_task_schedule`.`resource_id`=`time_window`.`resource_id`)
// WHERE (time_window.`status`="valid") AND (`time_window`.`start_time` <= 3600)
var (
orm = dao.ResourceTaskSchedule.Ctx(ctx)
tsTable = dao.ResourceTaskSchedule.Table()
tsCls = dao.ResourceTaskSchedule.Columns()
twTable = dao.TimeWindow.Table()
twCls = dao.TimeWindow.Columns()
scheduleItems []scheduleItem
)
orm = orm.FieldsPrefix(tsTable, tsCls)
orm = orm.FieldsPrefix(twTable, twCls.TimeWindow)
orm = orm.LeftJoinOnField(twTable, twCls.ResourceId)
orm = orm.WherePrefix(twTable, twCls.Status, "valid")
orm = orm.WherePrefixLTE(twTable, twCls.StartTime, 3600)
err = orm.Scan(&scheduleItems)
```
```go
// SELECT DISTINCT resource_info.* FROM `resource_info`
// LEFT JOIN `resource_network` ON (`resource_info`.`resource_id`=`resource_network`.`resource_id`)
// WHERE (`resource_info`.`resource_id` like '%10.0.1.3%')
// or (`resource_info`.`resource_name` like '%10.0.1.3%')
// or (`resource_network`.`vip`like '%10.0.1.3%')
// ORDER BY `id` Desc LIMIT 0,2
var (
orm = dao.ResourceInfo.Ctx(ctx).OmitEmpty()
rTable = dao.ResourceInfo.Table()
rCls = dao.ResourceInfo.Columns()
nTable = dao.ResourceNetwork.Table()
nCls = dao.ResourceNetwork.Columns()
)
orm = orm.LeftJoinOnField(nTable, rCls.ResourceId)
orm = orm.WherePrefix(rTable, do.ResourceInfo{
AppId: req.AppIds,
ResourceId: req.ResourceIds,
Region: req.Regions,
Zone: req.Zones,
ResourceName: req.ResourceNames,
Status: req.Statuses,
BusinessType: req.Products,
Engine: req.Engines,
Version: req.Versions,
})
orm = orm.WherePrefix(nTable, do.ResourceNetwork{
Vip: req.Vips,
VpcId: req.VpcIds,
SubnetId: req.SubnetIds,
})
// Fuzzy like querying.
if req.Key != "" {
var (
keyLike = "%" + req.Key + "%"
)
whereFormat := fmt.Sprintf(
"(`%s`.`%s` like ?) or (`%s`.`%s` like ?) or (`%s`.`%s`like ?) ",
rTable, rCls.ResourceId,
rTable, rCls.ResourceName,
nTable, nCls.Vip,
)
orm = orm.Where(whereFormat, keyLike, keyLike, keyLike)
}
// Resource items.
err = orm.Distinct().FieldsPrefix(rTable, "*").Order(req.Order, req.OrderDirection).Limit(req.Offset, req.Limit).Scan(&res.Items)
```
# ORM查询-分组排序过滤
## `Group/Order` 分组与排序
`Group` 方法用于查询分组, `Order` 方法用于查询排序。使用示例:
```go
// SELECT COUNT(*) total,age FROM `user` GROUP BY age
g.Model("user").Fields("COUNT(*) total,age").Group("age").All()
// SELECT * FROM `student` ORDER BY class asc,course asc,score desc
g.Model("student").Order("class asc,course asc,score desc").All()
```
同时, `goframe` 的 `ORM` 提供了一些常用的排序方法:
```go
// 按照指定字段递增排序
func (m *Model) OrderAsc(column string) *Model
// 按照指定字段递减排序
func (m *Model) OrderDesc(column string) *Model
// 随机排序
func (m *Model) OrderRandom() *Model
```
使用示例:
```go
// SELECT `id`,`title` FROM `article` ORDER BY `created_at` ASC
g.Model("article").Fields("id,title").OrderAsc("created_at").All()
// SELECT `id`,`title` FROM `article` ORDER BY `views` DESC
g.Model("article").Fields("id,title").OrderDesc("views").All()
// SELECT `id`,`title` FROM `article` ORDER BY RAND()
g.Model("article").Fields("id,title").OrderRandom().All()
```
## `Having` 条件过滤
`Having` 方法用于查询结果的条件过滤。使用示例:
```go
// SELECT COUNT(*) total,age FROM `user` GROUP BY age HAVING total>100
g.Model("user").Fields("COUNT(*) total,age").Group("age").Having("total>100").All()
// SELECT * FROM `student` ORDER BY class HAVING score>60
g.Model("student").Order("class").Having("score>?", 60).All()
```
# ORM查询-Union/UnionAll
`GoFrame ORM` 组件支持 `Union/UnionAll` 操作, `Union/UnionAll` 操作符用于连接两个以上的 `SELECT` 语句的结果组合到一个结果集合中,关于 `Union/UnionAll` 组合查询的相关介绍可以参考MySQL的官方文档介绍 [https://dev.mysql.com/doc/refman/8.0/en/union.html](https://dev.mysql.com/doc/refman/8.0/en/union.html)。我们可以通过链式操作或者方法操作来实现 `Union/UnionAll` 操作。
## 方法定义
```go
// Union does "(SELECT xxx FROM xxx) UNION (SELECT xxx FROM xxx) ..." statement.
func (c *Core) Union(unions ...*Model) *Model
// UnionAll does "(SELECT xxx FROM xxx) UNION ALL (SELECT xxx FROM xxx) ..." statement.
func (c *Core) UnionAll(unions ...*Model) *Model
```
## `Union`
使用 `Union` 操作符,多个 `SELECT` 语句会删除重复的数据。
```go
// 获取默认配置的数据库对象(配置名称为"default")
db := g.DB()
db.Union(
db.Model("user").Where("id", 1),
db.Model("user").Where("id", 2),
db.Model("user").WhereIn("id", g.Slice{1, 2, 3}),
).OrderDesc("id").All()
// (SELECT * FROM `user` WHERE `id`=1)
// UNION
// (SELECT * FROM `user` WHERE `id`=2)
// UNION
// (SELECT * FROM `user` WHERE `id` IN (1,2,3)
// ORDER BY `id` DESC) ORDER BY `id` DESC
```
也可以通过 `dao` 链式操作实现:
```go
dao.User.Union(
dao.User.Where(dao.User.Columns.Id, 1),
dao.User.Where(dao.User.Columns.Id, 2),
dao.User.WhereIn(dao.User.Columns.Id, g.Slice{1, 2, 3}),
).OrderDesc(dao.User.Columns.Id).All()
// (SELECT * FROM `user` WHERE `id`=1)
// UNION
// (SELECT * FROM `user` WHERE `id`=2)
// UNION
// (SELECT * FROM `user` WHERE `id` IN (1,2,3)
// ORDER BY `id` DESC) ORDER BY `id` DESC
```
## `UnionAll`
使用 `UnionAll` 操作符,多个 `SELECT` 语句不会删除重复的数据。
```go
db.UnionAll(
db.Model("user").Where("id", 1),
db.Model("user").Where("id", 2),
db.Model(table).WhereIn("id", g.Slice{1, 2, 3}),
).OrderDesc("id").All()
// (SELECT * FROM `user` WHERE `id`=1)
// UNION ALL
// (SELECT * FROM `user` WHERE `id`=2)
// UNION ALL
// (SELECT * FROM `user` WHERE `id` IN (1,2,3)
// ORDER BY `id` DESC) ORDER BY `id` DESC
```
也可以通过 `dao` 链式操作实现:
```go
dao.User.UnionAll(
dao.User.Where(dao.User.Columns.Id, 1),
dao.User.Where(dao.User.Columns.Id, 2),
dao.User.WhereIn(dao.User.Columns.Id, g.Slice{1, 2, 3}),
).OrderDesc(dao.User.Columns.Id).All()
// (SELECT * FROM `user` WHERE `id`=1)
// UNION ALL
// (SELECT * FROM `user` WHERE `id`=2)
// UNION ALL
// (SELECT * FROM `user` WHERE `id` IN (1,2,3)
// ORDER BY `id` DESC) ORDER BY `id` DESC
```
# ORM查询-子查询特性
`ORM` 组件目前支持常见的三种语法的子查询: `Where` 子查询、 `Having` 子查询及 `From` 子查询。
## `Where` 子查询
我们可以在 `Where` 条件中使用子查询语句,示例:
```go
g.Model("orders").Where("amount > ?", g.Model("orders").Fields("AVG(amount)")).Scan(&orders)
// SELECT * FROM "orders" WHERE amount > (SELECT AVG(amount) FROM "orders")
```
## `Having` 子查询
我们可以在 `Having` 条件中使用子查询语句,示例:
```go
subQuery := g.Model("users").Fields("AVG(age)").WhereLike("name", "name%")
g.Model("users").Fields("AVG(age) as avgage").Group("name").Having("AVG(age) > ?", subQuery).Scan(&results)
// SELECT AVG(age) as avgage FROM `users` GROUP BY `name` HAVING AVG(age) > (SELECT AVG(age) FROM `users` WHERE name LIKE "name%")
```
## `From` 子查询
我们可以在使用 `Model` 方法创建模型时使用子查询语句,示例:
```go
g.Model("? as u", g.Model("users").Fields("name", "age")).Where("age", 18).Scan(&users)
// SELECT * FROM (SELECT `name`,`age` FROM `users`) as u WHERE `age` = 18
subQuery1 := g.Model("users").Fields("name")
subQuery2 := g.Model("pets").Fields("name")
g.Model("? as u, ? as p", subQuery1, subQuery2).Scan(&users)
// SELECT * FROM (SELECT `name` FROM `users`) as u, (SELECT `name` FROM `pets`) as p
```
# ORM查询-常用操作示例
## `in` 查询
使用字符串、 `slice` 参数类型。当使用 `slice` 参数类型时,预处理占位符只需要一个 `?` 即可。
```go
// SELECT * FROM user WHERE uid IN(100,10000,90000)
g.Model("user").Where("uid IN(?,?,?)", 100, 10000, 90000).All()
g.Model("user").Where("uid", g.Slice{100, 10000, 90000}).All()
// SELECT * FROM user WHERE gender=1 AND uid IN(100,10000,90000)
g.Model("user").Where("gender=? AND uid IN(?)", 1, g.Slice{100, 10000, 90000}).All()
// SELECT COUNT(*) FROM user WHERE age in(18,50)
g.Model("user").Where("age IN(?,?)", 18, 50).Count()
g.Model("user").Where("age", g.Slice{18, 50}).Count()
```
使用任意 `map` 参数类型。
```go
// SELECT * FROM user WHERE gender=1 AND uid IN(100,10000,90000)
g.Model("user").Where(g.Map{
"gender" : 1,
"uid" : g.Slice{100,10000,90000},
}).All()
```
使用 `struct` 参数类型,注意查询条件的顺序和 `struct` 的属性定义顺序有关。
```go
type User struct {
Id []int `orm:"uid"`
Gender int `orm:"gender"`
}
// SELECT * FROM `user` WHERE uid IN(100,10000,90000) AND gender=1
g.Model("user").Where(User{
Gender: 1,
Id: []int{100, 10000, 90000},
}).All()
```
为提高易用性,当传递的 `slice` 参数为空或 `nil` 时,查询并不会报错,而是转换为一个 `false` 条件语句。
```go
// SELECT * FROM `user` WHERE 0=1
g.Model("user").Where("uid", g.Slice{}).All()
// SELECT * FROM `user` WHERE `uid` IS NULL
g.Model("user").Where("uid", nil).All()
```
`ORM` 同时也提供了常用条件方法 `WhereIn/WhereNotIn/WhereOrIn/WhereOrNotIn` 方法,用于常用的 `In` 查询条件过滤。方法定义如下:
```go
func (m *Model) WhereIn(column string, in interface{}) *Model
func (m *Model) WhereNotIn(column string, in interface{}) *Model
func (m *Model) WhereOrIn(column string, in interface{}) *Model
func (m *Model) WhereOrNotIn(column string, in interface{}) *Model
```
使用示例:
```go
// SELECT * FROM `user` WHERE (`gender`=1) AND (`type` IN(1,2,3))
g.Model("user").Where("gender", 1).WhereIn("type", g.Slice{1,2,3}).All()
// SELECT * FROM `user` WHERE (`gender`=1) AND (`type` NOT IN(1,2,3))
g.Model("user").Where("gender", 1).WhereNotIn("type", g.Slice{1,2,3}).All()
// SELECT * FROM `user` WHERE (`gender`=1) OR (`type` IN(1,2,3))
g.Model("user").Where("gender", 1).WhereOrIn("type", g.Slice{1,2,3}).All()
// SELECT * FROM `user` WHERE (`gender`=1) OR (`type` NOT IN(1,2,3))
g.Model("user").Where("gender", 1).WhereOrNotIn("type", g.Slice{1,2,3}).All()
```
## `like ` 查询
```go
// SELECT * FROM `user` WHERE name like '%john%'
g.Model("user").Where("name like ?", "%john%").All()
// SELECT * FROM `user` WHERE birthday like '1990-%'
g.Model("user").Where("birthday like ?", "1990-%").All()
```
从 `goframe v1.16` 版本开始, `goframe` 的 `ORM` 同时也提供了常用条件方法 `WhereLike/WhereNotLike/WhereOrLike/WhereOrNotLike` 方法,用于常用的 `Like` 查询条件过滤。方法定义如下:
```go
func (m *Model) WhereLike(column string, like interface{}) *Model
func (m *Model) WhereNotLike(column string, like interface{}) *Model
func (m *Model) WhereOrLike(column string, like interface{}) *Model
func (m *Model) WhereOrNotLike(column string, like interface{}) *Model
```
使用示例:
```go
// SELECT * FROM `user` WHERE (`gender`=1) AND (`name` LIKE 'john%')
g.Model("user").Where("gender", 1).WhereLike("name", "john%").All()
// SELECT * FROM `user` WHERE (`gender`=1) AND (`name` NOT LIKE 'john%')
g.Model("user").Where("gender", 1).WhereNotLike("name", "john%").All()
// SELECT * FROM `user` WHERE (`gender`=1) OR (`name` LIKE 'john%')
g.Model("user").Where("gender", 1).WhereOrLike("name", "john%").All()
// SELECT * FROM `user` WHERE (`gender`=1) OR (`name` NOT LIKE 'john%')
g.Model("user").Where("gender", 1).WhereOrNotLike("name", "john%").All()
```
## `min/max/avg/sum`
我们直接将统计方法使用在 `Fields` 方法上,例如:
```go
// SELECT MIN(score) FROM `user` WHERE `uid`=1 LIMIT 1
g.Model("user").Fields("MIN(score)").Where("uid", 1).Value()
// SELECT MAX(score) FROM `user` WHERE `uid`=1 LIMIT 1
g.Model("user").Fields("MAX(score)").Where("uid", 1).Value()
// SELECT AVG(score) FROM `user` WHERE `uid`=1 LIMIT 1
g.Model("user").Fields("AVG(score)").Where("uid", 1).Value()
// SELECT SUM(score) FROM `user` WHERE `uid`=1 LIMIT 1
g.Model("user").Fields("SUM(score)").Where("uid", 1).Value()
```
从 `goframe v1.16` 版本开始, `goframe` 的 `ORM` 同时也提供了常用统计方法 `Min/Max/Avg/Sum` 方法,用于常用的字段统计查询。方法定义如下:
```go
func (m *Model) Min(column string) (float64, error)
func (m *Model) Max(column string) (float64, error)
func (m *Model) Avg(column string) (float64, error)
func (m *Model) Sum(column string) (float64, error)
```
上面的示例使用快捷统计方法改造后:
```go
// SELECT MIN(`score`) FROM `user` WHERE `uid`=1 LIMIT 1
g.Model("user").Where("uid", 1).Min("score")
// SELECT MAX(`score`) FROM `user` WHERE `uid`=1 LIMIT 1
g.Model("user").Where("uid", 1).Max("score")
// SELECT AVG(`score`) FROM `user` WHERE `uid`=1 LIMIT 1
g.Model("user").Where("uid", 1).Avg("score")
// SELECT SUM(`score`) FROM `user` WHERE `uid`=1 LIMIT 1
g.Model("user").Where("uid", 1).Sum("score")
```
## `count` 查询
```go
// SELECT COUNT(1) FROM `user` WHERE `birthday`='1990-10-01'
g.Model("user").Where("birthday", "1990-10-01").Count()
// SELECT COUNT(uid) FROM `user` WHERE `birthday`='1990-10-01'
g.Model("user").Fields("uid").Where("birthday", "1990-10-01").Count()
```
从 `goframe v1.16` 版本开始, `goframe` 的 `ORM` 同时也提供了一个按照字段进行 `Count` 的常用方法 `CountColumn`。方法定义如下:
```go
func (m *Model) CountColumn(column string) (int, error)
```
使用示例:
```go
g.Model("user").Where("birthday", "1990-10-01").CountColumn("uid")
```
## `distinct` 查询
```go
// SELECT DISTINCT uid,name FROM `user`
g.Model("user").Fields("DISTINCT uid,name").All()
// SELECT COUNT(DISTINCT uid,name) FROM `user`
g.Model("user").Fields("DISTINCT uid,name").Count()
```
从 `goframe v1.16` 版本开始, `goframe` 的 `ORM` 同时也提供了一个字段唯一性过滤标记方法 `Distinct`。方法定义如下:
```go
func (m *Model) Distinct() *Model
```
使用示例:
```go
// SELECT COUNT(DISTINCT `name`) FROM `user`
g.Model("user").Distinct().CountColumn("name")
// SELECT COUNT(DISTINCT uid,name) FROM `user`
g.Model("user").Distinct().CountColumn("uid,name")
// SELECT DISTINCT group,age FROM `user`
g.Model("user").Fields("group, age").Distinct().All()
```
## `between` 查询
```go
// SELECT * FROM `user` WHERE age between 18 and 20
g.Model("user").Where("age between ? and ?", 18, 20).All()
```
从 `goframe v1.16` 版本开始, `goframe` 的 `ORM` 同时也提供了常用条件方法 `WhereBetween/WhereNotBetween/WhereOrBetween/WhereOrNotBetween` 方法,用于常用的 `Between` 查询条件过滤。方法定义如下:
```go
func (m *Model) WhereBetween(column string, min, max interface{}) *Model
func (m *Model) WhereNotBetween(column string, min, max interface{}) *Model
func (m *Model) WhereOrBetween(column string, min, max interface{}) *Model
func (m *Model) WhereOrNotBetween(column string, min, max interface{}) *Model
```
使用示例:
```go
// SELECT * FROM `user` WHERE (`gender`=0) AND (`age` BETWEEN 16 AND 20)
g.Model("user").Where("gender", 0).WhereBetween("age", 16, 20).All()
// SELECT * FROM `user` WHERE (`gender`=0) AND (`age` NOT BETWEEN 16 AND 20)
g.Model("user").Where("gender", 0).WhereNotBetween("age", 16, 20).All()
// SELECT * FROM `user` WHERE (`gender`=0) OR (`age` BETWEEN 16 AND 20)
g.Model("user").Where("gender", 0).WhereOrBetween("age", 16, 20).All()
// SELECT * FROM `user` WHERE (`gender`=0) OR (`age` NOT BETWEEN 16 AND 20)
g.Model("user").Where("gender", 0).WhereOrNotBetween("age", 16, 20).All()
```
## `null` 查询
`ORM` 提供了常用条件方法 `WhereNull/WhereNotNull/WhereOrNull/WhereOrNotNull` 方法,用于常用的 `Null` 查询条件过滤。方法定义如下:
```go
func (m *Model) WhereNull(columns ...string) *Model
func (m *Model) WhereNotNull(columns ...string) *Model
func (m *Model) WhereOrNull(columns ...string) *Model
func (m *Model) WhereOrNotNull(columns ...string) *Model
```
使用示例:
```go
// SELECT * FROM `user` WHERE (`created_at`>'2021-05-01 00:00:00') AND (`inviter` IS NULL)
g.Model("user").Where("created_at>?", gtime.New("2021-05-01")).WhereNull("inviter").All()
// SELECT * FROM `user` WHERE (`created_at`>'2021-05-01 00:00:00') AND (`inviter` IS NOT NULL)
g.Model("user").Where("created_at>?", gtime.New("2021-05-01")).WhereNotNull("inviter").All()
// SELECT * FROM `user` WHERE (`created_at`>'2021-05-01 00:00:00') OR (`inviter` IS NULL)
g.Model("user").Where("created_at>?", gtime.New("2021-05-01")).WhereOrNull("inviter").All()
// SELECT * FROM `user` WHERE (`created_at`>'2021-05-01 00:00:00') OR (`inviter` IS NOT NULL)
g.Model("user").Where("created_at>?", gtime.New("2021-05-01")).WhereOrNotNull("inviter").All()
```
同时,这几个方法的参数支持多个字段输入,例如:
```go
// SELECT * FROM `user` WHERE (`created_at`>'2021-05-01 00:00:00') AND (`inviter` IS NULL) AND (`creator` IS NULL)
g.Model("user").Where("created_at>?", gtime.New("2021-05-01")).WhereNull("inviter", "creator").All()
```
# 模型关联-动态关联-ScanList
`gf` 的 `ORM` 没有采用其他 `ORM` 常见的 `BelongsTo`, `HasOne`, `HasMany`, `ManyToMany` 这样的模型关联设计,这样的关联关系维护较繁琐,例如外键约束、额外的标签备注等,对开发者有一定的心智负担。因此 `gf` 框架不倾向于通过向模型结构体中注入过多复杂的标签内容、关联属性或方法,并一如既往地尝试着简化设计,目标是使得模型关联查询尽可能得易于理解、使用便捷。
**注意**
接下来关于 `gf ORM` 提供的模型关联实现,从 `GoFrame v1.13.6` 版本开始提供,目前属于实验性特性。
那么我们就使用一个例子来介绍 `gf ORM` 提供的模型关联吧。
### 数据结构
为简化示例,我们这里设计得表都尽可能简单,每张表仅包含3-4个字段,方便阐述关联关系即可。
```
# 用户表
CREATE TABLE `user` (
uid int(10) unsigned NOT NULL AUTO_INCREMENT,
name varchar(45) NOT NULL,
PRIMARY KEY (uid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
# 用户详情
CREATE TABLE `user_detail` (
uid int(10) unsigned NOT NULL AUTO_INCREMENT,
address varchar(45) NOT NULL,
PRIMARY KEY (uid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
# 用户学分
CREATE TABLE `user_scores` (
id int(10) unsigned NOT NULL AUTO_INCREMENT,
uid int(10) unsigned NOT NULL,
score int(10) unsigned NOT NULL,
course varchar(45) NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
```
### 数据模型
根据表定义,我们可以得知:
1. 用户表与用户详情是 `1:1` 关系。
2. 用户表与用户学分是 `1:N` 关系。
3. 这里并没有演示 `N:N` 的关系,因为相比较于 `1:N` 的查询只是多了一次关联、或者一次查询,最终处理方式和 `1:N` 类似。
那么 `Golang` 的模型可定义如下:
```go
// 用户表
type EntityUser struct {
Uid int `orm:"uid"`
Name string `orm:"name"`
}
// 用户详情
type EntityUserDetail struct {
Uid int `orm:"uid"`
Address string `orm:"address"`
}
// 用户学分
type EntityUserScores struct {
Id int `orm:"id"`
Uid int `orm:"uid"`
Score int `orm:"score"`
Course string `orm:"course"`
}
// 组合模型,用户信息
type Entity struct {
User *EntityUser
UserDetail *EntityUserDetail
UserScores []*EntityUserScores
}
```
其中, `EntityUser`, `EntityUserDetail`, `EntityUserScores` 分别对应的是用户表、用户详情、用户学分数据表的数据模型。 `Entity` 是一个组合模型,对应的是一个用户的所有详细信息。
### 数据写入
写入数据时涉及到简单的数据库事务即可。
```go
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
r, err := tx.Model("user").Save(EntityUser{
Name: "john",
})
if err != nil {
return err
}
uid, err := r.LastInsertId()
if err != nil {
return err
}
_, err = tx.Model("user_detail").Save(EntityUserDetail{
Uid: int(uid),
Address: "Beijing DongZhiMen #66",
})
if err != nil {
return err
}
_, err = tx.Model("user_scores").Save(g.Slice{
EntityUserScores{Uid: int(uid), Score: 100, Course: "math"},
EntityUserScores{Uid: int(uid), Score: 99, Course: "physics"},
})
return err
})
```
### 数据查询
#### 单条数据记录
查询单条模型数据比较简单,直接使用 `Scan` 方法即可,该方法会自动识别绑定查询结果到单个对象属性还是数组对象属性中。例如:
```go
// 定义用户列表
var user Entity
// 查询用户基础数据
// SELECT * FROM `user` WHERE `name`='john'
err := g.Model("user").Scan(&user.User, "name", "john")
if err != nil {
return err
}
// 查询用户详情数据
// SELECT * FROM `user_detail` WHERE `uid`=1
err := g.Model("user_detail").Scan(&user.UserDetail, "uid", user.User.Uid)
// 查询用户学分数据
// SELECT * FROM `user_scores` WHERE `uid`=1
err := g.Model("user_scores").Scan(&user.UserScores, "uid", user.User.Uid)
```
该方法在之前的章节中已经有介绍,因此这里不再赘述。
#### 多条数据记录
查询多条数据记录并绑定数据到数据模型数组中,需要使用到 `ScanList` 方法,该方法会需要用户指定结果字段与模型属性的关系,随后底层会遍历数组并自动执行数据绑定。例如:
```go
// 定义用户列表
var users []Entity
// 查询用户基础数据
// SELECT * FROM `user`
err := g.Model("user").ScanList(&users, "User")
// 查询用户详情数据
// SELECT * FROM `user_detail` WHERE `uid` IN(1,2)
err := g.Model("user_detail").
WhereIn("uid", gdb.ListItemValuesUnique(users, "User", "Uid")).
ScanList(&users, "UserDetail", "User", "uid:Uid")
// 查询用户学分数据
// SELECT * FROM `user_scores` WHERE `uid` IN(1,2)
err := g.Model("user_scores").
WhereIn("uid", gdb.ListItemValuesUnique(users, "User", "Uid")).
ScanList(&users, "UserScores", "User", "uid:Uid")
```
这其中涉及到两个比较重要的方法:
**1\. `ScanList`**
方法定义:
```go
// ScanList converts to struct slice which contains other complex struct attributes.
// Note that the parameter should be type of *[]struct/*[]*struct.
// Usage example:
//
// type Entity struct {
// User *EntityUser
// UserDetail *EntityUserDetail
// UserScores []*EntityUserScores
// }
// var users []*Entity
// or
// var users []Entity
//
// ScanList(&users, "User")
// ScanList(&users, "UserDetail", "User", "uid:Uid")
// ScanList(&users, "UserScores", "User", "uid:Uid")
// The parameters "User"/"UserDetail"/"UserScores" in the example codes specify the target attribute struct
// that current result will be bound to.
// The "uid" in the example codes is the table field name of the result, and the "Uid" is the relational
// struct attribute name. It automatically calculates the HasOne/HasMany relationship with given
// parameter.
// See the example or unit testing cases for clear understanding for this function.
func (m *Model) ScanList(listPointer interface{}, attributeName string, relation ...string) (err error)
```
该方法用于将查询到的数组数据绑定到指定的列表上,例如:
- `ScanList(&users, "User")`
表示将查询到的用户信息数组数据绑定到 `users` 列表中每一项的 `User` 属性上。
- `ScanList(&users, "UserDetail", "User", "uid:Uid")`
表示将查询到用户详情数组数据绑定到 `users` 列表中每一项的 `UserDetail` 属性上,并且和另一个 `User` 对象属性通过 `uid:Uid` 的 `字段:属性` 关联,内部将会根据这一关联关系自动进行数据绑定。其中 `uid:Uid` 前面的 `uid` 表示查询结果字段中的 `uid` 字段,后面的 `Uid` 表示目标关联对象中的 `Uid` 属性。
- `ScanList(&users, "UserScores", "User", "uid:Uid")`
表示将查询到用户详情数组数据绑定到 `users` 列表中每一项的 `UserScores` 属性上,并且和另一个 `User` 对象属性通过 `uid:Uid` 的 `字段:属性` 关联,内部将会根据这一关联关系自动进行数据绑定。由于 `UserScores` 是一个数组类型 `[]*EntityUserScores`,因此该方法内部可以自动识别到 `User` 到 `UserScores` 其实是 `1:N` 的关系,自动完成数据绑定。
需要提醒的是,如果关联数据中对应的关联属性数据不存在,那么该属性不会被初始化并将保持 `nil`。
**2\. `ListItemValues/ListItemValuesUnique`**
方法定义:
```go
// ListItemValues retrieves and returns the elements of all item struct/map with key .
// Note that the parameter should be type of slice which contains elements of map or struct,
// or else it returns an empty slice.
//
// The parameter supports types like:
// []map[string]interface{}
// []map[string]sub-map
// []struct
// []struct:sub-struct
// Note that the sub-map/sub-struct makes sense only if the optional parameter is given.
func ListItemValues(list interface{}, key interface{}, subKey ...interface{}) (values []interface{})
// ListItemValuesUnique retrieves and returns the unique elements of all struct/map with key .
// Note that the parameter should be type of slice which contains elements of map or struct,
// or else it returns an empty slice.
// See gutil.ListItemValuesUnique.
func ListItemValuesUnique(list interface{}, key string, subKey ...interface{}) []interface{}
```
`ListItemValuesUnique` 与 `ListItemValues` 方法的区别在于过滤重复的返回值,保证返回的列表数据中不带有重复值。这两个方法都会在当给定的列表中包含 `struct`/ `map` 数据项时,用于获取指定属性/键名的数据值,构造成数组 `[]interface{}` 返回。示例:
- `gdb.ListItemValuesUnique(users, "Uid")` 用于获取 `users` 数组中,每一个 `Uid` 属性,构造成 `[]interface{}` 数组返回。这里以便根据 `uid` 构造成 `SELECT...IN...` 查询。
- `gdb.ListItemValuesUnique(users, "User", "Uid")` 用于获取 `users` 数组中,每一个 `User` 属性项中的 `Uid` 属性,构造成 `[]interface{}` 数组返回。这里以便根据 `uid` 构造成 `SELECT...IN...` 查询。
# 模型关联-静态关联-With特性
## 一、设计背景
大家都知道易用性和易维护性一直是 `goframe` 一直努力建设的,也是 `goframe` 有别其他框架和组件比较大的一点差异。 `goframe` 没有采用其他 `ORM` 常见的 `BelongsTo`,`HasOne`,`HasMany`,`ManyToMany` 这样的模型关联设计,这样的关联关系维护较繁琐,例如外键约束、额外的标签备注等,对开发者有一定的心智负担。因此框架不倾向于通过向模型结构体中注入过多复杂的标签内容、关联属性或方法,并一如既往地尝试着简化设计,目标是使得模型关联查询尽可能得易于理解、使用便捷。因此在之前推出了 `ScanList` 方案,建议大家在继续了解 `With` 特性之前先了解一下 [模型关联-动态关联-ScanList](./gdb-chaining-relation-scan-list.md)。
经过一系列的项目实践,我们发现 `ScanList` 虽然从运行时业务逻辑的角度来维护了模型关联关系,但是这种关联关系维护也不如期望的简便。因此,我们继续改进推出了可以通过模型简单维护关联关系的 `With` 模型关联特性,当然,这种特性仍然致力于提升整体框架的易用性和维护性,可以把 `With` 特性看做 `ScanList` 与模型关联关系维护的一种结合和改进。
**注意**
`With` 特性目前属于实验性特性。
## 二、举个例子
我们先来一个简单的示例,便于大家更好理解 `With` 特性,该示例来自于之前的 `ScanList` 章节的相同示例,改进版。
### 1、数据结构
```sql
# 用户表
CREATE TABLE `user` (
id int(10) unsigned NOT NULL AUTO_INCREMENT,
name varchar(45) NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
# 用户详情
CREATE TABLE `user_detail` (
uid int(10) unsigned NOT NULL AUTO_INCREMENT,
address varchar(45) NOT NULL,
PRIMARY KEY (uid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
# 用户学分
CREATE TABLE `user_scores` (
id int(10) unsigned NOT NULL AUTO_INCREMENT,
uid int(10) unsigned NOT NULL,
score int(10) unsigned NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
```
### 2、数据结构
根据表定义,我们可以得知:
1. 用户表与用户详情是 `1:1` 关系。
2. 用户表与用户学分是 `1:N` 关系。
3. 这里并没有演示 `N:N` 的关系,因为相比较于 `1:N` 的查询只是多了一次关联、或者一次查询,最终处理方式和 `1:N` 类似。
那么 `Golang` 的模型可定义如下:
```go
// 用户详情
type UserDetail struct {
g.Meta `orm:"table:user_detail"`
Uid int `json:"uid"`
Address string `json:"address"`
}
// 用户学分
type UserScores struct {
g.Meta `orm:"table:user_scores"`
Id int `json:"id"`
Uid int `json:"uid"`
Score int `json:"score"`
}
// 用户信息
type User struct {
g.Meta `orm:"table:user"`
Id int `json:"id"`
Name string `json:"name"`
UserDetail *UserDetail `orm:"with:uid=id"`
UserScores []*UserScores `orm:"with:uid=id"`
}
```
### 3、数据写入
为简化示例,我们这里创建 `5` 条用户数据,采用事务操作方式写入:
- 用户信息, `id` 为 `1-5`, `name` 为 `name_1` 到 `name_5`。
- 同时创建 `5` 条用户详情数据, `address` 数据为 `address_1` 到 `address_5`。
- 每个用户创建 `5` 条学分信息,学分为 `1-5`。
```go
g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
for i := 1; i <= 5; i++ {
// User.
user := User{
Name: fmt.Sprintf(`name_%d`, i),
}
lastInsertId, err := g.Model(user).Data(user).OmitEmpty().InsertAndGetId()
if err != nil {
return err
}
// Detail.
userDetail := UserDetail{
Uid: int(lastInsertId),
Address: fmt.Sprintf(`address_%d`, lastInsertId),
}
_, err = g.Model(userDetail).Data(userDetail).OmitEmpty().Insert()
if err != nil {
return err
}
// Scores.
for j := 1; j <= 5; j++ {
userScore := UserScores{
Uid: int(lastInsertId),
Score: j,
}
_, err = g.Model(userScore).Data(userScore).OmitEmpty().Insert()
if err != nil {
return err
}
}
}
return nil
})
```
执行成功后,数据库数据如下:
```text
mysql> show tables;
+----------------+
| Tables_in_test |
+----------------+
| user |
| user_detail |
| user_score |
+----------------+
3 rows in set (0.01 sec)
mysql> select * from `user`;
+----+--------+
| id | name |
+----+--------+
| 1 | name_1 |
| 2 | name_2 |
| 3 | name_3 |
| 4 | name_4 |
| 5 | name_5 |
+----+--------+
5 rows in set (0.01 sec)
mysql> select * from `user_detail`;
+-----+-----------+
| uid | address |
+-----+-----------+
| 1 | address_1 |
| 2 | address_2 |
| 3 | address_3 |
| 4 | address_4 |
| 5 | address_5 |
+-----+-----------+
5 rows in set (0.00 sec)
mysql> select * from `user_score`;
+----+-----+-------+
| id | uid | score |
+----+-----+-------+
| 1 | 1 | 1 |
| 2 | 1 | 2 |
| 3 | 1 | 3 |
| 4 | 1 | 4 |
| 5 | 1 | 5 |
| 6 | 2 | 1 |
| 7 | 2 | 2 |
| 8 | 2 | 3 |
| 9 | 2 | 4 |
| 10 | 2 | 5 |
| 11 | 3 | 1 |
| 12 | 3 | 2 |
| 13 | 3 | 3 |
| 14 | 3 | 4 |
| 15 | 3 | 5 |
| 16 | 4 | 1 |
| 17 | 4 | 2 |
| 18 | 4 | 3 |
| 19 | 4 | 4 |
| 20 | 4 | 5 |
| 21 | 5 | 1 |
| 22 | 5 | 2 |
| 23 | 5 | 3 |
| 24 | 5 | 4 |
| 25 | 5 | 5 |
+----+-----+-------+
25 rows in set (0.00 sec)
```
### 4、数据查询
新的 `With` 特性下,数据查询相当简便,例如,我们查询一条数据:
```go
// 重新声明一下,防止大家上下来回拉动
// 用户详情
type UserDetail struct {
g.Meta `orm:"table:user_detail"`
Uid int `json:"uid"`
Address string `json:"address"`
}
// 用户学分
type UserScores struct {
g.Meta `orm:"table:user_scores"`
Id int `json:"id"`
Uid int `json:"uid"`
Score int `json:"score"`
}
// 用户信息
type User struct {
g.Meta `orm:"table:user"`
Id int `json:"id"`
Name string `json:"name"`
UserDetail *UserDetail `orm:"with:uid=id"`
UserScores []*UserScores `orm:"with:uid=id"`
}
var user *User
// WithAll 会查询带有with tag的字段,这个例子中,将会查询 UserDetail结构体对应的表和 UserScores结构体对应的表
g.Model(tableUser).WithAll().Where("id", 3).Scan(&user)
```
以上语句将会查询到用户ID为 `3` 的用户信息、用户详情以及用户学分信息,以上语句将会在数据库中自动执行以下 `SQL` 语句:
```text
2021-05-02 22:29:52.634 [DEBU] [ 2 ms] [default] SHOW FULL COLUMNS FROM `user`
2021-05-02 22:29:52.635 [DEBU] [ 1 ms] [default] SELECT * FROM `user` WHERE `id`=3 LIMIT 1
2021-05-02 22:29:52.636 [DEBU] [ 1 ms] [default] SHOW FULL COLUMNS FROM `user_detail`
2021-05-02 22:29:52.637 [DEBU] [ 1 ms] [default] SELECT `uid`,`address` FROM `user_detail` WHERE `uid`=3 LIMIT 1
2021-05-02 22:29:52.643 [DEBU] [ 6 ms] [default] SHOW FULL COLUMNS FROM `user_score`
2021-05-02 22:29:52.644 [DEBU] [ 0 ms] [default] SELECT `id`,`uid`,`score` FROM `user_score` WHERE `uid`=3
```
执行后,通过 `g.Dump(user)` 打印的用户信息如下:
```js
{
Id: 3,
Name: "name_3",
UserDetail: {
Uid: 3,
Address: "address_3",
},
UserScores: [
{
Id: 11,
Uid: 3,
Score: 1,
},
{
Id: 12,
Uid: 3,
Score: 2,
},
{
Id: 13,
Uid: 3,
Score: 3,
},
{
Id: 14,
Uid: 3,
Score: 4,
},
{
Id: 15,
Uid: 3,
Score: 5,
},
],
}
```
### 5、列表查询
我们来一个通过 `With` 特性查询列表的示例:
```go
var users []*User
// With(UserDetail{}) 只查询User结构体中的UserDetail对应的表
g.Model(users).With(UserDetail{}).Where("id>?", 3).Scan(&users)
```
执行后,通过 `g.Dump(users)` 打印用户数据如下:
```js
[
{
Id: 4,
Name: "name_4",
UserDetail: {
Uid: 4,
Address: "address_4",
},
UserScores: [],
},
{
Id: 5,
Name: "name_5",
UserDetail: {
Uid: 5,
Address: "address_5",
},
UserScores: [],
},
]
```
### 6、条件与排序
通过 `With` 特性关联时可以指定关联的额外条件,以及在多数据结果下指定排序规则。例如:
```go
type User struct {
g.Meta `orm:"table:user"`
Id int `json:"id"`
Name string `json:"name"`
UserDetail *UserDetail `orm:"with:uid=id, where:uid > 3"`
UserScores []*UserScores `orm:"with:uid=id, where:score>1 and score<5, order:score desc"`
}
```
通过 `orm` 标签中的 `where` 子标签以及 `order` 子标签指定额外关联条件体积排序规则。
### 7、`unscoped`标签
在`with`结构体标签中,支持使用`unscoped`特性,例如:
```go
type User struct {
g.Meta `orm:"table:user"`
Id int `json:"id"`
Name string `json:"name"`
UserDetail *UserDetail `orm:"with:uid=id, unscoped:true"`
UserScores []*UserScore `orm:"with:uid=id, unscoped:true"`
}
```
## 三、详细说明
想必一定对上面的某些使用比较好奇,比如 `gmeta` 包、比如 `WithAll` 方法、比如 `orm` 标签中的 `with` 语句、比如 `Model` 方法给定 `struct` 参数识别数据表名等等,那这就对啦,接下来,我们详细聊聊吧。
### 1、 `gmeta` 包
我们可以看到在上面的结构体数据结构中都使用 `embed` 方式嵌入了一个 `g.Meta` 结构体,例如:
```go
type UserDetail struct {
g.Meta `orm:"table:user_detail"`
Uid int `json:"uid"`
Address string `json:"address"`
}
```
其实在 `GoFrame` 框架中有很多这种小组件包用以实现特定的便捷功能。 `gmeta` 包的作用主要用于嵌入到用户自定义的结构体中,并且通过标签的形式给 `gmeta` 包的结构体(例如这里的 `g.Meta`)打上自定义的标签内容(列如这里的 `` `orm:"table:user_detail"` ``),并在运行时可以特定方法动态获取这些自定义的标签内容。详情请参考章节: [元数据-gmeta](../components/util-gmeta.md)
因此,这里嵌入 `g.Meta` 的目的是为了标记该结构体关联的数据表名称。
### 2、模型关联指定
在如下结构体中:
```go
type User struct {
g.Meta `orm:"table:user"`
Id int `json:"id"`
Name string `json:"name"`
UserDetail *UserDetail `orm:"with:uid=id"`
UserScores []*UserScore `orm:"with:uid=id"`
}
```
我们通过给指定的结构体属性绑定 `orm` 标签,并在 `orm` 标签中通过 `with` 语句指定当前结构体(数据表)与目标结构体(数据表)的关联关系, `with` 语句的语法如下:
```text
with:当前属性对应表关联字段=当前结构体对应数据表关联字段
```
并且字段名称 **忽略大小写以及特殊字符匹配**,例如以下形式的关联关系都是能够自动识别的:
```text
with:UID=ID
with:Uid=Id
with:U_ID=id
```
如果两个表的关联字段都是同一个名称,那么也可以直接写一个即可,例如:
```text
with:uid
```
在本示例中, `UserDetail` 属性对应的数据表为 `user_detail`, `UserScores` 属性对应的数据表为 `user_score`,两者与当前 `User` 结构体对应的表 `user` 都是使用 `uid` 进行关联,并且目标关联的 `user` 表的对应字段为 `id`。
### 3、 `With/WithAll`
#### 1)基本介绍
默认情况下,即使我们的结构体属性中的 `orm` 标签带有 `with` 语句, `ORM` 组件并不会默认启用 `With` 特性进行关联查询,而是需要依靠 `With/WithAll` 方法启用该查询特性。
- `With`:指定启用关联查询的数据表,通过给定的属性对象指定。
- `WithAll`:启用操作对象中所有带有 `with` 语句的属性结构体关联查询。
在我们本示例中,使用的是 `WithAll` 方法,因此自动启用了 `User` 表中的所有属性的模型关联查询,只要属性结构体关联了数据表,并且 `orm` 标签中带有 `with` 语句,那么都将会自动查询数据并根据模型结构的关联关系进行数据绑定。假如我们只启用某部分关联查询,并不启用全部属性模型的关联查询,那么可以使用 `With` 方法来指定。并且 `With` 方法可以指定启用多个关联模型的自动查询,在本示例中的 `WithAll` 就相当于:
```go
var user *User
g.Model(tableUser).With(UserDetail{}, UserScore{}).Where("id", 3).Scan(&user)
```
也可以这样:
```go
var user *User
g.Model(tableUser).With(User{}.UserDetail, User{}.UserScore).Where("id", 3).Scan(&user)
```
#### 2)仅关联用户详情模型
假如我们只需要查询用户详情,并不需要查询用户学分,那么我们可以使用 `With` 方法来启用指定对象对应数据表的关联查询,例如:
```go
var user *User
g.Model(tableUser).With(UserDetail{}).Where("id", 3).Scan(&user)
```
也可以这样:
```go
var user *User
g.Model(tableUser).With(User{}.UserDetail).Where("id", 3).Scan(&user)
```
执行后,通过 `g.Dump(user)` 打印用户数据如下:
```js
{
"id": 3,
"name": "name_3",
"UserDetail": {
"uid": 3,
"address": "address_3"
},
"UserScores": []
}
```
#### 3)仅关联用户学分模型
我们也可以只关联查询用户学分信息,例如:
```go
var user *User
g.Model(tableUser).With(UserScore{}).Where("id", 3).Scan(&user)
```
也可以这样:
```go
var user *User
g.Model(tableUser).With(User{}.UserScore).Where("id", 3).Scan(&user)
```
执行后,通过 `g.Dump(user)` 打印用户数据如下:
```js
{
"id": 3,
"name": "name_3",
"UserDetail": null,
"UserScores": [
{
"id": 11,
"uid": 3,
"score": 1
},
{
"id": 12,
"uid": 3,
"score": 2
},
{
"id": 13,
"uid": 3,
"score": 3
},
{
"id": 14,
"uid": 3,
"score": 4
},
{
"id": 15,
"uid": 3,
"score": 5
}
]
}
```
#### 4)不关联任何模型查询
假如,我们不需要关联查询,那么更简单,例如:
```go
var user *User
g.Model(tableUser).Where("id", 3).Scan(&user)
```
执行后,通过 `g.Dump(user)` 打印用户数据如下:
```js
{
"id": 3,
"name": "name_3",
"UserDetail": null,
"UserScores": []
}
```
## 四、使用限制
### 1、字段查询与过滤
可以看到,在我们上面的示例中,并没有指定查询的字段,但是在打印的 `SQL` 日志中可以看到查询语句不是简单的 `SELECT *` 而是执行了具体的字段查询。在 `With` 特性下,将会自动按照关联模型对象的属性进行查询,属性的名称将会与数据表的字段做自动映射,并且会自动过滤掉无法自动映射的字段查询。
所以,在 `With` 特性下,我们无法做到仅查询属性中对应的某几个字段。如果需要实现仅查询并赋值某几个字段,建议对 `model` 数据结构按照业务场景进行裁剪,创建满足特定业务场景的数据结构,而不是使用一个数据结构满足不同的多个场景。
我们来一个示例更好说明。假如我们有一个实体对象数据结构 `Content`,一个常见的 `CMS` 系统的内容模型如下,该模型与数据表字段一一对应:
```go
type Content struct {
Id uint `orm:"id,primary" json:"id"` // 自增ID
Key string `orm:"key" json:"key"` // 唯一键名,用于程序硬编码,一般不常用
Type string `orm:"type" json:"type"` // 内容模型: topic, ask, article等,具体由程序定义
CategoryId uint `orm:"category_id" json:"category_id"` // 栏目ID
UserId uint `orm:"user_id" json:"user_id"` // 用户ID
Title string `orm:"title" json:"title"` // 标题
Content string `orm:"content" json:"content"` // 内容
Sort uint `orm:"sort" json:"sort"` // 排序,数值越低越靠前,默认为添加时的时间戳,可用于置顶
Brief string `orm:"brief" json:"brief"` // 摘要
Thumb string `orm:"thumb" json:"thumb"` // 缩略图
Tags string `orm:"tags" json:"tags"` // 标签名称列表,以JSON存储
Referer string `orm:"referer" json:"referer"` // 内容来源,例如github/gitee
Status uint `orm:"status" json:"status"` // 状态 0: 正常, 1: 禁用
ReplyCount uint `orm:"reply_count" json:"reply_count"` // 回复数量
ViewCount uint `orm:"view_count" json:"view_count"` // 浏览数量
ZanCount uint `orm:"zan_count" json:"zan_count"` // 赞
CaiCount uint `orm:"cai_count" json:"cai_count"` // 踩
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` // 创建时间
UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` // 修改时间
}
```
内容的列表页又不需要展示这么详细的内容,特别是其中的 `Content` 字段非常大,我们列表页只需要查询几个字段而已。那么我们可以单独定义一个用于列表的返回数据结构(字段裁剪),而不是直接使用数据表实体对象数据结构。例如:
```go
type ContentListItem struct {
Id uint `json:"id"` // 自增ID
CategoryId uint `json:"category_id"` // 栏目ID
UserId uint `json:"user_id"` // 用户ID
Title string `json:"title"` // 标题
CreatedAt *gtime.Time `json:"created_at"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at"` // 修改时间
}
```
### 2、必须存在关联字段属性
由于 `With` 特性是通过识别数据结构关联关系,并自动执行多条SQL查询来实现的,因此关联的字段也必须作为对象的属性便于关联字段值得自动获取。简单地讲, `with` 标签中的字段必须存在于关联对象的属性上。
## 五、递归关联
如果关联的模型属性也带有 `with` 标签,那么将会递归执行关联查询。 `With` 特性支持无限层级的递归关联。以下示例,仅供参考:
```go
// 用户详情
type UserDetail struct {
g.Meta `orm:"table:user_detail"`
Uid int `json:"uid"`
Address string `json:"address"`
}
// 用户学分 - 必修课
type UserScoresRequired struct {
g.Meta `orm:"table:user_scores"`
Id int `json:"id"`
Uid int `json:"uid"`
Score int `json:"score"`
}
// 用户学分 - 选修课
type UserScoresOptional struct {
g.Meta `orm:"table:user_scores"`
Id int `json:"id"`
Uid int `json:"uid"`
Score int `json:"score"`
}
// 用户学分
type UserScores struct {
g.Meta `orm:"table:user_scores"`
Id int `json:"id"`
Uid int `json:"uid"`
Required []UserScoresRequired `orm:"with:id, where:type=1"`
Optional []UserScoresOptional `orm:"with:id, where:type=2"`
}
// 用户信息
type User struct {
g.Meta `orm:"table:user"`
Id int `json:"id"`
Name string `json:"name"`
UserDetail *UserDetail `orm:"with:uid=id"`
UserScores []*UserScores `orm:"with:uid=id"`
}
```
## 六、模型示例
根据当前的数据表,这里给了更多的一些模型编写示例供大家参考。
### 1、关联模型嵌套
```go
type UserDetail struct {
g.Meta `orm:"table:user_detail"`
Uid int `json:"uid"`
Address string `json:"address"`
}
type UserScores struct {
g.Meta `orm:"table:user_scores"`
Id int `json:"id"`
Uid int `json:"uid"`
Score int `json:"score"`
}
type User struct {
g.Meta `orm:"table:user"`
*UserDetail `orm:"with:uid=id"`
Id int `json:"id"`
Name string `json:"name"`
UserScores []*UserScores `orm:"with:uid=id"`
}
```
嵌套的模型也支持嵌套,只要是结构体嵌套的都支持自动数据赋值。例如:
```go
type UserDetail struct {
Uid int `json:"uid"`
Address string `json:"address"`
}
type UserDetailEmbedded struct {
UserDetail
}
type UserScores struct {
Id int `json:"id"`
Uid int `json:"uid"`
Score int `json:"score"`
}
type User struct {
*UserDetailEmbedded `orm:"with:uid=id"`
Id int `json:"id"`
Name string `json:"name"`
UserScores []*UserScores `orm:"with:uid=id"`
}
```
### 2、基础模型嵌套
```go
type UserDetail struct {
g.Meta `orm:"table:user_detail"`
Uid int `json:"uid"`
Address string `json:"address"`
}
type UserScores struct {
g.Meta `orm:"table:user_scores"`
Id int `json:"id"`
Uid int `json:"uid"`
Score int `json:"score"`
}
type UserEmbedded struct {
Id int `json:"id"`
Name string `json:"name"`
}
type User struct {
g.Meta `orm:"table:user"`
UserEmbedded
*UserDetail `orm:"with:uid=id"`
UserScores []*UserScores `orm:"with:uid=id"`
}
```
### 3、模型不带 `meta` 信息
模型中的 `meta` 结构重要的是指定数据表名称,当不存在 `meta` 信息时,查询的数据表将会自动以结构体名称的 `CaseSnake` 名称。例如, `UserDetail` 将会自动使用 `user_detail` 数据表名称, `UserScores` 将会自动使用 `user_scores` 数据表名称。
```go
type UserDetail struct {
Uid int `json:"uid"`
Address string `json:"address"`
}
type UserScores struct {
Id int `json:"id"`
Uid int `json:"uid"`
Score int `json:"score"`
}
type User struct {
*UserDetail `orm:"with:uid=id"`
Id int `json:"id"`
Name string `json:"name"`
UserScores []*UserScores `orm:"with:uid=id"`
}
```
## 七、后续改进
- 目前 `With` 特性仅实现了查询操作,还不支持写入更新等操作。
# ORM链式操作-时间维护
## 基本介绍
**注意**
需要注意,该特性仅对链式操作有效。
`gdb` 模块支持对数据记录的写入、更新、删除时间自动填充,提高开发维护效率。为了便于时间字段名称、类型的统一维护,如果使用该特性,我们约定:
- 字段的类型可以为时间类型、数字整型或者布尔型,如: `date`, `datetime`, `timestamp`, `int`, `uint`, `big int`, `bool`等。
- 字段的名称支持自定义设置,默认名称约定为:
- `created_at` 用于记录创建时更新,仅会写入一次。
- `updated_at` 用于记录修改时更新,每次记录变更时更新。
- `deleted_at` 用于记录的软删除特性,只有当记录删除时会写入一次。
字段名称不区分大小写,也会忽略特殊字符,例如 `CreatedAt`, `UpdatedAt`, `DeletedAt` 也是支持的。
## 特性配置
时间字段名称可以通过配置文件进行自定义修改,并可使用 `timeMaintainDisabled` 配置在数据库实例上完整关闭该特性。
在配置文件中对应配置项:
```yaml
database:
default: # 分组名称,可自定义,默认为default
createdAt: "created_at" # (可选)自动创建时间字段名称
updatedAt: "updated_time" # (可选)自动更新时间字段名称
deletedAt: "is_deleted" # (可选)软删除时间字段名称
timeMaintainDisabled: false # (可选)是否完全关闭时间更新特性,为true时CreatedAt/UpdatedAt/DeletedAt都将失效
```
**提示**
特别是针对历史项目,本身已经存在不一样的时间字段名称时,可以通过配置项灵活配置时间字段名称。
完整的数据库配置请参考 [ORM使用配置](./gdb-config.md) 章节。
## 特性启用
当数据表包含 `created_at`、 `updated_at`、 `deleted_at` 任意一个或多个字段时,或者包含配置文件中对应的配置字段时,该特性自动启用。
# 时间维护-基本使用
以下的示例中,我们默认示例中的数据表均包含了`created_at`、 `updated_at`、 `deleted_at`这3个字段,并且字段类型为`datetime`。
## 示例SQL
这是后续示例代码中用到的`MySQL`表结构。
```sql
CREATE TABLE `user` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(45) NOT NULL,
`status` tinyint DEFAULT 0,
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`deleted_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE IF NOT EXISTS `user_detail` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`address` varchar(45) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
```
**提示**
- 如果选择使用时间字段类型,那么需要将该字段设置为允许为`NULL`,这样软删除才能起作用。
- 如果尝试测试查看`ORM`操作执行的`SQL`语句,建议打开`debug`模式(相关文档:[调试模式](./gdb-senior-debugging.md)),`SQL`语句将会自动打印到日志输出。
## `created_at` 写入时间
在执行 `Insert/InsertIgnore/BatchInsert/BatchInsertIgnore` 方法时自动写入该时间,随后的更新/删除操作不会引起`created_at`字段内容的改变。
**注意**
需要注意的是 `Replace` 方法也会更新该字段,因为该操作相当于删除已存在的旧数据并重新写一条数据。
```go
// INSERT INTO `user`(`name`,`created_at`,`updated_at`) VALUES('john', `2020-06-06 21:00:00`, `2020-06-06 21:00:00`)
g.Model("user").Data(g.Map{"name": "john"}).Insert()
// INSERT IGNORE INTO `user`(`id`,`name`,`created_at`,`updated_at`) VALUES(10000,'john', `2020-06-06 21:00:00`, `2020-06-06 21:00:00`)
g.Model("user").Data(g.Map{"id": 10000, "name": "john"}).InsertIgnore()
// REPLACE INTO `user`(`id`,`name`,`created_at`,`updated_at`) VALUES(10000,'john', `2020-06-06 21:00:00`, `2020-06-06 21:00:00`)
g.Model("user").Data(g.Map{"id": 10000, "name": "john"}).Replace()
// INSERT INTO `user`(`id`,`name`,`created_at`,`updated_at`) VALUES(10001,'john', `2020-06-06 21:00:00`, `2020-06-06 21:00:00`) ON DUPLICATE KEY UPDATE `id`=VALUES(`id`),`name`=VALUES(`name`),`updated_at`=VALUES(`updated_at`)
g.Model("user").Data(g.Map{"id": 10001, "name": "john"}).Save()
```
## `deleted_at` 数据软删除
当软删除存在时(即`deleted_at`字段存在时),所有的查询语句都将会自动加上 `deleted_at` 的条件。
```go
// UPDATE `user` SET `deleted_at`='2020-06-06 21:00:00' WHERE id=10 AND `deleted_at` IS NULL
g.Model("user").Where("id", 10).Delete()
```
查询的时候会发生一些变化,例如:
```go
// SELECT * FROM `user` WHERE id>1 AND `deleted_at` IS NULL
g.Model("user").Where("id>?", 1).All()
```
可以看到当数据表中存在 `deleted_at` 字段时,所有涉及到该表的查询操作都将自动加上 `deleted_at IS NULL` 的条件。
## `updated_at` 更新时间
在执行 `Save/Update` 方法时自动写入该时间。
**注意**
需要注意的是 `Replace` 方法也会更新该字段,因为该操作相当于删除已存在的旧数据并重新写一条数据。
```go
// UPDATE `user` SET `name`='john guo',`updated_at`='2020-06-06 21:00:00' WHERE name='john' AND `deleted_at` IS NULL
g.Model("user").Data(g.Map{"name" : "john guo"}).Where("name", "john").Update()
// UPDATE `user` SET `status`=1,`updated_at`='2020-06-06 21:00:00' WHERE `deleted_at` IS NULL ORDER BY `id` ASC LIMIT 10
g.Model("user").Data("status", 1).OrderAsc("id").Limit(10).Update()
// INSERT INTO `user`(`id`,`name`,`update_at`) VALUES(1,'john guo','2020-12-29 20:16:14') ON DUPLICATE KEY UPDATE `id`=VALUES(`id`),`name`=VALUES(`name`),`update_at`=VALUES(`update_at`)
g.Model("user").Data(g.Map{"id": 1, "name": "john guo"}).Save()
```
## 联表查询的场景
如果关联查询的几个表都启用了软删除特性时,会发生以下这种情况,即条件语句中会增加所有相关表的软删除时间判断。
```go
// SELECT * FROM `user` AS `u` LEFT JOIN `user_detail` AS `ud` ON (ud.id=u.id) WHERE (`u`.`id`=10) AND `u`.`deleted_at` IS NULL LIMIT 1
g.Model("user", "u").LeftJoin("user_detail", "ud", "ud.id=u.id").Where("u.id", 10).One()
```
## `Unscoped` 忽略时间特性
`Unscoped` 用于在链式操作中忽略自动时间更新特性,例如上面的示例,加上 `Unscoped` 方法后:
```go
// SELECT * FROM `user` WHERE id>1
g.Model("user").Unscoped().Where("id>?", 1).All()
// SELECT * FROM `user` AS `u` LEFT JOIN `user_detail` AS `ud` ON (ud.id=u.id) WHERE u.id=10 LIMIT 1
g.Model("user", "u").LeftJoin("user_detail", "ud", "ud.id=u.id").Where("u.id", 10).Unscoped().One()
```
# 时间维护-整型字段
从`v2.8`版本开始,如果时间字段`created_at`、 `updated_at`、 `deleted_at`为整型字段,ORM组件会自动识别支持,并写入**秒级的时间戳**数值。
## 示例SQL
这是后续示例代码中用到的`MySQL`表结构。
```sql
CREATE TABLE `user` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(45) NOT NULL,
`status` tinyint DEFAULT 0,
`created_at` int(10) unsigned DEFAULT NULL,
`updated_at` int(10) unsigned DEFAULT NULL,
`deleted_at` int(10) unsigned DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE IF NOT EXISTS `user_detail` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`address` varchar(45) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
```
**提示**
如果尝试测试查看`ORM`操作执行的`SQL`语句,建议打开`debug`模式(相关文档:[调试模式](./gdb-senior-debugging.md)),`SQL`语句将会自动打印到日志输出。
## `created_at` 写入时间
在执行 `Insert/InsertIgnore/BatchInsert/BatchInsertIgnore` 方法时自动写入该时间,随后的更新/删除操作不会引起`created_at`字段内容的改变。
**注意**
需要注意的是 `Replace` 方法也会更新该字段,因为该操作相当于删除已存在的旧数据并重新写一条数据。
```go
// INSERT INTO `user`(`name`,`created_at`,`updated_at`,`deleted_at`) VALUES('john',1731481488,1731481488,0)
g.Model("user").Data(g.Map{"name": "john"}).Insert()
// INSERT IGNORE INTO `user`(`id`,`name`,`created_at`,`updated_at`,`deleted_at`) VALUES(10000,'john',1731481518,1731481518,0)
g.Model("user").Data(g.Map{"id": 10000, "name": "john"}).InsertIgnore()
// REPLACE INTO `user`(`id`,`name`,`created_at`,`updated_at`,`deleted_at`) VALUES(10000,'john',1731481747,1731481747,0)
g.Model("user").Data(g.Map{"id": 10000, "name": "john"}).Replace()
// INSERT INTO `user`(`id`,`name`,`created_at`,`updated_at`,`deleted_at`) VALUES(10001,'john',1731481766,1731481766,0) ON DUPLICATE KEY UPDATE `id`=VALUES(`id`),`name`=VALUES(`name`),`updated_at`=VALUES(`updated_at`),`deleted_at`=VALUES(`deleted_at`)
g.Model("user").Data(g.Map{"id": 10001, "name": "john"}).Save()
```
## `deleted_at` 数据软删除
当软删除存在时(即`deleted_at`字段存在时),所有的查询语句都将会自动加上 `deleted_at` 的条件。
```go
// UPDATE `user` SET `deleted_at`=1731481948 WHERE (`id`=10) AND `deleted_at`=0
g.Model("user").Where("id", 10).Delete()
```
查询的时候会发生一些变化,例如:
```go
// SELECT * FROM `user` WHERE (id>1) AND `deleted_at`=0
g.Model("user").Where("id>?", 1).All()
```
可以看到当数据表中存在 `deleted_at` 字段时,所有涉及到该表的查询操作都将自动加上 `deleted_at=0` 的条件。
## `updated_at` 更新时间
在执行 `Save/Update` 方法时自动写入该时间。需要注意的是 `Replace` 方法也会更新该字段,因为该操作相当于删除已存在的旧数据并重新写一条数据。
**说明**
如果同时存在`deleted_at`软删除字段,那么更新操作语句中同样会出现`deleted_at` 的条件。
```go
// UPDATE `user` SET `name`='john guo',`updated_at`=1731481821 WHERE (`name`='john') AND `deleted_at`=0
g.Model("user").Data(g.Map{"name" : "john guo"}).Where("name", "john").Update()
// UPDATE `user` SET `status`=1,`updated_at`=1731481895 WHERE `deleted_at`=0 ORDER BY `id` ASC LIMIT 10
g.Model("user").Data("status", 1).OrderAsc("id").Limit(10).Update()
// INSERT INTO `user`(`id`,`name`,`created_at`,`updated_at`,`deleted_at`) VALUES(1,'john guo',1731481915,1731481915,0) ON DUPLICATE KEY UPDATE `id`=VALUES(`id`),`name`=VALUES(`name`),`updated_at`=VALUES(`updated_at`),`deleted_at`=VALUES(`deleted_at`)
g.Model("user").Data(g.Map{"id": 1, "name": "john guo"}).Save()
```
## 联表查询的场景
如果关联查询的几个表都启用了软删除特性时,会发生以下这种情况,即条件语句中会增加所有相关表的软删除时间判断。
```go
// SELECT * FROM `user` AS `u` LEFT JOIN `user_detail` AS `ud` ON (ud.id=u.id) WHERE (`u`.`id`=10) AND `u`.`deleted_at`=0 LIMIT 1
g.Model("user", "u").LeftJoin("user_detail", "ud", "ud.id=u.id").Where("u.id", 10).One()
```
## 控制写入时间粒度
本章节的时间字段数值写入默认是秒级时间戳,但如果我们想要控制时间写入的粒度,写入毫秒级时间戳怎么做呢?我们可以使用[SoftTimeOption](./gdb-chaining-soft-time-option.md)。
# 时间维护-布尔字段
从`v2.8`版本开始,
如果时间字段`created_at`、 `updated_at`、 `deleted_at`为布尔字段,ORM组件会自动识别支持,并写入布尔类型数值(写入数值通过`0`和`1`表示)。
通常布尔字段为`deleted_at`字段,我们这里只演示`deleted_at`字段类型为`bool`的情况。
## 示例SQL
这是后续示例代码中用到的`MySQL`表结构。
```sql
CREATE TABLE `user` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(45) NOT NULL,
`status` tinyint DEFAULT 0,
`created_at` int(10) unsigned DEFAULT NULL,
`updated_at` int(10) unsigned DEFAULT NULL,
`deleted_at` bit(1) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
```
我们建议使用`bit(1)`来表示字段的`bool`类型,而非`tinyint(1)`或者`int(1)`。因为`tinyint(1)/int(1)`字段类型表示的范围是`-127~127`,通常可能会被用作状态字段类型。而`bit(1)`的类型范围为`0/1`,可以很好的表示`bool`类型的两个值`false/true`。
**提示**
如果尝试测试查看`ORM`操作执行的`SQL`语句,建议打开`debug`模式(相关文档:[调试模式](./gdb-senior-debugging.md)),`SQL`语句将会自动打印到日志输出。
## `created_at` 写入时间
```go
// INSERT INTO `user`(`name`,`created_at`,`updated_at`,`deleted_at`) VALUES('john',1731481488,1731481488,0)
g.Model("user").Data(g.Map{"name": "john"}).Insert()
```
## `deleted_at` 数据软删除
```go
// UPDATE `user` SET `deleted_at`=1 WHERE (`id`=10) AND `deleted_at`=0
g.Model("user").Where("id", 10).Delete()
```
查询的时候会发生一些变化,例如:
```go
// SELECT * FROM `user` WHERE (id>1) AND `deleted_at`=0
g.Model("user").Where("id>?", 1).All()
```
# 时间维护-SoftTimeOption
在前面的[整型字段](./gdb-chaining-soft-time-numeric-fields.md)的示例中,时间字段写入的都是秒级时间戳,但如果我们想要控制时间写入的粒度,写入毫秒级时间戳怎么做呢?
我们可以使用`SoftTimeOption`来控制写入的时间数值粒度。
## 示例SQL
这是后续示例代码中用到的`MySQL`表结构。由于需要写入比秒级更细粒度的数值,因此字段类型使用`big int`来存储。
```sql
CREATE TABLE `user` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(45) NOT NULL,
`status` tinyint DEFAULT 0,
`created_at` bigint unsigned DEFAULT NULL,
`updated_at` bigint unsigned DEFAULT NULL,
`deleted_at` bigint unsigned DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
```
**提示**
如果尝试测试查看`ORM`操作执行的`SQL`语句,建议打开`debug`模式(相关文档:[调试模式](./gdb-senior-debugging.md)),`SQL`语句将会自动打印到日志输出。
## `created_at` 写入时间
```go
// INSERT INTO `user`(`name`,`created_at`,`updated_at`,`deleted_at`) VALUES('john',1731484186556,1731484186556,0)
g.Model("user").SoftTime(gdb.SoftTimeOption{
SoftTimeType: gdb.SoftTimeTypeTimestampMilli,
}).Data(g.Map{"name": "john"}).Insert()
```
其中`SoftTimeType`控制时间粒度,粒度选项如下:
```go
const (
SoftTimeTypeAuto SoftTimeType = 0 // (Default)Auto detect the field type by table field type.
SoftTimeTypeTime SoftTimeType = 1 // Using datetime as the field value.
SoftTimeTypeTimestamp SoftTimeType = 2 // In unix seconds.
SoftTimeTypeTimestampMilli SoftTimeType = 3 // In unix milliseconds.
SoftTimeTypeTimestampMicro SoftTimeType = 4 // In unix microseconds.
SoftTimeTypeTimestampNano SoftTimeType = 5 // In unix nanoseconds.
)
```
# ORM事务处理
使用 `GoFrame ORM` 组件进行事务操作非常简便、安全,可以通过两种操作方式来实现。
1. 常规操作:通过 `Begin` 开启事务之后会返回一个事务操作接口 `gdb.TX`,随后可以使用该接口进行如之前章节介绍的方法操作和链式操作。常规操作容易漏掉关闭事务,有一定的事务操作安全风险。
2. 闭包操作:通过 `Transaction` 闭包方法的形式来操作事务,所有的事务逻辑在闭包中实现,闭包结束后自动关闭事务保障事务操作安全。并且闭包操作支持非常便捷的 **嵌套事务**,嵌套事务在业务操作中透明无感知。
**提示**
我们推荐事务操作均统一采用 `Transaction` 闭包方式实现。
接口文档: [https://pkg.go.dev/github.com/gogf/gf/v2/database/gdb#TX](https://pkg.go.dev/github.com/gogf/gf/v2/database/gdb#TX)
# ORM事务处理-常规操作
常规的事务操作方法为 `Begin/Commit/Rollback`,每一个方法指定特定的事务操作。开启事务操作可以通过执行 `db.Begin` 方法,该方法返回事务的操作接口,类型为 `gdb.Tx`,通过该对象执行后续的数据库操作,并可通过 `tx.Commit` 提交修改,或者通过 `tx.Rollback` 回滚修改。
**注意**
常见问题注意:开启事务操作后,请务必在不需要使用该事务对象时,通过 `Commit`/ `Rollback` 操作关闭掉该事务,建议充分利用好 `defer` 方法。如果事务使用后不关闭,在应用侧会引起 `goroutine` 不断激增泄露,在数据库侧会引起事务线程数量被打满,以至于后续的事务请求执行超时。此外,建议尽可能使用后续介绍的 `Transaction` 闭包方法来安全实现事务操作: [ORM事务处理-闭包操作](./gdb-transaction-closure.md)
## 一、开启事务操作
```go
db := g.DB()
if tx, err := db.Begin(ctx); err == nil {
fmt.Println("开启事务操作")
}
```
事务操作对象可以执行所有 `db` 对象的方法,具体请参考 [API文档](https://pkg.go.dev/github.com/gogf/gf/v2/database/gdb)。
## 二、事务回滚操作
```go
if tx, err := db.Begin(ctx); err == nil {
r, err := tx.Save("user", g.Map{
"id" : 1,
"name" : "john",
})
if err != nil {
tx.Rollback()
}
fmt.Println(r)
}
```
## 三、事务提交操作
```go
if tx, err := db.Begin(ctx); err == nil {
r, err := tx.Save("user", g.Map{
"id" : 1,
"name" : "john",
})
if err == nil {
tx.Commit()
}
fmt.Println(r)
}
```
## 四、事务链式操作
事务操作对象仍然可以通过 `tx.Model` 方法返回一个链式操作的对象,该对象与 `db.Model` 方法返回值相同,只不过数据库操作在事务上执行,可提交或回滚。
```go
if tx, err := db.Begin(); err == nil {
r, err := tx.Model("user").Data(g.Map{"id":1, "name": "john_1"}).Save()
if err == nil {
tx.Commit()
}
fmt.Println(r)
}
```
其他链式操作请参考 [ORM链式操作(重点)](./gdb-chaining.md) 章节。
# ORM事务处理-闭包操作
## 一、痛点描述
可以看到,通过常规的事务方法来管理事务有一些问题:
- **冗余代码较多**。代码中存在很多重复性的 `tx.Commit/Rollback` 操作。
- **操作风险较大**。非常容易遗忘执行 `tx.Commit/Rollback` 操作,或者由于代码逻辑判断 `BUG`,引发事务操作未正常关闭。在自行管理事务操作的情况下,大部分程序员都会踩到这个坑。作者已经见过了很多起由于事务未正常关闭引发的现网事故。我现在特意过来更新这段描述(2023-08-09),也是因为一个朋友由于自行通过 `tx.Commit/Rollback` 管理事务操作,细节未处理好,引发了现网事故。
- **嵌套事务实现复杂**。假如业务逻辑中存在多层级的事务处理(嵌套事务),需要考虑如何加个 `tx` 对象往下传递,处理起来更加繁琐。
## 二、闭包操作
因此为方便安全执行事务操作, `ORM` 组件同样提供了事务的闭包操作,通过 `Transaction` 方法实现,该方法定义如下:
```go
func (db DB) Transaction(ctx context.Context, f func(ctx context.Context, tx TX) error) (err error)
```
当给定的闭包方法返回的 `error` 为 `nil` 时,那么闭包执行结束后当前事务自动执行 `Commit` 提交操作;否则自动执行 `Rollback` 回滚操作。闭包中的 `context.Context` 参数为 `goframe v1.16` 版本后新增的上下文变量,主要用于链路跟踪传递以及嵌套事务管理。由于上下文变量是嵌套事务管理的重要参数,因此上下文变量通过显示的参数传递定义。
**提示**
如果闭包内部操作产生 `panic` 中断,该事务也将自动进行回滚,以保证操作安全。
使用示例:
```go
g.DB().Transaction(context.TODO(), func(ctx context.Context, tx gdb.TX) error {
// user
result, err := tx.Ctx(ctx).Insert("user", g.Map{
"passport": "john",
"password": "12345678",
"nickname": "JohnGuo",
})
if err != nil {
return err
}
// user_detail
id, err := result.LastInsertId()
if err != nil {
return err
}
_, err = tx.Ctx(ctx).Insert("user_detail", g.Map{
"uid": id,
"site": "https://johng.cn",
"true_name": "GuoQiang",
})
if err != nil {
return err
}
return nil
})
```
通过闭包操作的方式可以很简便地实现嵌套事务,且对上层业务开发同学来说无感知,具体可以继续阅读章节: [ORM事务处理-嵌套事务](./gdb-transaction-nested.md)
# ORM事务处理-嵌套事务
从 `GoFrame ORM` 支持数据库嵌套事务,嵌套事务在业务项目中用得比较多,特别是业务模块之间的相互调用,保证各个业务模块的数据库操作都处于一个事务中,其原理是通过传递的 `context` 上下文来隐式传递和关联同一个事务对象。需要注意的是,数据库服务往往并不支持嵌套事务,而是依靠 `ORM` 组件层通过 `Transaction Save Point` 特性实现的。同样的,我们推荐使用 `Transaction` 闭包方法来实现嵌套事务操作。为了保证文档的完整性,因此我们这里仍然从最基本的事务操作方法开始来介绍嵌套事务操作。
## 一、示例SQL
一个简单的示例 `SQL`,包含两个字段 `id` 和 `name`:
```sql
CREATE TABLE `user` (
`id` int(10) unsigned NOT NULL COMMENT '用户ID',
`name` varchar(45) NOT NULL COMMENT '用户名称',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
## 二、常规操作(不推荐)
```go
db := g.DB()
tx, err := db.Begin()
if err != nil {
panic(err)
}
if err = tx.Begin(); err != nil {
panic(err)
}
_, err = tx.Model(table).Data(g.Map{"id": 1, "name": "john"}).Insert()
if err = tx.Rollback(); err != nil {
panic(err)
}
_, err = tx.Model(table).Data(g.Map{"id": 2, "name": "smith"}).Insert()
if err = tx.Commit(); err != nil {
panic(err)
}
```
### 1、 `db.Begin` 与 `tx.Begin`
可以看到,在我们的嵌套事务中出现了 `db.Begin` 和 `tx.Begin` 两种事务开启方式,两者有什么区别呢? `db.Begin` 是在数据库服务上真正开启一个事务操作,并返回一个事务操作对象 `tx`,随后所有的事务操作都是通过该 `tx` 事务对象来操作管理。 `tx.Begin` 表示在当前事务操作中开启嵌套事务,默认情况下会对嵌套事务的 `SavePoint` 采用自动命名,命名格式为 `transactionN`,其中的 `N` 表示嵌套的层级数量,如果看到日志中出现 ``SAVEPOINT `transaction1` `` 表示当前嵌套层级为 `2`(从 `0` 开始计算)。
### 2、更详细的日志
`goframe` 的 `ORM` 拥有相当完善的日志记录机制,如果打开 `SQL` 日志,那么将会看到以下日志信息,展示了整个数据库请求的详细执行流程:
```html
2021-05-22 21:12:10.776 [DEBU] [ 4 ms] [default] [txid:1] BEGIN
2021-05-22 21:12:10.776 [DEBU] [ 0 ms] [default] [txid:1] SAVEPOINT `transaction0`
2021-05-22 21:12:10.789 [DEBU] [ 13 ms] [default] [txid:1] SHOW FULL COLUMNS FROM `user`
2021-05-22 21:12:10.790 [DEBU] [ 1 ms] [default] [txid:1] INSERT INTO `user`(`id`,`name`) VALUES(1,'john')
2021-05-22 21:12:10.791 [DEBU] [ 1 ms] [default] [txid:1] ROLLBACK TO SAVEPOINT `transaction0`
2021-05-22 21:12:10.791 [DEBU] [ 0 ms] [default] [txid:1] INSERT INTO `user`(`id`,`name`) VALUES(2,'smith')
2021-05-22 21:12:10.792 [DEBU] [ 1 ms] [default] [txid:1] COMMIT
```
其中的 `[txid:1]` 表示 `ORM` 组件记录的事务ID,多个真实的事务同时操作时,每个事务的ID将会不同。在同一个真实事务下的嵌套事务的事务ID是一样的。
执行后查询数据库结果:
```
mysql> select * from `user`;
+----+-------+
| id | name |
+----+-------+
| 2 | smith |
+----+-------+
1 row in set (0.00 sec)
```
可以看到第一个操作被成功回滚,只有第二个操作执行并提交成功。
## 三、闭包操作(推荐)
我们也可以通过闭包操作来实现嵌套事务,同样也是通过 `Transaction` 方法实现。
```go
db.Transaction(ctx, func(ctx context.Context, tx gdb.Tx) error {
// Nested transaction 1.
if err := tx.Transaction(ctx, func(ctx context.Context, tx gdb.Tx) error {
_, err := tx.Model(table).Ctx(ctx).Data(g.Map{"id": 1, "name": "john"}).Insert()
return err
}); err != nil {
return err
}
// Nested transaction 2, panic.
if err := tx.Transaction(ctx, func(ctx context.Context, tx gdb.Tx) error {
_, err := tx.Model(table).Ctx(ctx).Data(g.Map{"id": 2, "name": "smith"}).Insert()
// Create a panic that can make this transaction rollback automatically.
panic("error")
return err
}); err != nil {
return err
}
return nil
})
```
嵌套事务的闭包嵌套中也可以不使用其中的 `tx` 对象,而是直接使用 `db` 对象或者 `dao` 包,这种方式更常见一些。特别是在方法层级调用时,使得对于开发者来说并不用关心 `tx` 对象的传递,也并不用关心当前事务是否需要嵌套执行,一切都由组件自动维护,极大减少开发者的心智负担。但是务必记得将 `ctx` 上下文变量层层传递下去哦。例如:
```go
db.Transaction(ctx, func(ctx context.Context, tx gdb.Tx) error {
// Nested transaction 1.
if err := db.Transaction(ctx, func(ctx context.Context, tx gdb.Tx) error {
_, err := db.Model(table).Ctx(ctx).Data(g.Map{"id": 1, "name": "john"}).Insert()
return err
}); err != nil {
return err
}
// Nested transaction 2, panic.
if err := db.Transaction(ctx, func(ctx context.Context, tx gdb.Tx) error {
_, err := db.Model(table).Ctx(ctx).Data(g.Map{"id": 2, "name": "smith"}).Insert()
// Create a panic that can make this transaction rollback automatically.
panic("error")
return err
}); err != nil {
return err
}
return nil
})
```
如果打开 `SQL` 日志,那么执行后将会看到以下日志信息,展示了整个数据库请求的详细执行流程:
```html
2021-05-22 21:18:46.672 [DEBU] [ 2 ms] [default] [txid:1] BEGIN
2021-05-22 21:18:46.672 [DEBU] [ 0 ms] [default] [txid:1] SAVEPOINT `transaction0`
2021-05-22 21:18:46.673 [DEBU] [ 0 ms] [default] [txid:1] SHOW FULL COLUMNS FROM `user`
2021-05-22 21:18:46.674 [DEBU] [ 0 ms] [default] [txid:1] INSERT INTO `user`(`id`,`name`) VALUES(1,'john')
2021-05-22 21:18:46.674 [DEBU] [ 0 ms] [default] [txid:1] RELEASE SAVEPOINT `transaction0`
2021-05-22 21:18:46.675 [DEBU] [ 1 ms] [default] [txid:1] SAVEPOINT `transaction0`
2021-05-22 21:18:46.675 [DEBU] [ 0 ms] [default] [txid:1] INSERT INTO `user`(`name`,`id`) VALUES('smith',2)
2021-05-22 21:18:46.675 [DEBU] [ 0 ms] [default] [txid:1] ROLLBACK TO SAVEPOINT `transaction0`
2021-05-22 21:18:46.676 [DEBU] [ 1 ms] [default] [txid:1] ROLLBACK
```
**注意**
假如 `ctx` 上下文变量没有层层传递下去,那么嵌套事务将会失败,我们来看一个错误的例子:
```go
db.Transaction(ctx, func(ctx context.Context, tx gdb.Tx) error {
// Nested transaction 1.
if err := db.Transaction(ctx, func(ctx context.Context, tx gdb.Tx) error {
_, err := db.Model(table).Ctx(ctx).Data(g.Map{"id": 1, "name": "john"}).Insert()
return err
}); err != nil {
return err
}
// Nested transaction 2, panic.
if err := db.Transaction(ctx, func(ctx context.Context, tx gdb.Tx) error {
_, err := db.Model(table).Data(g.Map{"id": 2, "name": "smith"}).Insert()
// Create a panic that can make this transaction rollback automatically.
panic("error")
return err
}); err != nil {
return err
}
return nil
})
```
打开 `SQL` 执行日志,执行后,将会看到以下日志内容:
```html
2021-05-22 21:29:38.841 [DEBU] [ 3 ms] [default] [txid:1] BEGIN
2021-05-22 21:29:38.842 [DEBU] [ 1 ms] [default] [txid:1] SAVEPOINT `transaction0`
2021-05-22 21:29:38.843 [DEBU] [ 1 ms] [default] [txid:1] SHOW FULL COLUMNS FROM `user`
2021-05-22 21:29:38.845 [DEBU] [ 2 ms] [default] [txid:1] INSERT INTO `user`(`id`,`name`) VALUES(1,'john')
2021-05-22 21:29:38.845 [DEBU] [ 0 ms] [default] [txid:1] RELEASE SAVEPOINT `transaction0`
2021-05-22 21:29:38.846 [DEBU] [ 1 ms] [default] [txid:1] SAVEPOINT `transaction0`
2021-05-22 21:29:38.847 [DEBU] [ 1 ms] [default] INSERT INTO `user`(`id`,`name`) VALUES(2,'smith')
2021-05-22 21:29:38.848 [DEBU] [ 0 ms] [default] [txid:1] ROLLBACK TO SAVEPOINT `transaction0`
2021-05-22 21:29:38.848 [DEBU] [ 0 ms] [default] [txid:1] ROLLBACK
```
可以看到,第二条 `INSERT` 操作 ``INSERT INTO `user`(`id`,`name`) VALUES(2,'smith') `` 没有事务ID打印,表示没有使用到事务,那么该操作将会被真正提交到数据库执行,并不能被回滚。
## 四、 `SavePoint/RollbackTo`
开发者也可以灵活使用 `Transaction Save Point` 特性,并实现自定义的 `SavePoint` 命名以及指定 `Point` 回滚操作。
```go
tx, err := db.Begin()
if err != nil {
panic(err)
}
defer func() {
if err := recover(); err != nil {
_ = tx.Rollback()
}
}()
if _, err = tx.Model(table).Data(g.Map{"id": 1, "name": "john"}).Insert(); err != nil {
panic(err)
}
if err = tx.SavePoint("MyPoint"); err != nil {
panic(err)
}
if _, err = tx.Model(table).Data(g.Map{"id": 2, "name": "smith"}).Insert(); err != nil {
panic(err)
}
if _, err = tx.Model(table).Data(g.Map{"id": 3, "name": "green"}).Insert(); err != nil {
panic(err)
}
if err = tx.RollbackTo("MyPoint"); err != nil {
panic(err)
}
if err = tx.Commit(); err != nil {
panic(err)
}
```
如果打开 `SQL` 日志,那么将会看到以下日志信息,展示了整个数据库请求的详细执行流程:
```html
2021-05-22 21:38:51.992 [DEBU] [ 3 ms] [default] [txid:1] BEGIN
2021-05-22 21:38:52.002 [DEBU] [ 9 ms] [default] [txid:1] SHOW FULL COLUMNS FROM `user`
2021-05-22 21:38:52.002 [DEBU] [ 0 ms] [default] [txid:1] INSERT INTO `user`(`id`,`name`) VALUES(1,'john')
2021-05-22 21:38:52.003 [DEBU] [ 1 ms] [default] [txid:1] SAVEPOINT `MyPoint`
2021-05-22 21:38:52.004 [DEBU] [ 1 ms] [default] [txid:1] INSERT INTO `user`(`id`,`name`) VALUES(2,'smith')
2021-05-22 21:38:52.005 [DEBU] [ 1 ms] [default] [txid:1] INSERT INTO `user`(`id`,`name`) VALUES(3,'green')
2021-05-22 21:38:52.006 [DEBU] [ 0 ms] [default] [txid:1] ROLLBACK TO SAVEPOINT `MyPoint`
2021-05-22 21:38:52.006 [DEBU] [ 0 ms] [default] [txid:1] COMMIT
```
执行后查询数据库结果:
```
mysql> select * from `user`;
+----+------+
| id | name |
+----+------+
| 1 | john |
+----+------+
1 row in set (0.00 sec)
```
可以看到,通过在第一个 `Insert` 操作后保存了一个 `SavePoint` 名称 `MyPoint`,随后的几次操作都通过 `RollbackTo` 方法被回滚掉了,因此只有第一次 `Insert` 操作被成功提交执行。
## 五、嵌套事务在工程中的参考示例
为了简化示例,我们还是使用用户模块相关的示例,例如用户注册,通过事务操作保存用户基本信息( `user`)、详细信息( `user_detail`)两个表,任一个表操作失败整个注册操作都将失败。为展示嵌套事务效果,我们将用户基本信息管理和用户详细信息管理划分为了两个 `dao` 对象。
假如我们的项目按照 `goframe` 标准项目工程化分为三层 `api-service-dao`,那么我们的嵌套事务操作可能是这样的。
### `controller`
```go
// 用户注册HTTP接口
func (*cUser) Signup(r *ghttp.Request) {
// ....
service.User().Signup(r.Context(), userServiceSignupReq)
// ...
}
```
承接HTTP请求,并且将 `Context` 上下文边变量传递给后续的流程。
### `service`
```go
// 用户注册业务逻辑处理
func (*userService) Signup(ctx context.Context, r *model.UserServiceSignupReq) {
// ....
dao.User.Transaction(ctx, func(ctx context.Context, tx gdb.Tx) error {
err := dao.User.Ctx(ctx).Save(r.UserInfo)
if err != nil {
return err
}
err := dao.UserDetail.Ctx(ctx).Save(r.UserDetail)
if err != nil {
return err
}
return nil
})
// ...
}
```
可以看到,内部的 `user` 表和 `user_detail` 表使用了嵌套事务来统一执行事务操作。注意在闭包内部需要通过 `Ctx` 方法将上下文变量传递给下一层级。假如在闭包中存在对其他 `service` 对象的调用,那么也需要将 `ctx` 变量传递过去,例如:
```go
func (*userService) Signup(ctx context.Context, r *model.UserServiceSignupReq) {
// ....
dao.User.Transaction(ctx, func(ctx context.Context, tx gdb.Tx) (err error) {
if err = dao.User.Ctx(ctx).Save(r.UserInfo); err != nil {
return err
}
if err = dao.UserDetail.Ctx(ctx).Save(r.UserDetail); err != nil {
return err
}
if err = service.XXXA().Call(ctx, ...); err != nil {
return err
}
if err = service.XXXB().Call(ctx, ...); err != nil {
return err
}
if err = service.XXXC().Call(ctx, ...); err != nil {
return err
}
// ...
return nil
})
// ...
}
```
### `dao`
`dao` 层的代码由 `goframe cli` 工具全自动化生成及维护即可。
# ORM事务处理-事务传播
事务传播(`Transaction Propagation`)是`GoFrame ORM`框架提供的一种高级事务管理机制,用于控制嵌套事务的行为。在复杂的业务场景中,一个事务方法可能会调用其他事务方法,此时需要明确定义事务的传播行为,以确保数据的一致性和完整性。
## 基本介绍
事务传播定义了事务方法被另一个事务方法调用时应该如何表现。`GoFrame ORM`支持多种事务传播行为,开发者可以根据业务需求选择合适的传播类型。
`GoFrame ORM`支持以下事务传播类型:
- `PropagationNested` (默认):如果当前存在事务,则创建嵌套事务(使用保存点);如果不存在事务,则创建新事务。
- `PropagationRequired`:如果当前存在事务,则加入该事务;如果不存在事务,则创建新事务。
- `PropagationSupports`:如果当前存在事务,则加入该事务;如果不存在事务,则以非事务方式执行。
- `PropagationRequiresNew`:创建新事务,如果当前存在事务,则挂起当前事务。
- `PropagationNotSupported`:以非事务方式执行,如果当前存在事务,则挂起当前事务。
- `PropagationMandatory`:如果当前存在事务,则加入该事务;如果不存在事务,则抛出异常。
- `PropagationNever`:以非事务方式执行,如果当前存在事务,则抛出异常。
**说明**
注意事项:为保证和旧版本事务处理的兼容性,`GoFrame ORM`默认的事务传播类型为`PropagationNested`,而不是`PropagationRequired`。
## 使用优势
1. **灵活的事务管理**:根据业务需求选择不同的事务传播行为,实现更精细的事务控制。
2. **提高代码复用性**:服务层方法可以独立定义事务行为,无需关心调用环境是否已有事务。
3. **降低事务冲突**:通过适当的传播行为,减少长事务导致的锁定和冲突问题。
4. **增强错误隔离**:使用独立事务(如`PropagationRequiresNew`)可以隔离错误影响,避免整体事务回滚。
5. **简化复杂业务逻辑**:在复杂业务流程中,不同步骤可以使用不同的事务传播策略,使代码更清晰。
## 应用场景
1. **服务组合**:当一个服务方法调用多个其他服务方法时,可以根据业务需求选择是共享同一事务还是使用独立事务。
2. **日志记录**:业务操作需要记录日志,但日志写入失败不应影响主要业务逻辑,可以使用`PropagationRequiresNew`。
3. **批量处理**:在批量处理中,可能希望每个项目使用独立事务,以便某个项目失败不影响其他项目。
4. **事务补偿**:在分布式系统中,可以使用不同的传播行为实现事务补偿机制。
5. **嵌套业务逻辑**:复杂业务逻辑中的某些步骤可能需要作为一个独立的事务单元,可以使用`PropagationNested`。
## 使用示例
在开始示例之前,我们先创建一个用于测试的数据表:
```sql
CREATE TABLE IF NOT EXISTS `user` (
id INT PRIMARY KEY,
username VARCHAR(50)
);
```
**提示**
如果想逐步运行以下示例,需要在每次运行新示例之前清空一下`user`表数据。
### PropagationNested (嵌套事务,默认)
如果当前存在事务,则创建嵌套事务(使用保存点);如果不存在事务,则创建新事务。
```go
package main
import (
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
"context"
"fmt"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
func main() {
var (
ctx = context.Background()
db = g.DB()
)
db.SetDebug(true)
// 执行事务
err := db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// 在外层事务中插入数据
_, err := tx.Insert("user", g.Map{
"id": 1,
"username": "outer_user",
})
if err != nil {
return err
}
// 嵌套事务 - 使用PropagationNested创建嵌套事务(使用保存点)
err = tx.Transaction(ctx, func(ctx context.Context, tx2 gdb.TX) error {
// 在嵌套事务中插入数据
_, err = tx2.Insert("user", g.Map{
"id": 2,
"username": "nested_user",
})
if err != nil {
return err
}
// 模拟错误,导致嵌套事务回滚到保存点
return fmt.Errorf("嵌套事务故意失败")
})
// 嵌套事务失败,但外层事务可以继续
fmt.Println("嵌套事务错误:", err)
// 继续在外层事务中插入数据
_, err = tx.Insert("user", g.Map{
"id": 3,
"username": "outer_after_nested",
})
// 外层事务正常提交
return nil
})
if err != nil {
fmt.Println("事务执行失败:", err)
return
}
// 查询结果
result, err := db.Model("user").All()
if err != nil {
fmt.Println("查询失败:", err)
return
}
// 数据中只有id为1和3的数据,没有2
fmt.Println("查询结果:", result)
}
```
上述代码执行的`SQL`语句类似于:
```sql
-- 开始外层事务
BEGIN;
-- 插入外层数据
INSERT INTO user(id,username) VALUES(1,'outer_user');
-- 创建保存点
SAVEPOINT sp1;
-- 插入嵌套事务数据
INSERT INTO user(id,username) VALUES(2,'nested_user');
-- 嵌套事务失败,回滚到保存点
ROLLBACK TO SAVEPOINT sp1;
-- 继续外层事务
INSERT INTO user(id,username) VALUES(3,'outer_after_nested');
-- 提交外层事务
COMMIT;
```
### PropagationRequired (保证事务)
事务传播类型`PropagationRequired`与`PropagationNested`的主要区别在于,`PropagationRequired`在当前存在事务时加入该事务,
而在没有事务时创建新事务,**并不会创建嵌套事务**。
```go
package main
import (
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
"context"
"fmt"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
func main() {
var (
ctx = context.Background()
db = g.DB()
)
db.SetDebug(true)
// 执行事务
err := db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// 在外层事务中插入数据
// 在事务闭包中使用tx对象或者db对象来操作数据表都是等价的
_, err := tx.Insert("user", g.Map{
"id": 1,
"username": "outer_user",
})
if err != nil {
return err
}
// 嵌套事务 - 默认使用PropagationRequired
err = tx.TransactionWithOptions(ctx, gdb.TxOptions{
Propagation: gdb.PropagationRequired,
}, func(ctx context.Context, tx2 gdb.TX) error {
// 在嵌套事务中插入数据(使用相同的事务)
// 在事务闭包中使用tx2对象或者db对象来操作数据表都是等价的
_, err = tx2.Insert("user", g.Map{
"id": 2,
"username": "inner_user",
})
return err
})
return err
})
if err != nil {
fmt.Println("事务执行失败:", err)
return
}
// 查询结果
result, err := db.Model("user").All()
if err != nil {
fmt.Println("查询失败:", err)
return
}
fmt.Println("查询结果:", result)
}
```
上述代码执行的`SQL`语句类似于:
```sql
-- 开始外层事务
BEGIN;
-- 插入外层数据
INSERT INTO user(id,username) VALUES(1,'outer_user');
-- 插入内层数据(使用相同事务)
INSERT INTO user(id,username) VALUES(2,'inner_user');
-- 提交事务
COMMIT;
```
### PropagationRequiresNew (创建新事务)
创建新事务,如果当前存在事务,则挂起当前事务。
```go
package main
import (
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
"context"
"fmt"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
func main() {
var (
ctx = context.Background()
db = g.DB()
)
db.SetDebug(true)
// 执行事务
err := db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// 在外层事务中插入数据
_, err := tx.Insert("user", g.Map{
"id": 1,
"username": "outer_user",
})
if err != nil {
return err
}
// 嵌套事务 - 使用PropagationRequiresNew创建新事务
err = tx.TransactionWithOptions(ctx, gdb.TxOptions{
Propagation: gdb.PropagationRequiresNew,
}, func(ctx context.Context, tx2 gdb.TX) error {
// 在新事务中插入数据
_, err = tx2.Insert("user", g.Map{
"id": 2,
"username": "new_tx_user",
})
// 模拟错误,导致内层事务回滚
return fmt.Errorf("内层事务故意失败")
})
// 内层事务失败不影响外层事务
fmt.Println("内层事务错误:", err)
// 继续在外层事务中插入数据
_, err = tx.Insert("user", g.Map{
"id": 3,
"username": "outer_after_error",
})
// 外层事务正常提交
return nil
})
if err != nil {
fmt.Println("事务执行失败:", err)
return
}
// 查询结果
result, err := db.Model("user").All()
if err != nil {
fmt.Println("查询失败:", err)
return
}
fmt.Println("查询结果:", result)
}
```
上述代码执行的`SQL`语句类似于:
```sql
-- 开始外层事务
BEGIN;
-- 插入外层数据
INSERT INTO user(id,username) VALUES(1,'outer_user');
-- 开始新的独立事务
BEGIN;
-- 插入内层数据(使用新事务)
INSERT INTO user(id,username) VALUES(2,'new_tx_user');
-- 内层事务回滚
ROLLBACK;
-- 继续外层事务
INSERT INTO user(id,username) VALUES(3,'outer_after_error');
-- 提交外层事务
COMMIT;
```
### PropagationSupports (支持当前事务)
如果当前存在事务,则加入该事务;如果不存在事务,则以非事务方式执行。
```go
package main
import (
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
"context"
"fmt"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
func main() {
var (
ctx = context.Background()
db = g.DB()
)
db.SetDebug(true)
// 场景1: 有外部事务时,加入外部事务
err := db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// 在外层事务中插入数据
_, err := tx.Insert("user", g.Map{
"id": 1,
"username": "outer_user",
})
if err != nil {
return err
}
// 嵌套事务 - 使用PropagationSupports
err = tx.TransactionWithOptions(ctx, gdb.TxOptions{
Propagation: gdb.PropagationSupports,
}, func(ctx context.Context, tx2 gdb.TX) error {
// 在支持当前事务的方式下插入数据(使用外层事务)
_, err = tx2.Insert("user", g.Map{
"id": 2,
"username": "supports_user",
})
return err
})
return err
})
if err != nil {
fmt.Println("场景1执行失败:", err)
return
}
// 查询结果
result, err := db.Model("user").All()
if err != nil {
fmt.Println("查询失败:", err)
return
}
fmt.Println("场景1查询结果:", result)
// 清空数据表
_, err = db.Exec(ctx, `TRUNCATE TABLE user`)
if err != nil {
fmt.Println("执行失败:", err)
return
}
// 场景2: 没有外部事务时,非事务方式执行
err = db.TransactionWithOptions(ctx, gdb.TxOptions{
Propagation: gdb.PropagationSupports,
}, func(ctx context.Context, tx gdb.TX) error {
// 以非事务方式插入数据
_, err = tx.Insert("user", g.Map{
"id": 3,
"username": "non_tx_user",
})
return err
})
if err != nil {
fmt.Println("场景2执行失败:", err)
return
}
// 查询结果
result, err = db.Model("user").All()
if err != nil {
fmt.Println("查询失败:", err)
return
}
fmt.Println("场景2查询结果:", result)
}
```
上述代码执行的`SQL`语句类似于:
```sql
-- 场景1: 有外部事务
-- 开始外层事务
BEGIN;
-- 插入外层数据
INSERT INTO user(id,username) VALUES(1,'outer_user');
-- 插入内层数据(使用外层事务)
INSERT INTO user(id,username) VALUES(2,'supports_user');
-- 提交事务
COMMIT;
-- 场景2: 没有外部事务
-- 非事务方式直接插入数据
INSERT INTO user(id,username) VALUES(3,'non_tx_user');
```
### PropagationMandatory (强制使用事务)
如果当前存在事务,则加入该事务;如果不存在事务,则抛出异常。
```go
package main
import (
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
"context"
"fmt"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
func main() {
var (
ctx = context.Background()
db = g.DB()
)
db.SetDebug(true)
// 场景1: 有外部事务时,加入外部事务
err := db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// 在外层事务中插入数据
_, err := tx.Insert("user", g.Map{
"id": 1,
"username": "outer_user",
})
if err != nil {
return err
}
// 嵌套事务 - 使用PropagationMandatory
err = tx.TransactionWithOptions(ctx, gdb.TxOptions{
Propagation: gdb.PropagationMandatory,
}, func(ctx context.Context, tx2 gdb.TX) error {
// 在强制使用当前事务的方式下插入数据(使用外层事务)
_, err = tx2.Insert("user", g.Map{
"id": 2,
"username": "mandatory_user",
})
return err
})
return err
})
if err != nil {
fmt.Println("场景1执行失败:", err)
return
}
// 查询结果
result, err := db.Model("user").All()
if err != nil {
fmt.Println("查询失败:", err)
return
}
fmt.Println("场景1查询结果:", result)
// 清空数据表
_, err = db.Exec(ctx, `TRUNCATE TABLE user`)
if err != nil {
fmt.Println("执行失败:", err)
return
}
// 场景2: 没有外部事务时,将抛出异常
fmt.Println("场景2: 没有外部事务时使用PropagationMandatory")
err = db.TransactionWithOptions(ctx, gdb.TxOptions{
Propagation: gdb.PropagationMandatory,
}, func(ctx context.Context, tx gdb.TX) error {
// 这里的代码不会执行,因为没有外部事务时会抛出异常
_, err = tx.Insert("user", g.Map{
"id": 3,
"username": "will_not_insert",
})
return err
})
// 应该有错误,因为没有外部事务
fmt.Println("场景2错误:", err)
}
```
上述代码执行的`SQL`语句类似于:
```sql
-- 场景1: 有外部事务
-- 开始外层事务
BEGIN;
-- 插入外层数据
INSERT INTO user(id,username) VALUES(1,'outer_user');
-- 插入内层数据(使用外层事务)
INSERT INTO user(id,username) VALUES(2,'mandatory_user');
-- 提交事务
COMMIT;
-- 场景2: 没有外部事务
-- 抛出异常: "mandatory transaction is required, but none exists"
```
### PropagationNever (不允许在事务中执行)
以非事务方式执行,如果当前存在事务,则抛出异常。
```go
package main
import (
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
"context"
"fmt"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
func main() {
var (
ctx = context.Background()
db = g.DB()
)
db.SetDebug(true)
// 场景1: 有外部事务时,将抛出异常
fmt.Println("场景1: 有外部事务时使用PropagationNever")
err := db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// 在外层事务中插入数据
_, err := tx.Insert("user", g.Map{
"id": 1,
"username": "outer_user",
})
if err != nil {
return err
}
// 嵌套事务 - 使用PropagationNever
err = tx.TransactionWithOptions(ctx, gdb.TxOptions{
Propagation: gdb.PropagationNever,
}, func(ctx context.Context, tx2 gdb.TX) error {
// 这里的代码不会执行,因为在外部事务中使用PropagationNever会抛出异常
_, err := tx2.Insert("user", g.Map{
"id": 2,
"username": "will_not_insert",
})
return err
})
// 应该有错误
fmt.Println("嵌套事务错误:", err)
// 继续外层事务
return nil
})
if err != nil {
fmt.Println("场景1执行失败:", err)
return
}
// 查询结果
result, err := db.Model("user").All()
if err != nil {
fmt.Println("查询失败:", err)
return
}
// 应该只有id=1的记录
fmt.Println("场景1查询结果:", result)
// 清空数据表
_, err = db.Exec(ctx, `TRUNCATE TABLE user`)
if err != nil {
fmt.Println("执行失败:", err)
return
}
// 场景2: 没有外部事务时,非事务方式执行
err = db.TransactionWithOptions(ctx, gdb.TxOptions{
Propagation: gdb.PropagationNever,
}, func(ctx context.Context, tx gdb.TX) error {
// 以非事务方式插入数据
_, err = tx.Insert("user", g.Map{
"id": 3,
"username": "non_tx_user",
})
return err
})
if err != nil {
fmt.Println("场景2执行失败:", err)
return
}
// 查询结果
result, err = db.Model("user").All()
if err != nil {
fmt.Println("查询失败:", err)
return
}
// 应该有id=3的记录
fmt.Println("场景2查询结果:", result)
}
```
上述代码执行的`SQL`语句类似于:
```sql
-- 场景1: 有外部事务
-- 开始外层事务
BEGIN;
-- 插入外层数据
INSERT INTO user(id,username) VALUES(1,'outer_user');
-- 抛出异常: "transaction is existing, but never transaction is required"
-- 提交外层事务
COMMIT;
-- 场景2: 没有外部事务
-- 非事务方式直接插入数据
INSERT INTO user(id,username) VALUES(3,'non_tx_user');
```
### PropagationNotSupported (非事务方式执行)
以非事务方式执行,如果当前存在事务,则挂起当前事务。
```go
package main
import (
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
"context"
"fmt"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
func main() {
var (
ctx = context.Background()
db = g.DB()
)
db.SetDebug(true)
// 执行事务
err := db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// 在事务中插入数据
_, err := tx.Insert("user", g.Map{
"id": 1,
"username": "tx_user",
})
if err != nil {
return err
}
// 使用PropagationNotSupported挂起当前事务
err = tx.TransactionWithOptions(ctx, gdb.TxOptions{
Propagation: gdb.PropagationNotSupported,
}, func(ctx context.Context, tx2 gdb.TX) error {
// 非事务方式写入数据
_, err = tx2.Insert("user", g.Map{
"id": 2,
"username": "non_tx_user",
})
return err
})
if err != nil {
return err
}
// 模拟错误,导致外层事务回滚
return fmt.Errorf("外层事务故意失败")
})
fmt.Println("事务执行结果:", err)
// 查询结果
result, err := db.Model("user").All()
if err != nil {
fmt.Println("查询失败:", err)
return
}
// 应该只看到id=2的记录,因为id=1的记录在事务回滚时被撤销
fmt.Println("查询结果:", result)
}
```
上述代码执行的`SQL`语句类似于:
```sql
-- 开始事务
BEGIN;
-- 插入事务内数据
INSERT INTO user(id,username) VALUES(1,'tx_user');
-- 非事务方式执行(直接提交)
INSERT INTO user(id,username) VALUES(2,'non_tx_user');
-- 外层事务回滚
ROLLBACK;
```
## 总结
`GoFrame ORM`的事务传播功能为开发者提供了灵活的事务管理机制,能够满足复杂业务场景下的事务处理需求。通过合理选择事务传播行为,可以实现更精细的事务控制,提高系统的可靠性和性能。
在实际应用中,建议根据业务特点选择合适的传播行为:
1. 对于大多数场景,默认的`PropagationRequired`已经能够满足需求。
2. 当需要独立事务以隔离错误影响时,可以使用`PropagationRequiresNew`。
3. 对于需要保存点功能的场景,可以使用`PropagationNested`。
4. 当某些操作不需要事务保护时,可以使用`PropagationNotSupported`。
通过合理使用事务传播机制,可以构建更加健壮和可维护的应用程序。
# ORM事务处理-隔离级别
事务隔离级别是数据库事务处理的重要特性,它定义了一个事务可能受其他并发事务影响的程度。`GoFrame ORM`组件支持标准的`SQL`事务隔离级别,使开发者能够根据应用需求选择适当的隔离级别。
## 基本概念
事务隔离级别主要解决并发事务执行时可能出现的以下问题:
1. **脏读(Dirty Read)**:一个事务读取了另一个未提交事务修改过的数据。
2. **不可重复读(Non-repeatable Read)**:在同一事务内,多次读取同一数据返回的结果有所不同。
3. **幻读(Phantom Read)**:在同一事务内,多次执行同一查询返回不同的数据集(行数变化)。
## 隔离级别类型
`GoFrame ORM` 支持以下标准的事务隔离级别:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---------|------|-----------|------|------|
| 读未提交(`Read Uncommitted`) | 可能 | 可能 | 可能 | 最低的隔离级别,允许读取未提交的数据变更 |
| 读已提交(`Read Committed`) | 不可能 | 可能 | 可能 | 只能读取已经提交的数据 |
| 可重复读(`Repeatable Read`) | 不可能 | 不可能 | 可能 | MySQL 的默认级别,确保同一事务中多次读取数据一致 |
| 串行化(`Serializable`) | 不可能 | 不可能 | 不可能 | 最高的隔离级别,完全串行执行 |
## 优势与应用场景
不同的隔离级别适用于不同的应用场景:
### 读未提交(`Read Uncommitted`)
**优势**:
- 性能最好,没有锁开销
- 并发度最高
**应用场景**:
- 对数据一致性要求极低的场景
- 只读统计分析,允许少量不精确数据
- 性能要求极高且能容忍数据不一致的场景
### 读已提交(`Read Committed`)
**优势**:
- 避免脏读问题
- 较好的并发性能
- 大多数数据库的默认级别
**应用场景**:
- 需要避免脏读但可以接受不可重复读的场景
- 读多写少的应用
- 对数据一致性有一定要求但也关注性能的场景
### 可重复读(`Repeatable Read`)
**优势**:
- 避免脏读和不可重复读问题
- MySQL 的默认隔离级别
- 在同一事务中保证数据读取一致性
**应用场景**:
- 需要在事务内多次读取并保证数据一致的场景
- 财务类应用
- 对数据一致性要求较高的业务逻辑
### 串行化(`Serializable`)
**优势**:
- 完全避免并发问题
- 提供最高级别的数据一致性保证
**应用场景**:
- 对数据一致性要求极高的关键业务
- 银行转账等金融核心交易
- 数据完整性比性能更重要的场景
## 在 GoFrame 中使用事务隔离级别
`GoFrame ORM` 通过 `TxOptions` 结构体支持设置事务隔离级别。以下是使用不同隔离级别的示例:
### 基本用法
```go
// 使用特定隔离级别开始事务
tx, err := db.BeginWithOptions(ctx, gdb.TxOptions{
Isolation: sql.LevelReadCommitted,
})
if err != nil {
// 处理错误
return
}
// 确保事务最终会提交或回滚
defer func() {
if err := recover(); err != nil {
tx.Rollback()
panic(err)
}
}()
// 执行事务操作
_, err = tx.Insert(ctx, "user", g.Map{"name": "john"})
// SQL: INSERT INTO user(name) VALUES('john')
// 提交事务
if err = tx.Commit(); err != nil {
// 处理错误
tx.Rollback()
return
}
```
### 使用不同隔离级别的示例
#### 读已提交(`Read Committed`)
```go
// 使用读已提交隔离级别
tx, err := db.BeginWithOptions(ctx, gdb.TxOptions{
Isolation: sql.LevelReadCommitted,
})
if err != nil {
return
}
defer tx.Rollback()
// 查询用户余额
balance, err := tx.Model("account").Where("user_id", 1).Value("balance")
// SQL: SELECT balance FROM account WHERE user_id=1
// 更新余额
_, err = tx.Model("account").Where("user_id", 1).Update(g.Map{"balance": balance.Int() + 100})
// SQL: UPDATE account SET balance=balance+100 WHERE user_id=1
if err = tx.Commit(); err != nil {
return
}
```
#### 可重复读(`Repeatable Read`)
```go
// 使用可重复读隔离级别(MySQL默认)
tx, err := db.BeginWithOptions(ctx, gdb.TxOptions{
Isolation: sql.LevelRepeatableRead,
})
if err != nil {
return
}
defer tx.Rollback()
// 第一次查询用户数据
user1, err := tx.Model("user").Where("id", 1).One()
// SQL: SELECT * FROM user WHERE id=1
// ... 其他操作
// 第二次查询相同用户数据,在可重复读级别下,即使其他事务修改了该数据,这里读取的结果仍与第一次相同
user2, err := tx.Model("user").Where("id", 1).One()
// SQL: SELECT * FROM user WHERE id=1
if err = tx.Commit(); err != nil {
return
}
```
#### 串行化(`Serializable`)
```go
// 使用串行化隔离级别
tx, err := db.BeginWithOptions(ctx, gdb.TxOptions{
Isolation: sql.LevelSerializable,
})
if err != nil {
return
}
defer tx.Rollback()
// 查询满足条件的所有用户
users, err := tx.Model("user").Where("status", "active").All()
// SQL: SELECT * FROM user WHERE status='active'
// 在串行化级别下,其他事务无法同时修改或添加符合此条件的记录
// 这确保了在事务执行期间,查询结果的一致性
if err = tx.Commit(); err != nil {
return
}
```
### 使用事务闭包函数
`GoFrame ORM`还提供了便捷的事务闭包函数,可以同时指定隔离级别:
```go
err := db.TransactionWithOptions(ctx, gdb.TxOptions{
Isolation: sql.LevelSerializable,
}, func(ctx context.Context, tx gdb.TX) error {
// 执行事务操作
_, err := tx.Insert(ctx, "user", g.Map{"name": "john"})
// SQL: INSERT INTO user(name) VALUES('john')
if err != nil {
// 返回错误会自动回滚事务
return err
}
// 返回nil会自动提交事务
return nil
})
```
## 注意事项
1. 隔离级别越高,并发性能越低,请根据业务需求选择合适的隔离级别。
2. 不同数据库对隔离级别的实现可能有所不同,请参考具体数据库的文档。
3. `MySQL`的默认隔离级别是「可重复读」(`Repeatable Read`)。
4. 在使用高隔离级别时,需要注意死锁的可能性。
5. 如果不指定隔离级别,`GoFrame ORM`将使用数据库的默认隔离级别。
## 实际应用示例
### 电商订单处理
```go
// 使用可重复读隔离级别处理订单
err := db.TransactionWithOptions(ctx, gdb.TxOptions{
Isolation: sql.LevelRepeatableRead,
}, func(ctx context.Context, tx gdb.TX) error {
// 1. 查询商品库存
stock, err := tx.Model("product").Where("id", productId).Value("stock")
// SQL: SELECT stock FROM product WHERE id=?
if err != nil {
return err
}
// 2. 检查库存是否充足
if stock.Int() < quantity {
return errors.New("库存不足")
}
// 3. 创建订单
orderId, err := tx.Model("order").InsertAndGetId(g.Map{
"user_id": userId,
"product_id": productId,
"quantity": quantity,
"status": "pending",
})
// SQL: INSERT INTO order(user_id,product_id,quantity,status) VALUES(?,?,?,'pending')
if err != nil {
return err
}
// 4. 减少库存
_, err = tx.Model("product").Where("id", productId).
Update(g.Map{"stock": gdb.Raw("stock - ?", quantity)})
// SQL: UPDATE product SET stock=stock-? WHERE id=?
return err
})
```
### 银行转账
```go
// 使用串行化隔离级别处理转账
err := db.TransactionWithOptions(ctx, gdb.TxOptions{
Isolation: sql.LevelSerializable,
}, func(ctx context.Context, tx gdb.TX) error {
// 1. 检查源账户余额
sourceBalance, err := tx.Model("account").Where("id", sourceAccountId).Value("balance")
// SQL: SELECT balance FROM account WHERE id=?
if err != nil {
return err
}
if sourceBalance.Float64() < amount {
return errors.New("余额不足")
}
// 2. 减少源账户余额
_, err = tx.Model("account").Where("id", sourceAccountId).
Update(g.Map{"balance": gdb.Raw("balance - ?", amount)})
// SQL: UPDATE account SET balance=balance-? WHERE id=?
if err != nil {
return err
}
// 3. 增加目标账户余额
_, err = tx.Model("account").Where("id", targetAccountId).
Update(g.Map{"balance": gdb.Raw("balance + ?", amount)})
// SQL: UPDATE account SET balance=balance+? WHERE id=?
// 4. 记录交易历史
_, err = tx.Model("transaction").Insert(g.Map{
"source_account_id": sourceAccountId,
"target_account_id": targetAccountId,
"amount": amount,
"time": gtime.Now(),
})
// SQL: INSERT INTO transaction(source_account_id,target_account_id,amount,time) VALUES(?,?,?,?)
return err
})
```
## 总结
事务隔离级别是数据库并发控制的重要机制,`GoFrame ORM` 通过支持标准的`SQL`隔离级别,使开发者能够根据应用需求灵活选择合适的隔离级别,在数据一致性和性能之间取得平衡。
在实际应用中,建议根据业务场景的具体需求选择合适的隔离级别:
- 对于一般的 Web 应用,「读已提交」通常是一个好的选择
- 对于财务和订单处理,「可重复读」提供了更好的数据一致性保证
- 对于极其重要的金融交易,可以考虑使用「串行化」,但需要注意性能影响
# ORM事务处理-只读模式
## 基本介绍
事务只读模式(`ReadOnly`)是`GoFrame ORM`提供的一种事务处理特性,它允许开发者将事务标记为只读状态。
在只读事务中,数据库会禁止任何修改操作(如`INSERT`、`UPDATE`、`DELETE`等),只允许执行查询操作。
`GoFrame ORM`通过`TxOptions`结构体的`ReadOnly`字段支持此功能。
## 只读模式的优点
1. **提高安全性**:防止意外的数据修改操作,确保事务中只能进行读取操作
2. **优化性能**:数据库可以针对只读事务进行特定优化,如避免获取写锁,减少锁竞争
3. **减少资源消耗**:只读事务通常消耗更少的数据库资源,特别是在高并发场景下
4. **提高并发度**:多个只读事务可以并行执行而不会相互阻塞
5. **支持负载均衡**:只读事务可以被路由到只读副本,减轻主库压力
## 应用场景
1. **数据报表生成**:生成复杂报表时需要查询大量数据但不需要修改数据
2. **数据导出功能**:导出数据时确保数据一致性,同时防止意外修改
3. **复杂数据查询**:需要在事务中执行多个相关查询以确保数据一致性
4. **只读API接口**:为只提供数据查询的API接口提供事务保障
5. **数据分析操作**:执行复杂的数据分析查询时确保不会修改数据
6. **审计和日志查询**:查看历史记录和审计日志时防止意外修改
## 基本使用
`GoFrame ORM`提供了简单易用的API来创建只读事务,通过`BeginWithOptions`或`TransactionWithOptions`方法并设置`ReadOnly: true`即可启用只读模式。
### 使用`BeginWithOptions`
```go
// 开始一个只读事务
tx, err := db.BeginWithOptions(ctx, gdb.TxOptions{
ReadOnly: true,
})
// SQL: BEGIN
if err != nil {
// 处理错误
return
}
// 确保事务最终会提交或回滚
defer func() {
if err := recover(); err != nil {
tx.Rollback()
panic(err)
}
}()
// 执行查询操作
users, err := tx.Model("user").All()
// SQL: SELECT * FROM user
// 尝试执行修改操作会失败
_, err = tx.Update("user", g.Map{"status": "active"}, "id=1")
// 在只读事务中,这将返回错误
// 提交事务
if err = tx.Commit(); err != nil {
tx.Rollback()
return
}
```
### 使用`TransactionWithOptions`
```go
// 使用事务闭包函数
err := db.TransactionWithOptions(ctx, gdb.TxOptions{
ReadOnly: true,
}, func(ctx context.Context, tx gdb.TX) error {
// 执行查询操作
users, err := tx.Model("user").Where("status", "active").All()
// SQL: SELECT * FROM user WHERE status='active'
if err != nil {
return err
}
// 处理查询结果
fmt.Println("Active users count:", len(users))
// 尝试执行修改操作会失败并自动回滚事务
_, err = tx.Model("user").Data(g.Map{"login_time": gtime.Now()}).Where("id", 1).Update()
// 在只读事务中,这将返回错误
return err // 返回nil会自动提交事务,返回错误会自动回滚
})
```
## MySQL中的只读事务
在`MySQL`中,只读事务通过以下`SQL`语句实现:
```sql
-- 开始一个只读事务
SET TRANSACTION READ ONLY;
START TRANSACTION;
-- 执行查询操作
SELECT * FROM user;
-- 尝试执行修改操作会失败
UPDATE user SET status = 'active' WHERE id = 1;
-- 错误: ERROR 1792 (25006): Cannot execute statement in a READ ONLY transaction.
-- 提交事务
COMMIT;
```
## 注意事项
1. 只读事务中尝试执行数据修改操作会导致错误,事务将被回滚
2. 不同数据库对只读事务的支持和实现可能有所不同
3. 只读事务主要用于提高安全性和性能,而不是强制的访问控制机制
4. 在高并发系统中,合理使用只读事务可以显著提高性能
5. 只读事务通常可以被路由到数据库的只读副本上执行
## 实际应用示例
### 报表生成
```go
// 使用只读事务生成销售报表
err := db.TransactionWithOptions(ctx, gdb.TxOptions{
ReadOnly: true,
}, func(ctx context.Context, tx gdb.TX) error {
// 1. 查询总销售额
totalSales, err := tx.Model("order").Where("status", "completed").Sum("amount")
// SQL: SELECT SUM(amount) FROM order WHERE status='completed'
if err != nil {
return err
}
// 2. 查询各产品销售情况
productSales, err := tx.Model("order_item")
.Fields("product_id, SUM(quantity) as total_quantity, SUM(amount) as total_amount")
.Group("product_id")
.Order("total_amount DESC")
.All()
// SQL: SELECT product_id, SUM(quantity) as total_quantity, SUM(amount) as total_amount
// FROM order_item GROUP BY product_id ORDER BY total_amount DESC
if err != nil {
return err
}
// 3. 查询热门客户
topCustomers, err := tx.Model("order")
.Fields("customer_id, COUNT(*) as order_count, SUM(amount) as total_spent")
.Group("customer_id")
.Order("total_spent DESC")
.Limit(10)
.All()
// SQL: SELECT customer_id, COUNT(*) as order_count, SUM(amount) as total_spent
// FROM order GROUP BY customer_id ORDER BY total_spent DESC LIMIT 10
// 生成报表逻辑...
return nil // 返回nil自动提交事务
})
```
### 数据导出
```go
// 使用只读事务导出用户数据
err := db.TransactionWithOptions(ctx, gdb.TxOptions{
ReadOnly: true,
}, func(ctx context.Context, tx gdb.TX) error {
// 查询用户基本信息
users, err := tx.Model("user").All()
// SQL: SELECT * FROM user
if err != nil {
return err
}
// 对每个用户查询其详细信息
for _, user := range users {
userId := user["id"]
// 查询用户地址
address, err := tx.Model("user_address").Where("user_id", userId).One()
// SQL: SELECT * FROM user_address WHERE user_id=?
if err != nil {
return err
}
// 查询用户订单
orders, err := tx.Model("order").Where("user_id", userId).All()
// SQL: SELECT * FROM order WHERE user_id=?
if err != nil {
return err
}
// 导出数据处理逻辑...
}
return nil
})
```
## 事务只读模式与`DryRun`特性的区别
`GoFrame ORM`除了提供事务的只读模式外,还提供了`DryRun`特性(相关章节 [ORM高级特性-空跑特性](./gdb-senior-dry-run.md))。
虽然这两个特性都能防止数据修改,但它们在实现机制、应用场景和影响范围上有明显区别:
### 实现机制
1. **只读事务(ReadOnly)**:
- 在数据库层面实现,通过设置事务的只读属性
- 由数据库引擎强制执行,尝试修改操作会返回数据库错误
- 通过`TxOptions`结构体的`ReadOnly`字段启用,仅影响当前事务
2. **DryRun特性**:
- 在`ORM`层面实现,是一种模拟执行机制
- 由`GoFrame`框架拦截并忽略写操作,不会实际发送到数据库
- 通过配置文件、命令行参数或环境变量全局启用,影响所有数据库操作
### 应用场景
1. **只读事务适用于**:
- 生产环境中需要保证数据一致性的只读操作
- 需要利用数据库只读副本的场景
- 需要在事务中执行多个查询以保证数据一致性
- 对性能有较高要求的高并发只读操作
2. **DryRun特性适用于**:
- 开发和测试环境中验证SQL语句的正确性
- 调试程序逻辑而不想实际修改数据库
- 演示系统功能而不改变数据状态
- 批处理脚本的预检查
### 影响范围
1. **只读事务**:
- 仅影响特定的事务实例
- 可以与普通事务并存于同一应用中
- 需要显式地在每个事务中启用
2. **DryRun特性**:
- 全局影响所有数据库操作(包括非事务操作)
- 通常作为应用级别的配置选项
- 一旦启用,所有写操作都将被忽略
### 错误处理
1. **只读事务**:
- 尝试执行写操作会立即返回数据库错误
- 错误会导致事务回滚
- 可以在代码中捕获和处理这些错误
2. **DryRun特性**:
- 写操作不会返回错误,而是被静默忽略
- 应用程序会认为写操作成功执行
- 通常与日志和调试模式配合使用以查看SQL语句
### 配置方式
1. **只读事务**:
```go
// 通过代码配置,仅影响当前事务
tx, err := db.BeginWithOptions(ctx, gdb.TxOptions{
ReadOnly: true,
})
```
2. **DryRun特性**:
```yaml
# 通过配置文件启用
database:
default:
- link: "mysql:root:password@tcp(127.0.0.1:3306)/db"
debug: true
dryRun: true
```
```bash
# 或通过命令行参数启用
$ ./app --gf.gdb.dryrun=true
# 或通过环境变量启用
$ export GF_GDB_DRYRUN=true
$ ./app
```
### 如何选择
- 如果需要在**生产环境**中保证数据一致性并提高性能,选择**只读事务**
- 如果是在**开发/测试环境**中调试SQL语句或验证程序逻辑,选择`DryRun`特性
- 如果需要**细粒度控制**哪些操作是只读的,选择**只读事务**
- 如果需要**全局禁止**所有写操作(如演示环境),选择`DryRun`特性
两者可以结合使用,例如在开发环境中使用`DryRun`进行全局调试,同时在特定场景中使用只读事务来测试事务隔离和错误处理逻辑。
## 总结
事务只读模式是`GoFrame ORM`提供的一种重要特性,通过将事务标记为只读状态,可以提高数据库操作的安全性和性能。在只读事务中,数据库会禁止任何修改操作,只允许执行查询操作,这对于需要确保数据一致性但不需要修改数据的场景非常有用。
在实际应用中,建议在以下情况下考虑使用只读事务:
- 需要在事务中执行多个查询以确保数据一致性
- 需要防止意外的数据修改操作
- 高并发系统中的只读操作
- 需要利用数据库只读副本的场景
合理使用只读事务可以显著提高系统的安全性、性能和可扩展性。与`DryRun`特性相比,只读事务更适合生产环境中的实际业务需求,而`DryRun`则更适合开发和测试阶段的调试工作。
# ORM结果处理-结果类型
## 一、数据结构
查询结果的数据结构如下:
```go
type Value = *gvar.Var // 返回数据表记录值
type Record map[string]Value // 返回数据表记录键值对
type Result []Record // 返回数据表记录列表
```
1. `Value/Record/Result` 用于ORM操作的结果数据类型。
2. `Result` 表示 **数据表记录列表**, `Record` 表示 **一条数据表记录**, `Value` 表示记录中的 **一条键值数据**。
3. `Value` 是 `*gvar.Var` 类型的别名类型,是一个运行时泛型,以便支持数据表不同的字段类型,方便于后续的数据类型转换。
举个:
## 二、 `Record` 数据记录
接口文档: [https://pkg.go.dev/github.com/gogf/gf/v2/database/gdb](https://pkg.go.dev/github.com/gogf/gf/v2/database/gdb)
`gdb` 为数据表记录操作提供了极高的灵活性和简便性,除了支持以 `map` 的形式访问/操作数据表记录以外,也支持将数据表记录转换为 `struct` 进行处理。我们以下使用一个简单的示例来演示该特性。
首先,我们的用户表结构是这样的(简单设计的示例表):
```sql
CREATE TABLE `user` (
`uid` int(10) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(30) NOT NULL DEFAULT '' COMMENT '昵称',
`site` varchar(255) NOT NULL DEFAULT '' COMMENT '主页',
PRIMARY KEY (`uid`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
```
其次,我们的表数据如下:
```
uid name site
1 john https://goframe.org
```
最后,我们的示例程序如下:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
)
type User struct {
Uid int
Name string
}
func main() {
var (
user *User
ctx = gctx.New()
)
err := g.DB().Model("user").Where("uid", 1).Scan(&user)
if err != nil {
g.Log().Header(false).Fatal(ctx, err)
}
if user != nil {
g.Log().Header(false).Print(ctx, user)
}
}
```
执行后,输出结果为:
```json
{"Uid":1,"Name":"john"}
```
这里,我们自定义了一个 `struct`,里面只包含了 `Uid` 和 `Name` 属性,可以看到它的属性并不和数据表的字段一致,这也是 `ORM` 灵活的特性之一:支持指定属性获取。
通过 `gdb.Model.Scan` 方法可以将查询到的数据记录转换为 `struct` 对象或者 `struct` 对象数组。由于这里传递的参数为 `&user` 即 `**User` 类型,那么将会转换为一个 **结构体对象**,如果传递为 `[]*User` 类型的参数,将会转换为 **结构体数组** 结果,请查看后续示例。具体方法介绍请查看链式操作章节。
**属性字段映射规则:**
需要注意的是, `map` 中的键名为 `uid,name,site`,而 `struct` 中的属性为 `Uid,Name`,那么他们之间是如何执行映射的呢?主要是以下几点简单的规则:
1. `struct` 中需要匹配的属性必须为 `公开属性`(首字母大写);
2. 记录结果中键名会自动按照 **`不区分大小写`** 且 **忽略 `-/_/空格` 符号** 的形式与 `struct` 属性进行匹配;
3. 如果匹配成功,那么将键值赋值给属性,如果无法匹配,那么忽略该键值;
以下是几个匹配的示例:
```html
记录键名 struct属性 是否匹配
name Name match
Email Email match
nickname NickName match
NICKNAME NickName match
Nick-Name NickName match
nick_name NickName match
nick_name Nick_Name match
NickName Nick_Name match
Nick-Name Nick_Name match
```
**提示**
由于数据库结果集转 `struct` 的底层是依靠 `gconv.Struct` 方法实现的,因此如果想要实现 **自定义的属性转换**,以及更详细的映射规则说明,请参考 [类型转换-Struct转换](./gconv-struct.md) 章节。
## 三、 `Result` 数据集合
`Result/Record` 数据类型根据数据结果集操作的需要,往往需要根据记录中 **特定的字段** 作为键名进行数据检索,因此它包含多个用于转换 `Map/List` 的方法,同时也包含了常用数据结构 `JSON/XML` 的转换方法。
接口文档: [https://pkg.go.dev/github.com/gogf/gf/v2/database/gdb](https://pkg.go.dev/github.com/gogf/gf/v2/database/gdb)
由于方法比较简单,这里便不再举例说明。需要注意的是两个方法 `Record.Map` 及 `Result.List`,这两个方法也是使用比较频繁的方法,用以将 `ORM` 查询结果信息转换为可做展示的数据类型。由于结果集字段值底层为 `[]byte` 类型,虽然使用了新的 `Value` 类型做了封装,并且也提供了数十种常见的类型转换方法(具体请阅读 [泛型类型-gvar](../components/container-gvar.md) 章节),但是大多数时候需要直接将结果 `Result` 或者 `Record` 直接作为 `json` 或者 `xml` 数据结构返回,就需要做转换才行。
使用示例:
```go
package main
import (
"database/sql"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
)
type User struct {
Uid int
Name string
Site string
}
func main() {
var (
user []*User
ctx = gctx.New()
)
err := g.DB().Model("user").Where("uid", 1).Scan(&user)
if err != nil && err != sql.ErrNoRows {
g.Log().Header(false).Fatal(ctx, err)
}
if user != nil {
g.Log().Header(false).Print(ctx, user)
}
}
```
执行后,输出结果为:
```json
[{"Uid":1,"Name":"john","Site":"https://goframe.org"}]
```
# ORM结果处理-为空判断
使用 `GoFrame ORM` 对返回结果为空判断非常简便,大部分场景下直接判断返回的数据是否为 `nil` 或者长度为 `0`,或者使用 `IsEmpty/IsNil` 方法。
## 一、数据集合(多条)
```go
r, err := g.Model("order").Where("status", 1).All()
if err != nil {
return err
}
if len(r) == 0 {
// 结果为空
}
```
也可以使用 `IsEmpty` 方法:
```go
r, err := g.Model("order").Where("status", 1).All()
if err != nil {
return err
}
if r.IsEmpty() {
// 结果为空
}
```
## 二、数据记录(单条)
```go
r, err := g.Model("order").Where("status", 1).One()
if err != nil {
return err
}
if len(r) == 0 {
// 结果为空
}
```
也可以使用 `IsEmpty` 方法:
```go
r, err := g.Model("order").Where("status", 1).One()
if err != nil {
return err
}
if r.IsEmpty() {
// 结果为空
}
```
## 三、数据字段值
返回的是一个"泛型"变量,这个只能使用 `IsEmpty` 来判断是否为空了。
```go
r, err := g.Model("order").Where("status", 1).Value()
if err != nil {
return err
}
if r.IsEmpty() {
// 结果为空
}
```
## 四、字段值数组
查询返回字段值数组本身类型为 `[]gdb.Value` 类型,因此直接判断长度是否为 `0` 即可。
```go
// Array/FindArray
r, err := g.Model("order").Fields("id").Where("status", 1).Array()
if err != nil {
return err
}
if len(r) == 0 {
// 结果为空
}
```
## 五、 `Struct` 对象(注意)
关于 `Struct` 转换对象来说 **会有一点不一样**,我们直接看例子吧。
当传递的对象 **本身就是一个空指针时**,如果查询到数据,那么会在内部 **自动创建这个对象**;如果没有查询到数据,那么这个空指针仍旧是一个空指针,内部并不会做任何处理。
```go
var user *User
err := g.Model("order").Where("status", 1).Scan(&user)
if err != nil {
return err
}
if user == nil {
// 结果为空
}
```
当传递的对象 **本身已经是一个初始化的对象**,如果查询到数据,那么会在内部将数据赋值给这个对象; **如果没有查询到数据,那么此时就没办法将对象做 `nil` 判断空结果**。因此 `ORM` 会返回一个 `sql.ErrNoRows` 错误,提醒开发者没有查询到任何数据并且对象没有做任何赋值,对象的所有属性还是给定的初始化数值,以便开发者可以做进一步的空结果判断。
```go
var user = new(User)
err := g.Model("order").Where("status", 1).Scan(&user)
if err != nil && err != sql.ErrNoRows {
return err
}
if err == sql.ErrNoRows {
// 结果为空
}
```
**提示**
所以我们推荐开发者不要传递一个初始化过后的对象给 `ORM`,而是直接传递一个对象的指针的指针类型( `**struct` 类型), `ORM` 内部会根据查询结果智能地做自动初始化。
## 六、 `Struct` 数组
当传递的对象数组本身是一个空数组(长度为 `0`),如果查询到数据,那么会在内部自动赋值给数组;如果没有查询到数据,那么这个空数组仍旧是一个空数组,内部并不会做任何处理。
```go
var users []*User
err := g.Model("order").Where("status", 1).Scan(&users)
if err != nil {
return err
}
if len(users) == 0 {
// 结果为空
}
```
当传递的对象数组本身不是空数组,如果查询到数据,那么会在内部自动从索引 `0` 位置覆盖到数组上;如果没有查询到数据,那么此时就没办法将数组做长度为 `0` 判断空结果。因此 `ORM` 会返回一个 `sql.ErrNoRows` 错误,提醒开发者没有查询到任何数据并且数组没有做任何赋值,以便开发者可以做进一步的空结果判断。
```go
var users = make([]*User, 100)
err := g.Model("order").Where("status", 1).Scan(&users)
if err != nil {
return err
}
if err == sql.ErrNoRows {
// 结果为空
}
```
**注意**
由于 `struct` 转换利用了 `Golang` 反射特性,执行性能会有一定的损耗。如果涉及到大量查询结果数据的 `struct` 数组对象转换,并且需要提高转换性能,请参考自定义实现对应 `struct` 的 `UnmarshalValue` 方法:
[类型转换-UnmarshalValue](./gconv-unmarshal-value.md)
# ORM结果处理-空数组结构返回
## 痛点描述
经过前面的章节介绍,如果给定一个未初始化的数组(值为 `nil`),在 `ORM` 根据给定条件未查询到数据时,并不会自动初始化该数组。因此该未初始化的数组结果如果通过 `JSON` 进行编码后,会被转换为 `null` 值。
```go
package main
import (
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
"fmt"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
func main() {
type User struct {
Id uint64 // 主键
Passport string // 账号
Password string // 密码
NickName string // 昵称
CreatedAt *gtime.Time // 创建时间
UpdatedAt *gtime.Time // 更新时间
}
type Response struct {
Users []User
}
var res = &Response{}
err := g.Model("user").WhereGT("id", 10).Scan(&res.Users)
fmt.Println(err)
fmt.Println(gjson.MustEncodeString(res))
}
```
执行后,终端展示结果为:
```html
{"Users":null}
```
在大部分场景下, `ORM` 查询的数据需要渲染展示在浏览器页面上,也就意味着返回的数据需要给前端 `JS` 进行处理。为了对前端 `JS` 处理后端返回数据时更加友好,如果在后端查询不到数据时,期望返回一个空的数组结构,而不是返回一个 `null` 属性值。
## 改进方案
针对这种场景,可以给 `ORM` 的 `Scan` 方法一个初始化的空数组即可。当 `ORM` 查询不到数据时,该数组属性仍然是一个空数组,而不是 `nil`,返回 `JSON` 编码后也不会是 `null` 值。
```go
package main
import (
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
"fmt"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
func main() {
type User struct {
Id uint64 // 主键
Passport string // 账号
Password string // 密码
NickName string // 昵称
CreatedAt *gtime.Time // 创建时间
UpdatedAt *gtime.Time // 更新时间
}
type Response struct {
Users []User
}
var res = &Response{
Users: make([]User, 0),
}
err := g.Model("user").WhereGT("id", 10).Scan(&res.Users)
fmt.Println(err)
fmt.Println(gjson.MustEncodeString(res))
}
```
执行后,终端展示结果为:
```html
{"Users":[]}
```
# ORM分库分表
## 延伸阅读
- [电商系统案例,Go语言数据库分库分表实战](https://goframe.org/articles/go-ecommerce-database-sharding-millions)
# ORM分库分表-分表特性
## 基本介绍
`ORM`分表(`Table Sharding`)是`GoFrame ORM`提供的一种数据分片解决方案,允许开发者将一个逻辑表的数据分散存储到多个物理表中。分表是解决单表数据量过大问题的有效手段,通过将数据分散到不同的表中,可以显著提高查询性能和系统可扩展性。
## 分表的优点
1. **提高查询性能**:通过减少单表数据量,降低索引深度,提高查询效率
2. **优化写入性能**:分散写入压力,减少锁竞争,提高并发写入能力
3. **简化维护**:便于按分片进行数据备份、恢复和归档
4. **突破单表数据量限制**:规避数据库单表数据量上限的限制
5. **提高可用性**:单个分片故障不会影响整个系统
## 应用场景
1. **大规模用户系统**:按用户ID分表,应对海量用户数据
2. **订单系统**:按订单ID或时间分表,处理高并发订单写入和查询
3. **日志系统**:按时间分表,便于日志的存储和清理
4. **物联网数据**:按设备ID或时间分表,处理海量传感器数据
5. **社交媒体**:按用户ID分表,处理用户产生的大量社交数据
## 基本使用
`GoFrame ORM`提供了简单易用的分表API,通过`Sharding`和`ShardingValue`方法即可实现分表功能。
### 分表配置
```go
// 创建分表配置
shardingConfig := gdb.ShardingConfig{
Table: gdb.ShardingTableConfig{
Enable: true, // 启用分表
Prefix: "user_", // 分表前缀
Rule: &gdb.DefaultShardingRule{
TableCount: 4, // 分表数量
},
},
}
// 使用分表配置和分片值
model := db.Model("user").
Sharding(shardingConfig).
ShardingValue(10001) // 分片值,用于计算数据路由到哪个表,通常是主键ID值
```
### 默认分表规则
`GoFrame ORM`提供了默认的分表规则`DefaultShardingRule`,它基于分片值的哈希取模来确定表名:
```go
// 默认分表规则实现
func (r *DefaultShardingRule) TableName(ctx context.Context, config ShardingTableConfig, value any) (string, error) {
if r.TableCount == 0 {
return "", gerror.NewCode(
gcode.CodeInvalidParameter, "table count should not be 0 using DefaultShardingRule when table sharding enabled",
)
}
hashValue, err := getHashValue(value)
if err != nil {
return "", err
}
tableIndex := hashValue % uint64(r.TableCount)
return fmt.Sprintf("%s%d", config.Prefix, tableIndex), nil
}
```
## 完整示例
首先,以`MySQL`数据库为例,我们需要创建相应的数据库表结构。对于分表示例,我们需要创建多个具有相同结构的物理表:
```sql
CREATE TABLE `user_0` (
`id` int(11) NOT NULL,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `user_1` (
`id` int(11) NOT NULL,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `user_2` (
`id` int(11) NOT NULL,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `user_3` (
`id` int(11) NOT NULL,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### 基本CRUD操作
以下示例展示了如何在分表环境下进行基本的`CRUD`操作:
```go
package main
import (
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// User 用户结构体
type User struct {
Id int `json:"id"`
Name string `json:"name"`
}
func main() {
// 创建分表配置
shardingConfig := gdb.ShardingConfig{
Table: gdb.ShardingTableConfig{
Enable: true, // 启用分表
Prefix: "user_", // 分表前缀
Rule: &gdb.DefaultShardingRule{
TableCount: 4, // 分表数量
},
},
}
// 准备测试数据
user := User{
Id: 1,
Name: "John",
}
// 创建分表模型
db := g.DB()
db.SetDebug(true)
model := db.Model("user").
Sharding(shardingConfig).
ShardingValue(user.Id) // 使用用户ID作为分片值
// 插入数据
_, err := model.Data(user).Insert()
if err != nil {
panic(err)
}
// INSERT INTO `user_1`(`id`,`name`) VALUES(1,'John')
// 查询数据
var result User
err = model.Where("id", user.Id).Scan(&result)
if err != nil {
panic(err)
}
// SELECT * FROM `user_1` WHERE `id`=1 LIMIT 1
g.DumpJson(result)
// 更新数据
_, err = model.Data(g.Map{"name": "John Doe"}).
Where("id", user.Id).
Update()
if err != nil {
panic(err)
}
// UPDATE `user_1` SET `name`='John Doe' WHERE `id`=1
// 删除数据
_, err = model.Where("id", user.Id).Delete()
if err != nil {
panic(err)
}
// DELETE FROM `user_1` WHERE `id`=1
}
```
### 自定义分表规则
可以通过实现`ShardingRule`接口来自定义分表规则:
```go
package main
import (
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
"context"
"fmt"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// TimeShardingRule 按时间分表的规则
type TimeShardingRule struct{}
// TableName 实现按月份分表的规则
func (r *TimeShardingRule) TableName(ctx context.Context, config gdb.ShardingTableConfig, value any) (string, error) {
// 将分片值转换为时间
t, ok := value.(time.Time)
if !ok {
return "", fmt.Errorf("sharding value must be time.Time for TimeShardingRule")
}
// 按年月生成表名,例如: log_202501
return fmt.Sprintf("%s%04d%02d", config.Prefix, t.Year(), t.Month()), nil
}
// SchemaName 实现分库规则接口
func (r *TimeShardingRule) SchemaName(ctx context.Context, config gdb.ShardingSchemaConfig, value any) (string, error) {
// 这里不实现分库,返回空字符串
return "", nil
}
func main() {
// 创建按时间分表的配置
shardingConfig := gdb.ShardingConfig{
Table: gdb.ShardingTableConfig{
Enable: true, // 启用分表
Prefix: "log_", // 分表前缀
Rule: &TimeShardingRule{}, // 自定义分表规则
},
}
// 当前时间作为分片值
now := gtime.Now().Time
// 创建分表模型
db := g.DB()
db.SetDebug(true)
model := db.Model("log").
Sharding(shardingConfig).
ShardingValue(now) // 使用时间作为分片值
// 插入日志数据
_, err := model.Data(g.Map{
"content": "系统启动",
"level": "info",
"time": now,
}).Insert()
if err != nil {
panic(err)
}
// INSERT INTO `log_202503`(`content`,`level`,`time`) VALUES('系统启动','info','2025-03-13 12:02:54')
}
```
## 注意事项
1. **分片值必须提供**:启用分表后,必须通过`ShardingValue`方法提供分片值,否则会返回错误
2. **分表规则必须设置**:启用分表后,必须设置分表规则,否则会返回错误
3. **跨分片查询限制**:默认情况下,查询只会路由到单个分片表,如需跨分片查询,需要自行实现
4. **事务限制**:分表事务只能在同一个数据库内进行,跨库分表事务需要使用分布式事务
5. **表结构一致性**:所有分片表的结构应保持一致
## 高级用法
### 使用`Hook`实现动态分表
`GoFrame ORM`提供了强大的`Hook`机制,可以用于实现更灵活的分表策略:
```go
// 使用Hook实现动态分表
model := db.Model("log").Hook(gdb.HookHandler{
Select: func(ctx context.Context, in *gdb.HookSelectInput) (result gdb.Result, err error) {
// 根据查询条件动态确定表名
in.Table = "log_" + determineTableSuffix(in.Model.GetWhere())
return in.Next(ctx)
},
Insert: func(ctx context.Context, in *gdb.HookInsertInput) (result sql.Result, err error) {
// 根据插入数据动态确定表名
in.Table = "log_" + determineTableSuffixFromData(in.Data)
return in.Next(ctx)
},
// 其他Hook方法...
})
```
关于`Hook`特性介绍,请参考章节:[ORM链式操作-Hook特性](./gdb-chaining-hook.md)
## 总结
`GoFrame ORM`的分表特性提供了简单而强大的数据分片解决方案,适用于处理大规模数据的场景。通过合理配置分表策略,可以显著提高系统的性能和可扩展性。无论是使用内置的默认分表规则,还是实现自定义分表逻辑,`GoFrame`都提供了灵活的`API`支持。
在实际应用中,建议根据业务特点选择合适的分片键和分表策略,并注意处理好跨分片查询和事务等问题。
# ORM分库分表-分库特性
## 基本介绍
`ORM`分库(`Schema Sharding`)是`GoFrame ORM`提供的一种数据分片解决方案,允许开发者将一个逻辑表的数据分散存储到多个物理数据库中。分库是实现数据库水平扩展的重要手段,通过将数据分散到不同的数据库节点,可以显著提高系统的处理能力和可扩展性。
## 分库的优点
1. **突破单机性能瓶颈**:通过将数据分散到多个数据库节点,突破单个数据库服务器的性能限制
2. **提高并发处理能力**:分散读写压力到多个数据库节点,提高整体并发处理能力
3. **提高系统可用性**:单个数据库节点故障不会影响整个系统,提高系统的可用性
4. **地理分布优势**:可以根据地理位置分布数据,降低访问延迟,提高用户体验
5. **灵活的数据管理**:可以根据业务需求将不同类型的数据分布到不同的数据库节点
## 应用场景
1. **大规模用户系统**:按用户ID或地理位置分库,应对全球用户分布
2. **电商平台**:按商户或产品类别分库,处理大规模商品数据
3. **金融系统**:按账户类型或业务线分库,实现业务隔离和高可用性
4. **多租户SaaS平台**:按租户分库,确保数据隔离和安全
5. **大数据分析系统**:按时间或数据类型分库,优化大规模数据处理
## 基本使用
`GoFrame ORM`提供了简单易用的分库API,通过`Sharding`和`ShardingValue`方法即可实现分库功能。
### 分库配置
```go
// 创建分库配置
shardingConfig := gdb.ShardingConfig{
Schema: gdb.ShardingSchemaConfig{
Enable: true, // 启用分库
Prefix: "db_", // 分库前缀
Rule: &gdb.DefaultShardingRule{
SchemaCount: 2, // 分库数量
},
},
}
// 使用分库配置和分片值
model := db.Model("user").
Sharding(shardingConfig).
ShardingValue(10001) // 分片值,用于计算数据路由到哪个数据库
```
### 默认分库规则
`GoFrame ORM`提供了默认的分库规则`DefaultShardingRule`,它基于分片值的哈希取模来确定数据库名:
```go
// 默认分库规则实现
func (r *DefaultShardingRule) SchemaName(ctx context.Context, config ShardingSchemaConfig, value any) (string, error) {
if r.SchemaCount == 0 {
return "", gerror.NewCode(
gcode.CodeInvalidParameter, "schema count should not be 0 using DefaultShardingRule when schema sharding enabled",
)
}
hashValue, err := getHashValue(value)
if err != nil {
return "", err
}
nodeIndex := hashValue % uint64(r.SchemaCount)
return fmt.Sprintf("%s%d", config.Prefix, nodeIndex), nil
}
```
## 完整示例
首先,以`MySQL`数据库为例,我们需要创建多个数据库和相应的表结构。对于分库示例,我们需要创建多个具有相同表结构的数据库:
```sql
-- 创建分库所需的数据库
CREATE DATABASE IF NOT EXISTS `db_0`;
CREATE DATABASE IF NOT EXISTS `db_1`;
-- 在每个数据库中创建相同结构的表
-- 在 db_0 中创建表
USE `db_0`;
CREATE TABLE `user` (
`id` int(11) NOT NULL,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 在 db_1 中创建表
USE `db_1`;
CREATE TABLE `user` (
`id` int(11) NOT NULL,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### 基本CRUD操作
以下示例展示了如何在分库环境下进行基本的`CRUD`操作:
```go
package main
import (
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// User 用户结构体
type User struct {
Id int `json:"id"`
Name string `json:"name"`
}
func main() {
// 创建分库配置
shardingConfig := gdb.ShardingConfig{
Schema: gdb.ShardingSchemaConfig{
Enable: true, // 启用分库
Prefix: "db_", // 分库前缀
Rule: &gdb.DefaultShardingRule{
SchemaCount: 2, // 分库数量
},
},
}
// 准备测试数据
user := User{
Id: 1,
Name: "John",
}
// 创建分库模型
db := g.DB()
db.SetDebug(true)
model := db.Model("user").
Sharding(shardingConfig).
ShardingValue(user.Id) // 使用用户ID作为分片值
// 插入数据
_, err := model.Data(user).Insert()
if err != nil {
panic(err)
}
// INSERT INTO `user`(`id`,`name`) VALUES(1,'John')
// 注意:实际操作的是 db_1 数据库中的 user 表
// 查询数据
var result User
err = model.Where("id", user.Id).Scan(&result)
if err != nil {
panic(err)
}
// SELECT * FROM `user` WHERE `id`=1 LIMIT 1
// 注意:实际查询的是 db_1 数据库中的 user 表
g.DumpJson(result)
// 更新数据
_, err = model.Data(g.Map{"name": "John Doe"}).
Where("id", user.Id).
Update()
if err != nil {
panic(err)
}
// UPDATE `user` SET `name`='John Doe' WHERE `id`=1
// 注意:实际更新的是 db_1 数据库中的 user 表
// 删除数据
_, err = model.Where("id", user.Id).Delete()
if err != nil {
panic(err)
}
// DELETE FROM `user` WHERE `id`=1
// 注意:实际删除的是 db_1 数据库中的 user 表
}
```
### 自定义分库规则
可以通过实现`ShardingRule`接口来自定义分库规则:
```go
package main
import (
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
"context"
"fmt"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// RegionShardingRule 按地区分库的规则
type RegionShardingRule struct {
// 地区到数据库的映射
RegionMapping map[string]string
}
// SchemaName 实现按地区分库的规则
func (r *RegionShardingRule) SchemaName(ctx context.Context, config gdb.ShardingSchemaConfig, value any) (string, error) {
// 将分片值转换为地区信息
region, ok := value.(string)
if !ok {
return "", fmt.Errorf("sharding value must be string for RegionShardingRule")
}
// 获取地区对应的数据库名
if dbName, exists := r.RegionMapping[region]; exists {
return dbName, nil
}
// 如果没有找到对应的地区,使用默认数据库
return config.Prefix + "default", nil
}
// TableName 实现分表规则接口
func (r *RegionShardingRule) TableName(ctx context.Context, config gdb.ShardingTableConfig, value any) (string, error) {
// 这里不实现分表,返回空字符串
return "", nil
}
func main() {
// 创建地区到数据库的映射
regionMapping := map[string]string{
"east": "db_east",
"west": "db_west",
"north": "db_north",
"south": "db_south",
}
// 创建按地区分库的配置
shardingConfig := gdb.ShardingConfig{
Schema: gdb.ShardingSchemaConfig{
Enable: true, // 启用分库
Prefix: "db_", // 分库前缀
Rule: &RegionShardingRule{RegionMapping: regionMapping}, // 自定义分库规则
},
}
// 分片值为用户所在地区
region := "east"
// 创建分库模型
db := g.DB()
db.SetDebug(true)
model := g.DB().Model("user").
Sharding(shardingConfig).
ShardingValue(region) // 使用地区作为分片值
// 插入用户数据
_, err := model.Data(g.Map{
"id": 1001,
"name": "John",
"region": region,
}).Insert()
if err != nil {
panic(err)
}
// INSERT INTO `user`(`id`,`name`,`region`) VALUES(1001,'John','east')
// 注意:实际操作的是 db_east 数据库中的 user 表
}
```
### 结合分库分表
`GoFrame ORM`支持同时配置分库和分表,实现更精细的数据分片:
```go
// 同时配置分库和分表
shardingConfig := gdb.ShardingConfig{
Schema: gdb.ShardingSchemaConfig{
Enable: true, // 启用分库
Prefix: "db_", // 分库前缀
Rule: &gdb.DefaultShardingRule{
SchemaCount: 2, // 分库数量
},
},
Table: gdb.ShardingTableConfig{
Enable: true, // 启用分表
Prefix: "user_", // 分表前缀
Rule: &gdb.DefaultShardingRule{
TableCount: 4, // 分表数量
},
},
}
// 使用分库分表配置
model := g.DB().Model("user").
Sharding(shardingConfig).
ShardingValue(10001) // 同一个分片值用于计算分库和分表
```
## 使用`Hook`实现动态分库
`GoFrame ORM`提供了强大的`Hook`机制,可以用于实现更灵活的分库策略:
```go
// 使用Hook实现动态分库
model := db.Model("user").Hook(gdb.HookHandler{
Select: func(ctx context.Context, in *gdb.HookSelectInput) (result gdb.Result, err error) {
// 根据查询条件动态确定数据库
in.Schema = determineSchema(in.Model.GetWhere())
return in.Next(ctx)
},
Insert: func(ctx context.Context, in *gdb.HookInsertInput) (result sql.Result, err error) {
// 根据插入数据动态确定数据库
in.Schema = determineSchemaFromData(in.Data)
return in.Next(ctx)
},
// 其他Hook方法...
})
```
关于`Hook`特性介绍,请参考章节:[ORM链式操作-Hook特性](./gdb-chaining-hook.md)
## 注意事项
1. **分片值必须提供**:启用分库后,必须通过`ShardingValue`方法提供分片值,否则会返回错误
2. **分库规则必须设置**:启用分库后,必须设置分库规则,否则会返回错误
3. **跨库事务限制**:默认情况下,事务无法跨多个数据库,需要使用分布式事务来解决
4. **数据库配置要求**:所有分库节点需要在配置文件中正确配置,确保可以连接
5. **跨库查询限制**:默认情况下,查询只会路由到单个分库,如需跨库查询,需要自行实现
## 总结
`GoFrame ORM`的分库特性提供了灵活而强大的数据库水平分片解决方案,适用于需要大规模数据处理能力的场景。通过合理配置分库策略,可以显著提高系统的性能和可扩展性。无论是使用内置的默认分库规则,还是实现自定义分库逻辑,`GoFrame`都提供了灵活的`API`支持。
在实际应用中,建议根据业务特点和数据分布情况选择合适的分库策略,并注意处理好跨库查询和事务等问题。
# ORM高级特性-RawSQL
由于 `ORM` 的安全性保障,所有输入的参数在底层都将使用预处理模式执行,防止常见的 `SQL` 注入风险。在某一些场景中,我们期望在生成执行的SQL语句中嵌入自定义的SQL语句,那么我们可以使用 `ORM` 的 `RawSQL` 特性,通过 `gdb.Raw` 类型来实现。我们来看几个示例。
## 在 `Insert` 中使用 `RawSQL`
`gdb.Raw` 是字符串类型,该类型的参数将会直接作为 `SQL` 片段嵌入到提交到底层的 `SQL` 语句中,不会被自动转换为字符串参数类型、也不会被当做预处理参数。例如:
```go
// INSERT INTO `user`(`id`,`passport`,`password`,`nickname`,`create_time`) VALUES('id+2','john','123456','now()')
g.Model("user").Data(g.Map{
"id": "id+2",
"passport": "john",
"password": "123456",
"nickname": "JohnGuo",
"create_time": "now()",
}).Insert()
// 执行报错:Error Code: 1136. Column count doesn't match value count at row 1
```
使用 `gdb.Raw` 改造后:
```go
// INSERT INTO `user`(`id`,`passport`,`password`,`nickname`,`create_time`) VALUES(id+2,'john','123456',now())
g.Model("user").Data(g.Map{
"id": gdb.Raw("id+2"),
"passport": "john",
"password": "123456",
"nickname": "JohnGuo",
"create_time": gdb.Raw("now()"),
}).Insert()
```
## 在 `Update` 中使用 `RawSQL`
```go
// UPDATE `user` SET login_count='login_count+1',update_time='now()' WHERE id=1
g.Model("user").Data(g.Map{
"login_count": "login_count+1",
"update_time": "now()",
}).Where("id", 1).Update()
// 执行报错:Error Code: 1136. Column count doesn't match value count at row 1
```
使用 `gdb.Raw` 改造后:
```go
// UPDATE `user` SET login_count=login_count+1,update_time=now() WHERE id=1
g.Model("user").Data(g.Map{
"login_count": gdb.Raw("login_count+1"),
"update_time": gdb.Raw("now()"),
}).Where("id", 1).Update()
```
## 在 `Select` 中使用 `RawSQL`
时间函数 `now()` 将会被转换为字符串作为 `SQL` 参数执行:
```go
// SELECT * FROM `user` WHERE `created_at`<'now()'
g.Model("user").WhereLT("created_at", "now()").All()
```
使用 `gdb.Raw` 改造后:
```go
// SELECT * FROM `user` WHERE `created_at` `Value` 类型是 `*gvar.Var` 类型的别名,因此可以使用 `gvar.Var` 数据类型的所有转换方法,具体请查看 [泛型类型-gvar](../components/container-gvar.md) 章节
使用示例:
首先,数据表定义如下:
```
# 商品表
CREATE TABLE `goods` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`title` varchar(300) NOT NULL COMMENT '商品名称',
`price` decimal(10,2) NOT NULL COMMENT '商品价格',
...
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
```
其次,数据表中的数据如下:
```
id title price
1 IPhoneX 5999.99
```
最后,示例代码如下:
```go
if r, err := g.Model("goods").FindOne(1); err == nil {
fmt.Printf("goods id: %d\n", r["id"].Int())
fmt.Printf("goods title: %s\n", r["title"].String())
fmt.Printf("goods proce: %.2f\n", r["price"].Float32())
} else {
g.Log().Error(gctx.New(), err)
}
```
执行后,输出结果为:
```
goods id: 1
goods title: IPhoneX
goods proce: 5999.99
```
# ORM高级特性-连接池状态
我们可以通过 `DB.Stats` 方法获取 `orm` 对象的连接池状态。我们来看个示例:
```go
package main
import (
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
)
func main() {
var ctx = gctx.New()
db, err := gdb.New(gdb.ConfigNode{
Link: "mysql:root:12345678@tcp(127.0.0.1:3306)/test",
})
if err != nil {
g.Log().Fatal(ctx, err)
}
err = db.PingMaster()
if err != nil {
g.Log().Fatal(ctx, err)
}
stats := db.Stats(ctx)
g.Dump(stats)
}
```
执行后,终端输出如下,可以看到每个链接的数据库节点以及对应的连接池状态信息。
```html
[
{
node: {
Host: "127.0.0.1",
Port: "3306",
User: "root",
Pass: "12345678",
Name: "test",
Type: "mysql",
Link: "",
Extra: "",
Role: "",
Debug: false,
Prefix: "",
DryRun: false,
Weight: 0,
Charset: "utf8",
Protocol: "tcp",
Timezone: "",
Namespace: "",
MaxIdleConnCount: 0,
MaxOpenConnCount: 0,
MaxConnLifeTime: 0,
QueryTimeout: 0,
ExecTimeout: 0,
TranTimeout: 0,
PrepareTimeout: 0,
CreatedAt: "",
UpdatedAt: "",
DeletedAt: "",
TimeMaintainDisabled: false,
},
stats: {
MaxOpenConnections: 0,
OpenConnections: 1,
InUse: 0,
Idle: 1,
WaitCount: 0,
WaitDuration: 0,
MaxIdleClosed: 0,
MaxIdleTimeClosed: 0,
MaxLifetimeClosed: 0,
},
},
]
```
# ORM高级特性-内嵌结构支持
`GoFrame ORM` 组件针对于 `struct` 内嵌结构提供了良好的支持,包括参数传递、结果处理。例如:
```go
type Base struct {
Uid int `orm:"uid"`
CreateAt *gtime.Time `orm:"create_at"`
UpdateAt *gtime.Time `orm:"update_at"`
DeleteAt *gtime.Time `orm:"delete_at"`
}
type User struct {
Base
Passport string `orm:"passport"`
Password string `orm:"password"`
Nickname string `orm:"nickname"`
}
```
并且,无论多少层级的 `struct` 嵌套, `ORM` 的参数传递和结果处理都支持。
# ORM高级特性-自定义类型转换
当我们需要将查询的结果转换到自定义的类型中,无论是作为直接转换的类型还是作为 `struct` 的属性,都可以通过实现特定的接口来实现。详细介绍请参考 [类型转换-UnmarshalValue](./gconv-unmarshal-value.md) 章节。
# ORM接口开发
`gdb` 模块使用了非常灵活且扩展性强的接口设计,接口设计允许开发者可以非常方便地自定义实现和替换接口定义中的任何方法。
## `DB` 接口
接口文档: [https://pkg.go.dev/github.com/gogf/gf/v2/database/gdb#DB](https://pkg.go.dev/github.com/gogf/gf/v2/database/gdb#DB)
`DB` 接口是数据库操作的核心接口,也是我们通过 `ORM` 操作数据库时最常用的接口,这里主要对接口的几个重要方法做说明:
1. `Open` 方法用于创建特定的数据库连接对象,返回的是标准库的 `*sql.DB` 通用数据库对象。
2. `Do*` 系列方法的第一个参数 `link` 为 `Link` 接口对象,该对象在 `master-slave` 模式下可能是一个主节点对象,也可能是从节点对象,因此如果在继承的驱动对象实现中使用该 `link` 参数时,注意当前的运行模式。 `slave` 节点在大部分的数据库主从模式中往往是不可写的。
3. `HandleSqlBeforeCommit` 方法将会在每一条 `SQL` 提交给数据库服务端执行时被调用做一些提交前的回调处理。
4. 其他接口方法详见接口文档或者源码文件。
## `DB` 接口关系
`GoFrame ORM Relations`
## `Driver` 接口
接口文档: [https://pkg.go.dev/github.com/gogf/gf/v2/database/gdb#Driver](https://pkg.go.dev/github.com/gogf/gf/v2/database/gdb#Driver)
开发者自定义的驱动需要实现以下接口:
```go
// Driver is the interface for integrating sql drivers into package gdb.
type Driver interface {
// New creates and returns a database object for specified database server.
New(core *Core, node *ConfigNode) (DB, error)
}
```
其中的 `New` 方法用于根据 `Core` 数据库基础对象以及 `ConfigNode` 配置对象创建驱动对应的数据库操作对象,需要注意的是,返回的数据库对象需要实现 `DB` 接口。而数据库基础对象 `Core` 已经实现了 `DB` 接口,因此开发者只需要」继承」 `Core` 对象,然后根据需要覆盖对应的接口实现方法即可。
# ORM接口开发-回调处理
## 基本介绍
自定义回调处理是最常见的接口开发实现,开发中只需要对接口中的部分方法进行 **替换与修改**,在驱动默认实现逻辑中注入自定义逻辑。参考接口关系图( [ORM接口开发](./gdb-interface.md))我们可以知道,所有的 `SQL` 语句执行必定会通过 `DoQuery` 或者 `DoExec` 或者 `DoFilter` 接口,根据需求在自定义的驱动中 **实现并覆盖** 相关接口方法实现所需功能即可。
其中,最常见的使用场景是在 `ORM` 底层实现对 `SQL` 的 **日志记录或者鉴权等统一判断操作**。
## 使用示例
我们来看一个自定义回调处理的示例,现需要将所有执行的 `SQL` 语句记录到 `monitor` 表中,以方便于进行 `SQL` 审计。因此通过自定义 `Driver` 然后覆盖 `ORM` 的底层接口方法来实现是最简单的。为简化示例编写,以下代码实现了一个自定义的 `MySQL` 驱动,该驱动继承于 `drivers` 下 `mysql` 模块内已经实现的 `Driver`。
```go
package driver
import (
"context"
"github.com/gogf/gf/contrib/drivers/mysql/v2"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/os/gtime"
)
// MyDriver is a custom database driver, which is used for testing only.
// For simplifying the unit testing case purpose, MyDriver struct inherits the mysql driver
// gdb.Driver and overwrites its functions DoQuery and DoExec.
// So if there's any sql execution, it goes through MyDriver.DoQuery/MyDriver.DoExec firstly
// and then gdb.Driver.DoQuery/gdb.Driver.DoExec.
// You can call it sql "HOOK" or "HiJack" as your will.
type MyDriver struct {
*mysql.Driver
}
var (
// customDriverName is my driver name, which is used for registering.
customDriverName = "MyDriver"
)
func init() {
// It here registers my custom driver in package initialization function "init".
// You can later use this type in the database configuration.
if err := gdb.Register(customDriverName, &MyDriver{}); err != nil {
panic(err)
}
}
// New creates and returns a database object for mysql.
// It implements the interface of gdb.Driver for extra database driver installation.
func (d *MyDriver) New(core *gdb.Core, node *gdb.ConfigNode) (gdb.DB, error) {
return &MyDriver{
&mysql.Driver{
Core: core,
},
}, nil
}
// DoCommit commits current sql and arguments to underlying sql driver.
func (d *MyDriver) DoCommit(ctx context.Context, in gdb.DoCommitInput) (out gdb.DoCommitOutput, err error) {
tsMilliStart := gtime.TimestampMilli()
out, err = d.Core.DoCommit(ctx, in)
tsMilliFinished := gtime.TimestampMilli()
_, _ = in.Link.ExecContext(ctx,
"INSERT INTO `monitor`(`sql`,`cost`,`time`,`error`) VALUES(?,?,?,?)",
gdb.FormatSqlWithArgs(in.Sql, in.Args),
tsMilliFinished-tsMilliStart,
gtime.Now(),
err,
)
return
}
```
我们看到,这里在包初始化方法 `init` 中使用了 `gdb.Register("MyDriver", &MyDriver{})` 来注册了了一个自定义名称的驱动。我们也可以通过 `gdb.Register("mysql", &MyDriver{})` 来覆盖已有的框架 `mysql` 驱动为自己的驱动。
**提示**
驱动名称 `mysql` 为框架默认的 `DriverMysql` 驱动的名称。
由于这里我们使用了一个新的驱动名称 `MyDriver`,因此在 `gdb` 配置中的 `type` 数据库类型时,需要填写该驱动名称。以下是一个使用配置的示例:
```yaml
database:
default:
- link: "MyDriver:root:12345678@tcp(127.0.0.1:3306)/user"
```
## 注意事项
在接口方法实现中,需要使用接口的 `Link` 输入对象参数来操作数据库,如果使用 `g.DB` 方法获取数据库对象来操作可能会引起死锁问题。
# ORM接口开发-驱动开发
框架数据库组件的驱动意义在于,数据库上层业务使用的各种方法操作不用变化,只需要修改配置中的数据库类型即可切换支持到新的数据库。
我们可以通过数据库组件的接口设计实现:新增框架默认不支持的第三方数据库驱动、对已有支持的驱动进行定制化修改等。驱动的开发并不是完整地开发一类数据库的协议实现代码,而是使用第三方已有的数据库驱动,通过实现框架数据库组件的接口,将该第三方数据库驱动对接到框架数据库组件上来,保证上层操作的一致。
## 驱动注册
之前我们有提到 `Driver` 的驱动接口,在实现该接口之后,我们可以通过以下方法注册自定义驱动到 `gdb` 模块:
```go
// Register registers custom database driver to gdb.
func Register(name string, driver Driver) error
```
其中的驱动名称 `name` 可以是已有的驱动名称,例如 `mysql`, `mssql`, `pgsql` 等等,当出现同名的驱动注册时,新的驱动将会覆盖老的驱动。
## 驱动实现
开发一个自定义的驱动并注册到 `gdb` 模块中非常简单,可以参考 `gdb` 模块源码中已对接的数据库类型代码示例: [https://github.com/gogf/gf/tree/master/contrib/drivers](https://github.com/gogf/gf/tree/master/contrib/drivers)
需要说明的是,最常见的驱动开发或者修改方式是直接继承于现有 `*Core` 类型,因为在 `Driver` 接口中会传递该类型的对象,例如:
```go
// DriverMysql is the driver for mysql database.
type DriverMysql struct {
*Core
}
// New creates and returns a database object for mysql.
// It implements the interface of gdb.Driver for extra database driver installation.
func (d *DriverMysql) New(core *Core, node *ConfigNode) (DB, error) {
return &DriverMysql{
Core: core,
}, nil
}
```
## 注意事项
一个新的驱动至少应该实现以下接口方法:
| 接口方法 | 说明 |
| --- | --- |
| `Open` | 用于创建数据库连接。 |
| `GetChars` | 用于获取该数据库的安全/转义符号。 |
| `Tables` | 返回当前/指定数据库的数据表列表。 |
| `TableFields` | 返回指定数据表的字段列表信息。 |
# ORM最佳实践
数据库操作往往是业务项目中最复杂的部分, `GoFrame` 的数据库组件其实提供了非常强大和灵活的操作方式,我们在这里提供了一些最佳实践的案例,方便大家参考学习。
# 利用指针属性和do对象实现灵活的修改接口
大家都知道框架自带的开发工具可以生成 `do` 对象代码,该 `do` 对象主要用于查询、修改、写入等操作时对操作字段的自动 `nil` 过滤。
今天教给大家一个新的玩法,通过指针结合 `do` 对象快速实现灵活、便捷的修改操作 `API` 实现。
## 数据结构
以下是我们使用的用户表数据结构:
```sql
CREATE TABLE `user`(
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`passport` varchar(32) NOT NULL COMMENT '账号',
`password` varchar(32) NOT NULL COMMENT '密码',
`nickname` varchar(32) NOT NULL COMMENT '昵称',
`status` varchar(32) NOT NULL COMMENT '状态',
`brief` varchar(512) NOT NULL COMMENT '备注信息',
`create_at` datetime DEFAULT NULL COMMENT '创建时间',
`update_at` datetime DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_passport` (`passport`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
```
其中的用户状态,我们使用了单独的类型定义,用以实现枚举值:
```go
type Status string
const (
StatusEnabled Status = "enabled"
StatusDisabled Status = "disabled"
)
```
通过 `gf gen dao` 命令,我们自动生成的 `do` 对象如下:
```go
type User struct {
g.Meta `table:"user" orm:"do:true"`
Id interface{}
Passport interface{}
Password interface{}
Nickname interface{}
Status interface{}
Brief interface{}
CreatedAt interface{}
UpdatedAt interface{}
}
```
## 请求API定义
我们来实现一个用户信息修改的 `API` 接口,这是一个运维管理接口,可以通过用户账号名称来修改用户信息。该 `API` 的定义如下:
```go
type UpdateReq struct {
g.Meta `path:"/user/{Id}" method:"post" summary:"修改用户信息"`
Passport string `v:"required" dc:"用户账号"`
Password *string `dc:"修改用户密码"`
Nickname *string `dc:"修改用户昵称"`
Status *Status `dc:"修改用户状态"`
Brief *string `dc:"修改用户描述"`
}
```
其中,用户的可修改信息为密码、昵称、状态和描述,可能同时修改一项或者多项。这里使用了 **指针类型** 的属性参数, **用于实现:当传递该参数时执行修改,不传递时不修改。**
## 业务逻辑实现
为了简化实例,我们这里直接在控制器中将指针参数传递给 `do` 对象。我们知道当调用端没有传递该参数时,该参数为 `nil`,那么传递给 `do` 对象的字段时,仍然是 `nil`,这个时候执行数据库更新操作时, `do` 对象中的 `nil` 字段将会被自动过滤掉。
```go
func (c *Controller) Update(ctx context.Context, req *v1.UpdateReq) (res *v1.UpdateRes, err error) {
_, err = dao.User.Ctx(ctx).Data(do.User{
Password: req.Password,
Nickname: req.Nickname,
Status: req.Status,
Brief: req.Brief,
}).Where(do.User{
Passport: req.Passport,
}).Update()
return
}
```
# 复杂类型尽量使用json存储,便于Scan到对象时自动化转换,避免自定义解析
举一个。假如我们需要实现产品售卖规格列表,其中包含可选择的分片数量、分片容量以及副本数量,如下图(非现网代码,仅供示例学习):
我们的表设计如下:
```sql
CREATE TABLE `sell_spec` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`product` varchar(45) NOT NULL COMMENT '产品名称',
`resources` json NOT NULL COMMENT '资源规格(cpu:memory),例如:["0:0.25", "0:1", "1:2"]',
`disk_min` int(10) DEFAULT NULL COMMENT '磁盘最小容量',
`disk_max` int(10) DEFAULT NULL COMMENT '磁盘最大容量',
`disk_step` int(10) DEFAULT NULL COMMENT '磁盘递增大小',
`shards` json NOT NULL COMMENT '分片规格,例如:[1,3,5,8,12,16,24,32,40,48,64,80,96,128]',
`replicas` json NOT NULL COMMENT '副本规格,例如:[1,2,3,4,5,6,7,8,9,12]',
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='售卖规格配置';
```
其中的 `resources, shards, replicas` 我们定义为 `json` 格式,目的是可以存储自定义的资源、分片、副本规格列表(非顺序性)。那么我们的 `go struct` 定义如下:
```go
// SellSpec 是通过GoFrame工具自动生成的数据结构,由工具维护。
type SellSpec struct {
Id uint `description:"主键"`
Product string `description:"产品名称"`
Resources string `description:"资源规格(cpu:memory),例如:[\"0:0.25\", \"0:1\", \"1:2\"]"`
DiskMin int `description:"磁盘最小容量"`
DiskMax int `description:"磁盘最大容量"`
DiskStep int `description:"磁盘递增大小"`
Shards string `description:"分片规格,例如:[1,3,5,8,12,16,24,32,40,48,64,80,96,128]"`
Replicas string `description:"副本规格,例如:[1,2,3,4,5,6,7,8,9,12]"`
CreatedAt *gtime.Time `description:"创建时间"`
UpdatedAt *gtime.Time `description:"更新时间"`
}
// SellSpecItem 是扩展entity的自定义数据结构,
// 其中部分字段Resources/Shards/Replicas被覆盖为了数组类型,方便ORM操作时自动进行类型转换。
type SellSpecItem struct {
entity.SellSpec
Resources []string `dc:"资源规格"`
Shards []int `dc:"分片规格"`
Replicas []int `dc:"副本规格"`
}
```
那么在程序中我们可以这么来写入和查询数据记录。
数据写入:
```go
_, err = dao.SellSpec.Ctx(ctx).Data(v1.SellSpecItem{
SellSpec: entity.SellSpec{
Product: "redis",
DiskMin: 50,
DiskMax: 1000,
DiskStep: 10,
},
Resources: []string{"1:2", "2:4", "4:8"},
Shards: []int{1, 3, 5, 8, 12, 16, 24, 32, 40, 48, 64, 80, 96, 128},
Replicas: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 12},
}).Insert()
```
数据查询, `ORM` 组件将会自动地将数据表中的记录转换为 `go struct` 对应的数组类型属性:
```go
var items []v1.SellSpecItem
err = dao.SellSpec.Ctx(ctx).Scan(&items)
```
# 查询时避免返回对象初始化及sql.ErrNoRows判断
## 查询时避免返回对象初始化及 `sql.ErrNoRows` 判断
执行`SQL`查询时,请避免提前将查询结果初始化,以避免结构对象默认值的影响,避免创建不必要的对象内存。通过返回对象指针 `nil` 判断避免 `sql.ErrNoRows` 使用,降低代码对 `error` 处理的复杂度、统一项目中对空查询结果处理逻辑。
一个反面例子:
```go
func (s *Service) GetOne(ctx context.Context, id uint64) (out *entity.ResourceTask, err error) {
out = new(model.TaskDetail)
err = dao.ResourceTask.Ctx(ctx).WherePri(id).Scan(out)
if err != nil {
if err == sql.ErrNoRows {
err = gerror.Newf(`record not found for "%d"`, id)
}
return
}
return
}
```
在该例子中,实际返回的 `out` 对象由于对象初始化的缘故有了默认值(无论SQL是否查询到数据),并且 `sql.ErrNoRows` 的判断增加了代码逻辑中对 `error` 处理的复杂度。
建议改进如下:
```go
func (s *Service) GetOne(ctx context.Context, id uint64) (out *entity.ResourceTask, err error) {
err = dao.ResourceTask.Ctx(ctx).WherePri(id).Scan(&out)
if err != nil {
return
}
if out == nil {
err = gerror.Newf(`record not found for "%d"`, id)
}
return
}
```
**注意**
注意代码中 `&out` 的使用。
更多的介绍请参考: [ORM结果处理-为空判断](./gdb-result-empty-check.md)
# I18N国际化
## 基本介绍
`GoFrame` 框架提供了常用的 `I18N` 国际化组件,由 `gi18n` 模块实现。
**使用方式:**
```go
import "github.com/gogf/gf/v2/i18n/gi18n"
```
**接口文档**:
[https://pkg.go.dev/github.com/gogf/gf/v2/i18n/gi18n](https://pkg.go.dev/github.com/gogf/gf/v2/i18n/gi18n)
# I18N国际化-配置管理
作为框架的核心组件之一,传承 `goframe` 一贯的便捷化设计思想, `I18N` 国际化组件的配置管理非常简便。
## 文件格式
`gi18n` 国际化组件支持框架通用的五种配置文件格式: `xml/ini/yaml/toml/json/properties`(更详细的文件支持列表具体以配置管理章节为主: [配置管理](./gcfg.md))。同样的,和配置管理模块一样,框架推荐使用 `toml` 文件格式。
## 读取路径
默认情况下 `gi18n` 会自动查找并读取当前项目源码根目录(或者当前 `PWD` 运行目录下)下的以下目录:
- `manifest/i18n`
- `i18n`
默认将查找到的目录作为国际化转译文件存储目录。开发者也可以通过 `SetPath` 方法自定义 `i18n` 文件的存储目录路径。
## 文件存储
在 `i18n` 目录下可以直接按照国际化名称命名的文件如: `en.toml`/ `ja.toml`/ `zh-CN.toml`;也可以给定国际化名称目录,目录下随意自定义配置文件,如: `en/editor.toml`/ `en/user.toml`、 `zh-CN/editor.toml`/ `zh-CN/user.toml`。使用纯文件管理或者增加一级目录管理都是可以的, `gi18n` 可以智能识别加载。
**提示**
国际化的文件/目录名称都是开发者可自行定义和维护的,该名称主要用于程序中设置和使用。建议按照标准化的国际化地区语言码进行命名,具体请参考WIKI: [https://zh.wikipedia.org/wiki/ISO\_639-1](https://zh.wikipedia.org/wiki/ISO_639-1)
例如,以下的 `i18n` 目录结构以及文件格式都是支持的。
**通过单独的 `i18n` 文件区分不同的语言**
```
└── i18n
├── en.toml
├── ja.toml
├── ru.toml
├── zh-CN.toml
└── zh-TW.toml
```
**通过不同的目录名称区分不同的语言**
```
└── i18n
├── en
│ ├── hello.toml
│ └── world.toml
├── ja
│ ├── hello.yaml
│ └── world.yaml
├── ru
│ ├── hello.ini
│ └── world.ini
├── zh-CN
│ ├── hello.json
│ └── world.json
└── zh-TW
├── hello.xml
└── world.xml
```
**不同语言可以存在不同文件格式**
```
└── i18n
├── en.toml
├── ja.yaml
├── ru.ini
├── zh-CN.json
└── zh-TW.xml
```
## 资源管理器
`gi18n` 默认支持资源管理器(详情请参考章节: [资源管理](./gres.md)),默认情况下会从 `gres` 配置管理器中检索 `manifest/i18n` 及 `i18n` 目录,或者开发者设置的 `i18n` 目录路径。
# I18N国际化-使用介绍
## 对象创建
### 单例对象
大多数场景下,我们推荐使用 `g.I18n` 单例对象,并可自定义配置不同的单例对象,但是需要注意的是,单例对象的配置修改是全局有效的。例如:
```go
g.I18n().T(context.TODO(), "{#hello} {#world}")
```
**提示**
在所有的转译方法中,第一个参数都要求输入 `Context` 上下文变量参数,用于上下文变量的传递、翻译语言的指定、后续的可扩展能力。该参数虽然直接传递 `nil` 也是可以的,但是为保证程序的严谨性,我们建议当不知道传递什么或者没有特殊要求的时候传递 `context.TODO()` 或者 `context.Background()` 来替代。
### 独立对象
其次,我们也可以模块化独立使用 `gi18n` 模块,通过 `gi18n.New()` 方法创建独立的 `i18n` 对象,然后开发者自行进行管理。例如:
```go
i18n := gi18n.New()
i18n.T(context.TODO(), "{#hello} {#world}")
```
## 语言设置
设置转译语言有两种方式,一种是通过 `SetLanguage` 方法设置当前 `I18N` 对象统一的转译语言,另一种是通过上下文设置当前执行转译的语言。
### `SetLanguage`
例如,我们通过 `g.I18n().SetLanguage("zh-CN")` 即可设置当前转译对象的转译语言,随后使用该对象都将使用 `zh-CN` 进行转译。需要注意的是,组件的配置方法往往都不是并发安全的,该方法也同样如此,需要在程序初始化时进行设置,随后不能在运行时进行更改。
### `WithLanguage`
`WithLanguage` 方法可以创建一个新的上下文变量,并临时设置当前转译的语言,由于该方法作用于 `Context` 上下文,因此是并发安全的,常用于运行时转译语言设置。我们来看一个例子:
```go
ctx := gi18n.WithLanguage(context.TODO(), "zh-CN")
i18n.Translate(ctx, `hello`)
```
其中 `WithLanguage` 方法定义如下:
```go
// WithLanguage append language setting to the context and returns a new context.
func WithLanguage(ctx context.Context, language string) context.Context
```
用于将转译语言设置到上下文变量中,并返回一个新的上下文变量,该变量可用于后续的转译方法。
## 常用方法
### `T` 方法
`T` 方法为 `Translate` 方法的别名,也是大多数时候我们推荐使用的方法名称。 `T` 方法可以给定关键字名称,也可以直接给定模板内容,将会被自动转译并返回转译后的字符串内容。
此外, `T` 方法可以通过第二个语言参数指定需要转译的目标语言名称,该名称需要和配置文件/路径中的名称一致,往往是标准化的国际化语言缩写名称例如: `en/ja/ru/zh-CN/zh-TW` 等等。否则,将会自动使用 `Manager` 转译对象中设置的语言进行转译。
方法定义:
```go
// T translates with configured language and returns the translated content.
func T(ctx context.Context, content string)
```
**关键字转译**
关键字的转译直接给 `T` 方法传递关键字即可,例如: `T(context.TODO(), "hello")`、 `T(context.TODO(), "world")`。 `I18N` 组件将会优先将给定的关键字进行转译,转译成后返回转译后的内容,否则直接展示原内容。
**模板内容转译**
`T` 方法支持模板内容转换,模板中的关键字默认使用 `{#` 和 `}` 标签进行包含,模板解析时将会自动替换该标签中的关键字内容。使用示例:
#### 1)目录结构
```bash
├── main.go
└── i18n
├── en.toml
├── ja.toml
├── ru.toml
└── zh-CN.toml
```
#### 2)转译文件
`ja.toml`
```bash
hello = "こんにちは"
world = "世界"
```
`ru.toml`
```bash
hello = "Привет"
world = "мир"
```
`zh-CN.toml`
```bash
hello = "你好"
world = "世界"
```
#### 3)示例代码
```go
package main
import (
"fmt"
"github.com/gogf/gf/v2/os/gctx"
"github.com/gogf/gf/v2/i18n/gi18n"
)
func main() {
var (
ctx = gctx.New()
i18n = gi18n.New()
)
i18n.SetLanguage("en")
fmt.Println(i18n.Translate(ctx, `hello`))
fmt.Println(i18n.Translate(ctx, `GF says: {#hello}{#world}!`))
i18n.SetLanguage("ja")
fmt.Println(i18n.Translate(ctx, `hello`))
fmt.Println(i18n.Translate(ctx, `GF says: {#hello}{#world}!`))
i18n.SetLanguage("ru")
fmt.Println(i18n.Translate(ctx, `hello`))
fmt.Println(i18n.Translate(ctx, `GF says: {#hello}{#world}!`))
ctx = gi18n.WithLanguage(ctx, "zh-CN")
fmt.Println(i18n.Translate(ctx, `hello`))
fmt.Println(i18n.Translate(ctx, `GF says: {#hello}{#world}!`))
}
```
执行后,终端输出为:
```bash
Hello
GF says: HelloWorld!
こんにちは
GF says: こんにちは世界!
Привет
GF says: Приветмир!
你好
GF says: 你好世界!
```
### `Tf` 方法
我们都知道,模板内容中也会存在一些变量,这些变量可以通过 `Tf` 方法进行转译。
`Tf` 为 `TranslateFormat` 的别名,该方法支持格式化转译内容,字符串格式化语法参考标准库 `fmt` 包的 `Sprintf` 方法。
**方法定义:**
```go
// Tf translates, formats and returns the with configured language
// and given .
func Tf(ctx context.Context, format string, values ...interface{}) string
```
我们来看一个简单的示例。
#### 1)目录结构
```bash
├── main.go
└── i18n
├── en.toml
└── zh-CN.toml
```
#### 2) **转译文件**
`en.toml`
```bash
OrderPaid = "You have successfully complete order #%d payment, paid amount: ¥%0.2f."
```
`zh-CN.toml `
```bash
OrderPaid = "您已成功完成订单号 #%d 支付,支付金额¥%.2f。"
```
#### 3) **示例代码**
```go
package main
import (
"fmt"
"github.com/gogf/gf/v2/i18n/gi18n"
"github.com/gogf/gf/v2/os/gctx"
)
func main() {
var (
ctx = gctx.New()
orderId = 865271654
orderAmount = 99.8
)
i18n := gi18n.New()
i18n.SetLanguage("en")
fmt.Println(i18n.Tf(ctx, `{#OrderPaid}`, orderId, orderAmount))
i18n.SetLanguage("zh-CN")
fmt.Println(i18n.Tf(ctx, `{#OrderPaid}`, orderId, orderAmount))
}
```
执行后,终端输出为:
```bash
You have successfully complete order #865271654 payment, paid amount: ¥99.80.
您已成功完成订单号 #865271654 支付,支付金额¥99.80。
```
**备注**
为方便演示,该示例中对支付金额的处理比较简单,在实际项目中往往需要在业务代码中对支付金额的单位按照区域做自动转换,再渲染 `i18n` 显示内容。
### 上下文设置转译语言
我们将上面的示例做些改动来演示。
#### 1)目录结构
```bash
├── main.go
└── i18n
├── en.toml
└── zh-CN.toml
```
#### 2)转译文件
`en.toml`
```bash
OrderPaid = "You have successfully complete order #%d payment, paid amount: ¥%0.2f."
```
`zh-CN.toml`
```bash
OrderPaid = "您已成功完成订单号 #%d 支付,支付金额¥%.2f。"
```
#### 3)示例代码
```go
package main
import (
"context"
"fmt"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/i18n/gi18n"
)
func main() {
var (
orderId = 865271654
orderAmount = 99.8
)
fmt.Println(g.I18n().Tf(
gi18n.WithLanguage(context.TODO(), `en`),
`{#OrderPaid}`, orderId, orderAmount,
))
fmt.Println(g.I18n().Tf(
gi18n.WithLanguage(context.TODO(), `zh-CN`),
`{#OrderPaid}`, orderId, orderAmount,
))
}
```
执行后,终端输出为:
```bash
You have successfully complete order #865271654 payment, paid amount: ¥99.80.
您已成功完成订单号 #865271654 支付,支付金额¥99.80。
```
**备注**
为方便演示,该示例中对支付金额的处理比较简单,在实际项目中往往需要在业务代码中对支付金额的单位按照区域做自动转换,再渲染 `i18n` 显示内容。
## `I18N` 与视图引擎
`gi18n` 默认已经集成到了 `GoFrame` 框架的视图引擎中,直接在模板文件/内容中使用 `gi18n` 的关键字标签即可。我们同样可以通过上下文变量的形式来设置当前请求的转译语言。
**提示**
此外,我们也可以通过设置模板变量 `I18nLanguage` 设置当前模板的解析语言,该变量可以控制不同的模板内容按照不同的国际化语言进行解析。
使用示例:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/i18n/gi18n"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.Group("/", func(group *ghttp.RouterGroup) {
group.Middleware(func(r *ghttp.Request) {
r.SetCtx(gi18n.WithLanguage(r.Context(), r.Get("lang", "zh-CN").String()))
r.Middleware.Next()
})
group.ALL("/", func(r *ghttp.Request) {
r.Response.WriteTplContent(`{#hello}{#world}!`)
})
})
s.SetPort(8199)
s.Run()
}
```
执行后,访问以下页面,将会输出:
1. [http://127.0.0.1:8199](http://127.0.0.1:8199/)
```bash
你好世界!
```
2. [http://127.0.0.1:8199/?lang=ja](http://127.0.0.1:8199/?lang=ja)
```bash
こんにちは世界!
```
# 资源管理
## 基本介绍
`资源管理` 是指可以将任意文件/目录打包为 `Golang` 源码文件,并且编译到可执行文件中,随着可执行文件发布。
资源文件在程序启动时将会自解压释放到内存中,供程序只读访问,可以将它当做基于内存的文件管理器。同时, `GoFrame` 的资源管理特性也支持将文件/目录打包为独立的二进制资源文件使用。由于资源文件在程序运行时是基于内存的文件操作,没有磁盘 `IO` 的开销,因此其文件操作效率非常高。
**使用方式**:
```go
import "github.com/gogf/gf/v2/os/gres"
```
**接口文档**:
[https://pkg.go.dev/github.com/gogf/gf/v2/os/gres](https://pkg.go.dev/github.com/gogf/gf/v2/os/gres)
## 组件特性
`gres` 资源管理组件具有以下显著特点:
1. 可将任意的文件/目录打包为 `Go` 文件,支持自定义加解密。
2. 打包的 `Go` 文件/资源文件自动压缩,常见 `css/js` 等文件可达到 `50~90%` 的压缩率。
3. 支持对打包为 `Go` 文件的资源内容,方便地导出到本地文件系统。
4. 资源管理器内容完全基于内存,并且内容只读,无法动态修改。
5. 资源管理器默认整合支持到了 `WebServer`、配置管理、模板引擎模块中。
6. 任意文件如网站静态文件、配置文件等可编译到二进制文件中,也可编译到发布的可执行文件中。
7. 开发者可只需编译发布一个可执行文件,除了方便了软件分发,也为保护软件知识产权内容提供了可能。
## 与 `embed.FS` 的比较
从 `Golang v1.16` 版本开始官方提供了静态文件嵌入的 `embed.FS` 特性,整体底层设计和 `gres` 组件类似,压缩比、执行效率也差不多,只是在使用设计和工程化管理上有比较大的差别。 `GoFrame` 资源管理组件的功能更加丰富,框架的核心组件已经完全对接了 `gres` 资源管理组件,并且在 `GoFrame` 提供的标准的工程化管理下能够做到对开发者无感知地使用资源管理特性,具体可以参考章节 [资源管理-最佳实践](./gres-practice.md)。
未来 `GoFrame` 基础框架底层不会考虑内置对 `embed.FS` 组件的支持, `embed.FS` 与 `gres` 组件可以独立使用互不影响。
# 资源管理-工具打包
我们可以通过`gf`命令行工具的`pack`命令实现对任意文件/目录的打包,关于`gf`命令行工具的安装和使用具体请查看[资源打包-pack](../cli/pack.md)章节。由于通过命令行工具进行打包比较简便,因此也是推荐的打包方式。
## `gf pack`生成`Go`文件
比较推荐的方式是将`Go`文件直接生成到`boot`启动目录,并设置生成`Go`文件的包名为`boot`,这样该资源文件将会被自动引入到项目中。我们将项目的`config`、`public`、`template`三个目录的文件打包到`Go`文件,打包命令为:
```
gf pack config,public,template packed/data.go -n packed
```
生成的`Go`文件内容类似于:
```go
package packed
import "github.com/gogf/gf/v2/os/gres"
func init() {
if err := gres.Add("H4sIAAAAAAAC/5y8c5Bl0Zbuu9O2bVaq0rZZ6Urbtm3bNnfatipto9"); err != nil {
panic(err)
}
}
```
可以看到,生成的`Go`文件中通过`gres.Add`方法将资源文件的二进制内容添加到默认的资源管理器中,该方法的参数是压缩过后的`BASE64`字符串,将会在程序启动的时候做解压并在内存中生成一个文件树对象,便于在运行时快速操作文件。
## 使用打包的`Go`文件
### 在`boot`包中优先引入`packed`资源包
在项目的`boot`程序启动设置包中自动引入`packed`资源包,并且应当作为第一个引入的包,以便于其他引入的包在初始化时(`init`方法中)便能使用到资源内容,例如像这样(`module`名称为`my-app`):
```go
import (
_ "my-app/packed"
// 其他包
)
```
这里建议引入`packed`包和其他包之间加入一个空行以作区分,特别是`Goland` IDE的`import`插件不会将引入包进行自动排序。
### 在`main`包中优先引入`boot`包
由于项目的`main`入口程序文件会引入`boot`包,并且应当作为第一个引入的包:
```go
import (
_ "my-app/boot"
// 其他包
)
```
这里建议引入`boot`包和其他包之间加入一个空行以作区分,特别是`Goland` IDE的`import`插件不会将引入包进行自动排序。
随后可以在项目的任何地方使用`gres`模块来访问打包的资源文件。
> 如果使用`GoFrame`推荐的项目目录结构(新建项目),在目录结构中会存在`boot`目录(对应包名也是`boot`),用于程序启动设置。因此如果将`Go`文件生成到`boot`目录下,那么将会被自动编译进可执行文件中。
## 打印资源管理文件列表
可以通过`gres.Dump()`方法打印出当前资源管理器中所有的文件列表,输出内容类似于:
```
2019-09-15T13:36:28+00:00 0.00B config
2019-07-27T07:26:12+00:00 1.34K config/config.toml
2019-09-15T13:36:28+00:00 0.00B public
2019-06-25T17:03:56+00:00 0.00B public/resource
2018-12-04T12:50:16+00:00 0.00B public/resource/css
2018-12-17T12:54:26+00:00 0.00B public/resource/css/document
2018-12-17T12:54:26+00:00 4.20K public/resource/css/document/style.css
2018-08-24T01:46:58+00:00 32.00B public/resource/css/index.css
2019-05-23T03:51:24+00:00 0.00B public/resource/image
2018-08-20T05:02:08+00:00 24.01K public/resource/image/cover.png
2019-05-23T03:51:24+00:00 4.19K public/resource/image/favicon.ico
2018-08-23T01:44:50+00:00 4.19K public/resource/image/gf.ico
2018-12-04T13:04:34+00:00 0.00B public/resource/js
2019-06-27T11:06:12+00:00 0.00B public/resource/js/document
2019-06-27T11:06:12+00:00 11.67K public/resource/js/document/index.js
2019-09-15T13:36:28+00:00 0.00B template
2019-02-02T09:08:56+00:00 0.00B template/document
2018-12-04T12:49:08+00:00 0.00B template/document/include
2018-12-04T12:49:08+00:00 329.00B template/document/include/404.html
2019-03-06T01:52:56+00:00 3.42K template/document/index.html
...
```
需要注意的是,在使用资源管理器中的资源文件时,需要严格按照其路径进行检索获取。
# 资源管理-方法打包
**提示**
本章节的示例演示打包/解包的同时也演示了对数据的加密/解密。大部分业务项目其实并不需要加密/解密操作,因此直接使用工具打包即可。
在上一章节我们介绍通过 `gf` 工具链进行文件/目录打包,并生成 `Go` 文件编译到可执行文件中。在本章节中,我们介绍资源管理中涉及到的方法,并通过一个打包/解包二进制资源文件的示例,介绍这些方法实现自定义的打包/解包功能。同时,我们也演示了如何通过自定义加解密来保护我们的资源文件内容。
**接口文档:**
[https://pkg.go.dev/github.com/gogf/gf/v2/os/gres](https://pkg.go.dev/github.com/gogf/gf/v2/os/gres)
**简要介绍:**
1. 通过 `Pack*`/ `Unpack*` 方法可以实现对任意文件的打包/解包功能,可以打包到二进制文件或者Go代码文件。
2. 资源管理由 `Resource` 对象实现,可实现对打包内容的添加,文件的检索查找,以及对目录的遍历等功能。
3. 资源文件由 `File` 对象实现,该文件对象和 `os.File` 文件对象类似,并且该对象实现了 `http.File` 接口。
4. `ScanDir` 用于针对于特定目录下的文件/目录检索,并且支持递归检索。
5. `ScanDirFile` 用于针对于特定目录下的文件检索,并且支持递归检索。
6. 通过 `Dump` 方法在终端打印出 `Resource` 资源对象所有的文件列表,资源管理器中的文件分隔符号统一为 `/`。
7. 此外, `gres` 资源管理模块提供了默认的 `Resource` 对象,并通过包方法提供了对该默认对象的操作。
## 自定义打包示例
我们将项目根目录下的`public`和`config`目录打包为`data.bin`二进制文件,并通过`gaes`加密算法对生成的二进制内容进行加密。
```go
package main
import (
"github.com/gogf/gf/v2/crypto/gaes"
"github.com/gogf/gf/v2/os/gfile"
"github.com/gogf/gf/v2/os/gres"
)
var (
CryptoKey = []byte("x76cgqt36i9c863bzmotuf8626dxiwu0")
)
func main() {
binContent, err := gres.Pack("public,config")
if err != nil {
panic(err)
}
binContent, err = gaes.Encrypt(binContent, CryptoKey)
if err != nil {
panic(err)
}
if err := gfile.PutBytes("data.bin", binContent); err != nil {
panic(err)
}
}
```
## 自定义解包示例
我们使用将刚才打包生成的`data.bin`,需要解密和解包两步操作。
```go
package main
import (
"github.com/gogf/gf/v2/crypto/gaes"
"github.com/gogf/gf/v2/os/gfile"
"github.com/gogf/gf/v2/os/gres"
)
var (
CryptoKey = []byte("x76cgqt36i9c863bzmotuf8626dxiwu0")
)
func main() {
binContent := gfile.GetBytes("data.bin")
binContent, err := gaes.Decrypt(binContent, CryptoKey)
if err != nil {
panic(err)
}
if err := gres.Add(binContent); err != nil {
panic(err)
}
gres.Dump()
}
```
最后,我们使用 `gres.Dump()` 打印出添加成功的文件列表查看资源文件是否添加成功。
# 资源管理-方法介绍
**提示**
以下常用方法列表,文档更新可能滞后于代码新特性,更多的方法及示例请参考代码文档: [https://pkg.go.dev/github.com/gogf/gf/v2/os/gres](https://pkg.go.dev/github.com/gogf/gf/v2/os/gres)
## Add
- 说明:`Add`将`content`解压并添加到默认资源对象中。`prefix`是非必要参数,表示存储到当前资源对象中的每个文件的前缀。
- 格式:
```go
func Add(content string, prefix ...string) error
```
- 示例:
```go
package main
import "github.com/gogf/gf/v2/os/gres"
func main() {
if err := gres.Add("H4sIAAAAAAAC/5TWV1TTydsH8B+EpoAginEBDVIEQYp0kUTpSjAJhCwsxSwlgEtRwVCkKwJSo9IWCCChSAkoTZoivfeF0JEEQpMgvQrvcc/x1X/Zi/9zMTfzzPfMc/OZQcFBLKcBDoAD6DTsRQE/FS9wDLC/64xzl3PHuXng3GRt3R5iTFgBJt6X83cexsJLqjT4mrd9w0fQ6OPh+uAVEbGTj1k4TK1bdBu6nrT2Sab5GyC7ao5yc613l3Ly29Yfbl6fSovmph6AbK6L+TBrhl03yQhO9w7hHUcIcWSAEJd/z6I0MtJHaaKscBAb6STB5unZoVdhgDIFqQaCzyEuLwhza87xLoKCvMJtmjmYMYn63Sjx/X3IuE66VuXJKcVB08OurMS3IObBV6ilar8HjNhjTAGCzixCKecGJI4HfU3NBhngff1mmvfuihCWagSzqpwiiWaLhGkW8ZUMtrUOV0nZWSiGUl8ADw2xVAvrVMqUy2Fdmmmo4nl5CLKwynbmI/hlC5qZSd5INHqjEshnzRH8Sy60vatCdrS2MpPTWqGMEQEfxExDuC0T6/aRAmQZc8NLZ/0reVtVbeKKswKpFFBw4NFq4J7WWY2yE9p6i/oLwNCCT2HkioAirGf/cEcnDGH+6SCBPXAaCsiyOHNOMrfy8MpzqcyqbmcIUh9NqM3d240wHkieqIsLVQvYCCrFHtxYwQsjoEQNW6VrAbc/d9estPr1Cspt8kdMzResBrnITixfkXV+dH6IvwaET9i1fEFbKv2SHOHvYj8S86uPXU3zWv6LnglKVf37PpeU0WIVfU/lJFEJS++4ZAPt2x7NU1Ci+DwkxJCcWDN2IknmvjBEBoro/g0UJw2rLTQ17sCt42H05ZJ1KHyoWakNRBajN4lEB7di4EUDnXH9yfEp40qd719eKB6BRsV2o63HvMbKgno9JmOKCeTCKsmcLKPqdg+xwplYW9fUSI1IqgeMEV1/710WoQUzxyPavxZc3se1g7g4r9fS8uaZYvb7d0QmP1Qgof6MyYqy8ojAytWI2CZkYyHDerexvnz/tHI3+bNekf4Ted1wm4CRRXXwPpNvaf/RKdEvJ0PVw1h4zbCdtZVH5/Y3nlk9ciD31aPS+v4wPytp7FgDk2t6hUhHQ3pUryFB05l6FWr8Y50oQUWOCdzmfMJaGR5zDKIizdPHTiZBLNr2KKSEiQdJ/XyDq790DfrfjEmWdw/fqATXWcJqy8GeaC4g3dVTUAxU0vjc1n3Rcwwvv+RPt2iKQStpOVGrPat55gbqaiaq8LgBNkRdj3T7nBUE1+OB2o7aCJNRjZP7QFukFSRqGONqF7atay17bt2payyZJ5YEWuBmVnMC7KqjKHXLUCj0y3ZTgsnp3ZyPMc7U7ArlJj5cckJfjJFD3PM6ZL7o7zuVxT5eZWttT8do+Lwy+5fzU63SjjL41Mk9fzWuui63P1nLWsLF19Y+NmEDbqWVPDu3UZuzgWUCgKMjFJydY3YuFzkBAgAHVgD4H4xwwnn/vxHOg6p8+cPMLL0pmJqUvac3hnndgyJUmS8oRH7R8yrZw65fJCKuLSzOHKykcdwIGoL0zB2pnefaBe+QnvRMsV25wEJjYk2l6E6yahC5s051Gl8yKcNwSdom5bmPkaKirYs9P0SSM9wXevPii0iGUv2PxJTM8bRPKsfL1lfvk1O6kqX0Exhj3mnyDZf1FMCqqZ9sinQaph574+UOFgU3H0uIwNuVcYbRZ2S06elo89E8pajoz3OZCPHLq9Ikd4M8GjMX0TsiFdasw8gQumbczMQLiVK64At+IEO9jrKshRT0FtiHS97tHnJXik2CrTTmKLzeEigV2yHBamdlVzt/Oy+uY388ttv0KnXsEJHtLZdrDZS7HDyhpvC3oMhdPgf7KClBBwlHi5HY11hxb1Zrd33LYKE7Dfbs6lCLeyliUt7Jt++vi4dq3VXJ8ctsGM6ylFyVlfsietoLY6+Axns8mbGLdyFY28cyncgHm4QKfxIK1Bo9gZeKdGrmdngtkd6WoJ8aDksDw1uwbCXZX1MkUC9JW8/G4UPxBltxXLbVrnrCjg/gOr1hlRKEnrZZY8adj5KcebmdIkRpi9C8+fKrIImcV8QL8UeTe8uGHNTbyMpmSpLG80n/xvUXIQ0Or7Qe7xMgVKZqecdUOPnrznT9G2FU7cAWnfc859l7PAm65sBNtB84QJeueNjRvxFoVKWQqZ6ngI9Qi2JFaj8t3lQvNxMprPhzl3oT+dd6pfXUHySH34lvNyUT6plmYFEMuU3DCIr628SKmRGY0OZqsdwfpdoybrHg2HEx3Y8Du2vsvB08nypJIn1/Xerq8icM1iM7HK+LRZ2KYeFnGLnBtk4UXIljwBTrPsNDUkWmyhtquc055AbRDTqOqtUfK6adlDC2AreiOSUzDUhW2bZK0vG/uLedasI62TKvzrOdCTqz3Fb2m0UUsRFNC7ORz56dyDxWk0agq/fh2QfcorOXDhTuqJHqUy7uVCe60IDcvI8ZJZinotfsta+Li0HUFgweztjFPM4It8cqO+H5pa2KwcTKc3Iho0R72ssr+fQnzhlWmPJhq6CYItPnckjp9nuHkSnRmzym6C2GN5I8I8WVbWKju75d+OvbFldDghD25N2GIFMzPee7ngK5wwbJ7cGaR8NJqa1r8gEM053Lh9BKii86rjgew7aqAoS/f33+2YdCXrDwEM7kkAzBhl4d3UX2HGTXkQ1iz1RpJLp2okp8fL9aJt9zWTnvAEncndWB9RusvDMeNiehnp/zO86fTyUk+BZkmS4r2gnR4zbV98UF8TlTjf1RuItnalBGiXnMoa0dydUvRMX9fV2cd212waFRny7EQ4L4/FqalQRFkzC5C+ocOv3L+FOSL97dku7TetZ7Ur3g1Ko2ZnvuAbPyEvmAme6vmBQ1QJq0v42aWHzE4u+9Z/RG69ZtoEs6h9IbrccQe+yK9kgl+yIyE7jPpk2wjL6/SVacpfnZKef+FdwlZMZZgDbBalpLcncNr++XOPVuXbbzHuqjLmpdmuad6MtiPLWK2fSvWSHSbuSedlgifhWqgA/FebVe9NopPn5Dd9xRPFBrlB5KL5q+SbnDSP0gQHe7r5+o2VHNaYUIaRHkWygeRyC72aU5sT6mRuUR0W/4eBw0ZQvzfDpz1/TuZ7T4zfLpSJfOz6GbMkrTitSSv4ws1kn55L1h4RyMbcUaB4p/VVU+4vmOKYfAsggbKwBEsP3AFACgAWFGP2PK+h3TvwUNyejBfjv7cwcKzsR8GvSD4p+R/kbx98oM+rb+4+ft32N+vt6/xoixAP/s+4+Y/z7L9yfiSPPBMeA/JmNl+7YLAkBAOwAA5L/b/y8AAP//A6tAlY0KAAA="); err != nil {
panic("add binary content to resource manager failed: " + err.Error())
}
}
```
## `Load`
- 说明:`Load`加载、解压并将路径为`path`的文件数据读取到默认资源对象中。`prefix`是非必要参数,表示存储到当前资源对象中的每个文件的前缀。
- 格式:
```go
func Load(path string, prefix ...string) error
```
- 示例:
```go
package main
import "github.com/gogf/gf/v2/os/gres"
func main() {
if err := gres.Load("../res/myfile"); err != nil {
panic("load binary content to resource manager failed: " + err.Error())
}
}
```
## `Get`
- 说明:`Get` 返回指定路径的文件。
- 格式:
```go
func Get(path string) *File
```
- 示例:
```go
package main
import (
"fmt"
"github.com/gogf/gf/v2/os/gctx"
"github.com/gogf/gf/v2/os/glog"
"github.com/gogf/gf/v2/os/gres"
)
func main() {
file := gres.Get("../res/myfile")
if file == nil {
glog.Error(gctx.New(), "get file failed!")
return
}
fmt.Println("Get File Name:", file.Name())
}
```
## `GetWithIndex`
- 说明: `GetWithIndex` 用给定路径path搜索文件,如果文件是目录,那么它会在这个目录下进行索引文件搜索。 `GetWithIndex` 通常用于http静态文件服务。
- 格式:
```go
func GetWithIndex(path string, indexFiles []string) *File
```
- 示例:
```go
package main
import (
"fmt"
"github.com/gogf/gf/v2/os/gctx"
"github.com/gogf/gf/v2/os/glog"
"github.com/gogf/gf/v2/os/gres"
)
func main() {
file := gres.GetWithIndex("../res", []string{"myfile", "myconfig"})
if file == nil {
glog.Error(gctx.New(), "get file failed!")
return
}
fmt.Println("Get File Name:", file.Name())
}
```
## `GetContent`
- 说明:`GetContent` 在默认资源对象中直接返回路径为 `path` 的内容。
- 格式:
```go
func GetContent(path string) []byte
```
- 示例:
```go
package main
import (
"fmt"
"github.com/gogf/gf/v2/os/gres"
)
func main() {
fileContent := gres.GetContent("../res/myfile")
fmt.Println("Get File Content:", fileContent)
}
```
## `Contains`
- 说明: `Contains` 检查路径为 `path` 的资源是否存在于默认资源对象中。
- 格式:
```go
func Contains(path string) bool
```
- 示例:
```go
package main
import (
"fmt"
"github.com/gogf/gf/v2/os/gres"
)
func main() {
if gres.Contains("../res/myfile") {
fmt.Println("myfile is exist!")
} else{
fmt.Println("myfile is not exist!")
}
}
```
## `IsEmpty`
- 说明: `IsEmpty` 检查并返回资源管理器是否为空。
- 格式:
```go
func IsEmpty() bool
```
- 示例:
```go
package main
import (
"fmt"
"github.com/gogf/gf/v2/os/gres"
)
func main() {
fmt.Println(gres.IsEmpty())
gres.Add("xxxxxxxxxxxxxxxxx")
fmt.Println(gres.IsEmpty())
// Output:
// true
// false
}
```
## `ScanDir`
- 说明:`ScanDir` 返回给定路径下的文件,参数 `path` 应该是文件夹类型。参数 `pattern` 支持多个文件名模式,使用 `,` 符号分隔多个模式。如果参数 `recursive` 为 `true`,它会递归地扫描目录。
- 格式:
```go
func ScanDir(path string, pattern string, recursive ...bool) []*File
```
- 示例:
```go
package main
import (
"fmt"
"github.com/gogf/gf/v2/os/gres"
)
func main() {
files := gres.ScanDir("../res", "*.doc,*.go", true)
if len(files) > 0 {
for _, file := range files {
fmt.Println("ScanDir Result:", file.Name())
}
}
}
```
## `ScanDirFile`
- 说明:`ScanDirFile` 返回所有具有给定 `path` 的绝对路径的子文件,如果参数 `recursive` 为 `true`,则会递归扫描目录。
- 注意:只返回文件,不返回目录。
- 格式:
```go
func ScanDirFile(path string, pattern string, recursive ...bool) []*File
```
- 示例:
```go
package main
import (
"fmt"
"github.com/gogf/gf/v2/os/gres"
)
func main() {
files := gres.ScanDirFile("../res", "*.*", true)
if len(files) > 0 {
for _, file := range files {
fmt.Println("ScanDirFile Result:", file.Name())
}
}
}
```
## `Export`
- 说明:`Export` 将指定路径 `src` 及其所有子文件递归保存到指定的系统路径 `dst`。
- 格式:
```go
func Export(src, dst string, option ...ExportOption) error
```
- 示例:
```go
package main
import (
"fmt"
"github.com/gogf/gf/v2/os/gres"
)
func main() {
err := gres.Export("../res/src", "../res/dst")
if err != nil {
fmt.Println("gres.Export Error:", err)
}
}
```
## `Dump`
- 说明:`Dump` 打印默认资源对象的文件。
- 格式:
```go
func Dump()
```
- 示例:
```go
package main
import (
"github.com/gogf/gf/v2/os/gres"
)
func main() {
gres.Add("xxxxxxxxx")
gres.Dump()
}
```
# 资源管理-使用示例
我们来看一个使用示例,在该示例中,展示了资源管理在`WebServer`的静态服务、配置管理、模板引擎中的使用。
### 资源文件
资源文件源码: [https://github.com/gogf/gf/tree/master/os/gres/testdata/example/files](https://github.com/gogf/gf/tree/master/os/gres/testdata/example/files)
资源文件打包: [https://github.com/gogf/gf/tree/master/os/gres/testdata/example/boot](https://github.com/gogf/gf/tree/master/os/gres/testdata/example/boot)
资源文件列表:
```
2020-03-28T13:04:10+00:00 0.00B config
2020-03-28T13:03:06+00:00 135.00B config/config.toml
2020-03-28T13:04:10+00:00 0.00B public
2020-03-28T12:57:54+00:00 6.00B public/index.html
2020-03-28T13:04:10+00:00 0.00B template
2020-03-28T13:03:17+00:00 15.00B template/index.tpl
TOTAL FILES: 6
```
三个文件的内容分别为:
1. `config.toml`
```toml
[server]
Address = ":8888"
ServerRoot = "public"
[viewer]
DefaultFile = "index.tpl"
Delimiters = ["${", "}"]
```
该文件为应用的配置文件。
2. `index.html`
```html
Hello!
```
该文件用于静态资源请求。
3. `index.tpl`
```html
Hello ${.name}!
```
该文件用于模板文件解析展示。
### 创建应用
```go
package main
import (
_ "github.com/gogf/gf/v2/os/gres/testdata/example/boot"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.Group("/", func(group *ghttp.RouterGroup) {
group.GET("/template", func(r *ghttp.Request) {
r.Response.WriteTplDefault(g.Map{
"name": "GoFrame",
})
})
})
s.Run()
}
```
可以看到,整个代码中除了`import`中额外增加了一个`_ "github.com/gogf/gf/v2/os/gres/testdata/example/boot"`的包引入外,没有其他任何设置。这也是`GoFrame`框架的资源管理比较便捷的地方,资源管理并不需要开发阶段对代码做任何特殊设置,在应用程序部署之前打包好资源文件,并通过`import`增加资源文件的引入即可。
运行后,终端输出:
```html
2020-03-28 21:36:19.828 75892: http server started listening on [:8888]
SERVER | DOMAIN | ADDRESS | METHOD | ROUTE | HANDLER | MIDDLEWARE
|---------|---------|---------|--------|-----------|-------------------|------------|
default | default | :8888 | GET | /template | main.main.func1.1 |
|---------|---------|---------|--------|-----------|-------------------|------------|
```
可以看到,配置文件被自动读取并应用到了`WebServer`上。
我们通过`curl`命令测试一下静态文件以及模板引擎的访问。
```bash
$ curl http://127.0.0.1:8888/
Hello!
$ curl http://127.0.0.1:8888/template
Hello GoFrame!
```
可以看到,`index.html`静态文件以及`index.tpl`模板文件都被成功访问到了。
# 资源管理-最佳实践
资源管理设计的目标之一是在开发阶段不影响静态文件的开发管理,只有在发布的时候执行打包即可,打包完毕后清理临时文件,因此只会影响生成的二进制可执行文件,对开发者来说无感知且使用便捷。
## 准备工程
建议大家使用 `GoFrame` 官方提供的工程化目录结构,并且使用 `CLI` 工具来创建自己的项目工程,因为整个框架工程化思想以及一些示例都是基于标准化的工程目录结构,这样更有利于大家学习和使用整个开发框架,提高开发效率。特别是,在工程目录中存在一个 `packed` 目录,用以存放资源管理组件的相关打包内容,在默认情况下,里面是一个空的 `go` 文件,什么都不会做。
## 开发阶段
在开发阶段,一般来说,开发者并不需要关心资源管理的事情,该怎么撸码就怎么撸,静态的文件按照建议放到 `resource` 目录下。在开发阶段的静态文件访问都会直接通过文件系统访问。
## 准备发布
当开发完毕后,想要将静态文件、模板文件、配置文件打包到二进制可执行文件中,随着二进制可执行文件发布。好的,这个时候资源组件开始介入展示能力了。需要配置到交叉编译的配置,具体请参考命令行相关章节 [交叉编译-build](../cli/build.md)。我们需要使用到CLI工具来执行可执行文件编译,将的编译配置通过配置文件管理起来(放到hack/config.yaml文件中),一个参考的编译配置如下:
```
gfcli:
build:
name: "my-app"
arch: "amd64"
system: "linux"
mod: "none"
cgo: 0
packSrc: "manifest/config,manifest/i18n,resource/public,resource/template"
version: ""
output: "./bin"
extra: ""
```
请注意其中的 `pack` 配置,表示我们在执行编译的时候自动打包到二进制可执行文件中的目录。随后,我们在项目根目录下执行编译命令:
```
gf build
```
该命令在编译时会自动将配置文件中指定的目录进行打包成临时的打包 `go` 文件,再执行编译,编译完毕后自动清理临时的打包 `go` 文件。
**提示**
大部分场景下,配置文件可能不需要打包进二进制可执行文件,具体根据业务场景自行选择。
## 发布运行
将二进制执行发布执行即可。
# 开始使用
**提示**
`GoFrame` 是一款基础设施建设比较完善的模块化框架, `WebServer` 模块是其中比较核心的模块,我们这里将 `Web` 服务开发作为框架入门的选择,便于大家更容易学习和理解。
`GoFrame` 框架提供了非常强大的 `WebServer`,由 `ghttp` 模块实现。实现了丰富完善的相关组件,例如: `Router`、 `Cookie`、 `Session`、路由注册、配置管理、模板引擎、缓存控制等等,支持热重启、热更新、多域名、多端口、多实例、 `HTTPS`、 `Rewrite`、 `PProf` 等等特性。
接口文档地址:
[https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp](https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp)
## 哈喽世界
老规矩,我们先来一个 `Hello World`:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/", func(r *ghttp.Request) {
r.Response.Write("哈喽世界!")
})
s.Run()
}
```
这便是一个最简单的服务,默认情况下它不支持静态文件处理,只有一个功能,访问 [http://127.0.0.1/](http://127.0.0.1/) 的时候,它会返回 `哈喽世界!`。
任何时候,都可以通过 `g.Server()` 方法获得一个默认的 `Server` 对象,该方法采用 `单例模式` 设计,也就是说,多次调用该方法,返回的是同一个 `Server` 对象。通过 `Run()` 方法执行 `Server` 的监听运行,在没有任何额外设置的情况下,它默认监听 `80` 端口。
关于其中的路由注册,我们将会在后续的章节中介绍,我们继续来看看如何创建一个支持静态文件的 `Server`。
## 静态服务
创建并运行一个支持静态文件的 `WebServer`:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
)
func main() {
s := g.Server()
s.SetIndexFolder(true)
s.SetServerRoot("/home/www/")
s.Run()
}
```
创建了 `Server` 对象之后,我们可以使用 `Set*` 方法来设置 `Server` 的属性,我们这里的示例中涉及到了两个属性设置方法:
1. `SetIndexFolder` 用来设置是否允许列出 `Server` 主目录的文件列表(默认为 `false`)。
2. `SetServerRoot` 用来设置 `Server` 的主目录(类似 `nginx` 中的 `root` 配置,默认为空)。
`Server` 默认情况下是没有任何主目录的设置,只有设置了主目录,才支持对应主目录下的静态文件的访问。
## 多端口监听
`Server` 同时支持多端口监听,只需要往 `SetPort` 参数设置多个端口号即可(当然,针对于 `HTTPS` 服务,我们也同样可以通过 `SetHTTPSPort` 来设置绑定并支持多个端口号的监听, `HTTPS` 服务的介绍请参看后续对应章节)。
我们来看一个例子:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/", func(r *ghttp.Request){
r.Response.Writeln("go frame!")
})
s.SetPort(8100, 8200, 8300)
s.Run()
}
```
执行以上示例后,我们访问以下URL将会得到期望的相同结果:
```
http://127.0.0.1:8100/
http://127.0.0.1:8200/
http://127.0.0.1:8300/
```
## 多实例支持
`Server` 支持同一进程多实例运行,下面我们来看一个例子:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
)
func main() {
s1 := g.Server("s1")
s1.SetPort(8080)
s1.SetIndexFolder(true)
s1.SetServerRoot("/home/www/static1")
s1.Start()
s2 := g.Server("s2")
s2.SetPort(8088)
s2.SetIndexFolder(true)
s2.SetServerRoot("/home/www/static2")
s2.Start()
g.Wait()
}
```
可以看到我们在支持多个 `Server` 的语句中,给 `g.Server` 方法传递了不同的单例名称参数,该参数用于标识不同的 `Server` 实例,因此需要保证唯一性。如果需要获取同一个 `Server` 实例,那么传入同一个名称即可。例如在多个 `goroutine` 中,或者不同的模块中,都可以通过 `g.Server` 获取到同一个 `Server` 实例。
## 域名绑定
`Server` 支持多域名绑定,并且不同的域名可以绑定不同的服务。
我们来看一个简单的例子:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func Hello1(r *ghttp.Request) {
r.Response.Write("127.0.0.1: Hello1!")
}
func Hello2(r *ghttp.Request) {
r.Response.Write("localhost: Hello2!")
}
func main() {
s := g.Server()
s.Domain("127.0.0.1").BindHandler("/", Hello1)
s.Domain("localhost").BindHandler("/", Hello2)
s.Run()
}
```
我们访问 [http://127.0.0.1/](http://127.0.0.1/) 和 [http://localhost/](http://localhost/) 可以看输出不同的内容。
此外, `Domain` 方法支持多个域名参数,使用英文「,」号分隔,例如:
```
s.Domain("localhost1,localhost2,localhost3").BindHandler("/", Hello2)
```
这条语句的表示将 `Hello2` 方法注册到指定的3个域名中( `localhost1~3`),对其他域名不可见。
需要注意的是: `Domain` 方法的参数必须是 **准确的** 域名, **不支持泛域名形式**,例如: `*.goframe.org` 或者 `.goframe.org` 是不支持的, `api.goframe.org` 或者 `goframe.org` 才被认为是正确的域名参数。
## 路由特性
`Server` 提供了非常出色的路由特性,我们先来看一个简单的示例:
```go
package main
import (
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/frame/g"
)
func main() {
s := g.Server()
s.BindHandler("/{class}-{course}/:name/*act", func(r *ghttp.Request) {
r.Response.Writef(
"%v %v %v %v",
r.Get("class"),
r.Get("course"),
r.Get("name"),
r.Get("act"),
)
})
s.SetPort(8199)
s.Run()
}
```
这是一个混合的路由规则示例,用于展示某个班级、某个学科、某个学生、对应的操作,运行后,我们可以通过例如该地址: [http://127.0.0.1:8199/class3-math/john/score](http://127.0.0.1:8199/class3-math/john/score) 看到测试结果。在页面上可以看得到对应的路由规则都被一一解析,业务层可以根据解析的参数进行对应的业务逻辑处理。 具体的路由注册管理介绍请查看后续 [路由管理-路由规则](./router-pattern.md) 章节。
## 配置管理
`GoFrame` 的核心组件均实现了便捷的配置管理特性,仅需通过配置文件的修改即可实现组件的功能配置。大多数的场景中我们推荐使用配置文件来管理组件的配置, `Server` 的配置请查看 [服务配置](./server-config.md) 章节。
## 平滑重启
`Server` 内置支持平滑重启特性,详细介绍请参考 [平滑重启特性](./senior-hot-reload.md) 章节。
## HTTPS支持
`Server` 支持 `HTTPS` 服务,并且也同时支持单进程提供 `HTTP&HTTPS` 服务,HTTPS的详细介绍请参考 [HTTPS&TLS](./senior-https.md) 章节。
## 更多功能特性
更多功能及特性请继续阅读后续章节。
# Cookie
## 基本介绍
接口文档: [https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp#Cookie](https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp#Cookie)
常用方法:
```go
type Cookie
func GetCookie(r *Request) *Cookie
func (c *Cookie) Contains(key string) bool
func (c *Cookie) Flush()
func (c *Cookie) Get(key string, def ...string) string
func (c *Cookie) GetSessionId() string
func (c *Cookie) Map() map[string]string
func (c *Cookie) Remove(key string)
func (c *Cookie) RemoveCookie(key, domain, path string)
func (c *Cookie) Set(key, value string)
func (c *Cookie) SetCookie(key, value, domain, path string, maxAge time.Duration, httpOnly ...bool)
func (c *Cookie) SetHttpCookie(httpCookie *http.Cookie)
func (c *Cookie) SetSessionId(id string)
```
任何时候都可以通过 `*ghttp.Request` 对象获取到当前请求对应的 `Cookie` 对象,因为 `Cookie` 和 `Session` 都是和请求会话相关,因此都属于 `ghttp.Request` 的成员对象,并对外公开。 `Cookie` 对象不需要手动 `Close`,请求流程结束后, `HTTP Server` 会自动关闭掉。
此外, `Cookie` 中封装了两个 `SessionId` 相关的方法:
1. `Cookie.GetSessionId()` 用于获取当前请求提交的 `SessionId`,每个请求的 `SessionId` 都是唯一的,并且伴随整个请求流程,该值可能为空。
2. `Cookie.SetSessionId(id string)` 用于自定义设置 `SessionId` 到 `Cookie` 中,返回给客户端(往往是浏览器)存储,随后客户端每一次请求在 `Cookie` 中可带上该 `SessionId`。
在设置 `Cookie` 变量的时候可以给定过期时间,该时间为可选参数,默认的 `Cookie` 过期时间为一年。
**提示**
默认的 `SessionId` 在 `Cookie` 中的存储名称为 `gfsession`。
## 使用示例
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/cookie", func(r *ghttp.Request) {
datetime := r.Cookie.Get("datetime")
r.Cookie.Set("datetime", gtime.Datetime())
r.Response.Write("datetime:", datetime)
})
s.SetPort(8199)
s.Run()
}
```
执行外层的 `main.go`,可以尝试刷新页面 [http://127.0.0.1:8199/cookie](http://127.0.0.1:8199/cookie),显示的时间在一直变化。
对于控制器对象而言,从基类控制器中继承了很多会话相关的对象指针,可以看做alias,可以直接使用,他们都是指向的同一个对象:
```go
type Controller struct {
Request *ghttp.Request // 请求数据对象
Response *ghttp.Response // 返回数据对象(r.Response)
Server *ghttp.Server // WebServer对象(r.Server)
Cookie *ghttp.Cookie // COOKIE操作对象(r.Cookie)
Session *ghttp.Session // SESSION操作对象
View *View // 视图对象
}
```
由于对于Web开发者来讲, `Cookie` 都已经是非常熟悉的组件了,相关 `API` 也非常简单,这里便不再赘述。
## `Cookie` 会话过期
`Cookie` 的有效期默认是1年,如果我们期望Cookie随着用户的浏览会话过期,像这样:
那么我们仅需要通过 `SetCookie` 来设置 `Cookie` 键值对并将 `maxAge` 参数设置为 `0` 即可。像这样:
```
r.Cookie.SetCookie("MyCookieKey", "MyCookieValue", "", "/", 0)
```
# 异常处理
由于这个问题问得比较多,因此专门为大家撰写了这一章节。
## 基本介绍
目前大多数 `Golang` 的第三方 `WebServer` 库均没有默认对 `HTTP` 请求处理过程中产生的异常进行捕获,轻者错误产生后无法记录到日志造成排查错困难,重则异常造成进程直接崩溃,服务不可用。
当选择 `goframe`,很幸运。作为一款企业级的基础开发框架,默认情况下,执行过程中产生的 `panic` 是有被 `Server` 自动捕获的,产生 `panic` 时当前执行流程会立即中止,但是绝对不会影响进程直接崩溃。
## 获取异常错误
`HTTP` 执行流程中产生 `panic` 异常时,默认处理是记录到 `Server` 的日志文件中。当然,开发者也可以通过注册中间件方式手动捕获,然后自定义相关的错误处理。这一操作其实在中间件章节的示例中也有介绍,我们这里来再仔细说明下。
### 相关方法
异常的捕获我们通过 `Request` 对象中的 `GetError` 方法获取。
**提示**
开发者不能通过 `recover` 方法来捕获异常,因为 `goframe` 框架的 `Server` 已经做了捕获,并且为保证默认情况下异常不会引起进程崩溃,因此不会再次往上抛异常。
```go
// GetError returns the error occurs in the procedure of the request.
// It returns nil if there's no error.
func (r *Request) GetError() error
```
该方法往往使用在流程控制组件中,如后置中间件或者 `HOOK` 钩子方法中。
### 使用示例
我们这里使用一个全局的后置中间件来捕获异常,当异常产生后,捕获并写入到指定的日志文件中,返回固定友好的提示信息,避免敏感的报错信息暴露给调用端。
**提示**
需要注意的是:
- 即使开发者有自己捕获记录异常错误的日志,但是 `Server` 依旧会打印到 `Server` 自己的错误日志文件中。由开发者调用日志接口方法输出的日志属于业务日志(与业务相关),而 `Server` 自行管理的日志属于服务日志(类似于 `nginx` 的 `error.log`)。
- 由于 `goframe` 框架大部分的底层错误都包含有错误时的堆栈信息,如果对于 `error` 的具体堆栈信息感兴趣(具体调用链、报错文件路径、源码行号等),可以使用 `gerror` 来获取,具体请参考 [错误处理-堆栈特性](../core/gerror-stack.md) 章节。如果异常包含堆栈信息,默认情况下会打印到 `Server` 的 `error` 日志文件中。
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func MiddlewareErrorHandler(r *ghttp.Request) {
r.Middleware.Next()
if err := r.GetError(); err != nil {
// 记录到自定义错误日志文件
g.Log("exception").Error(err)
//返回固定的友好信息
r.Response.ClearBuffer()
r.Response.Writeln("服务器居然开小差了,请稍后再试吧!")
}
}
func main() {
s := g.Server()
s.Use(MiddlewareErrorHandler)
s.Group("/api.v2", func(group *ghttp.RouterGroup) {
group.ALL("/user/list", func(r *ghttp.Request) {
panic("db error: sql is xxxxxxx")
})
})
s.SetPort(8199)
s.Run()
}
```
执行后,我们通过 `curl` 工具来试试吧:
```bash
$ curl -v "http://127.0.0.1:8199/api.v2/user/list"
> GET /api.v2/user/list HTTP/1.1
> Host: 127.0.0.1:8199
> User-Agent: curl/7.61.1
> Accept: */*
>
< HTTP/1.1 500 Internal Server Error
< Server: GoFrame HTTP Server
< Date: Sun, 19 Jul 2020 07:44:30 GMT
< Content-Length: 52
< Content-Type: text/plain; charset=utf-8
<
服务器居然开小差了,请稍后再试吧!
```
## 获取异常堆栈
### 异常堆栈信息
`WebServer` 本身在捕获异常时,如果抛出的异常信息并不包含堆栈内容,那么 `WebServer` 会自动获取抛出异常位点(即 `panic` 的位置)的堆栈并创建一个新的包含该堆栈信息的错误对象。我们来看一个示例。
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func MiddlewareErrorHandler(r *ghttp.Request) {
r.Middleware.Next()
if err := r.GetError(); err != nil {
r.Response.ClearBuffer()
r.Response.Writef("%+v", err)
}
}
func main() {
s := g.Server()
s.Use(MiddlewareErrorHandler)
s.Group("/api.v2", func(group *ghttp.RouterGroup) {
group.ALL("/user/list", func(r *ghttp.Request) {
panic("db error: sql is xxxxxxx")
})
})
s.SetPort(8199)
s.Run()
}
```
可以看到,我们通过 `%+v` 的格式化打印来获取异常错误中的堆栈信息,具体原理请参考章节: [错误处理-堆栈特性](../core/gerror-stack.md)。执行后,我们通过 `curl` 工具来测试下:
```bash
$ curl "http://127.0.0.1:8199/api.v2/user/list"
db error: sql is xxxxxxx
1. db error: sql is xxxxxxx
1). main.main.func1.1
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/.example/other/test.go:25
2). github.com/gogf/gf/v2/net/ghttp.(*middleware).Next.func1.8
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_request_middleware.go:111
3). github.com/gogf/gf/v2/net/ghttp.niceCallFunc
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_func.go:46
4). github.com/gogf/gf/v2/net/ghttp.(*middleware).Next.func1
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_request_middleware.go:110
5). github.com/gogf/gf/v2/util/gutil.TryCatch
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/util/gutil/gutil.go:46
6). github.com/gogf/gf/v2/net/ghttp.(*middleware).Next
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_request_middleware.go:47
7). main.MiddlewareErrorHandler
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/.example/other/test.go:10
8). github.com/gogf/gf/v2/net/ghttp.(*middleware).Next.func1.9
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_request_middleware.go:117
9). github.com/gogf/gf/v2/net/ghttp.niceCallFunc
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_func.go:46
10). github.com/gogf/gf/v2/net/ghttp.(*middleware).Next.func1
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_request_middleware.go:116
11). github.com/gogf/gf/v2/util/gutil.TryCatch
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/util/gutil/gutil.go:46
12). github.com/gogf/gf/v2/net/ghttp.(*middleware).Next
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_request_middleware.go:47
13). github.com/gogf/gf/v2/net/ghttp.(*Server).ServeHTTP
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_server_handler.go:122
```
### 错误堆栈信息
如果抛出的异常是一个通过 `gerror` 组件的错误对象,或者实现堆栈打印接口的错误对象,由于该异常的错误对象已经包含了详细的堆栈信息,那么 `WebServer` 将会直接返回该错误对象,不会自动创建错误对象。我们来看一个示例。
```go
package main
import (
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func MiddlewareErrorHandler(r *ghttp.Request) {
r.Middleware.Next()
if err := r.GetError(); err != nil {
r.Response.ClearBuffer()
r.Response.Writef("%+v", err)
}
}
func DbOperation() error {
// ...
return gerror.New("DbOperation error: sql is xxxxxxx")
}
func UpdateData() {
err := DbOperation()
if err != nil {
panic(gerror.Wrap(err, "UpdateData error"))
}
}
func main() {
s := g.Server()
s.Use(MiddlewareErrorHandler)
s.Group("/api.v2", func(group *ghttp.RouterGroup) {
group.ALL("/user/list", func(r *ghttp.Request) {
UpdateData()
})
})
s.SetPort(8199)
s.Run()
}
```
执行后,我们通过 `curl` 工具来测试下:
```bash
$ curl "http://127.0.0.1:8199/api.v2/user/list"
UpdateData error: DbOperation error: sql is xxxxxxx
1. UpdateData error
1). main.UpdateData
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/.example/other/test.go:25
2). main.main.func1.1
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/.example/other/test.go:34
3). github.com/gogf/gf/v2/net/ghttp.(*middleware).Next.func1.8
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_request_middleware.go:111
4). github.com/gogf/gf/v2/net/ghttp.niceCallFunc
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_func.go:46
5). github.com/gogf/gf/v2/net/ghttp.(*middleware).Next.func1
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_request_middleware.go:110
6). github.com/gogf/gf/v2/util/gutil.TryCatch
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/util/gutil/gutil.go:46
7). github.com/gogf/gf/v2/net/ghttp.(*middleware).Next
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_request_middleware.go:47
8). main.MiddlewareErrorHandler
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/.example/other/test.go:10
9). github.com/gogf/gf/v2/net/ghttp.(*middleware).Next.func1.9
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_request_middleware.go:117
10). github.com/gogf/gf/v2/net/ghttp.niceCallFunc
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_func.go:46
11). github.com/gogf/gf/v2/net/ghttp.(*middleware).Next.func1
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_request_middleware.go:116
12). github.com/gogf/gf/v2/util/gutil.TryCatch
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/util/gutil/gutil.go:46
13). github.com/gogf/gf/v2/net/ghttp.(*middleware).Next
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_request_middleware.go:47
14). github.com/gogf/gf/v2/net/ghttp.(*Server).ServeHTTP
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_server_handler.go:122
2. DbOperation error: sql is xxxxxxx
1). main.DbOperation
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/.example/other/test.go:19
2). main.UpdateData
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/.example/other/test.go:23
3). main.main.func1.1
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/.example/other/test.go:34
4). github.com/gogf/gf/v2/net/ghttp.(*middleware).Next.func1.8
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_request_middleware.go:111
5). github.com/gogf/gf/v2/net/ghttp.niceCallFunc
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_func.go:46
6). github.com/gogf/gf/v2/net/ghttp.(*middleware).Next.func1
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_request_middleware.go:110
7). github.com/gogf/gf/v2/util/gutil.TryCatch
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/util/gutil/gutil.go:46
8). github.com/gogf/gf/v2/net/ghttp.(*middleware).Next
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_request_middleware.go:47
9). main.MiddlewareErrorHandler
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/.example/other/test.go:10
10). github.com/gogf/gf/v2/net/ghttp.(*middleware).Next.func1.9
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_request_middleware.go:117
11). github.com/gogf/gf/v2/net/ghttp.niceCallFunc
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_func.go:46
12). github.com/gogf/gf/v2/net/ghttp.(*middleware).Next.func1
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_request_middleware.go:116
13). github.com/gogf/gf/v2/util/gutil.TryCatch
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/util/gutil/gutil.go:46
14). github.com/gogf/gf/v2/net/ghttp.(*middleware).Next
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_request_middleware.go:47
15). github.com/gogf/gf/v2/net/ghttp.(*Server).ServeHTTP
/Users/john/Workspace/Go/GOPATH/src/github.com/gogf/gf/v2/net/ghttp/ghttp_server_handler.go:122
```
# 性能测试
## 关于性能测试
需要注意,GoFrame框架是一个模块化、低耦合的基础开发框架,HTTP Server只是其中一个核心组件。
这里只是展示HTTP Server的性能测试报告,以中立的第三方性能测试报告为准。
## 全框架/组件性能测试报告
[https://www.techempower.com/benchmarks/#section=data-r21&test=composite](https://www.techempower.com/benchmarks/#section=data-r21&test=composite)
## Go框架/组件性能测试报告
# 常见问题
## 服务端频繁出现 `context cancel` 错误
当 **客户端主动取消请求**(例如主动取消,或者请求超过客户端设置的时间)后,特别是浏览器访问时直接取消请求,服务端可能会遇到 `context canceled` 的错误。这个属于正常现象, `Golang` 标准库的 `http server` 设计亦如此。当客户端不再需要这个请求的结果时会取消请求,这时服务端继续往下执行也没有了意义。如果介意这个错误,可以使用一个自定义的中间件增加 `NeverDoneCtx` 的处理逻辑,或者直接使用框架提供的`ghttp.MiddlewareNeverDoneCtx`中间件。这个时候服务端会忽略客户端的取消请求并继续往下执行。
```go
// MiddlewareNeverDoneCtx sets the context never done for current process.
func MiddlewareNeverDoneCtx(r *Request) {
r.SetCtx(r.GetNeverDoneCtx())
r.Middleware.Next()
}
```
# 路由管理
路由管理是 `HTTP Web Server` 开发的基础,我们尽可能地通过详尽的示例让能更好理解本章节。
# 路由管理-路由规则
`GoFrame` 框架自建了非常强大的路由功能,提供了比任何同类框架更加出色的路由特性,支持流行的命名匹配规则、模糊匹配规则及字段匹配规则,并提供了优秀的优先级管理机制。
## 一个示例
在真正开启本章的核心内容之前,我们先来看一个简单的动态路由使用示例:
```go
package main
import (
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/frame/g"
)
func main() {
s := g.Server()
s.BindHandler("/:name", func(r *ghttp.Request){
r.Response.Writeln(r.Router.Uri)
})
s.BindHandler("/:name/update", func(r *ghttp.Request){
r.Response.Writeln(r.Router.Uri)
})
s.BindHandler("/:name/:action", func(r *ghttp.Request){
r.Response.Writeln(r.Router.Uri)
})
s.BindHandler("/:name/*any", func(r *ghttp.Request){
r.Response.Writeln(r.Router.Uri)
})
s.BindHandler("/user/list/{field}.html", func(r *ghttp.Request){
r.Response.Writeln(r.Router.Uri)
})
s.SetPort(8199)
s.Run()
}
```
以上示例中展示了 `goframe` 框架支持的三种模糊匹配路由规则, `:name`、 `*any`、 `{field}` 分别表示 **命名匹配规则**、 **模糊匹配规则** 及 **字段匹配规则**。不同的规则中使用 `/` 符号来划分层级,路由检索采用 **深度优先算法**,层级越深的规则优先级也会越高。我们运行以上示例,通过访问几个 `URL` 来看看效果:
```html
URL 结果
http://127.0.0.1:8199/user/list/2.html /user/list/{field}.html
http://127.0.0.1:8199/user/update /:name/update
http://127.0.0.1:8199/user/info /:name/:action
http://127.0.0.1:8199/user /:name/*any
```
在这个示例中我们也可以看到,由于优先级的限制,路由规则 `/:name` 会被 `/:name/*any` 规则覆盖,将会无法被匹配到,所以在分配路由规则的时候,需要进行统一规划和管理,避免类似情况的产生。
## 注册规则
### 路由注册参数
**最基础的** 路由绑定方法是 `BindHandler` 方法,我们来看一下之前一直使用的 `BindHandler` 的原型:
```go
func (s *Server) BindHandler(pattern string, handler interface{})
```
#### `pattern` 参数
其中的 `pattern` 为路由注册规则字符串,在其他路由注册方法中也会使用到,参数格式如下:
```html
[HTTPMethod:]路由规则[@域名]
```
其中 `HTTPMethod`( `GET/PUT/POST/DELETE/PATCH/HEAD/CONNECT/OPTIONS/TRACE`)和 `@域名` 为 **非必需参数**,大部分场景下直接给定路由规则参数即可, `BindHandler` 会自动绑定 **所有的** 请求方式,如果给定 `HTTPMethod`,那么路由规则仅会在该请求方式下有效。 `@域名` 可以指定生效的域名名称,那么该路由规则仅会在该域名下生效。
**提示**
`BindHandler` 是最原生的路由注册方法,在大部分场景中,我们通常使用 **分组路由** 方式来管理理由,后续章节将会介绍: [路由注册-分组路由](./router-registering-group.md)。
#### `handler` 参数
其中的 `handler` 参数通常用于指定路由函数,我们最基础的示例都是使用函数来注册路由,一个路由函数需要满足以下定义,即只要能接收请求对象 `ghttp.Request` 即可:
```go
func(r *ghttp.Request) {
// ...
}
```
我们来看一个例子:
```go
package main
import (
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/frame/g"
)
func main() {
s := g.Server()
// 该路由规则仅会在GET请求下有效
s.BindHandler("GET:/{table}/list/{page}.html", func(r *ghttp.Request){
r.Response.WriteJson(r.Router)
})
// 该路由规则仅会在GET请求及localhost域名下有效
s.BindHandler("GET:/order/info/{order_id}@localhost", func(r *ghttp.Request){
r.Response.WriteJson(r.Router)
})
// 该路由规则仅会在DELETE请求下有效
s.BindHandler("DELETE:/comment/{id}", func(r *ghttp.Request){
r.Response.WriteJson(r.Router)
})
s.SetPort(8199)
s.Run()
}
```
其中返回的参数 `r.Router` 是当前匹配的路由规则信息,访问当该方法的时候,服务端会输出当前匹配的路由规则信息。执行后,我们在终端使用 `curl` 命令进行测试:
```bash
$ curl -XGET http://127.0.0.1:8199/order/list/1.html
{"Domain":"default","Method":"GET","Priority":3,"Uri":"/{table}/list/{page}.html"}
$ curl -XGET http://127.0.0.1:8199/order/info/1
Not Found
$ curl -XGET http://localhost:8199/order/info/1
{"Domain":"localhost","Method":"GET","Priority":3,"Uri":"/order/info/{order_id}"}
$ curl -XDELETE http://127.0.0.1:8199/comment/1000
{"Domain":"default","Method":"DELETE","Priority":2,"Uri":"/comment/{id}"}
$ curl -XGET http://127.0.0.1:8199/comment/1000
Not Found
```
### 精准匹配规则
精准匹配规则即 **未使用任何动态规则** 的规则,如: `user`、 `order`、 `info` 等等这种 **确定名称** 的规则。在大多数场景下,精准匹配规则会和动态规则一起使用来进行路由注册(例如: `/:name/list`,其中层级1 `:name` 为命名匹配规则,层级2 `list` 是精准匹配规则)。
### 动态路由规则
动态路由规则分为三种: **命名匹配规则**、 **模糊匹配规则** 和 **字段匹配规则**。动态路由的底层数据结构是由层级 `哈希表` 和 `双向链表` 构建的 `路由树`,层级哈希表便于高效率地层级匹配 `URI`;数据链表用于优先级控制,同一层级的路由规则按照优先级进行排序,优先级高的规则排在链表头。底层的路由规则与请求 `URI` 的匹配计算采用的是正则表达式,并充分使用了缓存机制,执行效率十分高效。
所有匹配到的参数都将会以 `Router` 参数的形式传递给业务层,可以通过 `ghttp.Request` 对象的以下方法获取匹配到的路由参数:
```go
func (r *Request) GetRouter(key string, def ...interface{}) *gvar.Var
```
也可以使用 `ghttp.Request.Get` 方法获取匹配到的路由参数。
#### 命名匹配规则
使用 `:name` 方式进行匹配( `name` 为自定义的匹配名称),对 `URI` 指定层级的参数进行命名匹配(类似正则 `([^/]+)`,该 `URI` 层级必须有值),对应匹配参数会被解析为 `Router` 参数并传递给注册的服务接口使用。
匹配示例1:
```html
rule: /user/:user
/user/john match
/user/you match
/user/john/profile no match
/user/ no match
```
匹配示例2:
```html
rule: /:name/action
/john/name no match
/john/action match
/smith/info no match
/smith/info/age no match
/smith/action match
```
匹配示例3:
```html
rule: /:name/:action
/john/name match
/john/info match
/smith/info match
/smith/info/age no match
/smith/action/del no match
```
#### 模糊匹配规则
使用 `*any` 方式进行匹配( `any` 为自定义的匹配名称),对 `URI` 指定位置之后的参数进行模糊匹配(类似正则 `(.*)`,该 `URI` 层级可以为空),并将匹配参数解析为 `Router` 参数并传递给注册的服务接口使用。
匹配示例1:
```html
rule: /src/*path
/src/ match
/src/somefile.go match
/src/subdir/somefile.go match
/user/ no match
/user/john no match
```
匹配示例2:
```html
rule: /src/*path/:action
/src/ no match
/src/somefile.go match
/src/somefile.go/del match
/src/subdir/file.go/del match
```
匹配示例3:
```html
rule: /src/*path/show
/src/ no match
/src/somefile.go no match
/src/somefile.go/del no match
/src/somefile.go/show match
/src/subdir/file.go/show match
/src/show match
```
#### 字段匹配规则
使用 `{field}` 方式进行匹配( `field` 为自定义的匹配名称),可对 `URI` **任意位置** 的参数进行截取匹配(类似正则 `([\w\.\-]+)`,该 `URI` 层级必须有值,并且可以在同一层级进行多个字段匹配),并将匹配参数解析为 `Router` 参数并传递给注册的服务接口使用。
匹配示例1:
```html
rule: /order/list/{page}.php
/order/list/1.php match
/order/list/666.php match
/order/list/2.php5 no match
/order/list/1 no match
/order/list no match
```
匹配示例2:
```html
rule: /db-{table}/{id}
/db-user/1 match
/db-user/2 match
/db/user/1 no match
/db-order/100 match
/database-order/100 no match
```
匹配示例3:
```html
rule: /{obj}-{act}/*param
/user-delete/10 match
/order-update/20 match
/log-list match
/log/list/1 no match
/comment/delete/10 no match
```
### 动态路由示例
```go
package main
import (
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/frame/g"
)
func main() {
s := g.Server()
// 一个简单的分页路由示例
s.BindHandler("/user/list/{page}.html", func(r *ghttp.Request){
r.Response.Writeln(r.Get("page"))
})
// {xxx} 规则与 :xxx 规则混合使用
s.BindHandler("/{object}/:attr/{act}.php", func(r *ghttp.Request){
r.Response.Writeln(r.Get("object"))
r.Response.Writeln(r.Get("attr"))
r.Response.Writeln(r.Get("act"))
})
// 多种模糊匹配规则混合使用
s.BindHandler("/{class}-{course}/:name/*act", func(r *ghttp.Request){
r.Response.Writeln(r.Get("class"))
r.Response.Writeln(r.Get("course"))
r.Response.Writeln(r.Get("name"))
r.Response.Writeln(r.Get("act"))
})
s.SetPort(8199)
s.Run()
}
```
执行后,我们可以通过 `curl` 命令或者浏览器访问的方式进行测试,以下为测试结果:
```bash
$ curl -XGET http://127.0.0.1:8199/user/list/1.html
1
$ curl -XGET http://127.0.0.1:8199/user/info/save.php
user
info
save
$ curl -XGET http://127.0.0.1:8199/class3-math/john/score
class3
math
john
score
```
## 优先级控制
优先级控制按照 **深度优先策略**,简要计算策略:
1. **层级越深的规则优先级越高**;
2. **同一层级下,精准匹配优先级高于模糊匹配**;
3. **同一层级下,模糊匹配优先级:字段匹配 > 命名匹配 \> 模糊匹配**;
我们来看示例(左边的规则优先级比右边高):
```html
/:name > /*any
/user/name > /user/:action
/:name/info > /:name/:action
/:name/:action > /:name/*action
/:name/{action} > /:name/:action
/src/path/del > /src/path
/src/path/del > /src/path/:action
/src/path/*any > /src/path
```
# 路由管理-路由注册
## 基本介绍
`WebServer` 提供服务需要方法/对象的支持, `ghttp` 包支持多种路由注册模式,为开发者提供非常强大和灵活的接口功能。
路由注册是整个 `WebServer` 最核心的部分,也是 `goframe` 框架中最精心设计的一个模块。
接口文档: [https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp](https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp)
## 注意事项
从框架 `v2` 版本开始,增加了 **规范路由** 的特性,更符合业务项目工程化的场景,推荐在复杂的业务项目中使用规范路由: [路由注册-规范路由](./router-registering-strict-router.md)
# 路由注册-函数注册
## 函数注册
**函数注册** 方式是最简单且最灵活的的路由注册方式,注册的路由 `handler` 可以是一个实例化 **对象的方法地址**,也可以是一个 **包方法地址**。相关方法:
```go
func (s *Server) BindHandler(pattern string, handler interface{})
```
## 使用示例
### `Hello World`
我们来看一个简单的示例:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/", func(r *ghttp.Request) {
r.Response.Write("哈喽世界!")
})
s.SetPort(8199)
s.Run()
}
```
执行后,我们访问 [http://127.0.0.1:8199](http://127.0.0.1:8199) 可以看到我们熟悉的内容。
### 包方法注册
注册的路由函数 `handler` 参数可以是一个包方法,例如:
```go
package main
import (
"github.com/gogf/gf/v2/container/gtype"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
var (
total = gtype.NewInt()
)
func Total(r *ghttp.Request) {
r.Response.Write("total:", total.Add(1))
}
func main() {
s := g.Server()
s.BindHandler("/total", Total)
s.SetPort(8199)
s.Run()
}
```
在该示例中,我们通过包方法的形式来注册路由。该方法返回总共访问的次数,由于涉及到并发安全,因此 `total` 变量使用了 `gtype.Int` 并发安全类型。执行后,当我们不停访问 [http://127.0.0.1:8199/total](http://127.0.0.1:8199/total) 时,可以看到返回的数值不停递增。
### 对象方法注册
注册的路由函数 `handler` 参数可以是一个对象的方法,例如:
```go
package main
import (
"github.com/gogf/gf/v2/container/gtype"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
type Controller struct {
total *gtype.Int
}
func (c *Controller) Total(r *ghttp.Request) {
r.Response.Write("total:", c.total.Add(1))
}
func main() {
s := g.Server()
c := &Controller{
total: gtype.NewInt(),
}
s.BindHandler("/total", c.Total)
s.SetPort(8199)
s.Run()
}
```
该示例与示例1的实现的效果一致,但我们使用了对象来封装业务逻辑所需的变量,使用路由函数注册来灵活注册对应的对象方法。
# 路由注册-对象注册
**对象注册** 是通过一个实例化的对象来执行路由注册,以后每一个请求都交给该对象(同一对象)处理, **该对象常驻内存不释放**。
**相关方法**:
```go
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
```
**前置约定**:需要进行路由注册的方法必须为 **公开方法**,并且 **方法定义** 如下:
```go
func(r *ghttp.Request)
```
否则无法完成注册,路由注册时将会有错误提示,例如:
```
panic: interface conversion: interface {} is xxx, not func(*ghttp.Request)
```
## 对象注册- `BindObject`
我们可以通过 `BindObject` 方法完成对象的注册。
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
type Controller struct{}
func (c *Controller) Index(r *ghttp.Request) {
r.Response.Write("index")
}
func (c *Controller) Show(r *ghttp.Request) {
r.Response.Write("show")
}
func main() {
s := g.Server()
c := new(Controller)
s.BindObject("/object", c)
s.SetPort(8199)
s.Run()
}
```
可以看到,对象在进行路由注册时便生成了一个对象(对象在 `Server` 启动时便生成),此后不管多少请求进入, `Server` 都是将请求转交给该对象对应的方法进行处理。该示例执行后,终端输出的路由表如下:
```text
SERVER | DOMAIN | ADDRESS | METHOD | ROUTE | HANDLER | MIDDLEWARE
|---------|---------|---------|--------|---------------|--------------------------|------------|
default | default | :8199 | ALL | /object | main.(*Controller).Index |
|---------|---------|---------|--------|---------------|--------------------------|------------|
default | default | :8199 | ALL | /object/index | main.(*Controller).Index |
|---------|---------|---------|--------|---------------|--------------------------|------------|
default | default | :8199 | ALL | /object/show | main.(*Controller).Show |
|---------|---------|---------|--------|---------------|--------------------------|------------|
```
随后可以通过 [http://127.0.0.1:8199/object/show](http://127.0.0.1:8199/object/show) 查看效果。
**提示**
控制器中的 `Index` 方法是一个特殊的方法,例如,当注册的路由规则为 `/user` 时,HTTP请求到 `/user` 时,将会自动映射到控制器的 `Index` 方法。也就是说,访问地址 `/user` 和 `/user/index` 将会达到相同的执行效果。
### 路由内置变量
当使用 `BindObject` 方法进行对象注册时,在路由规则中可以使用两个内置的变量: `{.struct}` 和 `{.method}`,前者表示当前 **对象名称**,后者表示当前注册的 **方法名**。我们来看一个例子:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
type Order struct{}
func (o *Order) List(r *ghttp.Request) {
r.Response.Write("list")
}
func main() {
s := g.Server()
o := new(Order)
s.BindObject("/{.struct}-{.method}", o)
s.SetPort(8199)
s.Run()
}
```
执行后,终端输出的路由表如下:
```text
SERVER | DOMAIN | ADDRESS | METHOD | ROUTE | HANDLER | MIDDLEWARE
|---------|---------|---------|--------|-------------|--------------------|------------|
default | default | :8199 | ALL | /order-list | main.(*Order).List |
|---------|---------|---------|--------|-------------|--------------------|------------|
```
我们尝试着访问 [http://127.0.0.1:8199/order-list](http://127.0.0.1:8199/order-list),可以看到页面输出 `list`。如果路由规则中不使用内置变量,那么默认的情况下,方法将会被追加到指定的路由规则末尾。
### 命名风格规则
通过对象进行路由注册时,可以根据对象及方法名称自动生成路由规则,默认的路由规则为:当方法名称带有多个 `单词`(按照字符大写区分单词)时,路由控制器默认会自动使用英文连接符号 `-` 进行拼接,因此访问的时候方法名称需要带 `-` 号。
例如,方法名为 `UserName` 时,生成的路由为 `user-name`;方法名为 `ShowListItems` 时,生成的路由为 `show-list-items`;以此类推。
此外,我们可以通过 `.Server.SetNameToUriType` 方法来设置对象方法名称的路由生成方式。支持的方式目前有 `4` 种,对应 `4` 个常量定义:
```
UriTypeDefault = 0 // (默认)全部转为小写,单词以'-'连接符号连接
UriTypeFullName = 1 // 不处理名称,以原有名称构建成URI
UriTypeAllLower = 2 // 仅转为小写,单词间不使用连接符号
UriTypeCamel = 3 // 采用驼峰命名方式
```
注意:需要在通过对象进行路由注册前进行该参数的设置,在路由注册后设置将不会生效,那么将使用默认规则。
我们来看一个示例:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
type User struct{}
func (u *User) ShowList(r *ghttp.Request) {
r.Response.Write("list")
}
func main() {
u := new(User)
s1 := g.Server("UriTypeDefault")
s2 := g.Server("UriTypeFullName")
s3 := g.Server("UriTypeAllLower")
s4 := g.Server("UriTypeCamel")
s1.SetNameToUriType(ghttp.UriTypeDefault)
s2.SetNameToUriType(ghttp.UriTypeFullName)
s3.SetNameToUriType(ghttp.UriTypeAllLower)
s4.SetNameToUriType(ghttp.UriTypeCamel)
s1.BindObject("/{.struct}/{.method}", u)
s2.BindObject("/{.struct}/{.method}", u)
s3.BindObject("/{.struct}/{.method}", u)
s4.BindObject("/{.struct}/{.method}", u)
s1.SetPort(8100)
s2.SetPort(8200)
s3.SetPort(8300)
s4.SetPort(8400)
s1.Start()
s2.Start()
s3.Start()
s4.Start()
g.Wait()
}
```
为了对比演示效果,这个示例采用了 `多Server` 运行方式,将不同的名称转换方式使用了不同的 `Server` 来配置运行,因此我们可以方便地在同一个程序中,访问不同的 `Server`(通过不同的端口绑定)看到不同的结果。
执行后,终端输出的路由表如下:
```text
SERVER | DOMAIN | ADDRESS | METHOD | ROUTE | HANDLER | MIDDLEWARE
-----------------|---------|---------|--------|-----------------|-----------------------|-------------
UriTypeDefault | default | :8100 | ALL | /user/show-list | main.(*User).ShowList |
-----------------|---------|---------|--------|-----------------|-----------------------|-------------
SERVER | DOMAIN | ADDRESS | METHOD | ROUTE | HANDLER | MIDDLEWARE
------------------|---------|---------|--------|----------------|-----------------------|-------------
UriTypeFullName | default | :8200 | ALL | /User/ShowList | main.(*User).ShowList |
------------------|---------|---------|--------|----------------|-----------------------|-------------
SERVER | DOMAIN | ADDRESS | METHOD | ROUTE | HANDLER | MIDDLEWARE
------------------|---------|---------|--------|----------------|-----------------------|-------------
UriTypeAllLower | default | :8300 | ALL | /user/showlist | main.(*User).ShowList |
------------------|---------|---------|--------|----------------|-----------------------|-------------
SERVER | DOMAIN | ADDRESS | METHOD | ROUTE | HANDLER | MIDDLEWARE
---------------|---------|---------|--------|----------------|-----------------------|-------------
UriTypeCamel | default | :8400 | ALL | /user/showList | main.(*User).ShowList |
---------------|---------|---------|--------|----------------|-----------------------|-------------
```
可以分别访问以下 `URL` 地址得到期望的结果:
```
http://127.0.0.1:8100/user/show-list
http://127.0.0.1:8200/User/ShowList
http://127.0.0.1:8300/user/showlist
http://127.0.0.1:8400/user/showList
```
### 对象方法注册
假如控制器中有若干公开方法,但是我只想注册其中几个,其余的方法我不想对外公开,怎么办?我们可以通过 `BindObject` 传递 **第三个非必需参数** 替换实现,参数支持传入 **多个** 方法名称,多个名称以英文 `,` 号分隔( **方法名称参数区分大小写**)。
使用示例:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
type Controller struct{}
func (c *Controller) Index(r *ghttp.Request) {
r.Response.Write("index")
}
func (c *Controller) Show(r *ghttp.Request) {
r.Response.Write("show")
}
func main() {
s := g.Server()
c := new(Controller)
s.BindObject("/object", c, "Show")
s.SetPort(8199)
s.Run()
}
```
执行后,终端输出路由表为:
```text
SERVER | DOMAIN | ADDRESS | METHOD | ROUTE | HANDLER | MIDDLEWARE
|---------|---------|---------|--------|--------------|-------------------------|------------|
default | default | :8199 | ALL | /object/show | main.(*Controller).Show |
|---------|---------|---------|--------|--------------|-------------------------|------------|
```
## 绑定路由方法- `BindObjectMethod`
我们可以通过 `BindObjectMethod` 方法绑定指定的路由到指定的方法执行( **方法名称参数区分大小写**)。
**提示**
`BindObjectMethod` 和 `BindObject` 的区别: `BindObjectMethod` 将对象中的指定方法与指定路由规则进行绑定,第三个 `method` 参数只能指定一个方法名称; `BindObject` 注册时,所有的路由都是对象方法名称按照规则生成的,第三个 `methods` 参数可以指定多个注册的方法名称。
来看一个例子:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
type Controller struct{}
func (c *Controller) Index(r *ghttp.Request) {
r.Response.Write("index")
}
func (c *Controller) Show(r *ghttp.Request) {
r.Response.Write("show")
}
func main() {
s := g.Server()
c := new(Controller)
s.BindObjectMethod("/show", c, "Show")
s.SetPort(8199)
s.Run()
}
```
执行后,终端输出的路由表如下:
```text
SERVER | DOMAIN | ADDRESS | METHOD | ROUTE | HANDLER | MIDDLEWARE
|---------|---------|---------|--------|-------|-------------------------|------------|
default | default | :8199 | ALL | /show | main.(*Controller).Show |
|---------|---------|---------|--------|-------|-------------------------|------------|
```
## `RESTful` 对象注册- `BindObjectRest`
`RESTful` 设计方式的控制器,通常用于 `API` 服务。 **在这种模式下, `HTTP` 的 `Method` 将会映射到控制器对应的方法名称**,例如: `POST` 方式将会映射到控制器的 `Post` 方法中(公开方法,首字母大写), `DELETE` 方式将会映射到控制器的 `Delete` 方法中,以此类推。其他非 `HTTP Method` 命名的方法,即使是定义的包公开方法,将不会自动注册,对于应用端不可见。当然,如果控制器并未定义对应 `HTTP Method` 的方法,该 `Method` 请求下将会返回 `HTTP Status 404`。
**注意**
`GoFrame` 的这种 `RESTful` 对象注册方式是一种严格的 `REST` 路由注册方式,我们可以将控制器的对象看做 `REST` 中的资源,而其中的 `HTTP Method` 方法即为 `REST` 规范的资源操作方法。如果大家不太熟悉 `REST` 规范,或者不想太过严格的 `RESTful` 路由设计,那么请忽略该章节。
我们可以通过 `BindObjectRest` 方法完成 `REST` 对象的注册,示例:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
type Controller struct{}
// RESTFul - GET
func (c *Controller) Get(r *ghttp.Request) {
r.Response.Write("GET")
}
// RESTFul - POST
func (c *Controller) Post(r *ghttp.Request) {
r.Response.Write("POST")
}
// RESTFul - DELETE
func (c *Controller) Delete(r *ghttp.Request) {
r.Response.Write("DELETE")
}
// 该方法无法映射,将会无法访问到
func (c *Controller) Hello(r *ghttp.Request) {
r.Response.Write("Hello")
}
func main() {
s := g.Server()
c := new(Controller)
s.BindObjectRest("/object", c)
s.SetPort(8199)
s.Run()
}
```
执行后,终端输出路由表如下;
```text
SERVER | DOMAIN | ADDRESS | METHOD | ROUTE | HANDLER | MIDDLEWARE
|---------|---------|---------|--------|---------|---------------------------|------------|
default | default | :8199 | DELETE | /object | main.(*Controller).Delete |
|---------|---------|---------|--------|---------|---------------------------|------------|
default | default | :8199 | GET | /object | main.(*Controller).Get |
|---------|---------|---------|--------|---------|---------------------------|------------|
default | default | :8199 | POST | /object | main.(*Controller).Post |
|---------|---------|---------|--------|---------|---------------------------|------------|
```
## 构造方法 `Init` 与析构方法 `Shut`
对象中的 `Init` 和 `Shut` 是两个在 `HTTP` 请求流程中被 `Server` 自动调用的特殊方法(类似 `构造函数` 和 `析构函数` 的作用)。
1. `Init` 回调方法
对象收到请求时的初始化方法,在服务接口调用之前被回调执行。
方法定义:
```go
// "构造函数"对象方法
func (c *Controller) Init(r *ghttp.Request) {
}
```
2. `Shut` 回调方法
当请求结束时被 `Server` 自动调用,可以用于对象执行一些收尾处理的操作。
方法定义:
```go
// "析构函数"对象方法
func (c *Controller) Shut(r *ghttp.Request) {
}
```
举个例子:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
type Controller struct{}
func (c *Controller) Init(r *ghttp.Request) {
r.Response.Writeln("Init")
}
func (c *Controller) Shut(r *ghttp.Request) {
r.Response.Writeln("Shut")
}
func (c *Controller) Hello(r *ghttp.Request) {
r.Response.Writeln("Hello")
}
func main() {
s := g.Server()
c := new(Controller)
s.BindObject("/object", c)
s.SetPort(8199)
s.Run()
}
```
执行后,终端输出路由表如下:
```text
SERVER | DOMAIN | ADDRESS | METHOD | ROUTE | HANDLER | MIDDLEWARE
|---------|---------|---------|--------|---------------|--------------------------|------------|
default | default | :8199 | ALL | /object/hello | main.(*Controller).Hello |
|---------|---------|---------|--------|---------------|--------------------------|------------|
```
可以看到,并没有自动注册 `Init` 和 `Shut` 这两个方法的路由,我们访问 [http://127.0.0.1:8199/object/hello](http://127.0.0.1:8199/object/hello) 后,输出结果为:
```Init
Hello
Shut
```
# 路由注册-分组路由
分组路由是业务项目中主要使用的路由注册方式。
## 分组路由
`GoFrame` 框架支持分组路由的注册方式,可以给分组路由指定一个 `prefix` 前缀(也可以直接给定 `/` 前缀,表示注册在根路由下),在该分组下的所有路由注册都将注册在该路由前缀下。 **分组路由注册方式也是推荐的路由注册方式**。
**接口文档**: [https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp#RouterGroup](https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp#RouterGroup)
```go
// 创建分组路由
func (s *Server) Group(prefix string, groups ...func(g *RouterGroup)) *RouterGroup
// 注册Method路由
func (g *RouterGroup) ALL(pattern string, object interface{}, params...interface{})
func (g *RouterGroup) GET(pattern string, object interface{}, params...interface{})
func (g *RouterGroup) PUT(pattern string, object interface{}, params...interface{})
func (g *RouterGroup) POST(pattern string, object interface{}, params...interface{})
func (g *RouterGroup) DELETE(pattern string, object interface{}, params...interface{})
...
// 中间件绑定
func (g *RouterGroup) Middleware(handlers ...HandlerFunc) *RouterGroup
// 批量注册
func (g *RouterGroup) Map(m map[string]interface{})
func (g *RouterGroup) ALLMap(m map[string]interface{})
```
**简要介绍:**
1. `Group` 方法用于创建一个分组路由对象,并且支持在指定域名对象上创建。
2. 以 `HTTP Method` 命名的方法用于绑定指定的 `HTTP Method` 路由;其中 `ALL` 方法用于注册所有的 `HTTP Method` 到指定的函数/对象/控制器上; `REST` 方法用于注册 `RESTful` 风格的路由,需给定一个执行对象或者控制器对象。
3. `Middleware` 方法用于绑定一个或多个中间件到当前分组的路由上,具体详见中间件章节。
## 简单示例
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.Group("/api", func(group *ghttp.RouterGroup) {
group.ALL("/all", func(r *ghttp.Request) {
r.Response.Write("all")
})
group.GET("/get", func(r *ghttp.Request) {
r.Response.Write("get")
})
group.POST("/post", func(r *ghttp.Request) {
r.Response.Write("post")
})
})
s.SetPort(8199)
s.Run()
}
```
执行后,终端打印出路由表如下:
```text
SERVER | DOMAIN | ADDRESS | METHOD | ROUTE | HANDLER | MIDDLEWARE
|---------|---------|---------|--------|-----------|-----------------|------------|
default | default | :8199 | ALL | /api/all | main.main.func1 |
|---------|---------|---------|--------|-----------|-----------------|------------|
default | default | :8199 | GET | /api/get | main.main.func2 |
|---------|---------|---------|--------|-----------|-----------------|------------|
default | default | :8199 | POST | /api/post | main.main.func3 |
|---------|---------|---------|--------|-----------|-----------------|------------|
```
其中, `/api/get` 仅允许 `GET` 方式访问, `/api/post` 仅允许 `POST` 方式访问, `/api/all` 允许所有的方式访问。
我们使用 `curl` 工具来测试一下:
1. `/api/get`
```bash
$ curl http://127.0.0.1:8199/api/get
get
$ curl -X POST http://127.0.0.1:8199/api/get
Not Found
```
1. `/api/post`
```bash
$ curl http://127.0.0.1:8199/api/post
Not Found
$ curl -X POST http://127.0.0.1:8199/api/post post
```
3. `/api/all`
```bash
$ curl http://127.0.0.1:8199/api/all
all
$ curl -X POST http://127.0.0.1:8199/api/all
all
$ curl -X DELETE http://127.0.0.1:8199/api/all
all
$ curl -X OPTIONS http://127.0.0.1:8199/api/all
all
```
## 层级注册
**提示**
`GoFrame` 框架的层级路由注册方式灵感来源于 `PHP Laravel` 框架。
推荐使用路由层级注册方式,注册的路由代码更清晰直观。 `GoFrame` 框架的分组路由注册支持更加直观优雅层级的注册方式,以便于开发者更方便地管理路由列表。路由层级注册方式也是推荐的路由注册方式。我们来看一个比较完整的示例,该示例中注册了使用到了中间件、 `HOOK` 以及不同 `HTTP Method` 绑定的路由注册:
```go
package main
import (
"fmt"
"net/http"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func MiddlewareAuth(r *ghttp.Request) {
if r.Get("token").String() != "123456" {
r.Response.WriteStatus(http.StatusForbidden)
return
}
r.Middleware.Next()
}
func MiddlewareCORS(r *ghttp.Request) {
r.Response.CORSDefault()
r.Middleware.Next()
}
func MiddlewareLog(r *ghttp.Request) {
r.Middleware.Next()
fmt.Println(r.Response.Status, r.URL.Path)
}
func main() {
s := g.Server()
s.Use(MiddlewareLog)
s.Group("/api.v2", func(group *ghttp.RouterGroup) {
group.Middleware(MiddlewareAuth, MiddlewareCORS)
group.GET("/test", func(r *ghttp.Request) {
r.Response.Write("test")
})
group.Group("/order", func(group *ghttp.RouterGroup) {
group.GET("/list", func(r *ghttp.Request) {
r.Response.Write("list")
})
group.PUT("/update", func(r *ghttp.Request) {
r.Response.Write("update")
})
})
group.Group("/user", func(group *ghttp.RouterGroup) {
group.GET("/info", func(r *ghttp.Request) {
r.Response.Write("info")
})
group.POST("/edit", func(r *ghttp.Request) {
r.Response.Write("edit")
})
group.DELETE("/drop", func(r *ghttp.Request) {
r.Response.Write("drop")
})
})
group.Group("/hook", func(group *ghttp.RouterGroup) {
group.Hook("/*", ghttp.HookBeforeServe, func(r *ghttp.Request) {
r.Response.Write("hook any")
})
group.Hook("/:name", ghttp.HookBeforeServe, func(r *ghttp.Request) {
r.Response.Write("hook name")
})
})
})
s.SetPort(8199)
s.Run()
}
```
执行后,注册的路由列表如下:
```text
SERVER | DOMAIN | ADDRESS | METHOD | ROUTE | HANDLER | MIDDLEWARE
|---------|---------|---------|--------|----------------------|---------------------|-----------------------------------------|
default | default | :8199 | ALL | /* | main.MiddlewareLog | GLOBAL MIDDLEWARE
|---------|---------|---------|--------|----------------------|---------------------|-----------------------------------------|
default | default | :8199 | ALL | /api.v2/hook/* | main.main.func1.4.1 | HOOK_BEFORE_SERVE
|---------|---------|---------|--------|----------------------|---------------------|-----------------------------------------|
default | default | :8199 | ALL | /api.v2/hook/:name | main.main.func1.4.2 | HOOK_BEFORE_SERVE
|---------|---------|---------|--------|----------------------|---------------------|-----------------------------------------|
default | default | :8199 | GET | /api.v2/order/list | main.main.func1.2.1 | main.MiddlewareAuth,main.MiddlewareCORS
|---------|---------|---------|--------|----------------------|---------------------|-----------------------------------------|
default | default | :8199 | PUT | /api.v2/order/update | main.main.func1.2.2 | main.MiddlewareAuth,main.MiddlewareCORS
|---------|---------|---------|--------|----------------------|---------------------|-----------------------------------------|
default | default | :8199 | GET | /api.v2/test | main.main.func1.1 | main.MiddlewareAuth,main.MiddlewareCORS
|---------|---------|---------|--------|----------------------|---------------------|-----------------------------------------|
default | default | :8199 | DELETE | /api.v2/user/drop | main.main.func1.3.3 | main.MiddlewareAuth,main.MiddlewareCORS
|---------|---------|---------|--------|----------------------|---------------------|-----------------------------------------|
default | default | :8199 | POST | /api.v2/user/edit | main.main.func1.3.2 | main.MiddlewareAuth,main.MiddlewareCORS
|---------|---------|---------|--------|----------------------|---------------------|-----------------------------------------|
default | default | :8199 | GET | /api.v2/user/info | main.main.func1.3.1 | main.MiddlewareAuth,main.MiddlewareCORS
|---------|---------|---------|--------|----------------------|---------------------|-----------------------------------------|
```
## 批量注册
### `Map`
通过 `Map` 方法可以实现批量的分组路由注册,但是如果是相同的 `URI` 不同的 `HTTP Method` 需要按照路由规范给定 `HTTP Method`。使用示例:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func UserGet(r *ghttp.Request) {
r.Response.Write("get")
}
func UserDelete(r *ghttp.Request) {
r.Response.Write("delete")
}
func main() {
s := g.Server()
s.Group("/api", func(group *ghttp.RouterGroup) {
group.Map(g.Map{
"GET: /user": UserGet,
"DELETE: /user": UserDelete,
})
})
s.SetPort(8199)
s.Run()
}
```
### `AllMap`
也可以使用 `ALLMap` 方法实现批量的分组路由注册,通过该方法注册的路由将把路由函数/对象应用到所有的 `HTTP Method`。使用示例:
```go
s := g.Server()
// 前台系统路由注册
s.Group("/", func(group *ghttp.RouterGroup) {
group.Middleware(service.Middleware.Ctx)
group.ALLMap(g.Map{
"/": api.Index, // 首页
"/login": api.Login, // 登录
"/register": api.Register, // 注册
"/category": api.Category, // 栏目
"/topic": api.Topic, // 主题
"/topic/:id": api.Topic.Detail, // 主题 - 详情
"/ask": api.Ask, // 问答
"/ask/:id": api.Ask.Detail, // 问答 - 详情
"/article": api.Article, // 文章
"/article/:id": api.Article.Detail, // 文章 - 详情
"/reply": api.Reply, // 回复
"/search": api.Search, // 搜索
"/captcha": api.Captcha, // 验证码
"/user/:id": api.User.Index, // 用户 - 主页
})
// 权限控制路由
group.Group("/", func(group *ghttp.RouterGroup) {
group.Middleware(service.Middleware.Auth)
group.ALLMap(g.Map{
"/user": api.User, // 用户
"/content": api.Content, // 内容
"/interact": api.Interact, // 交互
"/file": api.File, // 文件
})
})
})
```
# 路由注册-规范路由
## 基本介绍
从 `v2.0` 版本开始,框架的 `Server` 组件额外提供了规范化的路由注册方式,更加适合团队规范化的使用场景、业务复杂度更高的项目。规范路由实现了以下特性:
- 规范化 `API` 按照结构化编程设计
- 规范化 `API` 接口方法参数风格定义
- 更加简化的路由注册与维护
- 统一接口返回数据格式设计
- 保障代码与接口文档同步维护
- 自动的 `API` 参数对象化接收与校验
- 自动生成基于标准 `OpenAPIv3` 协议的接口文档
- 自动生成 `SwaggerUI` 页面
**提示**
注意一下哦: **规范路由** 与原有的 **函数、对象、分组** 路由方式都是框架的 `HTTP Server` 组件并存支持的路由注册方式,是为了解决一些规范化和自动化管理接口的场景,更适用于团队多人协作使用。其他的路由方式,特别是框架 `v1` 版本的函数、对象注册方式也是在新版本支持的哈。看个人使用习惯进行合理选择哟。
# 规范路由-基本示例
## 配置文件
这里使用 `YAML` 配置文件: `config.yaml`
```yaml
server:
address: ":8199"
openapiPath: "/api.json"
swaggerPath: "/swagger"
```
## 示例代码
我们从一个简单的 `Hello` 例子开始:
```go
package main
import (
"context"
"fmt"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
type HelloReq struct {
g.Meta `path:"/hello" method:"get"`
Name string `v:"required" dc:"Your name"`
}
type HelloRes struct {
Reply string `dc:"Reply content"`
}
type Hello struct{}
func (Hello) Say(ctx context.Context, req *HelloReq) (res *HelloRes, err error) {
g.Log().Debugf(ctx, `receive say: %+v`, req)
res = &HelloRes{
Reply: fmt.Sprintf(`Hi %s`, req.Name),
}
return
}
func main() {
s := g.Server()
s.Use(ghttp.MiddlewareHandlerResponse)
s.Group("/", func(group *ghttp.RouterGroup) {
group.Bind(
new(Hello),
)
})
s.Run()
}
```
拷贝这段代码,我们运行起来试试,终端输出信息如下:
```html
2021-11-19 23:31:35.277 25580: http server started listening on [:8199]
SERVER | DOMAIN | ADDRESS | METHOD | ROUTE | HANDLER | MIDDLEWARE
----------|---------|---------|--------|------------|-----------------------------------------------------------|--------------------
default | default | :8199 | ALL | /* | github.com/gogf/gf/v2/net/ghttp.MiddlewareHandlerResponse | GLOBAL MIDDLEWARE
----------|---------|---------|--------|------------|-----------------------------------------------------------|--------------------
default | default | :8199 | ALL | /api.json | github.com/gogf/gf/v2/net/ghttp.(*Server).openapiSpec-fm |
----------|---------|---------|--------|------------|-----------------------------------------------------------|--------------------
default | default | :8199 | GET | /hello | main.(*Hello).Say |
----------|---------|---------|--------|------------|-----------------------------------------------------------|--------------------
default | default | :8199 | ALL | /swagger/* | github.com/gogf/gf/v2/net/ghttp.(*Server).swaggerUI-fm | HOOK_BEFORE_SERVE
----------|---------|---------|--------|------------|-----------------------------------------------------------|--------------------
```
可以看到,除了我们的业务路由之外, `Server` 自动帮我们注册了两个路由: `/api.json` 和 `/swagger/*`。前者是自动生成的基于标准的 `OpenAPIv3` 协议的接口文档,后者是自动生成 `SwaggerUI` 页面,方便开发者查看和调试。这两个功能默认是关闭的,开发者可以通过前面示例中的 `openapiPath` 和 `swaggerPath` 两个配置项开启。
## 接口文档
接口文档通过 `OpenAPIv3` 协议生成,一般来说需要结合相应的 `UI` 工具查看,地址: [http://127.0.0.1:8199/api.json](http://127.0.0.1:8199/api.json)
**提示**
由于 `OpenAPIv3` 协议是规范的接口定义协议,因此开发者根据协议内容可以做很多事,例如:自定义UI展示、Client代码生成、协议转换等等。
## SwaggerUI
我们来看看这个 `SwaggerUI` 页面: [http://127.0.0.1:8199/swagger/](http://127.0.0.1:8199/swagger/)
这里只有一个我们的路由地址以及对应的输入输出结构体。当然,这只是个简单的示例,可以在真实的项目中通过一些配置使得页面更加丰富多彩。
我们接着在这个页面上做下接口测试吧:
嗯,接口返回了一个固定数据格式的 `Json` 内容,但是能看到其中的 `data` 为我们需要的返回结果。
**提示**
提示:最新版本的 `SwaggerUI` 页面已经不支持接口测试功能,如果对此有要求的同学可以自定义 `SwaggerUI`(参考 [接口文档-自定义UI](./api-document-swagger-ui.md)), 也可以导入接口文件 `api.json` 到第三方工具(例如 `apifox`)进行测试:
## 返回中间件
等等,似乎漏掉了什么东西?是的,我们这里使用了一个 `Server` 组件提供的中间件,它是拿来做什么的呢?我们开看看它的方法定义:
是的,它在我们没有提供自定义的返回数据格式处理中间件时,使用了一个默认的中间件处理我们的请求,并返回了一个默认的数据格式。
# 规范路由-如何使用
## 路由方法定义
从上面的例子中,我们可以看到,路由方法定义使用固定的格式:
```go
func Handler(ctx context.Context, req *Request) (res *Response, err error)
```
其中输入参数和输出参数都是两个,并且都是必须的一个都不能少。简单介绍下:
| 参数 | 说明 | 注意事项 |
| --- | --- | --- |
| `ctx context.Context` | 上下文 | `Server` 组件会自动从请求中获取并传递给接口方法 |
| `req *Request` | 请求对象 | 就算没有接收参数也要定义,因为请求结构体中不仅仅包含请求参数的定义,也包含了接口的请求定义。 |
| `res *Response` | 返回对象 | 就算没有返回参数也要定义,因为返回结构体中不仅仅包含返回参数的定义,也可以包含接口返回定义。 |
| `err error` | 错误对象 | `Server` 通过该参数判断接口执行成功或失败。 |
## 路由统一注册
### `Group.Bind` 方法
我们推荐使用对象化的方式来管理所有路由方法,并通过 **分组路由** 的 `Bind` 方法执行统一注册。需要注意的是,在规范化路由方式下,路由地址以及请求方式将由请求结构体在 `g.Meta` 元数据对象中通过标签定义,通过分组路由可以定义分组下的所有路由前缀。
### `BindHandler` 方法
我们也可以通过基础的 `BindHandler` 方法来注册规范路由,但是该方法只能注册一个路由函数。使用示例:
```go
s := g.Server()
s.BindHandler("/user/{uid}", func(ctx context.Context, req *SaveReq) (res *SaveRes, err error) {
// ...
})
```
## 规范参数结构
在规范化路由注册中,非常重要的是请求/返回参数结构体的定义,在该结构体不仅仅包含了输入参数的定义,也包含了接口的定义,特别是路由地址、请求方法、接口描述等信息。通过结构体方式的数据结构参数维护,更有利于更丰富的接口能力扩展、团队间的接口交互、长期的接口维护、自动化的接口文档生成。
为保证命名规范化,输入数据结构以`XxxReq`方式命名,输出数据结构以`XxxRes`方式命名。即便输入或者输出参数为空,也需要定义相应的数据结构,这样的目的一个是便于后续扩展,另一个是便于接口信息的管理。关于结构体中涉及到 `OpenAPIv3` 协议的标签介绍请查看后续章节。
**提示**
请求参数自动转换到请求数据结构,字段映射转换不区分大小写,也会自动忽略特殊字符。
## 输入数据校验
请求结构体在进入 `API` 接口执行前将会被自动执行校验,如果其中一条规则校验失败,那么将终止后续规则的校验(自动使用了 `bail` 校验修饰规则)。校验功能使用的是框架统一的校验组件,具体请参考: [数据校验](../core/gvalid.md)
**注意**
需要特别注意:如果参数校验存在多条校验规则,并且规则中存在 `required*` 规则时,那么建议将 `required*` 校验规则放置于所有规则之前,否则规范路由中内置启用的 `bail` 校验规则的特性(校验失败即停止后续校验)有可能会引起后续规则的 `required*` 规则不生效。
## 统一返回中间件
接口的数据返回处理需要设置 **统一的后置中间件**,当然也可以使用 `Server` 默认提供的数据返回中间件。开发者自定义中间件时可以参考 `Server` 默认提供的中间件 `MiddlewareHandlerResponse`。
这里顺便提一下,在自定义返回中间件时一个重要的方法:
```go
// GetHandlerResponse retrieves and returns the handler response object and its error.
func (r *Request) GetHandlerResponse() interface{}
```
通过后置中间件执行时通过请求对象的 `GetHandlerResponse` 方法获取当前业务执行的结果,并根据需要做相应处理。
## 扩展介绍
### `OpenAPIv3` 协议
`Server` 组件自动生成的接口文档使用的是最新的 `OpenAPIv3` 协议。更多介绍请参考章节: [接口文档](./api-document.md)
### `Ctx` 中的 `Request` 对象
我们可以通过 `RequestFromCtx/g.RequestFromCtx` 方法从 `ctx` 中获取 `Request` 对象。
方法定义:
```go
func RequestFromCtx(ctx context.Context) *Request
```
使用示例:
```go
func (c *cHello) Hello(ctx context.Context, req *apiv1.HelloReq) (res *apiv1.HelloRes, err error) {
g.RequestFromCtx(ctx).Response.Writeln("Hello World!")
return
}
```
# 规范路由-常见问题
## 在规范路由下,同一接口如何支持多种 `HTTP Method` 提交方式
首先,一个接口应当只做一件事情, `HTTP Method` 是有意义的(例如 `RESTful` 接口风格设计),一个接口支持多种 `HTTP Method` 方式通常是接口设计不合理,建议重新审视接口设计。
通常是不存在一个 `API` 需要绑定多个 `HTTP Method` 的场景的。例如,拿用户接口来讲,一个 `CRUD` 接口在 `RESTful` 实现应当有 `4-5` 个 `API` 定义,实现不同的业务逻辑。那么可能存在以下 `API` 定义的 `RESTful` 接口:
```
接口名称 Method Path
创建用户 PUT /user
用户列表 GET /user
用户详情 GET /user/{uid}
修改用户 POST /user/{uid}
删除用户 DELETE /user/{uid}
```
如果确实存在一个接口支持多个 `HTTP Method` 的场景,那么可以通过在 `Meta` 标签中的 `method` 属性中使用英文 `,` 号分隔各个 `HTTP Method` 即可,例如:
```go
type SaveReq struct {
g.Meta `path:"/user/{uid}" method:"put,post" summary:"保存用户" tags:"用户管理"`
Uid int64 `dc:"用户ID"`
Name string `dc:"用户名"`
// ...
}
```
## 使用默认提供的 `Response` 结构体,如何让 `Data` 字段只返回数组而无需指定名称的键值对
使用类型别名即可。
源码地址:[JSON数组响应](../../examples/httpserver/response-json-array.md)
结果示例:
# 路由管理-中间件/拦截器
## 延伸阅读
- [为Go应用增加完整的权限认证能力](https://goframe.org/articles/rbac-permission-authentication-in-go)
# 中间件/拦截器-基本介绍
`GoFrame` 提供了优雅的中间件请求控制方式,
该方式也是主流的 `WebServer` 提供的请求流程控制方式,
基于中间件设计可以为 `WebServer` 提供更灵活强大的插件机制。经典的中间件洋葱模型:
## 中间件定义
中间件的定义和普通HTTP执行方法 `HandlerFunc` 一样,但是可以在 `Request` 参数中使用 `Middleware` 属性对象来控制请求流程。
我们拿一个跨域请求的中间件定义来示例说明一下:
```go
func MiddlewareCORS(r *ghttp.Request) {
r.Response.CORSDefault()
r.Middleware.Next()
}
```
可以看到在该中间件中执行完成跨域请求处理的逻辑后,使用 `r.Middleware.Next()` 方法进一步执行下一个流程;如果这个时候直接退出不调用 `r.Middleware.Next()` 方法的话,将会退出后续的执行流程(例如可以用于请求的鉴权处理)。
## 中间件类型
中间件的类型分为两种:前置中间件和后置中间件。前置即在路由服务函数调用之前调用,后置即在其后调用。
### 前置中间件
其定义类似于:
```go
func Middleware(r *ghttp.Request) {
// 中间件处理逻辑
r.Middleware.Next()
}
```
### 后置中间件
其定义类似于:
```go
func Middleware(r *ghttp.Request) {
r.Middleware.Next()
// 中间件处理逻辑
}
```
## 中间件注册
中间件的注册有多种方式,参考接口文档: [https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp](https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp)
### 全局中间件
```go
func (s *Server) Use(handlers ...HandlerFunc)
```
全局中间件是可以 **独立使用** 的请求拦截方法,通过路由规则的方式进行注册,绑定到 `Server` 上,由于中间件需要执行请求拦截操作,因此往往是使用"模糊匹配"或者"命名匹配"规则。
**提示**
全局中间件仅对动态请求拦截有效,无法拦截静态文件请求。
### 分组路由中间件
```go
func (g *RouterGroup) Middleware(handlers ...HandlerFunc) *RouterGroup
```
分组路由中注册的中间件绑定到当前分组路由中的所有的服务请求上,当服务请求被执行前会调用到其绑定的中间件方法。 分组路由仅有一个 `Middleware` 的中间件注册方法。分组路由中间件与全局中间件不同之处在于, **分组路由中间件无法独立使用**,必须在分组路由注册中使用,并且绑定到当前分组路由中所有的路由上作为路由方法的一部分。
## 执行优先级
### 全局中间件
由于全局中间件也是通过路由规则执行,那么也会存在执行优先级:
1. 首先,由于全局中间件是基于模糊路由匹配,因此 **当同一个路由匹配到多个中间件时,会按照路由的深度优先规则执行**,具体请查看路由章节;
2. 其次, **同一个路由规则下,会按照中间件的注册先后顺序执行**,中间件的注册方法也支持同时按照先后顺序注册多个中间件;
3. 最后,为避免优先级混淆和后续管理,建议将所有中间件放到同一个地方进行先后顺序注册来控制执行优先级;
**提示**
这里的建议来参考于 `gRPC` 的拦截器设计,没有过多的路由控制,仅在一个地方同一个方法统一注册。往往越简单,越容易理解,也便于长期维护。
### 分组路由中间件
分组路由中间件是绑定到分组路由上的服务方法,不存在路由规则匹配,因此只会按照注册的先后顺序执行。参考后续示例或如下代码的执行结果。
展开源码
```go
package main
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/glog"
)
type HelloReq struct {
g.Meta `path:"/hello" method:"get"`
}
type HelloRes struct {
}
type Hello struct{}
func (Hello) Say(ctx context.Context, req *HelloReq) (res *HelloRes, err error) {
glog.Debug(ctx, "中")
return
}
func RequestHandle1(r *ghttp.Request) {
glog.Debug(r.GetCtx(), "前1")
r.Middleware.Next()
}
func RequestHandle2(r *ghttp.Request) {
glog.Debug(r.GetCtx(), "前2")
r.Middleware.Next()
}
func RequestHandle3(r *ghttp.Request) {
glog.Debug(r.GetCtx(), "前3")
r.Middleware.Next()
}
func RequestHandle4(r *ghttp.Request) {
glog.Debug(r.GetCtx(), "前4")
r.Middleware.Next()
}
func ResponseHandle1(r *ghttp.Request) {
r.Middleware.Next()
glog.Debug(r.GetCtx(), "后1")
}
func ResponseHandle2(r *ghttp.Request) {
r.Middleware.Next()
glog.Debug(r.GetCtx(), "后2")
}
func ResponseHandle3(r *ghttp.Request) {
r.Middleware.Next()
glog.Debug(r.GetCtx(), "后3")
}
func ResponseHandle4(r *ghttp.Request) {
r.Middleware.Next()
glog.Debug(r.GetCtx(), "后4")
}
func main() {
s := g.Server()
s.Use(ghttp.MiddlewareHandlerResponse)
s.Group("/", func(group *ghttp.RouterGroup) {
// 前置中间件
group.Middleware(RequestHandle1)
group.Middleware(RequestHandle2)
// 后置中间件
group.Middleware(ResponseHandle1)
group.Middleware(ResponseHandle2)
group.Group("/sub", func(group *ghttp.RouterGroup) {
// 前置中间件
group.Middleware(RequestHandle3)
group.Middleware(RequestHandle4)
// 后置中间件
group.Middleware(ResponseHandle3)
group.Middleware(ResponseHandle4)
group.Bind(new(Hello))
})
})
s.Run()
}
```
执行结果如下:
## 执行中断
在 **分组路由中间件** 中,我们可以在 **前置中间件** 的 `Next()` 调用之前通过 `return` 对本次请求进行中断,在打断后, **后续** **所有** 的 **前置中间件**、 **同级及子级** 的 **后置中间件**、 **请求处理方法** 将不会被执行。
如上面用来展示分组路由中间件优先级的代码中:
- 在 `RequestHandle1` 的 `Next()` 调用前进行中断,则只会执行 `RequestHandle1`
- 在 `RequestHandle2` 的 `Next()` 调用前进行中断,则只会执行 `RequestHandle1`、 `RequestHandle2`
- 在 `RequestHandle3` 的 `Next()` 调用前进行中断,则会执行 `RequestHandle1`、 `RequestHandle2`、 `RequestHandle3` 以及 `ResponseHandle2`、 `ResponseHandle1`
- 在 `RequestHandle4` 的 `Next()` 调用前进行中断,则会执行 `RequestHandle1`、 `RequestHandle2`、 `RequestHandle3`、 `RequestHandle4` 以及 `ResponseHandle2`、 `ResponseHandle1`
上述的中断实例都不会执行 **请求处理方法**。
除了常见的return `在` 中间件中终端后续处理流程大方式外,框架还提供了 `Exit` 相关的方法来在代码执行处强制中断执行流程,具体可参考章节: [数据返回-Exit控制](./response-exit-exitall-exithook.md)
# 中间件/拦截器-使用示例
## 允许跨域请求
第一个例子,也是比较常见的功能需求。
我们需要在所有API请求之前增加允许跨域请求的返回 `Header` 信息,该功能可以通过中间件实现:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func MiddlewareCORS(r *ghttp.Request) {
r.Response.CORSDefault()
r.Middleware.Next()
}
func main() {
s := g.Server()
s.Group("/api.v2", func(group *ghttp.RouterGroup) {
group.Middleware(MiddlewareCORS)
group.ALL("/user/list", func(r *ghttp.Request) {
r.Response.Writeln("list")
})
})
s.SetPort(8199)
s.Run()
}
```
执行后,终端打印出路由表信息如下:
```text
SERVER | DOMAIN | ADDRESS | METHOD | ROUTE | HANDLER | MIDDLEWARE
|---------|---------|---------|--------|-------------------|-------------------|---------------------|
default | default | :8199 | ALL | /api.v2/user/list | main.main.func1.1 | main.MiddlewareCORS
|---------|---------|---------|--------|-------------------|-------------------|---------------------|
```
这里我们使用 `group.Middleware(MiddlewareCORS)` 将跨域中间件通过分组路由的形式注册绑定到了 `/api.v2` 路由下所有的服务函数中。随后我们可以通过请求 [http://127.0.0.1:8199/api.v2/user/list](http://127.0.0.1:8199/api.v2/user/list) 来查看允许跨域请求的 `Header` 信息是否有返回。
## 请求鉴权处理
我们在跨域请求中间件的基础之上加上鉴权中间件。
为了简化示例,在该示例中,当请求带有 `token` 参数,并且参数值为 `123456` 时可以通过鉴权,并且允许跨域请求,执行请求方法;否则返回 `403 Forbidden` 状态码。
```go
package main
import (
"net/http"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func MiddlewareAuth(r *ghttp.Request) {
token := r.Get("token")
if token.String() == "123456" {
r.Response.Writeln("auth")
r.Middleware.Next()
} else {
r.Response.WriteStatus(http.StatusForbidden)
}
}
func MiddlewareCORS(r *ghttp.Request) {
r.Response.Writeln("cors")
r.Response.CORSDefault()
r.Middleware.Next()
}
func main() {
s := g.Server()
s.Group("/api.v2", func(group *ghttp.RouterGroup) {
group.Middleware(MiddlewareCORS, MiddlewareAuth)
group.ALL("/user/list", func(r *ghttp.Request) {
r.Response.Writeln("list")
})
})
s.SetPort(8199)
s.Run()
}
```
执行后,终端打印出路由表信息如下:
```text
SERVER | DOMAIN | ADDRESS | METHOD | ROUTE | HANDLER | MIDDLEWARE
|---------|---------|---------|--------|-------------------|-------------------|-----------------------------------------|
default | default | :8199 | ALL | /api.v2/user/list | main.main.func1.1 | main.MiddlewareCORS,main.MiddlewareAuth
|---------|---------|---------|--------|-------------------|-------------------|-----------------------------------------|
```
可以看到,我们的服务方法绑定了两个中间件,跨域中间件和而鉴权中间件。 请求时将会按照中间件注册的先后顺序,先执行 `MiddlewareCORS` 全局中间件,再执行 `MiddlewareAuth` 分组中间件。 随后我们可以通过请求 [http://127.0.0.1:8199/api.v2/user/list](http://127.0.0.1:8199/api.v2/user/list) 和 [http://127.0.0.1:8199/api.v2/user/list?token=123456](http://127.0.0.1:8199/api.v2/user/list?token=123456) 对比来查看效果。
## 鉴权例外处理
使用分组路由中间件可以很方便地添加鉴权例外,因为只有当前分组路由下注册的服务方法才会绑定并执行鉴权中间件,否则并不会执行到鉴权中间件。
```go
package main
import (
"net/http"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func MiddlewareAuth(r *ghttp.Request) {
token := r.Get("token")
if token.String() == "123456" {
r.Middleware.Next()
} else {
r.Response.WriteStatus(http.StatusForbidden)
}
}
func main() {
s := g.Server()
s.Group("/admin", func(group *ghttp.RouterGroup) {
group.ALL("/login", func(r *ghttp.Request) {
r.Response.Writeln("login")
})
group.Group("/", func(group *ghttp.RouterGroup) {
group.Middleware(MiddlewareAuth)
group.ALL("/dashboard", func(r *ghttp.Request) {
r.Response.Writeln("dashboard")
})
})
})
s.SetPort(8199)
s.Run()
}
```
执行后,终端打印出路由表信息如下:
```text
SERVER | ADDRESS | DOMAIN | METHOD | P | ROUTE | HANDLER | MIDDLEWARE
|---------|---------|---------|--------|---|------------------|---------------------|---------------------|
default | :8199 | default | ALL | 2 | /admin/dashboard | main.main.func1.2.1 | main.MiddlewareAuth
|---------|---------|---------|--------|---|------------------|---------------------|---------------------|
default | :8199 | default | ALL | 2 | /admin/login | main.main.func1.1 |
|---------|---------|---------|--------|---|------------------|---------------------|---------------------|
```
可以看到,只有 `/admin/dashboard` 路由的服务方法绑定了鉴权中间件 `main.MiddlewareAuth`,而 `/admin/login` 路由的服务方法并没有添加鉴权处理。 随后我们访问以下URL查看效果:
1. [http://127.0.0.1:8199/admin/login](http://127.0.0.1:8199/admin/login)
2. [http://127.0.0.1:8199/admin/dashboard](http://127.0.0.1:8199/admin/dashboard)
3. [http://127.0.0.1:8199/admin/dashboard?token=123456](http://127.0.0.1:8199/admin/dashboard?token=123456)
## 统一的错误处理
基于中间件,我们可以在服务函数执行完成后做一些后置判断的工作,特别是统一数据格式返回、结果处理、错误判断等等。这种需求我们可以使用后置的中间件类型来实现。我们使用一个简单的例子,用来演示如何使用中间件对所有的接口请求做后置判断处理,作为一个抛砖引玉作用。
```go
package main
import (
"net/http"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func MiddlewareAuth(r *ghttp.Request) {
token := r.Get("token")
if token.String() == "123456" {
r.Middleware.Next()
} else {
r.Response.WriteStatus(http.StatusForbidden)
}
}
func MiddlewareCORS(r *ghttp.Request) {
r.Response.CORSDefault()
r.Middleware.Next()
}
func MiddlewareErrorHandler(r *ghttp.Request) {
r.Middleware.Next()
if r.Response.Status >= http.StatusInternalServerError {
r.Response.ClearBuffer()
r.Response.Writeln("哎哟我去,服务器居然开小差了,请稍后再试吧!")
}
}
func main() {
s := g.Server()
s.Use(MiddlewareCORS)
s.Group("/api.v2", func(group *ghttp.RouterGroup) {
group.Middleware(MiddlewareAuth, MiddlewareErrorHandler)
group.ALL("/user/list", func(r *ghttp.Request) {
panic("db error: sql is xxxxxxx")
})
})
s.SetPort(8199)
s.Run()
}
```
执行后,终端打印出路由表信息如下:
```text
SERVER | DOMAIN | ADDRESS | METHOD | ROUTE | HANDLER | MIDDLEWARE
|---------|---------|---------|--------|-------------------|---------------------|-------------------------------------------------|
default | default | :8199 | ALL | /* | main.MiddlewareCORS | GLOBAL MIDDLEWARE
|---------|---------|---------|--------|-------------------|---------------------|-------------------------------------------------|
default | default | :8199 | ALL | /api.v2/user/list | main.main.func1.1 | main.MiddlewareAuth,main.MiddlewareErrorHandler
|---------|---------|---------|--------|-------------------|---------------------|-------------------------------------------------|
```
在该示例中,我们在后置中间件中判断有无系统错误,如果有则返回固定的提示信息,而不是把敏感的报错信息展示给用户。当然,在真实的项目场景中,往往还有是需要解析返回缓冲区的数据,例如 `JSON` 数据,根据当前的执行结果进行封装返回固定的数据格式等等。
执行该示例后,访问 [http://127.0.0.1:8199/api.v2/user/list?token=123456](http://127.0.0.1:8199/api.v2/user/list?token=123456) 查看效果。
## 自定义日志处理
我们来更进一步完善一下以上示例,我们将请求日志包括状态码输出到终端。这里我们必须得使用」全局中间件」了,这样可以拦截处理到所有的服务请求,甚至 `404` 请求。
```go
package main
import (
"net/http"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func MiddlewareAuth(r *ghttp.Request) {
token := r.Get("token")
if token.String() == "123456" {
r.Middleware.Next()
} else {
r.Response.WriteStatus(http.StatusForbidden)
}
}
func MiddlewareCORS(r *ghttp.Request) {
r.Response.CORSDefault()
r.Middleware.Next()
}
func MiddlewareLog(r *ghttp.Request) {
r.Middleware.Next()
errStr := ""
if err := r.GetError(); err != nil {
errStr = err.Error()
}
g.Log().Println(r.Response.Status, r.URL.Path, errStr)
}
func main() {
s := g.Server()
s.SetConfigWithMap(g.Map{
"AccessLogEnabled": false,
"ErrorLogEnabled": false,
})
s.Use(MiddlewareLog, MiddlewareCORS)
s.Group("/api.v2", func(group *ghttp.RouterGroup) {
group.Middleware(MiddlewareAuth)
group.ALL("/user/list", func(r *ghttp.Request) {
panic("啊!我出错了!")
})
})
s.SetPort(8199)
s.Run()
}
```
可以看到,我们注册了一个全局的日志处理中间件以及跨域中间件,而鉴权中间件是注册到 `/api.v2` 路由下。
执行后,我们可以通过请求 [http://127.0.0.1:8199/api.v2/user/list](http://127.0.0.1:8199/api.v2/user/list) 和 [http://127.0.0.1:8199/api.v2/user/list?token=123456](http://127.0.0.1:8199/api.v2/user/list?token=123456) 对比来查看效果,并查看终端的日志输出情况。
# 中间件/拦截器-常见问题
## 如何通过中间件修改请求参数
在了解如何修改请求参数之前,请先了解一下请求参数输入的一些细节知识: [请求输入](./request.md)
### 自定义参数覆盖
修改请求参数最简单的方式,是通过 **自定义参数** 来覆盖客户端提交的参数。因为自定义参数在参数解析的优先级最高,因此在 **全量获取请求参数**(不是通过特定的提交方式获取参数)时,会使用自定义参数来覆盖掉其他提交方式的参数。这种修改方式比较常见,特别是使用规范路由时,接收参数时一个 `struct` 对象,底层是通过全量获取参数后再转换为 `struct` 对象。
但是如果用户是用过 `r.GetQuery, r.GetForm` 来获取参数时,自定义参数覆盖将会失效。而 `r.Get` 或者 `r.GetRequest` 方式获取参数,也可以实现参数覆盖的效果。
### 修改特定请求方式的参数
也可以在中间件中通过修改 `r.URL.RawQuery` 或者 `r.Body` 来修改原始参数,但是在修改完毕后,需要调用 `r.ReloadParam()` 来标识下一次获取请求参数时将重新解析参数。
## 通过中间件修改请求Body的注意事项
当通过中间件修改 `r.Body` 时常遇到的一个问题,是 `r.Body` 只允许读取一次,后续的读取都将读取不到数据,这是标准库 `http.Request` 的设计。在 `GoFrame` 框架的 `ghttp.Request` 对象中,允许通过框架 `ghttp.Request` 的 `GetBody`、 `GetBodyString` 方法连续读取 `Body` 内容。
但是,如果是在中间件中自行通过标准库 `http.Request` 对象直接读取 `r.Body`,那么建议通过重新赋值 `r.Body` 或者通过 `io.NopCloser` 重新包裹赋值给 `r.Body` 便于后续中间件或者流程进一步读取 `Body` 内容。
# 接口文档
## 基本介绍
`GoFrame` 框架支持全自动化的接口文档生成,保障代码与接口文档同步维护,自动生成的接口文档采用的是标准的 `OpenAPIv3` 协议。在介绍 `OpenAPIv3` 协议之前,建议先阅读了解框架的规范路由特性: [路由注册-规范路由](./router-registering-strict-router.md)。
# 接口文档-OpenAPIv3
**提示**
`OpenAPIv3` 协议主要使用在规范路由中,阅读接口文档协议介绍之前,请先了解一下规范路由: [路由注册-规范路由](./router-registering-strict-router.md)
## 一、 `OpenAPIv3`
详细的 `OpenAPIv3` 协议介绍请参考: [https://swagger.io/specification/](https://swagger.io/specification/)
## 二、 `g.Meta` 元数据
接口的元数据信息可以通过为输入结构体`embedded`方式嵌入`g.Meta`结构,并通过`g.Meta`的属性标签方式来实现。
关于元数据组件的介绍,详情请参考章节: [元数据-gmeta](../components/util-gmeta.md)
## 三、常用协议标签
输入输出结构体中的属性的标签完整支持 `OpenAPIv3` 协议,因此只要增加了对应的协议标签,那么生成的 `OpenAPIv3` 接口信息中将会自动包含该属性。
大部分的标签属性已经被 `Server` 组件自动生成,开发者需要手动设置的标签不多。
### 1、基础标签
常见的基础标签包括:
| 常见标签 | 说明 | 备注 |
| --- | --- | --- |
| `path` | 结合注册时的前缀共同构成接口URI路径 | 用于 `g.Meta` 标识接口元数据 |
| `tags` | 接口所属的标签,用于接口分类 | 用于 `g.Meta` 标识接口元数据 |
| `method` | 接口的请求方式: `ALL/GET/PUT/POST/DELETE...(不区分大小写)`。支持组合方式如`GET,POST`,多种方式使用`,`分隔。 | 用于 `g.Meta` 标识接口元数据 |
| `deprecated` | 标记该接口废弃 | 用于 `g.Meta` 标识接口元数据 |
| `summary` | 接口/参数概要描述 | 缩写 `sm` |
| `description` | 接口/参数详细描述 | 缩写 `dc` |
| `in` | 参数的提交方式 | `header/path/query/cookie` |
| `default` | 参数的默认值 | 缩写 `d` |
| `mime` | 接口的 `MIME` 类型,例如 `multipart/form-data` 一般是全局设置,默认为 `application/json`。 | 用于 `g.Meta` 标识接口元数据 |
| `type` | 参数的类型,一般不需要设置,特殊参数需要手动设置,例如 `file` | 仅用于参数属性 |
**提示**
更多标签请参考标准的 `OpenAPIv3` 协议: [https://swagger.io/specification/](https://swagger.io/specification/)
### 2、扩展标签
在 `OpenAPI` 规范里面,所有名称以 `x-` 开头的标签是开发者可自定义的扩展标签。扩展标签可以在任意的接口、属性中以 `Golang struct tag` 的形式定义,在接口文档生成时,将会作为独立的字段返回。例如:
```go
package main
import (
"context"
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
type GetListReq struct {
g.Meta `path:"/user" tags:"User" method:"get" x-group:"User/Info" summary:"Get user list with basic info."`
Page int `dc:"Page number" d:"1" x-sort:"1"`
Size int `dc:"Size for per page." d:"10" x-sort:"2"`
}
type GetListRes struct{}
type Controller struct{}
func (c *Controller) GetList(ctx context.Context, req *GetListReq) (res *GetListRes, err error) {
return nil, gerror.NewCode(gcode.CodeNotImplemented)
}
func main() {
s := g.Server()
s.Group("/", func(group *ghttp.RouterGroup) {
group.Bind(new(Controller))
})
s.SetOpenApiPath("/api.json")
s.SetSwaggerPath("/swagger")
s.SetPort(8199)
s.Run()
}
```
执行后,访问地址 [http://127.0.0.1:8199/swagger](http://127.0.0.1:8199/swagger) 可以查看 `swagger ui`,访问 [http://127.0.0.1:8199/api.json](http://127.0.0.1:8199/api.json) 可以查看对应的 `OpenAPIv3` 接口文档。其中生成的 `OpenAPIv3` 接口文档如下:
```
{
"openapi": "3.0.0",
"components": {
"schemas": {
"main.GetListReq": {
"properties": {
"Page": {
"default": 1,
"description": "Page number",
"format": "int",
"properties": {},
"type": "integer",
"x-sort": "1"
},
"Size": {
"default": 10,
"description": "Size for per page.",
"format": "int",
"properties": {},
"type": "integer",
"x-sort": "2"
}
},
"type": "object",
"x-group": "User/Info"
},
"main.GetListRes": {
"properties": {},
"type": "object"
}
}
},
"info": {
"title": "",
"version": ""
},
"paths": {
"/user": {
"get": {
"parameters": [
{
"description": "Page number",
"in": "query",
"name": "Page",
"schema": {
"default": 1,
"description": "Page number",
"format": "int",
"properties": {},
"type": "integer",
"x-sort": "1"
},
"x-sort": "1"
},
{
"description": "Size for per page.",
"in": "query",
"name": "Size",
"schema": {
"default": 10,
"description": "Size for per page.",
"format": "int",
"properties": {},
"type": "integer",
"x-sort": "2"
},
"x-sort": "2"
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/main.GetListRes"
}
}
},
"description": ""
}
},
"summary": "Get user list with basic info.",
"tags": [
"User"
],
"x-group": "User/Info"
},
"x-group": "User/Info"
}
}
}
```
可以看到,扩展标签已经生成到了接口文档中。
## 四、数据校验规则自动转换
`GoFrame`的`goai`组件能够自动将结构体字段的数据校验规则(`validation rules`)转换为`OpenAPIv3`中对应的校验数据结构,使接口文档更加准确和完整。这意味着只需在结构体字段中定义常规的数据校验规则,`goai`组件就会自动将其转换为`OpenAPIv3`规范中的对应约束。
### 1. 支持的校验规则
以下是`goai`组件自动识别并转换的校验规则:
| 校验规则 | 说明 | OpenAPIv3对应结构 |
| --- | --- | --- |
| `required` | 必填字段 | `required: true` |
| `min:{数值}` | 最小值限制 | `minimum: {数值}` |
| `max:{数值}` | 最大值限制 | `maximum: {数值}` |
| `length:{最小长度},{最大长度}` | 字符串长度范围 | `minLength: {最小长度}, maxLength: {最大长度}` |
| `min-length:{长度}` | 字符串最小长度 | `minLength: {长度}` |
| `max-length:{长度}` | 字符串最大长度 | `maxLength: {长度}` |
| `between:{最小值},{最大值}` | 数值范围 | `minimum: {最小值}, maximum: {最大值}` |
| `in:{值1},{值2},...` | 枚举值限制 | `enum: [{值1}, {值2}, ...]` |
### 2. 使用示例
```go
type CreateUserReq struct {
g.Meta `path:"/user" method:"post" summary:"创建用户"`
Name string `v:"required|length:2,30" dc:"用户名"`
Age int `v:"required|between:18,60" dc:"年龄"`
Email string `v:"required|email" dc:"电子邮箱"`
Score int `v:"min:0|max:100" dc:"分数"`
Type string `v:"in:admin,user,guest" dc:"用户类型"`
}
```
生成的`OpenAPIv3`文档将包含以下校验信息:
- `Name`字段:`required: true, minLength: 2, maxLength: 30`
- `Age`字段:`required: true, minimum: 18, maximum: 60`
- `Email`字段:`required: true`
- `Score`字段:`minimum: 0, maximum: 100`
- `Type`字段:`enum: ["admin", "user", "guest"]`
### 3. 注意事项
- 数值类型(如`int`、`float`等)的字段才会转换`min`、`max`和`between`规则为`minimum`和`maximum`
- 字符串类型的字段会转换`min-length`、`max-length`和`length`规则为`minLength`和`maxLength`
- `required`规则会转换为参数的`required: true`属性
- `in`规则会转换为`enum`数组,如果所有值都是数字,则会转换为数字类型的枚举,否则为字符串类型
## 五、扩展 `OpenAPIv3` 信息
核心的接口信息已经自动生成,如果开发者想要更进一步完善接口信息,可以通过 `s.GetOpenApi()` 接口获取到 `OpenAPIv3` 的结构体对象,并手动填充对应的属性内容即可。我们来看一个示例,在该示例中,我们设计每个接口外层公共的数据结构:
我们可以发现通过通用的 `OpenAPIv3` 对象我们可以自定义修改其内容,并且根据它生成其他各种自定义类型的接口文档。
## 五、添加`api.json(swagger)`自定义鉴权
对于需要进行`api`文档鉴权的情况,可以使用 `ghttp.BindHookHandler` 方法对 `s.GetOpenApiPath()` 路由绑定前置方法进行鉴权,示例如下:
``` go
func main() {
s := g.Server()
// if api.json requires authentication, add openApiBasicAuth handler
s.BindHookHandler(s.GetOpenApiPath(), ghttp.HookBeforeServe, openApiBasicAuth)
s.Run()
}
func openApiBasicAuth(r *ghttp.Request) {
if !r.BasicAuth("OpenApiAuthUserName", "OpenApiAuthPass", "Restricted") {
r.ExitAll()
return
}
}
```
详细介绍请参考代码示例文档:[swagger-auth](../../examples/httpserver/swagger-auth.md)
# 接口文档-扩展Response
## 一、概述
`RESTful API`设计中,不同的状态码表示不同的响应情况,例如 `200` 表示成功,`201` 表示创建成功,`400` 表示请求错误,`401` 表示未授权等。在`GoFrame`的`OpenAPIv3`接口文档中,默认情况下只会生成成功状态(通常是 `200`)的响应文档。
然而,在实际应用中,我们常常需要为一个`API`提供多种可能的响应状态,例如错误处理、权限验证失败等情况。为了满足这一需求,`GoFrame`在 `goai` 组件中提供了 `IEnhanceResponseStatus` 接口,开发者可以通过实现该接口来扩展响应结构体的信息,添加多种状态码的响应文档。
## 二、接口定义
`IEnhanceResponseStatus` 接口的相关定义如下:
``` go
type EnhancedStatusCode = int
type EnhancedStatusType struct {
Response any
Examples any
}
type IEnhanceResponseStatus interface {
EnhanceResponseStatus() map[EnhancedStatusCode]EnhancedStatusType
}
```
## 三、接口字段说明
- `EnhancedStatusCode`:表示 HTTP 状态码,例如 `200`、`201`、`400`、`401`、`403`、`404`、`500` 等。
- `EnhancedStatusType`:包含两个字段:
- `Response`:响应结构体,可以是任意类型(`any`)。可以为它添加 `g.Meta` 标签来添加文档信息,如果设置了 `mime` 标签,该结构体会覆盖通用响应结构体的内容。
- `Examples`:响应示例,可以是任意类型(`any`)。可以使用错误码列表自动生成示例内容并显示在文档中,这样可以保证文档内容与实际业务内容的同步。
- `IEnhanceResponseStatus`:接口定义,包含一个方法 `EnhanceResponseStatus()`,该方法返回一个映射,将 HTTP 状态码映射到相应的 `EnhancedStatusType` 类型。
## 四、使用方法
要使用 `IEnhanceResponseStatus` 接口,需要在响应结构体中实现 `EnhanceResponseStatus()` 方法。该方法返回一个映射,将 HTTP 状态码映射到相应的响应结构体和示例。
### 1. 实现接口
在响应结构体中实现 `EnhanceResponseStatus()` 方法,返回不同状态码对应的响应结构体和示例。
### 2. 设置响应状态码
可以在响应结构体的 `g.Meta` 标签中使用 `status` 属性来设置默认的响应状态码,例如 `g.Meta `status:"201"`。
### 3. 使用示例数据
可以在 `EnhanceResponseStatus()` 方法中返回示例数据,这些数据将会显示在生成的 OpenAPIv3 文档中。
## 五、完整示例
下面是一个完整的示例,演示如何使用 `IEnhanceResponseStatus` 接口来扩展响应结构体的信息:
``` go
package main
import (
"context"
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/net/goai"
)
type StoreMessageReq struct {
g.Meta `path:"/messages" method:"post" summary:"Store a message"`
Content string `json:"content"`
}
type StoreMessageRes struct {
g.Meta `status:"201"`
Id string `json:"id"`
}
type EmptyRes struct {
g.Meta `mime:"application/json"`
}
type CommonRes struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
var StoreMessageErr = map[int]gcode.Code{
500: gcode.New(1, "Server Dead", nil),
}
func (r StoreMessageRes) EnhanceResponseStatus() (resList map[int]goai.EnhancedStatusType) {
examples := []interface{}{}
example500 := CommonRes{
Code: StoreMessageErr[500].Code(),
Message: StoreMessageErr[500].Message(),
Data: nil,
}
examples = append(examples, example500)
return map[int]goai.EnhancedStatusType{
403: {
Response: EmptyRes{},
},
500: {
Response: struct{}{},
Examples: examples,
},
}
}
type Controller struct{}
func (c *Controller) StoreMessage(ctx context.Context, req *StoreMessageReq) (res *StoreMessageRes, err error) {
return nil, gerror.NewCode(gcode.CodeNotImplemented)
}
func main() {
s := g.Server()
s.Group("/", func(group *ghttp.RouterGroup) {
group.Bind(new(Controller))
})
oai := s.GetOpenApi()
oai.Config.CommonResponse = CommonRes{}
oai.Config.CommonResponseDataField = `Data`
s.SetOpenApiPath("/api.json")
s.SetSwaggerPath("/swagger")
s.SetPort(8199)
s.Run()
}
```
执行后,访问地址 [http://127.0.0.1:8199/swagger](http://127.0.0.1:8199/swagger) 可以查看 `swagger ui`,访问 [http://127.0.0.1:8199/api.json](http://127.0.0.1:8199/api.json) 可以查看对应的 `OpenAPIv3` 接口文档。其中生成的 `OpenAPIv3` 接口文档如下:
``` json
{
"openapi": "3.0.0",
"components": {
"schemas": {
"main.StoreMessageReq": {
"properties": {
"content": {
"format": "string",
"type": "string"
}
},
"type": "object"
},
"main.StoreMessageRes": {
"properties": {
"id": {
"format": "string",
"type": "string"
}
},
"type": "object"
},
"interface": {
"properties": {},
"type": "object"
},
"main.EmptyRes": {
"properties": {},
"type": "object"
},
"struct": {
"properties": {},
"type": "object"
}
}
},
"info": {
"title": "",
"version": ""
},
"paths": {
"/messages": {
"post": {
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/main.StoreMessageReq"
}
}
}
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"properties": {
"code": {
"format": "int",
"type": "integer"
},
"message": {
"format": "string",
"type": "string"
},
"data": {
"properties": {
"id": {
"format": "string",
"type": "string"
}
},
"type": "object"
}
},
"type": "object"
}
}
},
"description": ""
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/main.EmptyRes"
}
}
},
"description": ""
},
"500": {
"content": {
"application/json": {
"schema": {
"properties": {
"code": {
"format": "int",
"type": "integer"
},
"message": {
"format": "string",
"type": "string"
},
"data": {
"properties": {},
"type": "object"
}
},
"type": "object"
},
"examples": {
"example 1": {
"value": {
"code": 1,
"message": "Server Dead",
"data": null
}
}
}
}
},
"description": ""
}
},
"summary": "Store a message"
}
}
}
}
```
可以看到默认的响应状态码已经被更改为 `201`,并且响应示例也自动生成。
## 六、常见状态码使用场景
在实际应用中,不同的 HTTP 状态码用于表示不同的响应情况。以下是一些常见的状态码及其使用场景:
- `200 OK`:请求成功,返回请求的数据。
- `201 Created`:资源创建成功,通常用于 POST 请求。
- `204 No Content`:请求成功,但没有返回内容,通常用于 DELETE 请求。
- `400 Bad Request`:请求参数错误或格式不正确。
- `401 Unauthorized`:未授权,需要身份验证。
- `403 Forbidden`:已授权,但没有权限访问资源。
- `404 Not Found`:请求的资源不存在。
- `500 Internal Server Error`:服务器内部错误。
在实现 `IEnhanceResponseStatus` 接口时,可以根据实际业务需求,为这些不同的状态码提供相应的响应结构体和示例数据。
## 七、使用建议
1. **结构体复用**:对于类似的响应结构,可以定义公共的结构体进行复用,减少代码重复。
2. **错误码管理**:将错误码集中管理,例如使用 `map` 或常量来定义不同状态码对应的错误码,便于维护和扩展。
3. **示例数据的真实性**:提供的示例数据应尽量真实,反映实际业务场景,便于前端开发者理解和处理。
4. **文档同步更新**:当业务逻辑或错误码变更时,及时更新接口文档,保持文档与代码的一致性。
## 八、使用资源文件加载示例数据
在实际应用中,示例数据可能会比较大或复杂,直接在代码中编写可能会影响代码的可读性。`GoFrame`的`goai`组件提供了从外部文件加载示例数据的功能,支持从`gres`资源管理器或本地文件系统中读取示例数据,文件格式可以为`json/xml/yaml/ini`等`gjson`组件支持的格式。
### 1. 使用`g.Meta`标签指定示例文件
可以在响应结构体的`g.Meta`标签中使用`resEg`属性指定示例文件的路径:
```go
type MyResponse struct {
g.Meta `status:"201" resEg:"testdata/examples/201.json"`
// ...其他字段
}
```
其中`testdata/examples/201.json`是示例文件的路径,可以是相对路径或绝对路径。
### 2. 示例文件格式
示例文件可以是一个`JSON`对象或`JSON`数组:
- **JSON对象**:每个键将作为示例的名称,值作为示例的内容。
```json
{
"example1": {
"code": 0,
"message": "Success",
"data": { "id": 1 }
},
"example2": {
"code": 1,
"message": "Failed",
"data": null
}
}
```
- **JSON数组**:数组中的每个元素将作为一个示例,示例名称会自动生成为`example 1`、`example 2`等。
```json
[
{
"code": 0,
"message": "Success",
"data": { "id": 1 }
},
{
"code": 1,
"message": "Failed",
"data": null
}
]
```
### 3. 使用`gres`资源管理器
`GoFrame`的`goai`组件会首先尝试从`gres`资源管理器中读取指定路径的文件,如果找不到,则会尝试从本地文件系统中读取。这使得可以将示例数据打包到程序中,方便分发和部署。
要使用`gres`资源管理器,需要先将示例文件打包到资源文件中:
```go
package main
import (
"github.com/gogf/gf/v2/os/gres"
"github.com/gogf/gf/v2/frame/g"
)
func main() {
// 打包资源文件
gres.Dump()
// 初始化服务器
s := g.Server()
// ...其他配置
s.Run()
}
```
### 4. 完整示例
下面是一个使用资源文件加载示例数据的完整示例:
```go
package main
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
type CreateUserReq struct {
g.Meta `path:"/users" method:"post" summary:"创建用户"`
Name string `json:"name" v:"required" dc:"用户名"`
Email string `json:"email" v:"required|email" dc:"电子邮箱"`
}
type CreateUserRes struct {
g.Meta `status:"201" resEg:"testdata/examples/user/create_success.json"`
Id int `json:"id" dc:"用户ID"`
}
func (r CreateUserRes) EnhanceResponseStatus() map[int]goai.EnhancedStatusType {
return map[int]goai.EnhancedStatusType{
400: {
Response: struct{}{},
// 从文件中加载错误示例
},
500: {
Response: struct{}{},
// 从文件中加载错误示例
},
}
}
type Controller struct{}
func (c *Controller) CreateUser(ctx context.Context, req *CreateUserReq) (res *CreateUserRes, err error) {
// 实际业务逻辑
return &CreateUserRes{Id: 1}, nil
}
func main() {
s := g.Server()
s.Group("/", func(group *ghttp.RouterGroup) {
group.Bind(new(Controller))
})
s.SetOpenApiPath("/api.json")
s.SetSwaggerPath("/swagger")
s.Run()
}
```
其中`testdata/examples/user/create_success.json`文件内容可能是:
```json
{
"成功示例": {
"code": 0,
"message": "创建成功",
"data": {
"id": 1
}
}
}
```
这种方式可以使示例数据与代码分离,更容易维护和管理。
## 九、总结
`GoFrame`的 `IEnhanceResponseStatus` 接口提供了一种灵活、强大的方式来扩展 `OpenAPIv3`接口文档中的响应信息。通过实现该接口,开发者可以:
1. 为`API`提供多种响应状态的文档,使文档更符合`RESTful API`设计规范。
2. 自定义不同状态码的响应结构体,提供更精确的接口定义。
3. 通过示例数据展示不同状态的响应格式,便于前端开发者理解和处理。
4. 保持文档与代码的一致性,提高开发效率和协作质量。
通过合理使用 `IEnhanceResponseStatus` 接口,可以显著提升`API`文档的质量和完整性,为前后端开发者提供更好的开发体验。
# 接口文档-自定义UI
## 基本介绍
`GoFrame` 的 `Server` 默认自带的 `OpenAPI` 接口文档UI是 `redoc` 开源组件,该组件不支持页面 `Try It Out` 功能的。很多同学都在问,能否使用 `SwaggerUI` 页面来展示 `OpenAPI` 接口文档?有的企业内部并不支持连接外网的部分资源,那么能否将内部的接口文档UI替换为内部可访问的资源呢?
了解 `OpenAPI` 这个东东的小伙伴应该都知道, `OpenAPI` 只是通用的接口定义规范,而展示的接口文档UI是可以随便替换的,
并且这种`UI`界面以及平台还特别多。使用 `GoFrame Server` 来切换接口文档UI页面,
或者将接口文档对接到第三方接口文档平台非常简单。
## 使用示例
咱们通过代码来展示一下,如何快速地将接口文档UI切换为 `SwaggerUI`。 `SwaggerUI` 的相关链接:
- [https://github.com/swagger-api/swagger-ui](https://github.com/swagger-api/swagger-ui)
- [https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/installation.md](https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/installation.md)
main.go
```go
package main
import (
"context"
"fmt"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
// HelloReq hello request
type HelloReq struct {
g.Meta `path:"/hello" method:"get" sort:"1"`
Name string `v:"required" dc:"Your name"`
}
// HelloRes hello response
type HelloRes struct {
Reply string `dc:"Reply content"`
}
// Hello Controller
type Hello struct{}
// Say function
func (Hello) Say(ctx context.Context, req *HelloReq) (res *HelloRes, err error) {
g.Log().Debugf(ctx, `receive say: %+v`, req)
res = &HelloRes{
Reply: fmt.Sprintf(`Hi %s`, req.Name),
}
return
}
const (
MySwaggerUITemplate = `
SwaggerUI
`
)
func main() {
s := g.Server()
s.Use(ghttp.MiddlewareHandlerResponse)
s.Group("/", func(group *ghttp.RouterGroup) {
group.Bind(
new(Hello),
)
})
s.SetSwaggerUITemplate(MySwaggerUITemplate)
s.Run()
}
```
config.yaml
```yaml
server:
address: ":8199"
openapiPath: "/api.json"
swaggerPath: "/swagger"
```
我们这里只定义了一个 `Hello` 的接口。可以看到,我们通过一个接口来展示 `SwaggerUI` 的 `HTML` 页面,并且将 `OpenAPI` 的接口文件路径定义为 `/api.json`,没有启用 `Server` 自带的UI页面。执行后,终端输出:
```html
2022-05-18 20:41:09.160 [INFO] openapi specification is serving at address: http://127.0.0.1:8199/api.json
2022-05-18 20:41:09.161 [INFO] pid[57888]: http server started listening on [:8199]
ADDRESS | METHOD | ROUTE | HANDLER | MIDDLEWARE
----------|--------|-----------|-----------------------------------------------------------------|--------------------
:8199 | ALL | /* | github.com/gogf/gf/v2/net/ghttp.internalMiddlewareServerTracing | GLOBAL MIDDLEWARE
----------|--------|-----------|-----------------------------------------------------------------|--------------------
:8199 | ALL | /api.json | github.com/gogf/gf/v2/net/ghttp.(*Server).openapiSpec |
----------|--------|-----------|-----------------------------------------------------------------|--------------------
:8199 | GET | /hello | main.(*Hello).Say |
----------|--------|-----------|-----------------------------------------------------------------|--------------------
:8199 | GET | /swagger | main.main.func1.1 |
----------|--------|-----------|-----------------------------------------------------------------|--------------------
```
戳此链接访问: [http://127.0.0.1:8199/swagger/](http://127.0.0.1:8199/swagger/)
页面展示如下:
开发者自定义其他的接口文档UI类似如此。
## 常见UI模板
### swagger-ui
```
SwaggerUI
```
### openapi-ui
```
openAPI UI
```
# 请求输入
请求输入依靠 `ghttp.Request` 对象实现, `ghttp.Request` 继承了底层的 `http.Request` 对象。 `ghttp.Request` 包含一个与当前请求对应的返回输出对象 `Response`,用于数据的返回处理。
相关方法: [https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp#Request](https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp#Request)
## 简要说明
可以看到 `Request` 对象的 **参数获取方法** 非常丰富,常用方法如下:
| 常用方法 | 描述 |
| --- | --- |
| `Get` | 常用方法,简化参数获取, `GetRequest` 的别名。 |
| `GetQuery` | 获取 `GET` 方式传递过来的参数,包括 `Query String` 及 `Body` 参数解析。 |
| `GetForm` | 获取表单方式传递过来的参数,表单方式提交的参数 `Content-Type` 往往为 `application/x-www-form-urlencoded`, `application/form-data`, `multipart/form-data`, `multipart/mixed` 等等。 |
| `GetRequest` | 获取客户端提交的所有参数,按照参数优先级进行覆盖,不区分提交方式。 |
| `Get*Struct` | 将指定提交类型的所有请求参数绑定到指定的 `struct` 对象上,注意给定的参数为对象指针。绝大部分场景中往往使用 `Parse` 方法将请求数据转换为请求对象,具体详见后续章节。 |
| `GetBody/GetBodyString` | 获取客户端提交的原始数据,该数据是客户端写入到 `body` 中的原始数据,与 `HTTP Method` 无关,例如客户端提交 `JSON/XML` 数据格式时可以通过该方法获取原始的提交数据。 |
| `GetJson` | 自动将原始请求信息解析为 `gjson.Json` 对象指针返回, `gjson.Json` 对象具体在 [通用编解码-gjson](../components/encoding-gjson.md) 章节中介绍。 |
| `Exit*` | 用于请求流程退出控制,详见本章后续说明。 |
## 提交方式
`GoFrame` 框架的参数获取不是通过 `HTTP Method` 来做区分,而是通过参数提交类型来区分。例如,分别通过 `HTTP Method: POST、INPUT、DELETE` 来提交表单参数,在服务端获取参数不是通过 `GetPost`/ `GetInput`/ `GetDelete` 的方式来获取,而是统一通过 `GetForm` 方法来获取表单参数,针对其他的 `HTTP Method` 也是如此。
在 `GoFrame` 框架下,有以下几种提交类型:
| 提交类型 | 描述 |
| --- | --- |
| `Router` | **路由参数**。来源于路由规则匹配。 |
| `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-urlencoded`、 `multipart/form-data`、 `multipart/mixed`。 |
| `Body` | **内容参数**。从 `Body` 中获取并解析得到的参数, `JSON`/ `XML` 请求往往使用这种方式提交。 |
| `Custom` | **自定义参数**,往往在服务端的中间件、服务函数中通过 `SetParam/GetParam` 方法管理。 |
## 参数类型
获取的参数方法可以对指定键名的数据进行 **自动类型转换**,例如: `http://127.0.0.1:8199/?amount=19.66`,通过 `Get(xxx).String()` 将会返回 `19.66` 的字符串类型, `Get(xxx).Float32()`/ `Get(xxx).Float64()` 将会分别返回 `float32` 和 `float64` 类型的数值 `19.66`。但是, `Get(xxx).Int()`/ `Get(xxx).Uint()` 将会返回 `19`(如果参数为 `float` 类型的字符串,将会按照 **向下取整** 进行整型转换)。
**提示**
细心的读者一定发现了,获取到的参数都是 **泛型变量**,根据该泛型变量再根据需要调用对应的方法转换为对应的数据类型。
使用示例:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/", func(r *ghttp.Request) {
r.Response.Writeln(r.Get("amount").String())
r.Response.Writeln(r.Get("amount").Int())
r.Response.Writeln(r.Get("amount").Float32())
})
s.SetPort(8199)
s.Run()
}
```
执行后我们访问地址 [http://127.0.0.1:8199/?amount=19.66](http://127.0.0.1:8199/?amount=19.66) 页面输出
```html
19.66
19
19.66
```
## 参数优先级
我们考虑一种场景,当不同的提交方式中存在同名的参数名称会怎么样?在 `GoFrame` 框架下,我们根据不同的获取方法,将会按照不同的优先级进行获取,优先级高的方式提交的参数将会优先覆盖其他方式的同名参数。优先级规则如下:
1. `Get` 及 `GetRequset` 方法: `Router < Query < Body < Form < Custom`,也就是说自定义参数的优先级最高,其次是 `Form` 表单参数,再次是 `Body` 提交参数,以此类推。例如, `Query` 和 `Form` 中都提交了同样名称的参数 `id`,参数值分别为 `1` 和 `2`,那么 `Get("id")`/ `GetForm("id")` 将会返回 `2`,而 `GetQuery("id")` 将会返回 `1`。
2. `GetQuery` 方法: `Query > Body`,也就是说 `query string` 的参数将会覆盖 `Body` 中提交的同名参数。例如, `Query` 和 `Body` 中都提交了同样名称的参数 `id`,参数值分别为 `1` 和 `2`,那么 `Get("id")` 将会返回 `2`,而 `GetQuery("id")` 将会返回 `1`。
3. `GetForm` 方法:由于该类型的方法仅用于获取 `Form` 表单参数,因此没什么优先级的差别。
使用示例:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/input", func(r *ghttp.Request) {
r.Response.Writeln(r.Get("amount"))
})
s.BindHandler("/query", func(r *ghttp.Request) {
r.Response.Writeln(r.GetQuery("amount"))
})
s.SetPort(8199)
s.Run()
}
```
执行后,我们通过 `curl` 工具进行测试:
```bash
$ curl -d "amount=1" -X POST "http://127.0.0.1:8199/input?amount=100"
1
$ curl -d "amount=1" -X POST "http://127.0.0.1:8199/query?amount=100"
100
```
可以看到,当我们访问 `/input` 路由时,该路由方法中采用了 `Get` 方法获取 `amount` 参数,按照同名优先级的规则返回了 `1`,即 `body` 中传递的参数。而当我们通过 `/query` 路由访问时,该路由方法内部使用了 `GetQuery` 方法获取 `amount` 参数,因此获取到的是 `query string` 参数中的 `amount` 值,返回了 `100`。
## 大小写敏感
需要注意哦, **参数名称是大小写敏感** 的哦,例如客户端提交的参数 `name` 和 `Name` 是两个参数。 由于服务端默认是通过字符串名称获取参数,因此大小写敏感并不会产生什么问题,但是如果服务端接收的是一个API对象,那么可能需要注意一下。我们来看一个例子。
服务端:
```go
package main
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
type Controller struct{}
type PathReq struct {
g.Meta `path:"/api/v1/*path" method:"post"`
Path string
}
type PathRes struct {
Path string
}
func (c *Controller) Path(ctx context.Context, req *PathReq) (res *PathRes, err error) {
return &PathRes{Path: req.Path}, nil
}
func main() {
s := g.Server()
s.SetPort(8199)
s.Use(ghttp.MiddlewareHandlerResponse)
s.Group("/", func(group *ghttp.RouterGroup) {
group.Bind(&Controller{})
})
s.Run()
}
```
服务端的接口设计原意是定义一个路由参数 `path`,并且通过 `API` 对象的 `Path` 属性来接收。
客户端:
```go
package main
import (
"fmt"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
)
func main() {
var (
ctx = gctx.New()
client = g.Client()
)
client.SetPrefix("http://127.0.0.1:8199")
for i := 0; i < 10; i++ {
fmt.Println(client.PostContent(ctx, "/api/v1/user/info", `{"Path":"user/profile"}`))
}
}
```
根据我们的理解,客户端提交后,服务端应该会收到路由参数 `path` 的值为 `user/info`。但是很奇怪,由于程序 `BUG`,客户端提交的 `Body` 中同样提交了一个 `JSON`,参数名称为 `Path`,参数值为 `user/profile`。那么这个时候服务端 `API` 对象中的属性 `Path` 将会是哪个值呢?
我们将客户端反复执行多次,发现输出的结果都是:
```html
{"code":0,"message":"","data":{"Path":"user/profile"}}
{"code":0,"message":"","data":{"Path":"user/profile"}}
{"code":0,"message":"","data":{"Path":"user/profile"}}
{"code":0,"message":"","data":{"Path":"user/profile"}}
{"code":0,"message":"","data":{"Path":"user/profile"}}
{"code":0,"message":"","data":{"Path":"user/profile"}}
{"code":0,"message":"","data":{"Path":"user/profile"}}
{"code":0,"message":"","data":{"Path":"user/profile"}}
{"code":0,"message":"","data":{"Path":"user/profile"}}
{"code":0,"message":"","data":{"Path":"user/profile"}}
```
即参数都是 `Body` 中提交的值,而不是路由参数中的值。其实, **由于参数大小写敏感的缘故**,服务端这个时候接受到了两个参数,一个是路由参数 `path`,一个是 `Body JSON` 提交的 `Path`。服务端在转换 `API` 参数对象的时候,由于属性 `Path` 和 `Body JSON` 中提交的 `Path` 更匹配,因此,使用的是 `Body JSON` 中的 `Path` 的值。
# 请求输入-参数处理
**提示**
本章节的复杂参数针对于传统的 `Query` 或者 `Form` 参数传递,由于这两种参数传递方式对复杂参数传递的管理维护不是很优雅,因为我们推荐大家遇到复杂参数传递的场景尽量使用 `JSON` 数据编码来管理维护。
## 复杂参数
`ghttp.Request` 对象支持智能的参数类型解析(不区分请求提交方式及请求提交类型),以下为提交参数示例以及服务端对应解析的变量类型:
| Parameter | Variable |
| --- | --- |
| `k=m&k=n` | `map[k:n]` |
| `k1=m&k2=n` | `map[k1:m k2:n]` |
| `k[]=m&k[]=n` | `map[k:[m n]]` |
| `k[a][]=m&k[a][]=n` | `map[k:map[a:[m n]]]` |
| `k[a]=m&k[b]=n` | `map[k:map[a:m b:n]]` |
| `k[a][a]=m&k[a][b]=n` | `map[k:map[a:map[a:m b:n]]]` |
| `k=m&k[a]=n` | `error` |
## 同名参数
同名参数提交格式形如: `k=v1&k=v2`,后续的变量值将会覆盖前面的变量值。
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/", func(r *ghttp.Request) {
r.Response.Write(r.Get("name"))
})
s.SetPort(8199)
s.Run()
}
```
执行后,我们访问 [http://127.0.0.1:8199/?name=john&name=smith](http://127.0.0.1:8199/?name=john&name=smith) 后,将会得到返回值 `smith`。
**注意**
注意:框架的 `HTTP Server` 在这块的处理逻辑参考 `PHP`,会和 `Go` 标准库的处理逻辑有所差异。在 `Go` 标准库 `net/http` 处理中,提交的同名参数将会被转换为字符串数组。
## 数组参数
数组参数提交格式形如: `k[]=v1&k[]=v2`,即以空的中括号 `[]` 来表示数组参数。
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/", func(r *ghttp.Request) {
r.Response.Write(r.Get("array"))
})
s.SetPort(8199)
s.Run()
}
```
执行后,我们访问 [http://127.0.0.1:8199/?array\[\]=john&array\[\]=smith](http://127.0.0.1:8199/?array[]=john&array[]=smith) 后,将会得到返回值 `["john","smith"]`。
**注意**
注意:如果传递的参数带有中括号以及 **索引号**,那么该参数按照前面介绍的复杂参数转换规则将会被转换为 `map`。例如, `array[0]=john&array[1]=smith`,将会被转换为 `map{"0":"john","1":"smith"}`。
## Map参数
`Map` 参数提交格式形如: `k[a]=m&k[b]=n`,并且支持多级 `Map`,例如: `k[a][a]=m&k[a][b]=n`。
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/", func(r *ghttp.Request) {
r.Response.Write(r.Get("map"))
})
s.SetPort(8199)
s.Run()
}
```
执行后,我们访问 [http://127.0.0.1:8199/?map\[id\]=1&map\[name\]=john](http://127.0.0.1:8199/?map[id]=1&map[name]=john) 后,将会得到返回值 `{"id":"1","name":"john"}`。
我们再试试多级 `Map`,手动访问以下地址
[http://127.0.0.1:8199/?map\[user1\]\[id\]=1&map\[user1\]\[name\]=john&map\[user2\]\[id\]=2&map\[user2\]\[name\]=smith](http://127.0.0.1:8199/?map[user1][id]=1&map[user1][name]=john&map[user2][id]=2&map[user2][name]=smith)
将会得到返回值 `{"user1":{"id":"1","name":"john"},"user2":{"id":"2","name":"smith"}}`。
## Header参数
通过`GetHeader`方法获取`HTTP`请求头信息。
**方法定义:**
```go
func (r *Request) GetHeader(key string, def ...string) string
```
该方法根据给定的键名 `key` 获取请求头的值。如果指定的请求头不存在,则返回可选的默认值 `def`。
**版本信息:** 默认值参数支持从 `v2.10.0` 版本开始提供。
**使用示例:**
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/header", func(r *ghttp.Request) {
// 获取请求头,如果不存在则返回空字符串
userAgent := r.GetHeader("User-Agent")
// 获取请求头,如果不存在则返回默认值
customHeader := r.GetHeader("X-Custom-Header", "default-value")
r.Response.Writef("User-Agent: %s\n", userAgent)
r.Response.Writef("X-Custom-Header: %s\n", customHeader)
})
s.SetPort(8199)
s.Run()
}
```
执行后,我们通过 `curl` 工具进行测试:
```bash
$ curl -H "X-Custom-Header: my-value" http://127.0.0.1:8199/header
User-Agent: curl/7.68.0
X-Custom-Header: my-value
$ curl http://127.0.0.1:8199/header
User-Agent: curl/7.68.0
X-Custom-Header: default-value
```
可以看到,当客户端没有传递 `X-Custom-Header` 请求头时,`GetHeader` 方法返回了我们设置的默认值 `default-value`。
# 请求输入-对象处理
## 对象转换
对象转换在请求处理中非常常见。我们推荐将输入和输出定义为 `struct` 结构体对象,以便于结构化的参数输入输出维护。 `GoFrame` 框架支持非常便捷的对象转换,支持将客户端提交的参数如 `Query` 参数、表单参数、内容参数、 `JSON/XML` 等参数非常便捷地转换为指定的 `struct` 结构体,并且支持提交参数与 `struct` 属性的映射关系维护。
对象转换方法使用 `Request` 对象的 `Parse` 方法或者 `Get*Struct` 方法,具体方法定义请参考API文档: [https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp#Request](https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp#Request)
## 参数映射
### 默认规则
客户端提交的参数如果需要映射到服务端定义的 `struct` 属性上,可以采用默认的映射关系,这一点非常方便。默认的转换规则如下:
1. `struct` 中需要匹配的属性必须为 **`公开属性`**(首字母大写)。
2. 参数名称会自动按照 **`不区分大小写`** 且 **忽略 `-/_/空格` 符号** 的形式与 `struct` 属性进行匹配。
3. 如果匹配成功,那么将键值赋值给属性,如果无法匹配,那么忽略该键值。
以下是几个匹配的示例:
```html
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
```
由于底层对象转换实现使用的是 `gconv` 模块,因此也支持 `c/gconv/json` 标签,更详细的规则可以参考 [类型转换-Struct转换](../core/gconv-struct.md) 章节。
### 自定义规则
请在对象属性与参数名字差异较大的业务场景下使用自定义映射规则,否则请使用默认的参数映射规则。因为大量的自定义规则标签会增加代码的维护成本。
自定义的参数映射规则可以通过为 `struct` 属性绑定 `tag` 实现, `tag` 名称可以为 `p/param/params`。例如:
```go
type User struct{
Id int
Name string
Pass1 string `p:"password1"`
Pass2 string `p:"password2"`
}
```
其中我们使用了 `p` 标签来指定该属性绑定的参数名称。 `password1` 参数将会映射到 `Pass1` 属性, `password2` 将会映射到 `Pass2` 属性上。其他属性采用默认的转换规则即可,无需设置 `tag`。
## `Parse` 转换
我们同时可以使用 `Parse` 方法来实现 `struct` 的转换,该方法是一个便捷方法,内部会自动进行转换及数据校验,但如果 `struct` 中没有校验 `tag` 的绑定将不会执行校验逻辑。
**注意**
从 `GoFrame v2` 版本开始,我们推荐使用结构化的方式来定义路由方法,更便捷地管理输入输出数据结构及其实例对象,具体请参考: [路由注册-规范路由](./router-registering-strict-router.md)
使用示例:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
type RegisterReq struct {
Name string
Pass string `p:"password1"`
Pass2 string `p:"password2"`
}
type RegisterRes struct {
Code int `json:"code"`
Error string `json:"error"`
Data interface{} `json:"data"`
}
func main() {
s := g.Server()
s.BindHandler("/register", func(r *ghttp.Request) {
var req *RegisterReq
if err := r.Parse(&req); err != nil {
r.Response.WriteJsonExit(RegisterRes{
Code: 1,
Error: err.Error(),
})
}
// ...
r.Response.WriteJsonExit(RegisterRes{
Data: req,
})
})
s.SetPort(8199)
s.Run()
}
```
在该示例中,我们定义了两个结构体: `RegisterReq` 用于参数接收, `RegisterRes` 用于数据返回。
其中,我们使用 `r.Parse(&req)` 将客户端提交的参数转换为 `RegisterReq` 对象,当转换成功时 `req` 变量将会被初始化赋值(默认情况下为 `nil`),否则该方法返回 `err` 并且 `req` 变量为 `nil`。返回数据结构通过 `RegisterRes` 定义,并且返回格式为 `JSON`,通过 `r.Response.WriteJsonExit` 实现,该方法将 `RegisterRes` 根据其内部定义的 `json` 标签转换为 `JSON` 格式并退出当前服务方法,不再执行该服务方法的后续逻辑。
为了演示测试效果,这里在正常的返回结果 `Data` 属性中将 `RegisterReq` 对象返回,由于该对象没有绑定 `json` 标签,因此返回的 `JSON` 字段将会为其属性名称。
执行后,我们通过 `curl` 工具来测试一下:
```bash
$ curl "http://127.0.0.1:8199/register?name=john&password1=123&password2=456"
{"code":0,"error":"","data":{"Name":"john","Pass":"123","Pass2":"456"}}
$ curl -d "name=john&password1=123&password2=456" -X POST "http://127.0.0.1:8199/register"
{"code":0,"error":"","data":{"Name":"john","Pass":"123","Pass2":"456"}}
```
我们使用了 `GET` 和 `POST` 两种提交方式来做测试,可以看到服务端都能完美地获取到提交参数并完成对象转换。
# 请求输入-请求校验
`Request` 对象支持非常完美的请求校验能力,通过给结构体属性绑定 `v` 标签即可。由于底层校验功能通过 `gvalid` 模块实现,
更详细的校验规则和介绍请参考 [数据校验-Struct校验](../core/gvalid-parameter-type-struct-example.md) 章节。
**注意**
以下示例代码中的请求参数转结构体方式适用于框架 `v1` 版本, `v2` 版本开始建议通过规范路由实现自动化的参数结构体转换和校验: [路由注册-规范路由](./router-registering-strict-router.md)
## 示例1,基本使用
我们将之前的示例做下调整,增加 `v` 校验标签。
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
// RegisterReq 注册请求数据结构
type RegisterReq struct {
Name string `p:"username" v:"required|length:4,30#请输入账号|账号长度为:{min}到:{max}位"`
Pass string `p:"password1" v:"required|length:6,30#请输入密码|密码长度不够"`
Pass2 string `p:"password2" v:"required|length:6,30|same:password1#请确认密码|密码长度不够|两次密码不一致"`
}
// RegisterRes 注册返回数据结构
type RegisterRes struct {
Code int `json:"code"`
Error string `json:"error"`
Data interface{} `json:"data"`
}
func main() {
s := g.Server()
s.Group("/", func(group *ghttp.RouterGroup) {
group.ALL("/register", func(r *ghttp.Request) {
var req *RegisterReq
if err := r.Parse(&req); err != nil {
r.Response.WriteJsonExit(RegisterRes{
Code: 1,
Error: err.Error(),
})
}
// ...
r.Response.WriteJsonExit(RegisterRes{
Data: req,
})
})
})
s.SetPort(8199)
s.Run()
}
```
在该示例中,我们定义了两个结构体: `RegisterReq` 用于参数接收, `RegisterRes` 用于数据返回。由于该接口返回的是 `JSON` 数据结构,可以看到,只有返回的结构体中存在 `json` 标签,而接收的结构体中只有 `p` 标签。因为 `RegisterReq` 仅用于参数接收,无需设置返回的 `json` 标签。
**提示**
`p` 标签是可选的,默认情况下会通过 **忽略特殊字符+不区分大小写** 的规则进行属性名称匹配转换,默认匹配规则满足绝大部分业务场景。
为了演示测试效果,这里在正常的返回结果 `Data` 属性中将 `RegisterReq` 对象返回,由于该对象没有绑定 `json` 标签,因此返回的 `JSON` 字段将会为其属性名称。
执行后,我们通过 `curl` 工具来测试一下:
```bash
$ curl "http://127.0.0.1:8199/register?name=john&password1=123456&password2=123456"
{"code":0,"error":"","data":{"Name":"john","Pass":"123456","Pass2":"123456"}}
$ curl "http://127.0.0.1:8199/register?name=john&password1=123456&password2=12345"
{"code":1,"error":"密码长度不够","data":null}
$ curl "http://127.0.0.1:8199/register"
{"code":1,"error":"请输入账号","data":null}
```
## 示例2,校验错误处理
**提示**
最新版已经改成只返回第一个错误。
可以看到在以上示例中,当请求校验错误时,所有校验失败的错误都返回了,这样对于用户体验不是特别友好。当产生错误时,我们可以将校验错误转换为 `gvalid.Error` 接口对象,随后可以通过灵活的方法控制错误的返回。
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/util/gvalid"
)
type RegisterReq struct {
Name string `p:"username" v:"required|length:4,30#请输入账号|账号长度为:{min}到:{max}位"`
Pass string `p:"password1" v:"required|length:6,30#请输入密码|密码长度不够"`
Pass2 string `p:"password2" v:"required|length:6,30|same:password1#请确认密码|密码长度不够|两次密码不一致"`
}
type RegisterRes struct {
Code int `json:"code"`
Error string `json:"error"`
Data interface{} `json:"data"`
}
func main() {
s := g.Server()
s.Group("/", func(group *ghttp.RouterGroup) {
group.ALL("/register", func(r *ghttp.Request) {
var req *RegisterReq
if err := r.Parse(&req); err != nil {
// Validation error.
if v, ok := err.(gvalid.Error); ok {
r.Response.WriteJsonExit(RegisterRes{
Code: 1,
Error: v.FirstError().Error(),
})
}
// Other error.
r.Response.WriteJsonExit(RegisterRes{
Code: 1,
Error: err.Error(),
})
}
// ...
r.Response.WriteJsonExit(RegisterRes{
Data: req,
})
})
})
s.SetPort(8199)
s.Run()
}
```
可以看到,当错误产生后,我们可以通过 `err.(gvalid.Error)` 断言的方式判断错误是否为校验错误,如果是的话则返回第一条校验错误,而不是所有都返回。更详细的错误控制方法,请参考 [数据校验-校验结果](../core/gvalid-result-handling.md) 章节。
**提示**
此外,我们这里也可以使用 `gerror.Current` 获取第一条报错信息,而不是使用断言判断。例如:
```go
var req *RegisterReq
if err := r.Parse(&req); err != nil {
r.Response.WriteJsonExit(RegisterRes{
Code: 1,
Error: gerror.Current(err).Error(),
})
}
```
执行后,我们通过 `curl` 工具来测试一下:
```bash
$ curl "http://127.0.0.1:8199/register"
{"code":1,"error":"请输入账号","data":null}
$ curl "http://127.0.0.1:8199/register?name=john&password1=123456&password2=1234567"
{"code":1,"error":"两次密码不一致","data":null}
```
# 请求输入-JSON/XML
从 `GoFrame v1.11` 版本开始, `Request` 对象提供了对客户端提交的 `JSON/XML` 数据格式的原生支持,为开发者提供了更便捷的数据获取特性,以进一步提高开发效率。
## 示例1,简单示例
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/", func(r *ghttp.Request) {
r.Response.Writef("name: %v, pass: %v", r.Get("name"), r.Get("pass"))
})
s.SetPort(8199)
s.Run()
}
```
执行后,我们通过 `curl` 工具提交数据来测试一下:
1. `Query` 数据格式
```bash
$ curl "http://127.0.0.1:8199/?name=john&pass=123"
name: john, pass: 123
```
2. `Form` 表单提交
```bash
$ curl -d "name=john&pass=123" "http://127.0.0.1:8199/"
name: john, pass: 123
```
3. `JSON` 数据格式
```bash
$ curl -d '{"name":"john","pass":"123"}' "http://127.0.0.1:8199/"
name: john, pass: 123
```
4. `XML` 数据格式
```bash
$ curl -d 'john123' "http://127.0.0.1:8199/"
name: john, pass: 123
$ curl -d 'john123' "http://127.0.0.1:8199/"
name: john, pass: 123
```
## 示例2,对象转换及校验
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/util/gvalid"
)
type RegisterReq struct {
Name string `p:"username" v:"required|length:6,30#请输入账号|账号长度为:{min}到:{max}位"`
Pass string `p:"password1" v:"required|length:6,30#请输入密码|密码长度不够"`
Pass2 string `p:"password2" v:"required|length:6,30|same:password1#请确认密码|密码长度不够|两次密码不一致"`
}
type RegisterRes struct {
Code int `json:"code"`
Error string `json:"error"`
Data interface{} `json:"data"`
}
func main() {
s := g.Server()
s.BindHandler("/register", func(r *ghttp.Request) {
var req *RegisterReq
if err := r.Parse(&req); err != nil {
// Validation error.
if v, ok := err.(gvalid.Error); ok {
r.Response.WriteJsonExit(RegisterRes{
Code: 1,
Error: v.FirstString(),
})
}
// Other error.
r.Response.WriteJsonExit(RegisterRes{
Code: 1,
Error: err.Error(),
})
}
// ...
r.Response.WriteJsonExit(RegisterRes{
Data: req,
})
})
s.SetPort(8199)
s.Run()
}
```
执行后,我们通过 `curl` 工具提交数据来测试一下:
1. `JSON` 数据格式
```bash
$ curl -d '{"username":"johngcn","password1":"123456","password2":"123456"}' "http://127.0.0.1:8199/register"
{"code":0,"error":"","data":{"Name":"johngcn","Pass":"123456","Pass2":"123456"}}
$ curl -d '{"username":"johngcn","password1":"123456","password2":"1234567"}' "http://127.0.0.1:8199/register"
{"code":1,"error":"两次密码不一致","data":null}
```
可以看到,我们提交的 `JSON` 内容也被 `Parse` 方法智能地转换为了结构体对象。
2. `XML` 数据格式
```bash
$ curl -d 'johngcn123456123456' "http://127.0.0.1:8199/register"
{"code":0,"error":"","data":{"Name":"johngcn","Pass":"123456","Pass2":"123456"}}
$ curl -d 'johngcn1234561234567' "http://127.0.0.1:8199/register"
{"code":1,"error":"两次密码不一致","data":null}
```
可以看到,我们提交的 `XML` 内容也被 `Parse` 方法智能地转换为了结构体对象。
# 请求输入-默认值绑定
从 `v1.15` 版本开始, `Request` 请求对象支持通过 `struct tag` 的方式为输入对象的属性绑定默认值。默认值的 `struct tag` 名称为 `d`(也可以使用 `default`)。
我们来看一个示例以便更好理解。
## 参数对象定义
```go
type GetListReq struct {
g.Meta `path:"/" method:"get"`
Type string `v:"required#请选择内容模型" dc:"内容模型"`
Page int `v:"min:0#分页号码错误" dc:"分页号码" d:"1"`
Size int `v:"max:50#分页数量最大50条" dc:"分页数量,最大50" d:"10"`
Sort int `v:"in:0,1,2#排序类型不合法" dc:"排序类型(0:最新, 默认。1:活跃, 2:热度)"`
}
type GetListRes struct {
Items []Item `dc:"内容列表"`
}
type Item struct {
Id int64 `dc:"内容ID"`
Title string `dc:"内容标题"`
}
```
这个是一个查询内容列表请求的参数接受对象,其中我们通过 `d` 的标签为属性 `Page` 和 `Size` 指定了默认值,当这两个参数不传递时,默认为 `1` 和 `10`,表示分页从第 `1` 页开始,每页查询数量为 `10`。
## 参数对象使用
```go
package main
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
type GetListReq struct {
g.Meta `path:"/" method:"get"`
Type string `v:"required#请选择内容模型" dc:"内容模型"`
Page int `v:"min:0#分页号码错误" dc:"分页号码" d:"1"`
Size int `v:"max:50#分页数量最大50条" dc:"分页数量,最大50" d:"10"`
Sort int `v:"in:0,1,2#排序类型不合法" dc:"排序类型(0:最新, 默认。1:活跃, 2:热度)"`
}
type GetListRes struct {
Items []Item `dc:"内容列表"`
}
type Item struct {
Id int64 `dc:"内容ID"`
Title string `dc:"内容标题"`
}
type Controller struct{}
func (Controller) GetList(ctx context.Context, req *GetListReq) (res *GetListRes, err error) {
g.Log().Info(ctx, req)
return
}
func main() {
s := g.Server()
s.Group("/content", func(group *ghttp.RouterGroup) {
group.Middleware(ghttp.MiddlewareHandlerResponse)
group.Bind(&Controller{})
})
s.SetPort(8199)
s.Run()
}
```
我们访问以下地址,并查看服务端终端输出结果:
[http://127.0.0.1:8199/content?type=ask](http://127.0.0.1:8199/content?type=ask)
```html
2023-03-21 21:58:23.058 [INFO] {2883f9c2dc734e170a35c73ea3560b4b} {"Type":"ask","Page":1,"Size":10,"Sort":0}
```
[http://127.0.0.1:8199/content?type=ask&page=](http://127.0.0.1:8199/content?type=ask&page=)
```html
2023-03-21 21:58:32.555 [INFO] {b86e22f9de734e170b35c73edf07859d} {"Type":"ask","Page":1,"Size":10,"Sort":0}
```
[http://127.0.0.1:8199/content?type=ask&page=2](http://127.0.0.1:8199/content?type=ask&page=2)
```html
2023-03-21 22:01:02.907 [INFO] {a016c8fa01744e170f35c73e99082f53} {"Type":"ask","Page":2,"Size":10,"Sort":0}
```
可以看到,当调用端不传递或者传递空的 `page` 参数时,服务端都将使用定义的默认值;而当调用端传递具体的 `page` 参数时,默认值并不会生效。
## 注意事项
默认值参数绑定是根据客户端 **未提交该参数** 来识别是否启用默认值,如果客户端已经提交了该参数, **即便该参数值是空字符串** 也会被当做客户端已经传递了具体的值,那么服务端数据结构上的默认值标签将不会生效。
# 请求输入-自定义参数
开发者可以在请求中自定义一些变量设置,自定义变量的获取优先级是最高的,可以覆盖原有的客户端提交参数。
**提示**
自定义变量往往也可以做请求流程的变量共享,但是需要注意的是该变量会成为请求参数的一部分,是对业务执行流程公开的变量。
方法列表:
```go
func (r *Request) SetParam(key string, value interface{})
func (r *Request) GetParam(key string, def ...interface{}) interface{}
func (r *Request) GetParamVar(key string, def ...interface{}) *gvar.Var
```
自定义变量可以通过 `SetParam` 方法进行设置。自定义变量的获取可以通过请求参数的获取方法获得到,例如: `Get/GetVar/GetMap` 等等,也可以通过特定的自定义变量方法获取到 `GetParam/GetParamVar`。
使用示例:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
// 前置中间件1
func MiddlewareBefore1(r *ghttp.Request) {
r.SetParam("name", "GoFrame")
r.Response.Writeln("set name")
r.Middleware.Next()
}
// 前置中间件2
func MiddlewareBefore2(r *ghttp.Request) {
r.SetParam("site", "https://goframe.org")
r.Response.Writeln("set site")
r.Middleware.Next()
}
func main() {
s := g.Server()
s.Group("/", func(group *ghttp.RouterGroup) {
group.Middleware(MiddlewareBefore1, MiddlewareBefore2)
group.ALL("/", func(r *ghttp.Request) {
r.Response.Writefln(
"%s: %s",
r.GetParam("name").String(),
r.GetParam("site").String(),
)
})
})
s.SetPort(8199)
s.Run()
}
```
可以看到,我们可以通过 `SetParam` 和 `GetParam` 来设置和获取自定义的变量,该变量生命周期仅限于当前请求流程。
执行后,访问 [http://127.0.0.1:8199/](http://127.0.0.1:8199/),页面输出内容为:
```
set name
set site
GoFrame: https://goframe.org
```
# 请求输入-Context
## 基本介绍
请求流程往往会在上下文中共享一些自定义设置的变量,例如在请求开始之前通过中间件设置一些变量,随后在路由服务方法中可以获取该变量并相应对一些处理。这种需求非常常见。在 `GoFrame` 框架中,我们推荐使用 `Context` 上下文对象来处理流程共享的上下文变量,甚至将该对象进一步传递到依赖的各个模块方法中。该 `Context` 对象类型实现了标准库的 `context.Context` 接口,该接口往往会作为模块间调用方法的第一个参数,该接口参数也是 `Golang` 官方推荐的在模块间传递上下文变量的推荐方式。
**方法列表:**
```go
func (r *Request) GetCtx() context.Context
func (r *Request) SetCtx(ctx context.Context)
func (r *Request) GetCtxVar(key interface{}, def ...interface{}) *gvar.Var
func (r *Request) SetCtxVar(key interface{}, value interface{})
```
**简要说明:**
1. `GetCtx` 方法用于获取当前的 `context.Context` 对象,作用同 `Context` 方法。
2. `SetCtx` 方法用于设置自定义的 `context.Context` 上下文对象。
3. `GetCtxVar` 方法用于获取上下文变量,并可给定当该变量不存在时的默认值。
4. `SetCtxVar` 方法用于设置上下文变量。
## 使用示例
### 示例1, `SetCtxVar/GetCtxVar`
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
const (
TraceIdName = "trace-id"
)
func main() {
s := g.Server()
s.Group("/", func(group *ghttp.RouterGroup) {
group.Middleware(func(r *ghttp.Request) {
r.SetCtxVar(TraceIdName, "HBm876TFCde435Tgf")
r.Middleware.Next()
})
group.ALL("/", func(r *ghttp.Request) {
r.Response.Write(r.GetCtxVar(TraceIdName))
})
})
s.SetPort(8199)
s.Run()
}
```
可以看到,我们可以通过 `SetCtxVar` 和 `GetCtxVar` 来设置和获取自定义的变量,该变量生命周期仅限于当前请求流程。
执行后,访问 [http://127.0.0.1:8199/](http://127.0.0.1:8199/),页面输出内容为:
```text
HBm876TFCde435Tgf
```
### 示例2, `SetCtx`
`SetCtx` 方法常用于中间件中整合一些第三方的组件,例如第三方的链路跟踪组件等等。
为简化示例,这里我们将上面的例子通过 `SetCtx` 方法来改造一下来做演示。
```go
package main
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
const (
TraceIdName = "trace-id"
)
func main() {
s := g.Server()
s.Group("/", func(group *ghttp.RouterGroup) {
group.Middleware(func(r *ghttp.Request) {
ctx := context.WithValue(r.Context(), TraceIdName, "HBm876TFCde435Tgf")
r.SetCtx(ctx)
r.Middleware.Next()
})
group.ALL("/", func(r *ghttp.Request) {
r.Response.Write(r.Context().Value(TraceIdName))
// 也可以使用
// r.Response.Write(r.GetCtxVar(TraceIdName))
})
})
s.SetPort(8199)
s.Run()
}
```
执行后,访问 [http://127.0.0.1:8199/](http://127.0.0.1:8199/),页面输出内容为:
```text
HBm876TFCde435Tgf
```
# 请求输入-文件上传
文件上传请具体参考 [HTTPClient-文件上传](./http-client-file-uploading.md) 章节。
# 数据返回
## 基本介绍
`HTTP Server` 的数据返回通过 `ghttp.Response` 对象实现, `ghttp.Response` 对象实现了标准库的 `http.ResponseWriter` 接口。数据输出使用 `Write*` 相关方法实现,并且数据输出采用了 `Buffer` 机制,因此数据的处理效率比较高。任何时候可以通过 `OutputBuffer` 方法输出缓冲区数据到客户端,并清空缓冲区数据。
常用方法:更详细的接口列表请参考 [https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp#Response](https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp#Response)
```go
func (r *Response) Write(content ...interface{})
func (r *Response) WriteExit(content ...interface{})
func (r *Response) WriteJson(content interface{}) error
func (r *Response) WriteJsonExit(content interface{}) error
func (r *Response) WriteJsonP(content interface{}) error
func (r *Response) WriteJsonPExit(content interface{}) error
func (r *Response) WriteOver(content ...interface{})
func (r *Response) WriteOverExit(content ...interface{})
func (r *Response) WriteStatus(status int, content ...interface{})
func (r *Response) WriteStatusExit(status int, content ...interface{})
func (r *Response) WriteTpl(tpl string, params ...gview.Params) error
func (r *Response) WriteTplContent(content string, params ...gview.Params) error
func (r *Response) WriteTplDefault(params ...gview.Params) error
func (r *Response) WriteXml(content interface{}, rootTag ...string) error
func (r *Response) WriteXmlExit(content interface{}, rootTag ...string) error
func (r *Response) Writef(format string, params ...interface{})
func (r *Response) WritefExit(format string, params ...interface{})
func (r *Response) Writefln(format string, params ...interface{})
func (r *Response) WriteflnExit(format string, params ...interface{})
func (r *Response) Writeln(content ...interface{})
func (r *Response) WritelnExit(content ...interface{})
```
简要说明:
1. `Write*` 方法用于往返回的数据缓冲区追加写入数据,参数可为任意的数据格式,内部通过断言对参数做自动分析。
2. `Write*Exit` 方法用于往返回的数据缓冲区追加写入数据后退出当前执行的 `HTTP Handler` 方法,可用于替代 `return` 返回方法。
3. `WriteOver*` 方法用于覆盖缓冲区写入,原有缓冲区的数据将会被覆盖为新写入的数据。
4. `WriteStatus*` 方法用于设置当前请求执行返回的状态码。
5. `WriteJson*`/ `WriteXml` 方法用于特定数据格式的输出,这是为开发者提供的简便方法。
6. `WriteTpl*` 方法用于模板输出,解析并输出模板文件,也可以直接解析并输出给定的模板内容。
7. 其他方法详见接口文档;
此外,需要提一下, `Header` 的操作可以通过标准库的方法来实现,例如:
```
Response.Header().Set("Content-Type", "text/plain; charset=utf-8")
```
# 数据返回-缓冲控制
## 基本介绍
`Response` 输出采用了缓冲控制,输出的内容预先写入到一块缓冲区,等待服务方法执行完毕后才真正地输出到客户端。该特性在提高执行效率同时为输出内容的控制提供了更高的灵活性。
相关方法:
```go
func (r *Response) Buffer() []byte
func (r *Response) BufferLength() int
func (r *Response) BufferString() string
func (r *Response) Flush()
func (r *Response) SetBuffer(data []byte)
```
## 使用示例
我们通过后置中间件统一对返回的数据做处理,如果服务方法产生了异常,那么不能将敏感错误信息推送到客户端,而统一设置错误提示信息。
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"net/http"
)
func MiddlewareErrorHandler(r *ghttp.Request) {
r.Middleware.Next()
if r.Response.Status >= http.StatusInternalServerError {
r.Response.ClearBuffer()
r.Response.Writeln("服务器居然开小差了,请稍后再试吧!")
}
}
func main() {
s := g.Server()
s.Group("/api.v2", func(group *ghttp.RouterGroup) {
group.Middleware(MiddlewareErrorHandler)
group.ALL("/user/list", func(r *ghttp.Request) {
panic("db error: sql is xxxxxxx")
})
})
s.SetPort(8199)
s.Run()
}
```
访问 [http://127.0.0.1:8199/api.v2/user/list](http://127.0.0.1:8199/api.v2/user/list) 可以看到,页面输出了:
```text
服务器居然开小差了,请稍后再试吧!
```
# 数据返回-JSON/XML
相关方法:
```go
func (r *Response) WriteJson(content interface{}) error
func (r *Response) WriteJsonExit(content interface{}) error
func (r *Response) WriteJsonP(content interface{}) error
func (r *Response) WriteJsonPExit(content interface{}) error
func (r *Response) WriteXml(content interface{}, rootTag ...string) error
func (r *Response) WriteXmlExit(content interface{}, rootTag ...string) error
```
`Response` 提供了对 `JSON/XML` 数据格式输出的原生支持,通过以下方法实现:
1. `WriteJson*` 方法用于返回 `JSON` 数据格式,参数为任意类型,可以为 `string`、 `map`、 `struct` 等等。返回的 `Content-Type` 为 `application/json`。
2. `WriteXml*` 方法用于返回 `XML` 数据格式,参数为任意类型,可以为 `string`、 `map`、 `struct` 等等。返回的 `Content-Type` 为 `application/xml`。
**提示**
对 `JSON` 数据格式支持的同时,同时也支持 `JSONP` 协议。
## `JSON`
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.Group("/", func(group *ghttp.RouterGroup) {
group.ALL("/json", func(r *ghttp.Request) {
r.Response.WriteJson(g.Map{
"id": 1,
"name": "john",
})
})
})
s.SetPort(8199)
s.Run()
}
```
执行后,我们通过 `curl` 工具测试下:
```bash
$ curl -i http://127.0.0.1:8199/json
HTTP/1.1 200 OK
Content-Type: application/json
Server: GoFrame HTTP Server
Date: Sun, 05 Jan 2020 02:49:31 GMT
Content-Length: 22
{"id":1,"name":"john"}
```
## `JSONP`
需要注意使用 `JSONP` 协议时必须通过 `Query` 方式提供 `callback` 参数。
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.Group("/", func(group *ghttp.RouterGroup) {
group.ALL("/jsonp", func(r *ghttp.Request) {
r.Response.WriteJsonP(g.Map{
"id": 1,
"name": "john",
})
})
})
s.SetPort(8199)
s.Run()
}
```
执行后,我们通过 `curl` 工具测试下:
```bash
$ curl -i "http://127.0.0.1:8199/jsonp?callback=MyCallback"
HTTP/1.1 200 OK
Server: GoFrame HTTP Server
Date: Sun, 05 Jan 2020 02:50:42 GMT
Content-Length: 34
Content-Type: text/plain; charset=utf-8
MyCallback({"id":1,"name":"john"})
```
## `XML`
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.Group("/", func(group *ghttp.RouterGroup) {
group.ALL("/xml", func(r *ghttp.Request) {
r.Response.Write(``)
r.Response.WriteXml(g.Map{
"id": 1,
"name": "john",
})
})
})
s.SetPort(8199)
s.Run()
}
```
执行后,我们通过 `curl` 工具测试下:
```bash
$ curl -i http://127.0.0.1:8199/xml
HTTP/1.1 200 OK
Content-Type: application/xml
Server: GoFrame HTTP Server
Date: Sun, 05 Jan 2020 03:00:55 GMT
Content-Length: 76
1john
```
# 数据返回-Redirect
我们可以通过 `RedirectTo/RedirectBack` 来实现页面之间的跳转,该功能通过 `Location Header` 实现。相关方法:
```go
func (r *Response) RedirectBack(code ...int)
func (r *Response) RedirectTo(location string, code ...int)
```
## `RedirectTo`
`ReditectTo` 用以引导客户端跳转到指定的地址,地址可以是一个本地服务的相对路径,也可以是一个完整的 `HTTP` 地址。使用示例:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/", func(r *ghttp.Request) {
r.Response.RedirectTo("/login")
})
s.BindHandler("/login", func(r *ghttp.Request) {
r.Response.Writeln("Login First")
})
s.SetPort(8199)
s.Run()
}
```
运行后,我们通过浏览器访问 [http://127.0.0.1:8199/](http://127.0.0.1:8199/) 随后可以 发现浏览器立即跳转到了 [http://127.0.0.1:8199/login](http://127.0.0.1:8199/login) 页面。
## `RedirectBack`
`RedirectBack` 用以引导客户端跳转到上一页面地址,上一页面地址是通过 `Referer Header` 获取的,一般来说浏览器客户端都会传递这一Header。使用示例:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/page", func(r *ghttp.Request) {
r.Response.Writeln(`back`)
})
s.BindHandler("/back", func(r *ghttp.Request) {
r.Response.RedirectBack()
})
s.SetPort(8199)
s.Run()
}
```
运行后,我们通过浏览器访问 [http://127.0.0.1:8199/page](http://127.0.0.1:8199/page) 点击页面的 `back` 连接,可以发现点击后页面随后又跳转了回来。
# 数据返回-Exit控制
## `Exit`, `ExitAll` 与 `ExitHook`
1. `Exit`: 仅退出当前执行的逻辑方法,不退出后续的请求流程,可用于替代 `return`。
2. `ExitAll`: 强行中断当前执行流程,当前执行方法的后续逻辑以及后续所有的逻辑方法将不再执行,常用于权限控制。
3. `ExitHook`: 当路由匹配到多个 `HOOK` 方法时,默认是按照路由匹配优先级顺序执行 `HOOK` 方法。当在 `HOOK` 方法中调用 `ExitHook` 方法后,后续的 `HOOK` 方法将不会被继续执行,作用类似 `HOOK` 方法覆盖。
4. 这三个退出函数仅在服务函数和 `HOOK` 事件回调函数中有效,无法控制中间件的执行流程。
由于 `ExitAll` 和 `ExitHook` 方法在应用层比较少用,因此这里仅介绍 `Exit` 方法的使用。
**提示**
`Exit*` 流程退出特性底层采用的是 `panic...recover...` 机制来实现的,CPU执行损耗大约几十纳秒( `ns`),通过极少的运行时开销来提高易用性。
## `Exit` 返回方法
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/", func(r *ghttp.Request) {
if r.Get("type").Int() == 1 {
r.Response.Writeln("john")
}
r.Response.Writeln("smith")
})
s.SetPort(8199)
s.Run()
}
```
执行后,我们访问 [http://127.0.0.1:8199/?type=1](http://127.0.0.1:8199/?type=1),可以看到页面输出了:
```text
john
smith
```
我们将以上代码稍微调整一下:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/", func(r *ghttp.Request) {
if r.Get("type").Int() == 1 {
r.Response.Writeln("john")
r.Exit()
}
r.Response.Writeln("smith")
})
s.SetPort(8199)
s.Run()
}
```
执行后,我们再次访问 [http://127.0.0.1:8199/?type=1](http://127.0.0.1:8199/?type=1),可以看到页面输出了:
```text
john
```
此外, `Response` 对象中提供了很多 `Write*Exit` 的方法,表示输出内容后立即调用 `Exit` 方法退出当前服务方法。
# 数据返回-文件下载
`Response` 对象支持文件下载。
相关方法:
```go
func (r *Response) ServeFile(path string, allowIndex ...bool)
func (r *Response) ServeFileDownload(path string, name ...string)
```
## `ServeFile`
通过给定文件路径 `path`, `ServeFile` 方法将会自动识别文件格式,如果是目录或者文本内容将会直接展示文件内容。如果 `path` 参数为目录,那么第二个参数 `allowIndex` 控制是否可以展示目录下的文件列表。
使用示例:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/", func(r *ghttp.Request) {
r.Response.ServeFile("test.txt")
})
s.SetPort(8999)
s.Run()
}
```
访问 [http://127.0.0.1:8999](http://127.0.0.1:8999) 可以发现文件内容被展示到了页面。
## `ServeFileDownload`
`ServeFileDownload` 是相对使用频率比较高的方法,用于直接引导客户端下载指定路径的文件,并可以重新给定下载的文件名称。 `ServeFileDownload` 方法采用的是流式下载控制,对内存占用较少。使用示例,我们把上面示例中的 `ServeFile` 方法改为 `ServeFileDownload` 方法:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/", func(r *ghttp.Request) {
r.Response.ServeFileDownload("test.txt")
})
s.SetPort(8999)
s.Run()
}
```
访问 [http://127.0.0.1:8999](http://127.0.0.1:8999) 可以发现文件被引导下载,而不是展示页面内容。
# 数据返回-模板解析
相关方法:
```go
func (r *Response) WriteTpl(tpl string, params ...gview.Params) error
func (r *Response) WriteTplContent(content string, params ...gview.Params) error
func (r *Response) WriteTplDefault(params ...gview.Params) error
func (r *Response) ParseTpl(tpl string, params ...gview.Params) (string, error)
func (r *Response) ParseTplContent(content string, params ...gview.Params) (string, error)
func (r *Response) ParseTplDefault(params ...gview.Params) (string, error)
```
`Response` 支持模板文件/内容解析输出,或者模板文件/内容解析返回。与直接使用模板对象解析模板功能不同的是, `Response` 的解析支持一些请求相关的内置变量。模板解析包含以下方法:
1. `WriteTpl*` 方法用于模板输出,解析并输出模板文件,也可以直接解析并输出给定的模板内容。
2. `ParseTpl*` 方法用于模板解析,解析模板文件或者模板内容,返回解析后的内容。
**提示**
解析模板时组件底层会自动通过 `Request` 对象获取当前链路的 `Context` 上下文变量并传递给模板引擎,因此开发者不用显示给模板引擎传递 `Context` 上下文变量。
## 内置变量
### `Config`
访问默认的配置管理( `config.toml`)对象配置项。
**使用方式**:
```
{{.Config.配置项}}
```
### `Cookie`
访问当前请求的 `Cookie` 对象参数值。
**使用方式**:
```
{{.Cookie.键名}}
```
### `Session`
访问当前请求的 `Session` 对象参数值。
**使用方式**:
```
{{.Session.键名}}
```
### `Query`
访问当前 `Query String` 中的请求参数值。
**使用方式**:
```
{{.Query.键名}}
```
### `Form`
访问当前表单请求参数值。
**使用方式**:
```
{{.Form.键名}}
```
### `Request`
访问当前请求参数值(不区分参数提交方式)。
**使用方式**:
```
{{.Request.键名}}
```
## 使用示例
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/", func(r *ghttp.Request){
r.Cookie.Set("theme", "default")
r.Session.Set("name", "john")
content :=`Config:{{.Config.redis.cache}}, Cookie:{{.Cookie.theme}}, Session:{{.Session.name}}, Query:{{.Query.name}}`
r.Response.WriteTplContent(content, nil)
})
s.SetPort(8199)
s.Run()
}
```
其中, `config.toml` 内容为:
```toml
# Redis数据库配置
[redis]
disk = "127.0.0.1:6379,0"
cache = "127.0.0.1:6379,1"
```
执行后,访问 [http://127.0.0.1:8199/?name=john](http://127.0.0.1:8199/?name=john),输出结果为:
```html
Config:127.0.0.1:6379,1, Cookie:default, Session:john, Query:john
```
# 数据返回-Stream返回
我们可以很简单实现`HTTP SSE`流式数据返回。
## 框架版本 >= `v2.4`
由于以上操作有点繁琐,因此在该版本以后做了一些操作上的改进,如果当前使用的框架版本在 `v2.4` 正式版以上,那么可以使用以下方式快速实现流式数据返回。
```go
package main
import (
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/", func(r *ghttp.Request) {
r.Response.Header().Set("Content-Type", "text/event-stream")
r.Response.Header().Set("Cache-Control", "no-cache")
r.Response.Header().Set("Connection", "keep-alive")
for i := 0; i < 100; i++ {
r.Response.Writefln("data: %d", i)
r.Response.Flush()
time.Sleep(time.Millisecond * 200)
}
})
s.SetPort(8999)
s.Run()
}
```
## 框架版本 < `v2.4`
如果当前使用的框架版本小于 `v2.4` 正式版,使用以下方式返回(标准库原生写法)。
```go
package main
import (
"fmt"
"net/http"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/", func(r *ghttp.Request) {
rw := r.Response.RawWriter()
flusher, ok := rw.(http.Flusher)
if !ok {
http.Error(rw, "Streaming unsupported!", http.StatusInternalServerError)
return
}
r.Response.Header().Set("Content-Type", "text/event-stream")
r.Response.Header().Set("Cache-Control", "no-cache")
r.Response.Header().Set("Connection", "keep-alive")
for i := 0; i < 100; i++ {
_, err := fmt.Fprintf(rw, "data: %d\n", i)
if err != nil {
panic(err)
}
flusher.Flush()
time.Sleep(time.Millisecond * 200)
}
})
s.SetPort(8999)
s.Run()
}
```
## 示例结果
执行后访问 [http://127.0.0.1:8999/](http://127.0.0.1:8999/) 可以看到数据通过流式方式不断地将数据返回给调用端。
## 延伸阅读
- [打造高性能实时通信:Go语言SSE实现指南](https://goframe.org/articles/go-sse-implementation-guide)
## 注意事项
- 如果是在控制器中使用, `Request` 对象的获取可以通过 `g.RequestFromCtx(ctx)` 方法。
- 如果有前置统一输入输出处理的中间件,请将该方法放置于中间件作用域之外,或者使用 `r.ExitAll()` 方法跳出中间件控制。
## 参考资料
- [Server-Sent Events (SSE)](https://www.ruanyifeng.com/blog/2017/05/server-sent_events.html)
# 服务配置
`GoFrame` 框架的 `WebServer` 配置管理非常方便,支持通过配置文件配置、多种配置文件格式、多种配置方式以及若干配置方法。
# 服务配置-配置文件
`Server` 对象支持通过配置文件进行便捷的配置。支持的配置选项以及配置说明请查看接口文档说明,文档中有详细说明,以下章节不会对配置选项作介绍。
当使用 `g.Server(单例名称)` 获取 `Server` 单例对象时,将会自动通过默认的配置管理对象获取对应的 `Server` 配置。默认情况下会读取 `server.单例名称` 配置项,当该配置项不存在时,将会读取 `server` 配置项。
支持的配置文件配置项请参考 `Server` 配置管理对象属性: [https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp#ServerConfig](https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp#ServerConfig)
## 示例1,默认配置项
```yaml
server:
address: ":80"
serverRoot: "/var/www/Server"
```
随后可以使用 `g.Server()` 获取默认的单例对象时自动获取并设置该配置。
**注意**
在没有配置 `address` 的情况下, `http server` 将会使用本地网卡的所有 `ip` 地址加上 **随机空闲端口** 来启动(默认配置 `:0`)。如果想要指定 `ip` 但是使用随机空闲端口启动 `http server`,可以使用 `ip:0` 的格式来配置 `address`,例如: `192.168.1.1:0, 10.0.1.1:0`。
## 示例2,多个配置项
多个 `Server` 的配置示例:
```yaml
server:
address: ":80"
serverRoot: "/var/www/Server"
server1:
address: ":8080"
serverRoot: "/var/www/Server1"
server2:
address: ":8088"
serverRoot: "/var/www/Server2"
```
我们可以通过单例对象名称获取对应配置的 `Server` 单例对象:
```go
// 对应 server.server1 配置项
s1 := g.Server("server1")
// 对应 server.server2 配置项
s2 := g.Server("server2")
// 对应默认配置项 server
s3 := g.Server("none")
// 对应默认配置项 server
s4 := g.Server()
```
## 示例3,较完整示例
比如上一个章节的示例,对应的配置文件如下:
```yaml
server:
address: ":8199"
serverRoot: "/var/www/Server"
indexFiles: ["index.html", "main.html"]
accessLogEnabled: true
errorLogEnabled: true
pprofEnabled: true
logPath: "/var/log/ServerLog"
sessionIdName: "MySessionId"
sessionPath: "/tmp/MySessionStoragePath"
sessionMaxAge: "24h"
dumpRouterMap: false
```
同理,配置属性项的名称也不区分大小写,单词间也支持使用 `-`/ `_` 符号连接。也就是说以下配置文件效果和上面的配置文件一致:
```yaml
server:
address: ":8199"
serverRoot: "/var/www/Server"
indexFiles: ["index.html", "main.html"]
accessLogEnabled: true
errorLogEnabled: true
pprofEnabled: true
log-path: "/var/log/ServerLog"
session_Id_Name: "MySessionId"
Session-path: "/tmp/MySessionStoragePath"
session_MaxAge: "24h"
DumpRouterMap: false
```
**提示**
我们推荐配置文件中的配置项名称统一使用小驼峰的格式。
## 上传限制
`Server` 对于客户端提交的数据是由大小限制的,主要有两个配置参数控制:
- `MaxHeaderBytes`:请求头大小限制,请求头包括客户端提交的 `Cookie` 数据,默认设置为 `10KB`。
- `ClientMaxBodySize`:客户端提交的 `Body` 大小限制,同时也影响文件上传大小,默认设置为 `8MB`。
由于安全性的考虑,默认的上传限制都不是很高,特别是 `ClientMaxBodySize` 的大小限制,在需要文件上传的场景可适当考虑调整,通过配置文件配置即可,例如:
```yaml
server:
maxHeaderBytes: "20KB"
clientMaxBodySize: "200MB"
```
这样便修改请求头大小限制为 `20KB`,文件上传大小限制为 `200MB`。如果不想做上传大小做任何限制,那么将 `clientMaxBodySize` 设置为 `0` 即可。
## 日志配置
从 `v2` 版本开始, `Server` 增加了对配置文件中 `Logger` 的配置项支持,主要是为了统一日志组件配置、并解决日志滚动切分的问题。配置示例:
```yaml
server:
address: ":8080"
logger:
path: "/var/log/server"
file: "{Y-m-d}.log"
stdout: false
rotateSize: "100M"
rotateBackupLimit: 10
rotateBackupExpire: "60d"
rotateBackupCompress: 9
rotateCheckInterval: "24h"
```
关于其中 `logger` 项的详细介绍请参考 [日志组件-配置管理](../core/glog-config.md) 章节。
# 服务配置-配置文件模板
所有的配置项请参考 `ServerConfig` 对象属性: [https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp#ServerConfig](https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp#ServerConfig)
以下为配置文件模板:
```yaml
server:
# 基本配置
address: ":80" # 本地监听地址。默认":80",多个地址以","号分隔。例如:"192.168.2.3:8000,10.0.3.10:8001"
httpsAddr: ":443" # TLS/HTTPS配置,同时需要配置证书和密钥。默认关闭。配置格式同上。
httpsCertPath: "" # TLS/HTTPS证书文件本地路径,建议使用绝对路径。默认关闭
httpsKeyPath: "" # TLS/HTTPS密钥文件本地路径,建议使用绝对路径。默认关闭
readTimeout: "60s" # 请求读取超时时间,一般不需要配置。默认为60秒
writeTimeout: "0" # 数据返回写入超时时间,一般不需要配置。默认不超时(0)
idleTimeout: "60s" # 仅当Keep-Alive开启时有效,请求闲置时间。默认为60秒
maxHeaderBytes: "10240" # 请求Header大小限制(Byte)。默认为10KB
keepAlive: true # 是否开启Keep-Alive功能。默认true
serverAgent: "GoFrame HTTP Server" # 服务端Agent信息。默认为"GoFrame HTTP Server"
# 接口文档
openapiPath: "/api.json" # OpenAPI接口文档地址
swaggerPath: "/swagger" # 内置SwaggerUI展示地址
# 静态服务配置
indexFiles: ["index.html","index.htm"] # 自动首页静态文件检索。默认为["index.html", "index.htm"]
indexFolder: false # 当访问静态文件目录时,是否展示目录下的文件列表。默认关闭,那么请求将返回403
serverRoot: "/var/www" # 静态文件服务的目录根路径,配置时自动开启静态文件服务。默认关闭
searchPaths: ["/home/www","/var/lib/www"] # 提供静态文件服务时额外的文件搜索路径,当根路径找不到时则按照顺序在搜索目录查找。默认关闭
fileServerEnabled: false # 静态文件服务总开关。默认false
# Cookie配置
cookieMaxAge: "365d" # Cookie有效期。默认为365天
cookiePath: "/" # Cookie有效路径。默认为"/"表示全站所有路径下有效
cookieDomain: "" # Cookie有效域名。默认为当前配置Cookie时的域名
# Sessions配置
sessionMaxAge: "24h" # Session有效期。默认为24小时
sessionIdName: "gfsessionid" # SessionId的键名名称。默认为gfsessionid
sessionCookieOutput: true # Session特性开启时,是否将SessionId返回到Cookie中。默认true
sessionPath: "/tmp/gsessions" # Session存储的文件目录路径。默认为当前系统临时目录下的gsessions目录
# 日志基本配置
# 此配置类似nginx,主要对请求日志的记录
logPath: "" # 日志文件存储目录路径,建议使用绝对路径。默认为空,表示关闭
logStdout: true # 日志是否输出到终端。默认为true
errorStack: true # 当Server捕获到异常时是否记录堆栈信息到日志中。默认为true
errorLogEnabled: true # 是否记录异常日志信息到日志中。默认为true
errorLogPattern: "error-{Ymd}.log" # 异常错误日志文件格式。默认为"error-{Ymd}.log"
accessLogEnabled: false # 是否记录访问日志。默认为false
accessLogPattern: "access-{Ymd}.log" # 访问日志文件格式。默认为"access-{Ymd}.log"
# 日志扩展配置(参数日志组件配置)
# 此配置主要影响server(ghttp模块)日志,如web服务监听端口号、web服务路由,不会对 g.Log 产生影响
logger:
path: "/var/log/" # 日志文件路径。默认为空,表示关闭,仅输出到终端
file: "{Y-m-d}.log" # 日志文件格式。默认为"{Y-m-d}.log"
prefix: "" # 日志内容输出前缀。默认为空
level: "all" # 日志输出级别
timeFormat: "2006-01-02T15:04:05" # 自定义日志输出的时间格式,使用Golang标准的时间格式配置
ctxKeys: [] # 自定义Context上下文变量名称,自动打印Context的变量到日志中。默认为空
header: true # 是否打印日志的头信息。默认true
stdout: true # 日志是否同时输出到终端。默认true
rotateSize: 0 # 按照日志文件大小对文件进行滚动切分。默认为0,表示关闭滚动切分特性
rotateExpire: 0 # 按照日志文件时间间隔对文件滚动切分。默认为0,表示关闭滚动切分特性
rotateBackupLimit: 0 # 按照切分的文件数量清理切分文件,当滚动切分特性开启时有效。默认为0,表示不备份,切分则删除
rotateBackupExpire: 0 # 按照切分的文件有效期清理切分文件,当滚动切分特性开启时有效。默认为0,表示不备份,切分则删除
rotateBackupCompress: 0 # 滚动切分文件的压缩比(0-9)。默认为0,表示不压缩
rotateCheckInterval: "1h" # 滚动切分的时间检测间隔,一般不需要设置。默认为1小时
stdoutColorDisabled: false # 关闭终端的颜色打印。默认开启
writerColorEnable: false # 日志文件是否带上颜色。默认false,表示不带颜色
# PProf配置
pprofEnabled: false # 是否开启PProf性能调试特性。默认为false
pprofPattern: "" # 开启PProf时有效,表示PProf特性的页面访问路径,对当前Server绑定的所有域名有效。
# 平滑重启特性
graceful: false # 是否开启平滑重启特性,开启时将会在本地增加10000的本地TCP端口用于进程间通信。默认false
gracefulTimeout: 2 # 父进程在平滑重启后多少秒退出,默认2秒。若请求耗时大于该值,可能会导致请求中断
gracefulShutdownTimeout: 5 # 关闭Server时如果存在正在执行的HTTP请求,Server等待多少秒才执行强行关闭
# 其他配置
clientMaxBodySize: 810241024 # 客户端最大Body上传限制大小,影响文件上传大小(Byte)。默认为8*1024*1024=8MB
formParsingMemory: 1048576 # 解析表单时的缓冲区大小(Byte),一般不需要配置。默认为1024*1024=1MB
nameToUriType: 0 # 路由注册中使用对象注册时的路由生成规则。默认为0
routeOverWrite: false # 当遇到重复路由注册时是否强制覆盖。默认为false,重复路由存在时将会在启动时报错退出
dumpRouterMap: true # 是否在Server启动时打印所有的路由列表。默认为true
```
# 服务配置-配置方法
## 配置对象
配置对象定义: [https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp#ServerConfig](https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp#ServerConfig)
## 配置方法
方法列表: [https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp#Server](https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp#Server)
简要说明:
1. 可以通过 `SetConfig` 及 `SetConfigWithMap` 来设置。
2. 也可以使用 `Server` 对象的 `Set*/Enable*` 方法进行特定配置的设置。
3. 主要注意的是,配置项在 `Server` 执行 `Start` 之后便不能再修改,防止产生并发安全问题。
### `SetConfigWithMap` 方法
我们可以使用 `SetConfigWithMap` 方法通过 `Key-Value` 键值对来设置/修改 `Server` 的特定配置,其余的配置使用默认配置即可。其中 `Key` 的名称即是 `ServerConfig` 这个 `struct` 中的属性名称,并且不区分大小写,单词间也支持使用 `-`/ `_`/ `空格` 符号连接,具体可参考 [类型转换-Struct转换](../core/gconv-struct.md) 章节。
简单示例:
```go
s := g.Server()
s.SetConfigWithMap(g.Map{
"Address": ":80",
"ServerRoot": "/var/www/MyServerRoot",
})
s.Run()
```
其中 `ServerRoot` 的键名也可以使用 `serverRoot`, `server-root`, `server_root`, `server root`,其他配置属性以此类推。
一个比较完整的示例:
```go
s := g.Server()
s.SetConfigWithMap(g.Map{
"address": ":80",
"serverRoot": "/var/www/Server",
"indexFiles": g.Slice{"index.html", "main.html"},
"accessLogEnabled": true,
"errorLogEnabled": true,
"pprofEnabled": true,
"logPath": "/var/log/ServerLog",
"sessionIdName": "MySessionId",
"sessionPath": "/tmp/MySessionStoragePath",
"sessionMaxAge": 24 * time.Hour,
"dumpRouterMap": false,
})
s.Run()
```
# Session
`GoFrame` 框架提供了完善的 `Session` 管理能力,由 `gsession` 组件实现。由于 `Session` 机制在 `HTTP` 服务中最常用,因此后续章节中将着重以 `HTTP` 服务为示例介绍 `Session` 的使用。
## 基本介绍
接口文档: [https://pkg.go.dev/github.com/gogf/gf/v2/os/gsession](https://pkg.go.dev/github.com/gogf/gf/v2/os/gsession)
任何时候都可以通过 `ghttp.Request` 获取 `Session` 对象,因为 `Cookie` 和 `Session` 都是和请求会话相关,因此都属于 `Request` 的成员对象,并对外公开。 `GoFrame` 框架的 `Session` 默认过期时间是 `24小时`。
`SessionId` 默认通过 `Cookie` 来传递,并且也支持客户端通过 `Header` 传递 `SessionId`, `SessionId` 的识别名称可以通过 `ghttp.Server` 的 `SetSessionIdName` 进行修改。 `Session` 的操作是支持 `并发安全` 的,这也是框架在对 `Session` 的设计上不采用直接以 `map` 的形式操作数据的原因。在 `HTTP` 请求流程中,我们可以通过 `ghttp.Request` 对象来获取 `Session` 对象,并执行相应的数据操作。
此外, `ghttp.Server` 中的 `SessionId` 使用的是客户端的 `RemoteAddr + Header` 请求信息通过 `guid` 模块来生成的,保证随机及唯一性: [https://github.com/gogf/gf/blob/master/net/ghttp/ghttp\_request.go](https://github.com/gogf/gf/blob/master/net/ghttp/ghttp_request.go)
## `gsession` 模块
`Session` 的管理功能由独立的 `gsession` 模块实现,并已完美整合到了 `ghttp.Server` 中。由于该模块是解耦独立的,因此可以应用到更多不同的场景中,例如: `TCP` 通信、 `gRPC` 接口服务等等。在 `gsession` 模块中有比较重要的三个对象/接口:
1. `gsession.Manager`:管理 `Session` 对象、 `Storage` 持久化存储对象、以及过期时间控制。
2. `gsession.Session`:单个 `Session` 会话管理对象,用于 `Session` 参数的增删查改等数据管理操作。
3. `gsession.Storage`:这是一个接口定义,用于 `Session` 对象的持久化存储、数据写入/读取、存活更新等操作,开发者可基于该接口实现自定义的持久化存储特性。 该接口定义详见: [https://github.com/gogf/gf/blob/master/os/gsession/gsession\_storage.go](https://github.com/gogf/gf/blob/master/os/gsession/gsession_storage.go)
## 存储实现方式
`gsession` 实现并为开发者提供了常见的四种 `Session` 存储实现方式:
| Storage | 支持分布式 | 支持持久化 | 内存占用 | 执行效率 | 简要介绍 |
| --- | --- | --- | --- | --- | --- |
| `StorageFile` | 否 | 是 | 中 | 中 | 基于文件存储(默认)。单节点部署方式下比较高效的持久化存储方式: [Session-File](./session-file.md) |
| `StorageMemory` | 否 | 否 | 高 | 高 | 基于纯内存存储。单节点部署,性能最高效,但是无法持久化保存,重启即丢失: [Session-Memory](./session-memory.md) |
| `StorageRedis` | 是 | 是 | 中 | 中 | 基于 `Redis` 存储( `Key-Value`)。远程 `Redis` 节点存储 `Session` 数据,支持应用多节点部署: [Session-Redis-KeyValue](./session-redis-key-value.md) |
| `StorageRedisHashTable` | 是 | 是 | 低 | 低 | 基于 `Redis` 存储( `HashTable`)。远程 `Redis` 节点存储 `Session` 数据,支持应用多节点部署: [Session-Redis-HashTable](./session-redis-hash-table.md) |
四种方式各有优劣,详细介绍请查看对应章节。
## `Session` 的初始化
以常见的HTTP请求为例。 `ghttp.Request` 中的 `Session` 对象采用了" **懒初始化( `LazyInitialization`)**"设计方式,默认在 `Request` 中有一个 `Session` 属性对象,但是并未初始化(一个空对象),只有在使用 `Session` 属性对象的方法时才会真正执行初始化。这样的设计既保障了未使用 `Session` 特性的请求执行性能,也保证了组件使用的易用性。
## `Session` 的销毁/注销
用户 `Session` 不再使用,例如用户注销登录状态,需要从存储中硬删除,那么可以调用 `RemoveAll` 方法。
# Session-File
## 文件存储
在默认情况下, `ghttp.Server` 的 `Session` 存储使用了 `内存+文件` 的方式,使用 `StorageFile` 对象实现。具体原理为:
1. `Session` 的数据操作完全基于内存;
2. 使用 `gcache` 进程缓存模块控制数据过期;
3. 使用文件存储持久化存储管理 `Session` 数据;
4. 当且仅有当 `Session` 被标记为 `dirty` 时(数据有更新)才会执行 `Session` 序列化并执行文件持久化存储;
5. 当且仅当内存中的 `Session` 不存在时,才会从文件存储中反序列化恢复 `Session` 数据到内存中,降低 `IO` 调用;
6. 序列化/反序列化使用的是标准库的 `json.Marshal/UnMarshal` 方法;
从原理可知,当 `Session` 为读多写少的场景中, `Session` 的数据操作非常高效。
**提示**
有个注意的细节,由于文件存储涉及到文件操作,为便于降低 `IO` 开销并提高 `Session` 操作性能,并不是每一次 `Session` 请求结束后都会立即刷新对应 `Session` 的 `TTL` 时间。而只有当涉及到更新操作(被标记为 `dirty`)时才会立即刷新其 `TTL`;针对于读取请求,将会每隔 `一分钟` 更新前一分钟内读取操作对应的 `Session` 文件 `TTL` 时间,以便于 `Session` 自动续活。
## 使用示例
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gtime"
"time"
)
func main() {
s := g.Server()
s.SetSessionMaxAge(time.Minute)
s.Group("/", func(group *ghttp.RouterGroup) {
group.ALL("/set", func(r *ghttp.Request) {
r.Session.Set("time", gtime.Timestamp())
r.Response.Write("ok")
})
group.ALL("/get", func(r *ghttp.Request) {
r.Response.Write(r.Session.Data())
})
group.ALL("/del", func(r *ghttp.Request) {
_ = r.Session.RemoveAll()
r.Response.Write("ok")
})
})
s.SetPort(8199)
s.Run()
}
```
在该实例中,为了方便观察过期失效,我们将 `Session` 的过期时间设置为 `1分钟`。执行后,
1. 首先,访问 [http://127.0.0.1:8199/set](http://127.0.0.1:8199/set) 设置一个 `Session` 变量;
2. 随后,访问 [http://127.0.0.1:8199/get](http://127.0.0.1:8199/get) 可以看到该 `Session` 变量已经设置并成功获取;
3. 接着,我们停止程序,并重新启动,再次访问 [http://127.0.0.1:8199/get](http://127.0.0.1:8199/get),可以看到 `Session` 变量已经从文件存储中恢复;
4. 等待1分钟后,再次访问 [http://127.0.0.1:8199/get](http://127.0.0.1:8199/get) 可以看到已经无法获取该 `Session`,因为该 `Session` 已经过期;
# Session-Memory
## 内存存储
内存存储比较简单,性能也很高效,但没有持久化存储 `Session` 数据,因此应用程序重启之后便会丢失 `Session` 数据,可用于特定的业务场景中。 `gsession` 的 `内存` 存储使用 `StorageMemory` 对象实现,
## 使用示例
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gsession"
"github.com/gogf/gf/v2/os/gtime"
"time"
)
func main() {
s := g.Server()
s.SetSessionMaxAge(time.Minute)
s.SetSessionStorage(gsession.NewStorageMemory())
s.Group("/", func(group *ghttp.RouterGroup) {
group.ALL("/set", func(r *ghttp.Request) {
r.Session.MustSet("time", gtime.Timestamp())
r.Response.Write("ok")
})
group.ALL("/get", func(r *ghttp.Request) {
r.Response.Write(r.Session.Data())
})
group.ALL("/del", func(r *ghttp.Request) {
_ = r.Session.RemoveAll()
r.Response.Write("ok")
})
})
s.SetPort(8199)
s.Run()
}
```
在该实例中,为了方便观察过期失效,我们将 `Session` 的过期时间设置为 `1分钟`。执行后,
1. 首先,访问 [http://127.0.0.1:8199/set](http://127.0.0.1:8199/set) 设置一个 `Session` 变量;
2. 随后,访问 [http://127.0.0.1:8199/get](http://127.0.0.1:8199/get) 可以看到该 `Session` 变量已经设置并成功获取;
3. 接着,我们停止程序,并重新启动,再次访问 [http://127.0.0.1:8199/get](http://127.0.0.1:8199/get),可以看到 `Session` 变量已经没有了;
# Session-Redis-KeyValue
## Redis KeyValue Storage
文件存储的方式在单节点的场景下非常不错,但是涉及到对应用进行多节点部署的场景下,各个节点的 `Session` 无法共享,因此需要将 `Session` 存储单独剥离出来管理, `Redis` 服务器是比较常见的一个选择。
`gsession` 的 `Redis` 存储使用 `StorageRedis` 对象实现,与文件存储比较类似,为了提高执行效率,也是采用了 `内存+Redis` 的方式。与文件存储唯一不同的是,在每一次请求中如果需要对 `Session` 进行操作时,将会从 `Redis` 中拉取一次最新的 `Session` 数据(而文件存储只会在 `Session` 不存在时读取一次文件)。在每一次请求结束之后,将全量的 `Session` 数据通过 `JSON` 序列化之后通过 `KeyValue` 方式更新到 `Redis` 服务中。
**提示**
如果单个用户下(以用户维度举例) `Session` 数据量不大的业务场景中,都推荐使用这种 `Storage` 方式。如果单个用户 `Session` 数据量较大(例如 `>10MB`),可以参考 `HashTable` 的 `Storage` 方式: [Session-Redis-HashTable](./session-redis-hash-table.md)
## 使用示例
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gsession"
"github.com/gogf/gf/v2/os/gtime"
"time"
)
func main() {
s := g.Server()
s.SetSessionMaxAge(time.Minute)
s.SetSessionStorage(gsession.NewStorageRedis(g.Redis()))
s.Group("/", func(group *ghttp.RouterGroup) {
group.ALL("/set", func(r *ghttp.Request) {
r.Session.Set("time", gtime.Timestamp())
r.Response.Write("ok")
})
group.ALL("/get", func(r *ghttp.Request) {
r.Response.Write(r.Session.Data())
})
group.ALL("/del", func(r *ghttp.Request) {
_ = r.Session.RemoveAll()
r.Response.Write("ok")
})
})
s.SetPort(8199)
s.Run()
}
```
在该实例中,为了方便观察过期失效,我们将 `Session` 的过期时间设置为 `1分钟`。执行后,
1. 首先,访问 [http://127.0.0.1:8199/set](http://127.0.0.1:8199/set) 设置一个 `Session` 变量;
2. 随后,访问 [http://127.0.0.1:8199/get](http://127.0.0.1:8199/get) 可以看到该 `Session` 变量已经设置并成功获取;
3. 接着,我们停止程序,并重新启动,再次访问 [http://127.0.0.1:8199/get](http://127.0.0.1:8199/get),可以看到 `Session` 变量已经从 `Redis` 存储中恢复;如果我们手动修改 `Redis` 中的对应键值数据,页面刷新时也会读取到最新的值;
4. 等待1分钟后,再次访问 [http://127.0.0.1:8199/get](http://127.0.0.1:8199/get) 可以看到已经无法获取该 `Session`,因为该 `Session` 已经过期;
# Session-Redis-HashTable
## RedisHashTableStorage
与 `RedisKeyValueStorage` 不同的地方在于 `RedisHashTableStorage` 底层使用 `HashTable` 存储 `Session` 数据,每一次对 `Session` 的增删查改都是直接访问 `Redis` 服务实现(单条数据项操作),不存在像 `RedisKeyValueStorage` 那样初始化全量拉取一次,请求结束后如有修改再全量更新到 `Redis` 服务的操作。
## 使用示例
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gsession"
"github.com/gogf/gf/v2/os/gtime"
"time"
)
func main() {
s := g.Server()
s.SetSessionMaxAge(time.Minute)
s.SetSessionStorage(gsession.NewStorageRedisHashTable(g.Redis()))
s.Group("/", func(group *ghttp.RouterGroup) {
group.ALL("/set", func(r *ghttp.Request) {
r.Session.Set("time", gtime.Timestamp())
r.Response.Write("ok")
})
group.ALL("/get", func(r *ghttp.Request) {
r.Response.Write(r.Session.Data())
})
group.ALL("/del", func(r *ghttp.Request) {
_ = r.Session.RemoveAll()
r.Response.Write("ok")
})
})
s.SetPort(8199)
s.Run()
}
```
在该实例中,为了方便观察过期失效,我们将 `Session` 的过期时间设置为 `1分钟`。执行后,
1. 首先,访问 [http://127.0.0.1:8199/set](http://127.0.0.1:8199/set) 设置一个 `Session` 变量;
2. 随后,访问 [http://127.0.0.1:8199/get](http://127.0.0.1:8199/get) 可以看到该 `Session` 变量已经设置并成功获取;
3. 接着,我们停止程序,并重新启动,再次访问 [http://127.0.0.1:8199/get](http://127.0.0.1:8199/get),可以看到 `Session` 变量已经从 `Redis` 存储中恢复;如果我们手动修改 `Redis` 中的对应键值数据,页面刷新时也会读取到最新的值;
4. 等待1分钟后,再次访问 [http://127.0.0.1:8199/get](http://127.0.0.1:8199/get) 可以看到已经无法获取该 `Session`,因为该 `Session` 已经过期;
# Session-Storage接口开发
大部分场景下,通过 `gsession` 组件内置提供的常见 `Storage` 实现已经能够满足需求。如果有特殊的场景需要制定不易开发 `Storage` 当然也是支持的,因为 `gsession` 的功能都采用了接口化设计。
## Storage定义
[https://github.com/gogf/gf/blob/master/os/gsession/gsession\_storage.go](https://github.com/gogf/gf/blob/master/os/gsession/gsession_storage.go)
```go
// Storage is the interface definition for session storage.
type Storage interface {
// New creates a custom session id.
// This function can be used for custom session creation.
New(ctx context.Context, ttl time.Duration) (id string, err error)
// Get retrieves and returns session value with given key.
// It returns nil if the key does not exist in the session.
Get(ctx context.Context, id string, key string) (value interface{}, err error)
// GetMap retrieves all key-value pairs as map from storage.
GetMap(ctx context.Context, id string) (data map[string]interface{}, err error)
// GetSize retrieves and returns the size of key-value pairs from storage.
GetSize(ctx context.Context, id string) (size int, err error)
// Set sets one key-value session pair to the storage.
// The parameter `ttl` specifies the TTL for the session id.
Set(ctx context.Context, id string, key string, value interface{}, ttl time.Duration) error
// SetMap batch sets key-value session pairs as map to the storage.
// The parameter `ttl` specifies the TTL for the session id.
SetMap(ctx context.Context, id string, data map[string]interface{}, ttl time.Duration) error
// Remove deletes key with its value from storage.
Remove(ctx context.Context, id string, key string) error
// RemoveAll deletes all key-value pairs from storage.
RemoveAll(ctx context.Context, id string) error
// GetSession returns the session data as `*gmap.StrAnyMap` for given session id from storage.
//
// The parameter `ttl` specifies the TTL for this session.
// The parameter `data` is the current old session data stored in memory,
// and for some storage it might be nil if memory storage is disabled.
//
// This function is called ever when session starts. It returns nil if the TTL is exceeded.
GetSession(ctx context.Context, id string, ttl time.Duration, data *gmap.StrAnyMap) (*gmap.StrAnyMap, error)
// SetSession updates the data for specified session id.
// This function is called ever after session, which is changed dirty, is closed.
// This copy all session data map from memory to storage.
SetSession(ctx context.Context, id string, data *gmap.StrAnyMap, ttl time.Duration) error
// UpdateTTL updates the TTL for specified session id.
// This function is called ever after session, which is not dirty, is closed.
UpdateTTL(ctx context.Context, id string, ttl time.Duration) error
}
```
每一个方法的调用时机都在注释中详细介绍了,开发者在实现自定义的 `Storage` 时,可以充分参考内置的几种 `Storage` 实现。
## 注意事项
- `Storage` 接口中,并不是所有的接口方法都需要实现,开发者仅需要根据业务需要,实现特定调用时机的一些接口即可。
- 为了提高 `Session` 的执行性能,接口有 `gmap.StrAnyMap` 容器类型的使用,开发时可以参考一下章节: [字典类型-gmap](../components/container-gmap.md)
# Session-RegenerateId
## 会话固定攻击简介
会话固定攻击(`Session Fixation Attacks`)是一种常见的Web应用安全漏洞,攻击者通过设置用户的会话标识符(`Session ID`)来控制用户的会话。这种攻击的基本流程如下:
1. 攻击者获取一个有效的 `Session ID`(通过访问目标网站或其他方式)
2. 攻击者诱使受害者使用这个`Session ID`访问目标网站(例如通过特制的URL、XSS攻击等)
3. 受害者使用这个预设的`Session ID`登录网站
4. 登录成功后,该`Session ID`与受害者的账户关联
5. 攻击者使用同一个`Session ID`访问网站,从而获得受害者的身份和权限
这种攻击的危害在于,攻击者无需破解用户的密码就能获取用户的身份,从而访问用户的敏感信息或执行未授权的操作。
## 防范会话固定攻击
防范会话固定攻击的最佳实践是在用户成功认证(登录)后重新生成`Session ID`。这样,即使攻击者设法让用户使用特定的`Session ID`,在登录成功后该 ID 也会被替换,从而使攻击失效。
`GoFrame` 框架提供了 `RegenerateId` 和 `MustRegenerateId` 方法来实现这一安全机制。
## `RegenerateId` 方法
在一些安全性要求较高的应用场景中,为了防止**会话固定攻击**(`Session Fixation Attacks`),通常需要在用户登录成功后重新生成 `Session ID`。`GoFrame` 框架提供了 `RegenerateId` 和 `MustRegenerateId` 方法来实现这一功能。
### 实现原理
`RegenerateId` 方法的实现原理如下:
1. 生成新的`Session ID`
2. 将当前会话数据复制到新的`Session ID`下
3. 根据`deleteOld`参数决定是否删除旧的会话数据
4. 更新当前会话的ID为新生成的 ID
这样就实现了会话数据的无缝迁移,同时保证了安全性。
### `RegenerateId` 方法
- 说明:`RegenerateId` 方法用于为当前会话重新生成一个新的 `Session ID`,同时保留会话中的所有数据。这在用户登录成功后特别有用,可以防止会话固定攻击。
- 格式:
```go
RegenerateId(deleteOld bool) (newId string, err error)
```
- 参数说明:
- `deleteOld`:指定是否立即删除旧的会话数据
- 如果为 `true`:旧的会话数据将被立即删除
- 如果为 `false`:旧的会话数据将被保留,并根据其 TTL 自动过期
- 返回值:
- `newId`:新生成的`Session ID`
- `err`:操作过程中可能出现的错误
### `MustRegenerateId` 方法
- 说明:`MustRegenerateId` 方法与 `RegenerateId` 功能相同,但如果操作过程中出现错误,它会直接`panic`。这在确保`Session ID`必须被重新生成的场景中非常有用。
- 格式:
```go
MustRegenerateId(deleteOld bool) string
```
- 参数说明:
- `deleteOld`:与 `RegenerateId` 方法中的参数含义相同
- 返回值:
- 新生成的`Session ID`
### 使用示例
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gtime"
)
func main() {
s := g.Server()
// 模拟登录接口
s.BindHandler("/login", func(r *ghttp.Request) {
username := r.Get("username").String()
// password := r.Get("password").String()
// 假设这里进行了用户名密码验证
// password ...
// 验证通过后,存储用户信息到 Session
r.Session.MustSet("user", g.Map{
"username": username,
"login_time": gtime.Now(),
})
// 重要:登录成功后重新生成 Session ID,防止会话固定攻击
// 参数为 true 表示立即删除旧的会话数据
// 返回的session id可以不使用,server将自动通过header返回最新的session id
_, err := r.Session.RegenerateId(true)
if err != nil {
r.Response.WriteJson(g.Map{
"code": 500,
"message": "Session ID 重新生成失败",
})
return
}
r.Response.WriteJson(g.Map{
"code": 0,
"message": "登录成功",
})
})
// 获取用户信息接口
// 注意需要通过header提交session id
s.BindHandler("/user/info", func(r *ghttp.Request) {
user := r.Session.MustGet("user")
if user == nil {
r.Response.WriteJson(g.Map{
"code": 403,
"message": "未登录或会话已过期",
})
return
}
r.Response.WriteJson(g.Map{
"code": 0,
"message": "获取成功",
"data": user,
})
})
// 注销登录接口
s.BindHandler("/logout", func(r *ghttp.Request) {
// 清除所有会话数据
_ = r.Session.RemoveAll()
r.Response.WriteJson(g.Map{
"code": 0,
"message": "注销成功",
})
})
s.SetPort(8000)
s.Run()
}
```
### 安全建议
1. **登录后重新生成 Session ID**:在用户成功登录后,始终调用 `RegenerateId` 方法重新生成`Session ID`,这是防止会话固定攻击的基本做法。
2. **敏感操作后重新生成 Session ID**:在用户执行密码修改、权限变更等敏感操作后,也应考虑重新生成`Session ID`。
3. **删除旧会话数据**:在重新生成`Session ID`时,通常建议将 `deleteOld` 参数设置为 `true`,立即删除旧的会话数据,防止会话数据被恶意利用。
4. **使用 HTTPS**:始终使用`HTTPS`协议传输`Session ID`,防止会话信息被网络安全工具或中间人攻击捕获。
5. **设置正确的 Cookie 属性**:对于存储`Session ID`的`Cookie`,应设置 `HttpOnly`、`Secure` 和 `SameSite` 属性,防止`XSS`攻击和`CSRF`攻击。
# HTTPClient
## 基本介绍
`GoFrame` 框架提供了强大便捷易用的 `HTTP` 客户端,由 `gclient` 组件实现,对象创建可以通过 `gclient.New()` 包方法,也可以通过 `g.Client()` 方法调用。推荐使用 `g.Client()` 来便捷地创建 `HTTP` 客户端对象。由于 `gclient.Client` 内部封装扩展于标准库的 `http.Client` 对象,因此标准库 `http.Client` 有的特性, `gclient.Client` 也是支持的。
**方法列表**:[https://pkg.go.dev/github.com/gogf/gf/v2/net/gclient](https://pkg.go.dev/github.com/gogf/gf/v2/net/gclient)
**简要说明**:
1. 我们可以使用 `New` 创建一个自定义的HTTP客户端对象 `Client`,随后可以使用该对象执行请求,该对象底层使用了连接池设计,因此没有 `Close` 关闭方法。 `HTTP` 客户端对象也可以通过 `g.Client()` 快捷方法创建。
2. 客户端提供了一系列以 `HTTP Method` 命名的方法,调用这些方法将会发起对应的 `HTTP Method` 请求。常用的方法是 `Get` 和 `Post` 方法,同时 `DoRequest` 是核心的请求方法,用户可以调用该方法实现自定义的 `HTTP Method` 发送请求。
3. 请求返回结果为 `*ClientResponse` 对象,可以通过该结果对象获取对应的返回结果,通过 `ReadAll`/ `ReadAllString` 方法可以获得返回的内容,该对象在使用完毕后需要通过 `Close` 方法关闭,防止内存溢出。
4. `*Bytes` 方法用于获得服务端返回的二进制数据,如果请求失败返回 `nil`; `*Content` 方法用于请求获得字符串结果数据,如果请求失败返回空字符串; `Set*` 方法用于 `Client` 的参数设置。
5. `*Var` 方法直接请求并获取HTTP接口结果为泛型类型便于转换。如果请求失败或者请求结果为空,会返回一个空的 `g.Var` 泛型对象,不影响转换方法调用。
6. 可以看到,客户端的请求参数的数据参数 `data` 数据类型为 `interface{}` 类型,也就是说可以传递任意的数据类型,常见的参数数据类型为 `string`/ `map`,如果参数为 `map` 类型,参数值将会被自动 `urlencode` 编码。
**注意**
请使用给定的方法创建 `Client` 对象,而不要使用 `new(ghttp.Client)` 或者 `&ghttp.Client{}` 创建客户端对象,否则,哼哼。
## 链式操作
`GoFrame` 框架的客户端支持便捷的链式操作,常用方法如下(文档方法列表可能滞后于源码,建议查看接口文档或源码 [https://pkg.go.dev/github.com/gogf/gf/v2/net/gclient](https://pkg.go.dev/github.com/gogf/gf/v2/net/gclient)):
```go
func (c *Client) Timeout(t time.Duration) *Client
func (c *Client) Cookie(m map[string]string) *Client
func (c *Client) Header(m map[string]string) *Client
func (c *Client) HeaderRaw(headers string) *Client
func (c *Client) ContentType(contentType string) *Client
func (c *Client) ContentJson() *Client
func (c *Client) ContentXml() *Client
func (c *Client) BasicAuth(user, pass string) *Client
func (c *Client) Retry(retryCount int, retryInterval time.Duration) *Client
func (c *Client) Prefix(prefix string) *Client
func (c *Client) Proxy(proxyURL string) *Client
func (c *Client) RedirectLimit(redirectLimit int) *Client
func (c *Client) Dump(dump ...bool) *Client
func (c *Client) Use(handlers ...HandlerFunc) *Client
```
简要说明:
1. `Timeout` 方法用于设置当前请求超时时间。
2. `Cookie` 方法用于设置当前请求的自定义 `Cookie` 信息。
3. `Header*` 方法用于设置当前请求的自定义 `Header` 信息。
4. `Content*` 方法用于设置当前请求的 `Content-Type` 信息,并且支持根据该信息自动检查提交参数并自动编码。
5. `BasicAuth` 方法用于设置 `HTTP Basic Auth` 校验信息。
6. `Retry` 方法用于设置请求失败时重连次数和重连间隔。
7. `Proxy` 方法用于设置http访问代理。
8. `RedirectLimit` 方法用于限制重定向跳转次数。
## 返回对象
`gclient.Response` 为HTTP对应请求的返回结果对象,该对象继承于 `http.Response`,可以使用 `http.Response` 的所有方法。在此基础之上增加了以下几个方法:
```go
func (r *Response) GetCookie(key string) string
func (r *Response) GetCookieMap() map[string]string
func (r *Response) Raw() string
func (r *Response) RawDump()
func (r *Response) RawRequest() string
func (r *Response) RawResponse() string
func (r *Response) ReadAll() []byte
func (r *Response) ReadAllString() string
func (r *Response) Close() error
```
这里也要提醒的是, `Response` 需要手动调用 `Close` 方法关闭,也就是说,不管使用不使用返回的 `Response` 对象,都需要将该返回对象赋值给一个变量,并且手动调用其 `Close` 方法进行关闭(往往使用 `defer r.Close()`),否则会造成文件句柄溢出、内存溢出。
## 重要说明
1. `ghttp` 客户端默认关闭了 `KeepAlive` 功能以及对服务端 `TLS` 证书的校验功能,如果需要启用可自定义客户端的 `Transport` 属性。
2. **连接池参数设定**、 **连接代理设置** 等这些高级功能也可以通过自定义客户端的 `Transport` 属性实现,该数据继承于标准库的 `http.Transport` 对象。
# HTTPClient-基本使用
## 基本使用
最基本的 `HTTP` 客户端使用是通过 `HTTP Method` 同名的几个操作方法来发送请求, **但需要注意的是返回的结果对象需要执行 `Close` 防止内存溢出**。我们来看几个 `HTTP` 客户端请求的简单示例。
### 发送 `GET` 请求,打印出返回值
```go
r, err := g.Client().Get(ctx, "https://goframe.org")
if err != nil {
panic(err)
}
defer r.Close()
fmt.Println(r.ReadAllString())
```
### 发送 `GET` 请求,下载远程文件
```go
r, err := g.Client().Get(ctx, "https://goframe.org/cover.png")
if err != nil {
panic(err)
}
defer r.Close()
gfile.PutBytes("/Users/john/Temp/cover.png", r.ReadAll())
```
下载文件操作,小文件下载非常简单。需要注意的是,如果远程文件比较大时,服务端会分批返回数据,因此会需要客户端发多个 `GET` 请求,每一次通过 `Header` 来请求分批的文件范围长度,感兴趣的同学可自行研究相关细节。
### 发送 `POST` 请求,打印出返回值
```go
r, err := g.Client().Post(ctx, "http://127.0.0.1:8199/form", "name=john&age=18")
if err != nil {
panic(err)
}
defer r.Close()
fmt.Println(r.ReadAllString())
```
传递多参数的时候用户可以使用 `&` 符号进行连接,注意参数值往往需要通过 `gurl.Encode` 编码一下。
### 发送 `POST` 请求,参数为 `map` 类型,打印出返回值
```go
r, err := g.Client().Post(
ctx,
"http://127.0.0.1:8199/form",
g.Map{
"submit" : "1",
"callback" : "http://127.0.0.1/callback?url=http://baidu.com",
},
)
if err != nil {
panic(err)
}
defer r.Close()
fmt.Println(r.ReadAllString())
```
传递多参数的时候用户可以使用 `&` 符号进行连接,也可以直接使用 `map`( **其实之前也提到,任意数据类型都支持,包括 `struct`**)。
### 发送 `POST` 请求,参数为 `JSON` 数据,打印出返回值
```go
r, err := g.Client().Post(
ctx,
"http://user.svc/v1/user/create",
`{"passport":"john","password":"123456","password-confirm":"123456"}`,
)
if err != nil {
panic(err)
}
defer r.Close()
fmt.Println(r.ReadAllString())
```
可以看到,通过 `ghttp` 客户端发送 `JSON` 数据请求非常方便,直接通过 `Post` 方法提交即可。当没有显式设置 `ContentType` 时,客户端会自动识别参数类型并将请求的 `Content-Type` 设置为 `application/json`。
### 发送 `DELETE` 请求,打印出返回值
```go
r, err := g.Client().Delete(ctx, "http://user.svc/v1/user/delete/1", "10000")
if err != nil {
panic(err)
}
defer r.Close()
fmt.Println(r.ReadAllString())
```
## `*Bytes` 及 `*Content` 方法
以 `Bytes` 及 `Content` 后缀结尾的请求方法为直接获取返回内容的 **快捷方法**,这些方法将会自动读取服务端返回内容 **并自动关闭请求连接**。 `*Bytes` 方法用于获取 `[]byte` 类型结果, `*Content` 方法用于获取 `string` 类型结果。 **需要注意的是,如果请求执行失败,返回内容将会为空。**
### 发送 `GET` 请求,打印出返回值
```go
// 返回content为[]bytes类型
content := g.Client().GetBytes(ctx, "https://goframe.org")
```
```go
// 返回content为string类型
content := g.Client().GetContent(ctx, "https://goframe.org")
```
### 发送 `POST` 请求,打印出返回值
```go
// 返回content为[]bytes类型
content := g.Client().PostBytes(
ctx,
"http://user.svc/v1/user/create",
`{"passport":"john","password":"123456","password-confirm":"123456"}`,
)
```
```go
// 返回content为string类型
content := g.Client().PostContent(
ctx,
"http://user.svc/v1/user/create",
`{"passport":"john","password":"123456","password-confirm":"123456"}`,
)
```
## `*Var` 方法
以 `Var` 后缀结尾的请求方法直接请求并获取 `HTTP` 接口结果为 `g.Var` 泛型类型 **便于下一步执行类型转换,特别是将请求结果转换到结构体对象上**。往往用于服务端返回格式为 `JSON/XML` 的情况,通过返回的 `g.Var` 泛型对象可根据需要自动解析。此外,如果请求失败或者请求结果为空,会返回一个空的 `g.Var` 泛型对象,不影响转换方法调用。
使用示例:
```go
type User struct {
Id int
Name string
}
```
```go
// Struct
var user *User
g.Client().GetVar(ctx, url).Scan(&user)
```
```go
// Struct数组
var users []*User
g.Client().GetVar(ctx, url).Scan(&users)
```
# HTTPClient-文件上传
`GoFrame` 支持非常方便的表单文件上传功能,并且HTTP客户端对上传功能进行了必要的封装并极大简化了上传功能调用。
**注意**
注意哦:上传文件大小受到 `ghttp.Server` 的 `ClientMaxBodySize` 配置影响: [https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp#ServerConfig](https://pkg.go.dev/github.com/gogf/gf/v2/net/ghttp#ServerConfig) 默认支持的上传文件大小为 `8MB`。
## 服务端
在服务端通过 `Request` 对象获取上传文件:
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
// Upload uploads files to /tmp .
func Upload(r *ghttp.Request) {
files := r.GetUploadFiles("upload-file")
names, err := files.Save("/tmp/")
if err != nil {
r.Response.WriteExit(err)
}
r.Response.WriteExit("upload successfully: ", names)
}
// UploadShow shows uploading simgle file page.
func UploadShow(r *ghttp.Request) {
r.Response.Write(`
GoFrame Upload File Demo
`)
}
// UploadShowBatch shows uploading multiple files page.
func UploadShowBatch(r *ghttp.Request) {
r.Response.Write(`
GoFrame Upload Files Demo
`)
}
func main() {
s := g.Server()
s.Group("/upload", func(group *ghttp.RouterGroup) {
group.POST("/", Upload)
group.ALL("/show", UploadShow)
group.ALL("/batch", UploadShowBatch)
})
s.SetPort(8199)
s.Run()
}
```
该服务端提供了3个接口:
1. [http://127.0.0.1:8199/upload/show](http://127.0.0.1:8199/upload/show) 地址用于展示单个文件上传的H5页面;
2. [http://127.0.0.1:8199/upload/batch](http://127.0.0.1:8199/upload/batch) 地址用于展示多个文件上传的H5页面;
3. [http://127.0.0.1:8199/upload](http://127.0.0.1:8199/upload) 接口用于真实的表单文件上传,该接口同时支持单个文件或者多个文件上传;
我们这里访问 [http://127.0.0.1:8199/upload/show](http://127.0.0.1:8199/upload/show) 选择需要上传的单个文件,提交之后可以看到文件上传成功到服务器上。
**关键代码说明**
1. 我们在服务端可以通过 `r.GetUploadFiles` 方法获得上传的所有文件对象,也可以通过 `r.GetUploadFile` 获取单个上传的文件对象。
2. 在 `r.GetUploadFiles("upload-file")` 中的参数 `"upload-file"` 为本示例中客户端上传时的表单文件域名称,开发者可以根据前后端约定在客户端中定义,以方便服务端接收表单文件域参数。
3. 通过 `files.Save` 可以将上传的多个文件方便地保存到指定的目录下,并返回保存成功的文件名。如果是批量保存,只要任意一个文件保存失败,都将会立即返回错误。此外, `Save` 方法的第二个参数支持随机自动命名上传文件。
4. 通过 `group.POST("/", Upload)` 注册的路由仅支持 `POST` 方式访问。
## 客户端
### 单文件上传
```go
package main
import (
"fmt"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
"github.com/gogf/gf/v2/os/glog"
)
func main() {
var (
ctx = gctx.New()
path = "/home/john/Workspace/Go/github.com/gogf/gf/v2/version.go"
)
result, err := g.Client().Post(ctx, "http://127.0.0.1:8199/upload", "upload-file=@file:"+path)
if err != nil {
glog.Fatalf(ctx, `%+v`, err)
}
defer result.Close()
fmt.Println(result.ReadAllString())
}
```
注意到了吗?文件上传参数格式使用了 `参数名=@file:文件路径`,HTTP客户端将会自动解析 **文件路径** 对应的文件内容并读取提交给服务端。原本复杂的文件上传操作被 `gf` 进行了封装处理,用户只需要使用 `@file:+文件路径` 来构成参数值即可。其中, `文件路径` 请使用本地文件绝对路径。
首先运行服务端程序之后,我们再运行这个上传客户端(注意修改上传的文件路径为本地真实文件路径),执行后可以看到文件被成功上传到服务器的指定路径下。
### 多文件上传
```go
package main
import (
"fmt"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
"github.com/gogf/gf/v2/os/glog"
)
func main() {
var (
ctx = gctx.New()
path1 = "/Users/john/Pictures/logo1.png"
path2 = "/Users/john/Pictures/logo2.png"
)
result, err := g.Client().Post(
ctx,
"http://127.0.0.1:8199/upload",
fmt.Sprintf(`upload-file=@file:%s&upload-file=@file:%s`, path1, path2),
)
if err != nil {
glog.Fatalf(ctx, `%+v`, err)
}
defer result.Close()
fmt.Println(result.ReadAllString())
}
```
可以看到,多个文件上传提交参数格式为 `参数名=@file:xxx&参数名=@file:xxx...`,也可以使用 `参数名[]=@file:xxx&参数名[]=@file:xxx...` 的形式。
首先运行服务端程序之后,我们再运行这个上传客户端(注意修改上传的文件路径为本地真实文件路径),执行后可以看到文件被成功上传到服务器的指定路径下。
## 自定义文件名称
很简单,修改 `FileName` 属性即可。
```go
s := g.Server()
s.BindHandler("/upload", func(r *ghttp.Request) {
file := r.GetUploadFile("TestFile")
if file == nil {
r.Response.Write("empty file")
return
}
file.Filename = "MyCustomFileName.txt"
fileName, err := file.Save(gfile.TempDir())
if err != nil {
r.Response.Write(err)
return
}
r.Response.Write(fileName)
})
s.SetPort(8999)
s.Run()
```
## 规范路由接收上传文件
服务端如果通过规范路由方式,那么可以通过结构化的参数获取上传文件:
- 参数接收的数据类型使用 `*ghttp.UploadFile`
- 如果需要接口文档也支持文件类型,那么参数的标签中设置 `type` 为 `file` 类型
# HTTPClient-自定义Cookie
HTTP客户端发起请求时可以自定义发送给服务端的 `Cookie` 内容,该特性使用 `SetCookie*` 相关方法实现。
方法列表:
```go
func (c *Client) SetCookie(key, value string) *Client
func (c *Client) SetCookieMap(m map[string]string) *Client
```
我们来看一个客户端自定义 `Cookie` 的示例。
### 服务端
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/", func(r *ghttp.Request){
r.Response.Write(r.Cookie.Map())
})
s.SetPort(8199)
s.Run()
}
```
由于是作为示例,服务端的逻辑很简单,直接将接收到的 `Cookie` 参数全部返回给客户端。
### 客户端
1. 使用 `SetCookie` 方法
```go
package main
import (
"fmt"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
)
func main() {
c := g.Client()
c.SetCookie("name", "john")
c.SetCookie("score", "100")
if r, e := c.Get(gctx.New(), "http://127.0.0.1:8199/"); e != nil {
panic(e)
} else {
fmt.Println(r.ReadAllString())
}
}
```
通过 `g.Client()` 创建一个自定义的HTTP请求客户端对象,并通过 `c.SetCookie` 方法设置自定义的 `Cookie`,这里我们设置了两个示例用的 `Cookie` 参数,一个 `name`,一个 `score`。
2. 使用 `SetCookieMap` 方法
这个方法更加简单,可以批量设置 `Cookie` 键值对。
```go
package main
import (
"fmt"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
)
func main() {
c := g.Client()
c.SetCookieMap(g.MapStrStr{
"name": "john",
"score": "100",
})
if r, e := c.Get(gctx.New(), "http://127.0.0.1:8199/"); e != nil {
panic(e)
} else {
fmt.Println(r.ReadAllString())
}
}
```
3. 执行结果
客户端代码执行后,终端将会打印出服务端的返回结果,如下:
```
map[name:john score:100]
```
可以看到,服务端已经接收到了客户端自定义的 `Cookie` 参数。
# HTTPClient-自定义Header
HTTP客户端发起请求时可以自定义发送给服务端的 `Header` 内容,该特性使用 `SetHeader*` 相关方法实现。
方法列表:
```go
func (c *Client) SetHeader(key, value string) *Client
func (c *Client) SetHeaderMap(m map[string]string) *Client
func (c *Client) SetHeaderRaw(headers string) *Client
```
我们来看一个客户端通过 `Header` 来自定义发送自定义链路跟踪信息 `Span-Id` 及 `Trace-Id` 消息头的示例。
### 服务端
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.BindHandler("/", func(r *ghttp.Request) {
r.Response.Writef(
"Span-Id:%s,Trace-Id:%s",
r.Header.Get("Span-Id"),
r.Header.Get("Trace-Id"),
)
})
s.SetPort(8199)
s.Run()
}
```
由于是作为示例,服务端的逻辑很简单,直接将接收到的 `Span-Id` 及 `Trace-Id` 参数返回给客户端。
### 客户端
1. 使用 `SetHeader` 方法
```go
package main
import (
"fmt"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
)
func main() {
c := g.Client()
c.SetHeader("Span-Id", "0.0.1")
c.SetHeader("Trace-Id", "NBC56410N97LJ016FQA")
if r, e := c.Get(gctx.New(), "http://127.0.0.1:8199/"); e != nil {
panic(e)
} else {
fmt.Println(r.ReadAllString())
}
}
```
通过 `g.Client()` 创建一个自定义的HTTP请求客户端对象,并通过 `c.SetHeader` 设置自定义的 `Header` 信息。
2. 使用 `SetHeaderRaw` 方法
这个方法更加简单,可以通过原始的Header字符串来设置客户端请求Header。
```go
package main
import (
"fmt"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
)
func main() {
c := g.Client()
c.SetHeaderRaw(`
Referer: https://localhost
Span-Id: 0.0.1
Trace-Id: NBC56410N97LJ016FQA
User-Agent: MyTestClient
`)
if r, e := c.Get(gctx.New(), "http://127.0.0.1:8199/"); e != nil {
panic(e)
} else {
fmt.Println(r.ReadAllString())
}
}
```
3. 执行结果
客户端代码执行后,终端将会打印出服务端的返回结果,如下:
```
Span-Id:0.0.1,Trace-Id:NBC56410N97LJ016FQA
```
# HTTPClient-自定义ContentType
## 示例1,提交 `Json` 请求
```go
g.Client().ContentJson().PostContent(ctx, "http://order.svc/v1/order", g.Map{
"uid" : 1,
"sku_id" : 10000,
"amount" : 19.99,
"create_time" : "2020-03-26 12:00:00",
})
```
通过调用 `ContentJson` 链式操作方法,该请求将会将 `Content-Type` 设置为 `application/json`,并且将提交参数自动编码为 `Json`:
```
{"uid":1,"sku_id":10000,"amount":19.99,"create_time":"2020-03-26 12:00:00"}
```
## 示例2,提交 `Xml` 请求
```go
g.Client().ContentXml().PostContent(ctx, "http://order.svc/v1/order", g.Map{
"uid" : 1,
"sku_id" : 10000,
"amount" : 19.99,
"create_time" : "2020-03-26 12:00:00",
})
```
通过调用 `ContentXml` 链式操作方法,该请求将会将 `Content-Type` 设置为 `application/xml`,并且将提交参数自动编码为 `Xml`:
```
19.992020-03-26 12:00:00100001
```
## 示例3,自定义 `ContentType`
我们可以通过 `ContentType` 方法自定义客户端请求的 `ContentType`。如果是给定的 `string/[]byte` 参数,客户端将会直接将参数提交给服务端;如果是其他数据类型将会自动对参数执行 `url encode` 再提交到服务端。
示例1:
```go
g.Client().ContentType("application/json").PostContent(
ctx,
"http://order.svc/v1/order",
`{"uid":1,"sku_id":10000,"amount":19.99,"create_time":"2020-03-26 12:00:00"}`,
)
```
或
```go
g.Client().ContentType("application/json; charset=utf-8").PostContent(
ctx,
"http://order.svc/v1/order",
`{"uid":1,"sku_id":10000,"amount":19.99,"create_time":"2020-03-26 12:00:00"}`,
)
```
提交的参数如下:
```
{"uid":1,"sku_id":10000,"amount":19.99,"create_time":"2020-03-26 12:00:00"}
```
示例2:
```go
g.Client().ContentType("application/x-www-form-urlencoded; charset=utf-8").GetContent(
ctx,
"http://order.svc/v1/order",
g.Map{
"category" : 1,
"sku_id" : 10000,
},
)
```
提交的参数如下:
```
category=1&sku_id=10000
```
# HTTPClient-自定义Transport
由于 `gclient.Client` 内部封装扩展于标准库的 `http.Client` 对象,因此标准库 `http.Client` 有的特性, `gclient.Client` 也是支持的。我们这里提到的例子是 `Transport` 使用。来看几个示例:
## 使用 `Unix Socket`
客户端和服务端使用 `Unix Socket` 通信,使用 `Transport` 来实现。以下代码为真实项目代码摘选,无法独立运行,仅做参考。
```go
func (*Guardian) ConvertContainerPathToHostPath(
ctx context.Context, namespace, podName, containerName, containerPath string,
) (string, error) {
var (
client = g.Client()
url = "http://localhost/api/v1/pod/path"
req = webservice.HostPathInfoReq{
Namespace: namespace,
PodName: podName,
ContainerName: containerName,
ContainerPath: containerPath,
}
res *webservice.HostPathInfoRes
)
client.Transport = &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return net.Dial("unix", serviceSocketPath)
},
}
err := client.ContentJson().GetVar(ctx, url, req).Scan(&res)
if err != nil {
return "", gerror.Wrapf(
err,
`request guardian failed for url: %s, req: %s`,
url, gjson.MustEncodeString(req),
)
}
if res == nil {
return "", gerror.Newf(
`nil response from guardian request url: %s, req: %s`,
url, gjson.MustEncodeString(req),
)
}
return res.HostPath, nil
}
```
## 设置客户端连接池大小参数
```go
func ExampleNew_MultiConn_Recommend() {
var (
ctx = gctx.New()
client = g.Client()
)
// controls the maximum idle(keep-alive) connections to keep per-host
client.Transport.(*http.Transport).MaxIdleConnsPerHost = 5
for i := 0; i < 5; i++ {
go func() {
if r, err := client.Get(ctx, "http://127.0.0.1:8999/var/json"); err != nil {
panic(err)
} else {
fmt.Println(r.ReadAllString())
r.Close()
}
}()
}
time.Sleep(time.Second * 1)
// Output:
//{"id":1,"name":"john"}
//{"id":1,"name":"john"}
//{"id":1,"name":"john"}
//{"id":1,"name":"john"}
//{"id":1,"name":"john"}
}
```
# HTTPClient-请求信息打印
## 基本介绍
`http` 客户端支持对HTTP请求的输入与输出原始信息获取与打印,方便调试,相关方法如下:
```go
func (r *Response) Raw() string
func (r *Response) RawDump()
func (r *Response) RawRequest() string
func (r *Response) RawResponse() string
```
可以看到,所有的方法均绑定在 `Response` 对象上,也就是说必须要请求结束后才能打印。
## 使用示例
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
)
func main() {
response, err := g.Client().Post(
gctx.New(),
"https://goframe.org",
g.Map{
"name": "john",
},
)
if err != nil {
panic(err)
}
response.RawDump()
}
```
执行后,终端输出为:
```
+---------------------------------------------+
| REQUEST |
+---------------------------------------------+
POST / HTTP/1.1
Host: goframe.org
User-Agent: GoFrameHTTPClient v2.0.0-beta
Content-Length: 9
Content-Type: application/x-www-form-urlencoded
Accept-Encoding: gzip
name=john
+---------------------------------------------+
| RESPONSE |
+---------------------------------------------+
HTTP/1.1 405 Method Not Allowed
Connection: close
Transfer-Encoding: chunked
Allow: GET
Cache-Control: no-store
Content-Security-Policy: frame-ancestors 'self'
Content-Type: text/html;charset=UTF-8
Date: Fri, 03 Dec 2021 09:43:29 GMT
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Server: nginx
...
```
# HTTPClient-代理Proxy设置
## 代理 `Proxy` 设置
HTTP客户端发起请求时可以设置代理服务器地址 `proxyURL`,该该特性使用 `SetProxy*` 相关方法实现。代理主要支持 `http` 和 `socks5` 两种形式,分别为 `http://USER:PASSWORD@IP:PORT` 或 `socks5://USER:PASSWORD@IP:PORT` 形式。
方法列表:
```go
func (c *Client) SetProxy(proxyURL string)
func (c *Client) Proxy(proxyURL string) *Client
```
我们来看下客户端设置 `proxyURL` 的示例。
## 普通调用示例
使用 `SetProxy` 配置方法。
```go
client := g.Client()
client.SetProxy("http://127.0.0.1:1081")
client.SetTimeout(5 * time.Second)
response, err := client.Get(gctx.New(), "https://api.ip.sb/ip")
if err != nil {
fmt.Println(err)
}
response.RawDump()
```
## 链式调用示例
使用 `Proxy` 链式方法。
```go
client := g.Client()
response, err := client.Proxy("http://127.0.0.1:1081").Get(gctx.New(), "https://api.ip.sb/ip")
if err != nil {
fmt.Println(err)
}
fmt.Println(response.RawResponse())
```
# HTTPClient-拦截器/中间件
## 基本介绍
`HTTPClient` 支持强大的拦截器/中间件特性,该特性使得对于客户端的全局请求拦截及注入成为了可能,例如修改/注入提交参数、修改/注入返回参数、基于客户端的参数校验等等。中间件的注入通过以下方法实现:
```go
func (c *Client) Use(handlers ...HandlerFunc) *Client
```
在中间件中通过 `Next` 方法执行下一步流程, `Next` 方法定义如下:
```go
func (c *Client) Next(req *http.Request) (*Response, error)
```
## 中间件类型
`HTTPClient` 中间件功能同 `HTTPServer` 的中间件功能类似,同样也是分为了前置中间件和后置中间件两种。
### 前置中间件
处理逻辑位于 `Next` 方法之前,格式形如:
```go
c := g.Client()
c.Use(func(c *gclient.Client, r *http.Request) (resp *gclient.Response, err error) {
// 自定义处理逻辑
resp, err = c.Next(r)
return resp, err
})
```
### 后置中间件
处理逻辑位于 `Next` 方法之后,格式形如:
```go
c := g.Client()
c.Use(func(c *gclient.Client, r *http.Request) (resp *gclient.Response, err error) {
resp, err = c.Next(r)
// 自定义处理逻辑
return resp, err
})
```
## 使用示例
我们来一个代码示例更好介绍使用,该示例通过给客户端增加拦截器,对提交的JSON数据注入自定义的额外参数,这些额外参数实现对提交参数的签名生成体积签名相关参数提交,也就是实现一版简单的接口参数安全校验。
### 服务端
服务端的逻辑很简单,就是把客户端提交的 `JSON` 参数按照 `map` 解析后再构造成 `JSON` 字符串返回给客户端。
**备注**
往往服务端也需要通过中间件进行签名校验,我这里偷了一个懒,直接返回了客户端提交的数据。体谅一下文档维护作者。
```go
package main
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
func main() {
s := g.Server()
s.Group("/", func(group *ghttp.RouterGroup) {
group.ALL("/", func(r *ghttp.Request) {
r.Response.Write(r.GetMap())
})
})
s.SetPort(8199)
s.Run()
}
```
### 客户端
客户端的逻辑是实现基本的客户端参数提交、拦截器注入、签名相关参数注入以及签名参数生成。
```go
package main
import (
"bytes"
"fmt"
"io/ioutil"
"net/http"
"github.com/gogf/gf/v2/container/garray"
"github.com/gogf/gf/v2/crypto/gmd5"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/internal/json"
"github.com/gogf/gf/v2/net/gclient"
"github.com/gogf/gf/v2/os/gctx"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
"github.com/gogf/gf/v2/util/guid"
"github.com/gogf/gf/v2/util/gutil"
)
const (
appId = "123"
appSecret = "456"
)
// 注入统一的接口签名参数
func injectSignature(jsonContent []byte) []byte {
var m map[string]interface{}
_ = json.Unmarshal(jsonContent, &m)
if len(m) > 0 {
m["appid"] = appId
m["nonce"] = guid.S()
m["timestamp"] = gtime.Timestamp()
var (
keyArray = garray.NewSortedStrArrayFrom(gutil.Keys(m))
sigContent string
)
keyArray.Iterator(func(k int, v string) bool {
sigContent += v
sigContent += gconv.String(m[v])
return true
})
m["signature"] = gmd5.MustEncryptString(gmd5.MustEncryptString(sigContent) + appSecret)
jsonContent, _ = json.Marshal(m)
}
return jsonContent
}
func main() {
c := g.Client()
c.Use(func(c *gclient.Client, r *http.Request) (resp *gclient.Response, err error) {
bodyBytes, _ := ioutil.ReadAll(r.Body)
if len(bodyBytes) > 0 {
// 注入签名相关参数,修改Request原有的提交参数
bodyBytes = injectSignature(bodyBytes)
r.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes))
r.ContentLength = int64(len(bodyBytes))
}
return c.Next(r)
})
content := c.ContentJson().PostContent(gctx.New(), "http://127.0.0.1:8199/", g.Map{
"name": "goframe",
"site": "https://goframe.org",
})
fmt.Println(content)
}
```
### 运行测试
先运行服务端:
```bash
$ go run server.go
SERVER | DOMAIN | ADDRESS | METHOD | ROUTE | HANDLER | MIDDLEWARE
----------|---------|---------|--------|-------|-------------------|-------------
default | default | :8199 | ALL | / | main.main.func1.1 |
----------|---------|---------|--------|-------|-------------------|-------------
2021-05-18 09:23:41.865 97906: http server started listening on [:8199]
```
再运行客户端:
```bash
$ go run client.go
{"appid":"123","name":"goframe","nonce":"12vd8tx23l6cbfz9k59xehk1002pixfo","signature":"578a90b67bdc63d551d6a18635307ba2","site":"https://goframe.org","timestamp":1621301076}
$
```
可以看到,服务端接受到的参数多了多了几项,包括 `appid/nonce/timestamp/signature`,这些参数往往都是签名校验算法所需要的参数。
# HTTPClient-常见问题
## 是否需要保存复用创建的 `gclient.Client` 对象
无论是通过 `g.Client` 或者 `gclient.New` 方法创建 `gclient.Client` 对象,该对象都应该保存下来复用,而不是每一次都新建 `Client` 对象,这样可以提高效率、降低资源使用、使用方式对 `GC` 友好。该对象内建连接池设计,可充分管理大量的短连接请求。由于 `Client` 对象对资源消耗不是很高,所以很多同学可能都没太注意这个点。
什么情况下我应该新建 `gclient.Client` 对象而不是复用呢?可以按照业务模块的解耦设计,每个业务模块单独管理维护自己的 `gclient.Client` 对象。也可以当针对不同的场景,使用 `Client` 的配置不同时,那么可以新建不同的 `Client` 来使用。
## `invalid semicolon separator in query`
**问题原因**:默认 **表单请求** 中带 `;` 字符是非法的(需要 `urlencode`)。具体请参考讨论: [https://github.com/golang/go/issues/25192](https://github.com/golang/go/issues/25192)
**错误示例**:
```bash
curl localhost:8000/Execute -d '{
"Component": "mysql",
"ResourceId": "cdb-gy6hm0ee",
"Port": 6379,
"SQL": "show databases;",
"UserName": "root",
"Password": ""
}'
```
**修复示例**:
提交请求的时候需要注明 `ContentType`,例如这里应当标明是 `JSON` 请求。
```bash
curl -X POST -H "Content-Type: application/json" localhost:8000/Execute -d '{
"Component": "mysql",
"ResourceId": "cdb-gy6hm0ee",
"Port": 6379,
"SQL": "show databases;",
"UserName": "root",
"Password": ""
}'
```
# HTTPClient-监控指标
`HTTP` 客户端支持监控指标能力,默认是关闭的不影响性能,只有在 `metric` 特性全局开启时该组件才会默认开启监控指标计算和生成功能。
## 指标列表
| **指标名称** | **指标类型** | **指标单位** | **指标描述** |
| --- | --- | --- | --- |
| `http.client.request.duration` | `Histogram` | `ms` | 客户端请求执行的时间开销。 |
| `http.client.request.duration_total` | `Counter` | `ms` | 每个请求使用的总时间开销。 |
| `http.client.connection.duration` | `Histogram` | `ms` | 创建连接所使用的时间开销。 |
| `http.client.request.total` | `Counter` | | 已经执行完毕的请求总数。 |
| `http.client.request.active` | `Gauge` | | 当前正在处理的请求数量。 |
| `http.client.request.body_size` | `Counter` | `bytes` | 请求的字节总大小。 |
| `http.client.response.body_size` | `Counter` | `bytes` | 返回的字节总大小。 |
## 属性列表
| **Label名称** | **Label描述** | **Label示例** |
| --- | --- | --- |
| `server.address` | 请求的目标服务地址。可能是域名、IP地址。 | `goframe.org`
`10.0.1.132` |
| `server.port` | 请求的目标服务端口。 | `8000` |
| `http.request.method` | 请求的方法名称。 | `GET`; `POST`; `DELETE` |
| `http.response.status_code` | 处理返回的 `HTTP` 状态码。 | `200` |
| `url.schema` | 使用的请求协议。 | `http`
`https` |
| `network.protocol.version` | 请求协议版本。 | `1.0`
`1.1` |
# 分页管理
**注意**:废弃提示
从版本`v2.10.0`开始,`gpage`组件已被标记为**废弃**,将在未来版本中被移除。
**推荐替代方案**:
1. **使用ORM分页功能**:`GoFrame`的`gdb` ORM组件提供了完善的分页支持
2. **自定义分页逻辑**:根据业务需求实现分页,前端使用现代化的UI组件库
3. **API分页**:返回分页数据结构,由前端框架处理分页显示
## 基本介绍
分页管理由 `gpage` 模块实现, `gpage` 提供了强大的动态分页及静态分页功能,并且为开发者自定义分页样式提供了极高的灵活度。
**提示**
`gpage` 模块主要用于生成分页的HTML代码,常用于 `MVC` 开发场景。
**使用方式**:
```go
import "github.com/gogf/gf/v2/util/gpage"
```
**接口文档**:
[https://pkg.go.dev/github.com/gogf/gf/v2/util/gpage](https://pkg.go.dev/github.com/gogf/gf/v2/util/gpage)
**分页对象**:
```go
// Page is the pagination implementer.
// All the attributes are public, you can change them when necessary.
type Page struct {
TotalSize int // Total size.
TotalPage int // Total page, which is automatically calculated.
CurrentPage int // Current page number >= 1.
UrlTemplate string // Custom url template for page url producing.
LinkStyle string // CSS style name for HTML link tag .
SpanStyle string // CSS style name for HTML span tag , which is used for first, current and last page tag.
SelectStyle string // CSS style name for HTML select tag