前言

现在Golang似乎也是后端开发的热门语言了,前后端开发如果不熟悉的话连接口都写不了吧,为了不被时代淘汰,必须要学学了。

Golang结构

Golang的结构主要是包括模块,包,以及函数。

  • **Package(包)**是 Go 组织代码的基本单位。通常,一个目录对应一个 package;同一包可以由多个 .go 文件组成,文件中可以定义函数、类型、方法、常量和变量。包通常按功能或职责组织。
  • **Module(模块)**是 Go 管理依赖和版本的单位,可以包含一个或多个 package。每个 module 由根目录中的 go.mod 文件定义;go.mod 所在的目录就是 module 根目录。
    go.mod 中的 module 声明模块路径,这个路径也是模块内 package 导入路径的前缀;go 声明模块使用的 Go 语言版本;require 声明模块依赖的其他 module 及其版本。
    .go 文件中的 import 则指定当前文件要使用的 package。简单来说,require 管理模块依赖,import 管理具体使用的包。
    涉及的知识页面一,页面二,页面三。

数据格式

编码与解码

与前端类似,Go语言使用JavaScript对象处理数据,所以对于数据传输时需要先编码为JSON处理,通过使用 encoding/json/v2 包,可以方便地在 Go 程序中读写 JSON。其中,对于需要传输的数据,我们使用Marshal函数将数据编码为 JSON;而对于前端传入的JSON数据,我们需要使用Unmarshal函数将其解码为Go值。
需要注意的是,编码和解码数据也需要遵守相关规定:

  • 只有能够表示为有效 JSON 的数据结构才能被编码:
  • 指针会被编码为它指向的值;如果指针是 nil,则编码为 null。
  • JSON 对象的键只能是字符串。因此,Go 的 map 键必须能编码为 JSON 字符串,例如 map[string]T。其中 T 可以是任何受 JSON 包支持的 Go 类型。
  • channel、复数和函数类型不能被编码。
  • 不支持循环引用的数据结构。
    JSON 包只会读取 struct 中已导出的字段(类似于前端的type类型),也就是名称以大写字母开头的字段。因此,输出 JSON 中只会包含 struct 的已导出字段。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Message struct {
Name string
Body string
Time time.Time
}

b := []byte(`{"Name":"Bob","Food":"Pickle"}`)
var m Message

err := json.Unmarshal(b, &m)
//解码后:
// JSON 键 Name 匹配 Message.Name,所以 m.Name == "Bob"。
// JSON 中的 Food 在 Message 里没有对应字段,所以默认忽略。
// Body 和 Time 没出现在 JSON 中,保持各自的零值:空字符串和零时间。

此外,可以使用any来作为通用容器承接任意数据,JSON 包使用 map[string]any 和 []any 保存任意 JSON 对象和数组;它可以将任何有效的 JSON 数据解码到普通的 any 值中。默认使用的 Go 具体类型如下:

  • JSON 布尔值对应 bool
  • JSON 数字对应 float64
  • JSON 字符串对应 string
  • JSON null 对应 nil

所以不知道某段JSON数据的结构时,我们可以使用Unmarshal 将它解码到 any 值中。

1
2
3
4
5
6
7
8
9
10
11
12
var f any
err := json.Unmarshal(b, &f)

// 此时,f 中存放的是一个 map,键是字符串,值则以 any 类型存储
f = map[string]any{
"Name": "Wednesday",
"Age": 6,
"Parents": []any{
"Gomez",
"Morticia",
},
}

使用

使用时,我们可以通过断言来访问其中具体存放的类型,或使用条件判断来根据不同的数据类型来做输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 用类型断言取得它实际存放的 map[string]any
m := f.(map[string]any)

// 通过 range 遍历 map,再用类型 switch 按具体类型处理每个值
for k, v := range m {
switch vv := v.(type) {
case string:
fmt.Println(k, "是字符串", vv)
case float64:
fmt.Println(k, "是 float64", vv)
case []any:
fmt.Println(k, "是数组:")
for i, u := range vv {
fmt.Println(i, u)
}
default:
fmt.Println(k, "的类型暂不支持处理")
}
}

初始化逻辑

Go在初始化后不会先分配底层数组,等真正需要保存元素时再分配,这样避免为没用到的字段预留空间。此时Go的切片零值是 nil,可以直接使用;当你对它 append 时,Go才会按需分配底层数组,对于指针和其他初始化分配也保持这种机制。

下面例子展示将数据解码到 FamilyMember 值中,结果会符合预期。不过仔细看会发现一个值得注意的细节:我们用 var 创建了 FamilyMember struct,然后将它的指针传给 Unmarshal。此时,Parents 字段还是一个 nil 切片。而需要填充 Parents时,Unmarshal 在后台分配了新的切片。

1
2
3
4
5
6
7
8
type FamilyMember struct {
Name string
Age int
Parents []string
}

var m FamilyMember
err := json.Unmarshal(b, &m)

流式编码与解码

io.Reader 和 io.Writer 接口在 Go 中应用广泛,可以通过流的方式访问 HTTP 连接、WebSocket 或文件等资源。
MarshalWrite 和 UnmarshalRead 函数可以直接将数据编码到流中,或从流中解码数据,无需先用一个中间的 []byte 保存整条消息。

1
2
3
4
5
// 形式定义
func MarshalWrite(out io.Writer, in any, opts ...Options) (err error)
func UnmarshalRead(in io.Reader, out any, opts ...Options) (err error)
// 使用
err := json.MarshalWrite(os.Stdout, m)

自定义编码与解码

有时,默认的编码方式不适合你的类型。我们可以自定义一个编码与解码过程。
后续遇到了再详细解释。

总结

本节涉及的知识页面。

连接数据库

后端使用上必不可少的步骤就是连接并访问数据库,这里以MySQL数据库为例,展示Golang连接并访问数据库的流程。

前置条件

首先需要一个可以运行的MySQL数据库,还需要数据库的使用权限,最后我们需要为Golang后端安装对应的连接包database/sql以及数据库驱动github.com/go-sql-driver/mysql。

连接和访问

第一步,在MySQL中创建一个数据库,创建你想使用的表。

这过于简单,省略详细过程了。
第二步,需要先导入驱动,虽然database/sql包可以提供访问SQL数据库的通用接口,但其本身不包括对应数据库的驱动,所以我们需要先安装github.com/go-sql-driver/mysql驱动。

1
2
1.在需要用到 SQL 交互的 Go 源码中添加 MySQL 驱动的 import,import "github.com/go-sql-driver/mysql"
2.执行 go mod tidy,让 Go 根据源码整理 go.mod 并获取所需依赖,完成安装。

第三步,使用database/sql包提供数据库连接池,调用 sql.Open 可以创建一个数据库句柄 db 。

1
db, err := sql.Open("mysql", "user:password@/dbname")

db 也就是数据库句柄,可以理解为程序访问数据库的管理入口,它通常不是一条数据库连接,而是一个并发安全的连接池管理对象:它负责按需创建、复用和回收底层连接。“句柄”是对这种管理入口的泛称。
简单说则是当前 Go 后端通过 db(*sql.DB)统一管理数据库连接池;后端执行数据库操作时,db 会按需提供并复用连接。
第四步,一个简单的访问Go源码如下,对于的解释在代码中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
package main

import (
"database/sql"
"fmt"
"log"

"github.com/go-sql-driver/mysql"
)
// 创建句柄
var db *sql.DB

func main() {
// 配置数据库连接信息。
cfg := mysql.Config{
User: "user",
Passwd: "password",
Net: "tcp",
Addr: "127.0.0.1:3306",
DBName: "recordings",
}
var err error

// 获取数据库句柄,FormatDSN 将配置格式化为 MySQL 驱动使用的连接字符串
db, err = sql.Open("mysql", cfg.FormatDSN())
if err != nil {
log.Fatal(err)
}

// 验证数据库连接是否可用
pingErr := db.Ping()
if pingErr != nil {
log.Fatal(pingErr)
}
fmt.Println("Connected!")

// 查询艺术家名为 John Coltrane 的所有唱片
albums, err := albumsByArtist("John Coltrane")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Albums found: %v\n", albums)
}

// albumsByArtist 查询指定艺术家的唱片
func albumsByArtist(name string) ([]Album, error) {
// 查询艺术家名匹配的所有唱片
// 使用 Query 可以执行可能返回多行结果的查询
rows, err := db.Query(
"SELECT * FROM album WHERE artist = ?",
name,
)
if err != nil {
return nil, fmt.Errorf("albumsByArtist %q: %v", name, err)
}
// 安排在当前函数即将返回时调用 rows.Close(),释放查询结果占用的资源
defer rows.Close()

// 保存查询结果到一个[]Album 切片
var albums []Album

// 遍历查询结果中的每一行,rows.Next() 会让查询结果 rows 移动到下一行,并返回一个布尔值
for rows.Next() {
var alb Album
// 先运行 Scan,把它返回的错误暂存在 err 中;如果 err 不为 nil,就执行大括号里的代码
if err := rows.Scan(&alb.ID, &alb.Title, &alb.Artist, &alb.Price); err != nil {
return nil, fmt.Errorf("albumsByArtist %q: %v", name, err)
}
// 否则正常存入
albums = append(albums, alb)
}

// 检查遍历过程中是否发生错误,这是两种错误
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("albumsByArtist %q: %v", name, err)
}
return albums, nil
}

rows.Next() 返回 false 表示没有取得下一行。这通常是因为结果集正常读完了;也可能是读取过程中出了错。单看这个 false,无法区分这两种情况。rows.Err() 用来区分:

  • rows.Err() == nil:遍历正常结束,没有发现读取错误。
  • rows.Err() != nil:遍历中发生了读取错误,例如连接问题。

当然也有仅限单行读取的查询操作,使用 QueryRow,它会返回一个结果对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// albumByID 查询指定编号的唱片。
func albumByID(id int64) (Album, error) {
// 查询编号匹配的唱片。
var alb Album
row := db.QueryRow("SELECT * FROM album WHERE id = ?", id)
if err := row.Scan(&alb.ID, &alb.Title, &alb.Artist, &alb.Price); err != nil {
if err == sql.ErrNoRows {
return alb, fmt.Errorf("albumByID %d: no such album", id)
}
return alb, fmt.Errorf("albumByID %d: %v", id, err)
}
return alb, nil
}
如果查询没有找到记录,Scan 会返回 sql.ErrNoRows。程序可以单独处理这个情况,向调用者说明指定编号的唱片不存在。

存入数据

使用 Exec 可以执行不需要返回查询结果的 SQL,例如插入、更新或删除数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// addAlbum 向数据库添加一张唱片。
func addAlbum(alb Album) (int64, error) {
result, err := db.Exec(
"INSERT INTO album (title, artist, price) VALUES (?, ?, ?)",
alb.Title,
alb.Artist,
alb.Price,
)
if err != nil {
return 0, fmt.Errorf("addAlbum: %v", err)
}

// 获取数据库自动生成的主键编号,使用Exchec返回结果来获取
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("addAlbum: %v", err)
}
return id, nil
}

简单的SQL数据库连接示例就到这里结束了,本节涉及的知识页面。

开发 RESTful API

这里我们使用 Go 和 Gin Web Framework(Gin)编写一个简单的 RESTful API 服务。Gin 能帮助你处理 Web 开发中的常见工作,例如路由请求、读取请求信息,以及把数据转换为 JSON 响应。

设计API路由时,需要按照功能对应区分不同的GET/POST接口方法。
这里我们先安装gin,在项目根目录执行go get github.com/gin-gonic/gin,然后在代码中导入 Gin。

预置数据格式

为了方便展示过程,我们先不结合数据库做,而是设置一些保存在内存中的预置数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// album 表示一张唱片的数据。
type album struct {
ID string `json:"id"`
Title string `json:"title"`
Artist string `json:"artist"`
Price float64 `json:"price"`
}

// albums 切片用于预置唱片数据。
var albums = []album{
{ID: "1", Title: "Blue Train", Artist: "John Coltrane", Price: 56.99},
{ID: "2", Title: "Jeru", Artist: "Gerry Mulligan", Price: 17.99},
{ID: "3", Title: "Sarah Vaughan and Clifford Brown", Artist: "Sarah Vaughan", Price: 39.99},
}

GET接口与请求

现在数据已经创建,我们需要从前端发送请求到后端获取数据,具体的流程展示如下:

1
2
3
4
5
6
7
8
9
前端发送 GET /albums
↓
Gin 匹配 router.GET("/albums", getAlbums)
↓
Gin 创建并传入请求对应的 *gin.Context
↓
getAlbums 使用 c 写入状态码和 JSON 响应
↓
前端收到响应

这里我们先定义后端请求入口,,在main.go中设置路由并启动服务,如下:

1
2
3
4
5
6
7
func main() {
router := gin.Default()
//可以定义方法类型和入口
router.GET("/albums", getAlbums)
// router.Run 会启动 HTTP 服务并监听 localhost:8080
router.Run("localhost:8080")
}

gin.Default() 创建一个默认路由器。router.GET 将 GET /albums 请求交给 getAlbums 处理。这里传入的是函数本身 getAlbums,不是调用函数得到的结果 getAlbums()。
当然,在这之前我们需要引入依赖包,如下:

1
2
3
4
5
6
7
8
package main

import (
// net/http 提供 HTTP 状态码等标准定义
"net/http"
// github.com/gin-gonic/gin 是 Gin 框架
"github.com/gin-gonic/gin"
)

在对应功能GET /albums时,我们应该返回所有唱片为JSON,处理请求的函数叫作 handler(处理函数)。教程中的处理函数如下:

1
2
3
4
// getAlbums 将所有唱片作为 JSON 返回。
func getAlbums(c *gin.Context) {
c.IndentedJSON(http.StatusOK, albums)
}

一般来讲,前端发送 HTTP 请求;Gin 收到请求后,根据请求方法和路径匹配路由,然后创建并填充这个请求对应的 *gin.Context,也就是本示例中的c,再把它传给 getAlbums。
进入函数时,c 已经包含了可供 handler 使用的请求和响应上下文,例如请求对象、路径参数、查询参数,以及写响应的方法。但 Gin 不会预先把请求体里的 JSON 自动绑定到某个业务结构体;需要 handler 显式调用 c.BindJSON(…) 才会解析。
但在当前例子中,getAlbums 不读取请求数据,只通过 c.IndentedJSON(…) 写入 HTTP 响应(这包含状态码和数据内容),因为当前数据已经存在本地而且没有设置查询条件。
那么这里我们启动服务发送请求就可以得到我们预设的数据:

1
2
3
4
# 启动服务
go run .
# 在另一个窗口发送请求
curl http://localhost:8080/albums

如果我们要加入参数,也很简单,我们只需要在前端传入查询条件,并在后端读取该条件查询即可,这里以唱片ID为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// getAlbumByID 查找并返回 ID 与请求参数匹配的唱片。
func getAlbumByID(c *gin.Context) {
// 从c中读取条件参数
id := c.Param("id")

// 遍历唱片列表,查找 ID 匹配的唱片。
for _, a := range albums {
if a.ID == id {
c.IndentedJSON(http.StatusOK, a)
return
}
}
c.IndentedJSON(http.StatusNotFound, gin.H{"message": "album not found"})
}

这样c.Param("id") 会取得路由中的 id 参数。for _, a := range albums 遍历切片:_ 忽略元素下标,a 表示当前唱片。
如果找到唱片,就返回 JSON 和 200 OK。return 用来立即结束函数,避免继续遍历或执行后面的“未找到”逻辑。
如果没找到,程序返回 404 Not Found,并用 gin.H 构造一段 JSON 对象:

1
{"message": "album not found"}

然后记得添加路由router.GET("/albums/:id", getAlbumByID)。
然后再进行测试curl http://localhost:8080/albums/2。

Post接口与请求

这里我们通过新增唱片来模拟我们的POST接口任务。先创建路由于main.go:

1
2
3
4
5
6
7
func main() {
router := gin.Default()
router.GET("/albums", getAlbums)
router.POST("/albums", postAlbums)

router.Run("localhost:8080")
}

由于我们发送信息到后端,所以服务端要读取请求体中的 JSON,创建唱片数据并添加到列表中。也就是说我们需要从c中读取请求信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
// postAlbums 根据请求体中的 JSON 添加一张唱片。
func postAlbums(c *gin.Context) {
var newAlbum album

// 将收到的 JSON 绑定到 newAlbum。
if err := c.BindJSON(&newAlbum); err != nil {
return
}

// 将新唱片添加到切片。
albums = append(albums, newAlbum)
c.IndentedJSON(http.StatusCreated, newAlbum)
}

绑定成功后,append 将新唱片加入 albums 切片。响应使用 http.StatusCreated,也就是 HTTP 201 Created,并返回刚添加的唱片。
同样地,启动服务后使用命令行模拟请求,我们可以添加一张唱片。

1
2
3
4
5
curl http://localhost:8080/albums \
--include \
--header "Content-Type: application/json" \
--request "POST" \
--data '{"id": "4","title": "The Modern Sound of Betty Carter","artist": "Betty Carter","price": 49.99}'

其中:

  • –include 要求 curl 显示 HTTP 响应头。
  • –header 声明请求体是 JSON。
  • –request “POST” 指定 HTTP 方法。
  • –data 提供要发送的 JSON 内容。
    成功时,响应状态是 201 Created,响应体会包含新唱片的数据。之后再请求 GET /albums,可以看到新记录已经加入列表。

总结

这个接口开发实例可以按四个环节理解:

  1. 数据模型:album 结构体定义一张唱片包含哪些字段;JSON 标签控制字段在接口中的名称。
  2. 内存数据:albums 切片保存唱片。它暂时替代数据库,但服务重启后会恢复到代码中的初始数据。
  3. 处理函数:getAlbums、postAlbums 和 getAlbumByID 分别处理列表查询、添加和按 ID 查询。
  4. 路由:main 把 HTTP 方法和 URL 路径映射到处理函数,再启动监听 localhost:8080 的服务器。
    参考的教程文档。

Go 泛型