Go ORM框架 – GORM 踩坑指南

今天聊聊目前業界使用比較多的 ORM 框架:GORM。GORM 相關的文檔原作者已經寫得非常的詳細,具體可以看這裡,這一篇主要做一些 GORM 使用過程中關鍵功能的介紹,GORM 約定的一些配置資訊說明,防止大家在使用過程中踩坑。

以下示例程式碼都可以在 Github : gorm-demo 中找到。


GORM 官方支援的資料庫類型有: MySQL, PostgreSQL, SQlite, SQL Server。

連接 MySQL 的示例:

import (
  "gorm.io/driver/mysql"
  "gorm.io/gorm"
)

func main() {
  // 參考 //github.com/go-sql-driver/mysql#dsn-data-source-name 獲取詳情
  dsn := "user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local"
  db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
}

MySQl 驅動程式提供了一些高級配置可以在初始化過程中使用,例如:

db, err := gorm.Open(mysql.New(mysql.Config{
  DSN: "gorm:gorm@tcp(127.0.0.1:3306)/gorm?charset=utf8&parseTime=True&loc=Local", // DSN data source name
  DefaultStringSize: 256, // string 類型欄位的默認長度
  DisableDatetimePrecision: true, // 禁用 datetime 精度,MySQL 5.6 之前的資料庫不支援
  DontSupportRenameIndex: true, // 重命名索引時採用刪除並新建的方式,MySQL 5.7 之前的資料庫和 MariaDB 不支援重命名索引
  DontSupportRenameColumn: true, // 用 `change` 重命名列,MySQL 8 之前的資料庫和 MariaDB 不支援重命名列
  SkipInitializeWithVersion: false, // 根據當前 MySQL 版本自動配置
}), &gorm.Config{})

注意到 gorm.Open(dialector Dialector, opts …Option) 函數的第二個參數是接收一個 gorm.Config{} 類型的參數,這裡就是 gorm 在資料庫建立連接後框架本身做的一些默認配置,請注意這裡如果沒有配置好,後面你的資料庫操作將會很痛苦!

GORM 提供的配置可以在初始化時使用:

type Config struct {
  SkipDefaultTransaction   bool
  NamingStrategy           schema.Namer
  Logger                   logger.Interface
  NowFunc                  func() time.Time
  DryRun                   bool
  PrepareStmt              bool
  DisableNestedTransaction bool
  AllowGlobalUpdate        bool
  DisableAutomaticPing     bool
  DisableForeignKeyConstraintWhenMigrating bool
}

這些參數我們一個一個來說:

SkipDefaultTransaction

跳過默認開啟事務模式。為了確保數據一致性,GORM 會在事務里執行寫入操作(創建、更新、刪除)。如果沒有這方面的要求,可以在初始化時禁用它。

db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{
  SkipDefaultTransaction: true,
})

NamingStrategy

表名稱的命名策略,下面會說。GORM 允許用戶通過覆蓋默認的NamingStrategy來更改命名約定,這需要實現介面 Namer

type Namer interface {
    TableName(table string) string
    ColumnName(table, column string) string
    JoinTableName(table string) string
    RelationshipFKName(Relationship) string
    CheckerName(table, column string) string
    IndexName(table, column string) string
}

默認 NamingStrategy 也提供了幾個選項,如:

db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{
  NamingStrategy: schema.NamingStrategy{
    TablePrefix: "t_",   // 表名前綴,`User`表為`t_users`
    SingularTable: true, // 使用單數表名,啟用該選項後,`User` 表將是`user`
    NameReplacer: strings.NewReplacer("CID", "Cid"), // 在轉為資料庫名稱之前,使用NameReplacer更改結構/欄位名稱。
  },
})

一般來說這裡是一定要配置 SingularTable: true 這一項的。

Logger

允許通過覆蓋此選項更改 GORM 的默認 logger。

NowFunc

更改創建時間使用的函數:

db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{
  NowFunc: func() time.Time {
    return time.Now().Local()
  },
})

DryRun

生成 SQL 但不執行,可以用於準備或測試生成的 SQL:

db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{
  DryRun: false,
})

PrepareStmt

PreparedStmt 在執行任何 SQL 時都會創建一個 prepared statement 並將其快取,以提高後續的效率:

db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{
  PrepareStmt: false,
})

GORM 約定配置

使用別人的框架就要受制別人的約束,在 GORM 中有很多的約定,如果你沒有遵循這些約定可能你認為正常的程式碼跑起來會發生意想不到的問題。

模型定義

默認情況下,GORM 會使用 ID 作為表的主鍵。

type User struct {
  ID   string // 默認情況下,名為 `ID` 的欄位會作為表的主鍵
  Name string
}

如果你當前的表主鍵不是 id 欄位,那麼你可以通過 primaryKey標籤將其它欄位設為主鍵:

// 將 `UUID` 設為主鍵
type Animal struct {
  ID     int64
  UUID   string `gorm:"primaryKey"`
  Name   string
  Age    int64
}

如果你的表採用了複合主鍵,那也沒關係:

type Product struct {
  ID           string `gorm:"primaryKey"`
  LanguageCode string `gorm:"primaryKey"`
  Code         string
  Name         string
}

注意:默認情況下,整型 PrioritizedPrimaryField 啟用了 AutoIncrement,要禁用它,您需要為整型欄位關閉 autoIncrement

type Product struct {
  CategoryID uint64 `gorm:"primaryKey;autoIncrement:false"`
  TypeID     uint64 `gorm:"primaryKey;autoIncrement:false"`
}
GORM 標籤

GORM 通過在 struct 上定義自定義的 gorm 標籤來實現自動化創建表的功能:

type User struct {
	Name string  `gorm:"size:255"` //string默認長度255,size重設長度
	Age int `gorm:"column:my_age"` //設置列名為my_age
	Num int  `gorm:"AUTO_INCREMENT"` //自增
	IgnoreMe int `gorm:"-"` // 忽略欄位
	Email string `gorm:"type:varchar(100);unique_index"` //type設置sql類型,unique_index為該列設置唯一索引
	Address string `gorm:"not null;unique"` //非空
	No string `gorm:"index:idx_no"` // 創建索引並命名,如果有其他同名索引,則創建組合索引
	Remark string `gorm:"default:''"` //默認值
}

定義完這些標籤之後,你可以使用 AutoMigrate 在 MySQL 建立連接之後創建表:

func main() {
	db, err := gorm.Open("mysql", "root:123456789@/test_db?charset=utf8&parseTime=True&loc=Local")
	if err != nil {
		fmt.Println("connect db error: ", err)
	}
	db.AutoMigrate(&model.User{})
}

AutoMigrate 用於自動遷移你的 schema,保持 schema 是最新的。 該 API 會創建表、缺失的外鍵、約束、列和索引。 如果大小、精度、是否為空可以更改,則 AutoMigrate 會改變列的類型。

出於保護數據的目的,它 不會 刪除未使用的列。

默認模型

GORM 定義一個 gorm.Model 結構體,其包括欄位 IDCreatedAtUpdatedAtDeletedAt

// gorm.Model 的定義
type Model struct {
  ID        uint           `gorm:"primaryKey"`
  CreatedAt time.Time
  UpdatedAt time.Time
  DeletedAt gorm.DeletedAt `gorm:"index"`
}

如果你覺得上面這幾個欄位名欄位名是你想要的,那麼你完全可以在你的模型中引入它:

type User struct {
	gorm.Model
	Id    int64  `json:"id"`
	Name  string `json:"name"`
	Age   int32  `json:"age"`
	Sex   int8   `json:"sex"`
	Phone string `json:"phone"`
}

反之如果不是你需要的,就沒必要多此一舉。

表名

這裡是一個很大的坑。GORM 使用結構體名的 蛇形命名 作為表名。對於結構體 User,根據約定,其表名為 users

當然我們的表名肯定不會是這樣設置的,所以為什麼作者要採用這種設定實在是難以捉摸。

這裡有兩種方式去修改表名:第一種就是去掉這個默認設置;第二種就是在保留默認設置的基礎上通過重新設定表名來替換。

先說如何通過重新設定表名來替換,可以實現 Tabler 介面來更改默認表名,例如:

type Tabler interface {
    TableName() string
}

// TableName 會將 User 的表名重寫為 `user_new_name`
func (User) TableName() string {
  return "user_new_name"
}

通過去掉默認配置上面已經有提,配置 SingularTable: true 這選項即可。

列名覆蓋

默認情況下列名遵循普通 struct 的規則:

type User struct {
  ID        uint      // 列名是 `id`
  Name      string    // 列名是 `name`
  Birthday  time.Time // 列名是 `birthday`
  CreatedAt time.Time // 列名是 `created_at`
}

如果你的列名和欄位不匹配的時候,可以通過如下方式重新指定:

type Animal struct {
  AnimalID int64     `gorm:"column:beast_id"`         // 將列名設為 `beast_id`
  Birthday time.Time `gorm:"column:day_of_the_beast"` // 將列名設為 `day_of_the_beast`
  Age      int64     `gorm:"column:age_of_the_beast"` // 將列名設為 `age_of_the_beast`
}
日期欄位時間類型設置

GORM 約定使用 CreatedAtUpdatedAt 追蹤創建/更新時間。如果你定義了這種欄位,GORM 在創建、更新時會自動填充。

如果想要保存 UNIX(毫/納)秒時間戳而不是 time,只需簡單地將 time.Time 修改為 int 即可:

type User struct {
  CreatedAt time.Time // 在創建時,如果該欄位值為零值,則使用當前時間填充
  UpdatedAt int       // 在創建時該欄位值為零值或者在更新時,使用當前時間戳秒數填充
  Updated   int64 `gorm:"autoUpdateTime:nano"` // 使用時間戳填納秒數充更新時間
  Updated   int64 `gorm:"autoUpdateTime:milli"` // 使用時間戳毫秒數填充更新時間
  Created   int64 `gorm:"autoCreateTime"`      // 使用時間戳秒數填充創建時間
}
嵌入結構體

對於匿名欄位,GORM 會將其欄位包含在父結構體中,例如:

type User struct {
  gorm.Model
  Name string
}
// 等效於
type User struct {
  ID        uint           `gorm:"primaryKey"`
  CreatedAt time.Time
  UpdatedAt time.Time
  DeletedAt gorm.DeletedAt `gorm:"index"`
  Name string
}

對於正常的結構體欄位,你也可以通過標籤 embedded 將其嵌入,例如:

type Author struct {
    Name  string
    Email string
}

type Blog struct {
  ID      int
  Author  Author `gorm:"embedded"`
  Upvotes int32
}
// 等效於
type Blog struct {
  ID    int64
    Name  string
    Email string
  Upvotes  int32
}

CRUD操作

新增相關

單行插入,gorm 會返回插入之後的主鍵資訊:

func InsertOneUser(user model.User) (id int64, err error) {
	tx := constants.GVA_DB.Create(&user)
	if tx.Error != nil {
		constants.GVA_LOG.Error("InsertOne err", zap.Any("err", tx.Error))
		return 0, tx.Error
	}
	return user.Id, nil
}

批量插入,批量插入也會同步返回插入之後的主鍵資訊:

func BatchInsertUsers(users []model.User) (ids []int64, err error) {
	tx := constants.GVA_DB.CreateInBatches(users, len(users))
	if tx.Error != nil {
		constants.GVA_LOG.Error("BatchInsert err", zap.Any("err", tx.Error))
		return []int64{}, tx.Error
	}
	ids = []int64{}
	for idx, user := range users {
		ids[idx] = user.Id
	}
	return ids, nil
}

插入衝突操作-Upsert:

如果你的表設置了唯一索引的情況下,插入可能會出現主鍵衝突的情況,MySQL 本身是提供了相關的操作命令 ON DUPLICATE KEY UPDATE,那麼對應到 Gorm 中的函數是 Upsert:

// 在衝突時,什麼都不做
constants.GVA_DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&user)

// 在`id`衝突時,將列更新為默認值
constants.GVA_DB.Clauses(clause.OnConflict{
  Columns:   []clause.Column{{Name: "id"}},
  DoUpdates: clause.Assignments(map[string]interface{}{"name": "","age":0, "sex": 1}),
}).Create(&user)

// 在`id`衝突時,將列更新為新值
constants.GVA_DB.Clauses(clause.OnConflict{
  Columns:   []clause.Column{{Name: "id"}},
  DoUpdates: clause.AssignmentColumns([]string{"name", "age", "sex", "phone"}),
}).Create(&user)

// 在衝突時,更新除主鍵以外的所有列到新值。
constants.GVA_DB.Clauses(clause.OnConflict{UpdateAll: true,}).Create(&user)
刪除相關

根據主鍵刪除:

//根據 id 刪除數據
func DeleteUserById(id int64) (err error) {
	user := model.User{Id: id}
	err = constants.GVA_DB.Delete(&user).Error
	if err != nil {
		constants.GVA_LOG.Error("DeleteUserById err", zap.Any("err", err))
		return err
	}
	return nil
}

根據條件刪除:

constants.GVA_DB.Where("sex = ?", 0).Delete(model.User{})

批量刪除:

//根據 id 批量刪除數據
func BatchDeleteUserByIds(ids []int64) (err error) {
	if ids == nil || len(ids) == 0 {
		return
	}
	//刪除方式1
	err = constants.GVA_DB.Where("id in ?", ids).Delete(model.User{}).Error
	if err != nil {
		constants.GVA_LOG.Error("DeleteUserById err", zap.Any("err", err))
		return err
	}

	//刪除方式 2
	//constants.GVA_DB.Delete(model.User{}, "id in ?", ids)

	return nil
}

對於全局刪除的阻止設定

如果在沒有任何條件的情況下執行批量刪除,GORM 不會執行該操作,並返回 ErrMissingWhereClause 錯誤。對此,你必須加一些條件,或者使用原生 SQL,或者啟用 AllowGlobalUpdate 模式,例如:

// DELETE FROM `user` WHERE 1=1
constants.GVA_DB.Where("1 = 1").Delete(&model.User{})

//原生sql刪除
constants.GVA_DB.Exec("DELETE FROM user")

//跳過設定
constants.GVA_DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.User{})
更新操作

全量更新 struct 的所有欄位,包括零值:

//根據id更新數據,全量欄位更新,即使欄位是0值
func UpdateUserById(user model.User) (err error) {
	err = constants.GVA_DB.Save(&user).Error
	if err != nil {
		constants.GVA_LOG.Error("UpdateUserById err", zap.Any("err", err))
		return err
	}
	return nil
}

更新指定列:

//更新指定列
//update user set `columnName` = v where id = id;
func UpdateSpecialColumn(id int64, columnName string, v interface{}) (err error) {
	err = constants.GVA_DB.Model(&model.User{Id: id}).Update(columnName, v).Error
	if err != nil {
		constants.GVA_LOG.Error("UpdateSpecialColumn err", zap.Any("err", err))
		return err
	}
	return nil
}

更新非0值的欄位:

//更新- 根據 `struct` 更新屬性,只會更新非零值的欄位
//update user set `columnName` = v where id = id;
//當通過 struct 更新時,GORM 只會更新非零欄位。 如果您想確保指定欄位被更新,你應該使用 Select 更新選定欄位,或使用 map 來完成更新操作
func UpdateSelective(user model.User) (effected int64, err error) {
	tx := constants.GVA_DB.Model(&user).Updates(&model.User{
		Id:    user.Id,
		Name:  user.Name,
		Age:   user.Age,
		Sex:   user.Sex,
		Phone: user.Phone,
	})
}

如果你想更新0值的欄位,那麼可以使用 Select 函數先選擇指定的列名,或者使用 map 來完成:

//map 方式會更新0值欄位
tx = constants.GVA_DB.Model(&user).Updates(map[string]interface{}{
  "Id":    user.Id,
  "Name":  user.Name,
  "Age":   user.Age,
  "Sex":   user.Sex,
  "Phone": user.Phone,
})

Select 方式指定列名:

//Select 方式指定列名
tx = constants.GVA_DB.Model(&user).Select("Name", "Age", "Phone").Updates(&model.User{
  Id:    user.Id,
  Name:  user.Name,
  Age:   user.Age,
  Sex:   user.Sex,
  Phone: user.Phone,
})

Select 選定所有列名:

// Select 所有欄位(查詢包括零值欄位的所有欄位)
tx = constants.GVA_DB.Model(&user).Select("*").Updates(&model.User{
  Id:    user.Id,
  Name:  user.Name,
  Age:   user.Age,
  Sex:   user.Sex,
  Phone: user.Phone,
})

Select 排除指定列名:

// Select 除 Phone 外的所有欄位(包括零值欄位的所有欄位)
tx = constants.GVA_DB.Model(&user).Select("*").Omit("Phone").Updates(&model.User{
  Id:    user.Id,
  Name:  user.Name,
  Age:   user.Age,
  Sex:   user.Sex,
  Phone: user.Phone,
})

根據條件批量更新:

//根據 條件 批量更新
func BatchUpdateByIds(ids []int64, user model.User) (effected int64, err error) {
  if ids == nil || len(ids) == 0 {
    return
  }
  tx := constants.GVA_DB.Model(model.User{}).Where("id in ?", ids).Updates(&user)
  if tx.Error != nil {
    return 0, tx.Error
  }
  return tx.RowsAffected, nil
}
查詢操作

查詢是重頭戲放在最後。

Gorm 提供的便捷查詢:

First:獲取第一條記錄(主鍵升序)

// SELECT * FROM user ORDER BY id LIMIT 1;
constants.GVA_DB.First(&user)

獲取一條記錄,沒有指定排序欄位:

// SELECT * FROM user LIMIT 1;
constants.GVA_DB.Take(&user)

獲取最後一條記錄(主鍵降序):

// SELECT * FROM user ORDER BY id DESC LIMIT 1;
constants.GVA_DB.Last(&user)

使用主鍵的方式查詢:

// SELECT * FROM user WHERE id = 10;
constants.GVA_DB.First(&user, 10)

// SELECT * FROM user WHERE id = 10;
constants.GVA_DB.First(&user, "10")

// SELECT * FROM user WHERE id IN (1,2,3);
constants.GVA_DB.Find(&user, []int{1,2,3})

條件查詢:

// 獲取第一條匹配的記錄
// SELECT * FROM user WHERE name = 'xiaoming' ORDER BY id LIMIT 1;
constants.GVA_DB.Where("name = ?", "xiaoming").First(&user)

// 獲取全部匹配的記錄
// SELECT * FROM user WHERE name <> 'xiaoming';
constants.GVA_DB.Where("name <> ?", "xiaoming").Find(&user)

// IN
// SELECT * FROM user WHERE name IN ('xiaoming','xiaohong');
constants.GVA_DB.Where("name IN ?", []string{"xiaoming", "xiaohong"}).Find(&user)

// LIKE
// SELECT * FROM user WHERE name LIKE '%ming%';
constants.GVA_DB.Where("name LIKE ?", "%ming%").Find(&user)

// AND
// SELECT * FROM user WHERE name = 'xiaoming' AND age >= 33;
constants.GVA_DB.Where("name = ? AND age >= ?", "xiaoming", 33).Find(&user)

// Time
// SELECT * FROM user WHERE updated_at > '2021-03-10 15:44:23';
constants.GVA_DB.Where("updated_at > ?", "2021-03-10 15:44:23").Find(&user)

// BETWEEN
// SELECT * FROM user WHERE created_at BETWEEN ''2021-03-07 15:44:23' AND '2021-03-10 15:44:23';
constants.GVA_DB.Where("created_at BETWEEN ? AND ?", "2021-03-07 15:44:23", "2021-03-10 15:44:23").Find(&user)

not 條件操作:

// SELECT * FROM user WHERE NOT name = "xiaoming" ORDER BY id LIMIT 1;
constants.GVA_DB.Not("name = ?", "xiaoming").First(&user)

// Not In
// SELECT * FROM user WHERE name NOT IN ("xiaoming", "xiaohong");
constants.GVA_DB.Not(map[string]interface{}{"name": []string{"xiaoming", "xiaohong"}}).Find(&user)

// Struct
// SELECT * FROM user WHERE name <> "xiaoming" AND age <> 20 ORDER BY id LIMIT 1;
constants.GVA_DB.Not(model.User{Name: "xiaoming", Age: 20}).First(&user)

// 不在主鍵切片中的記錄
// SELECT * FROM user WHERE id NOT IN (1,2,3) ORDER BY id LIMIT 1;
constants.GVA_DB.Not([]int64{1,2,3}).First(&user)

or 操作:

// SELECT * FROM user WHERE name = 'xiaoming' OR name = 'xiaohong';
constants.GVA_DB.Where("name = ?", "xiaoming").Or("name = ?", "xiaohong").Find(&user)

// Struct
// SELECT * FROM user WHERE name = 'xiaoming' OR (name = 'xiaohong' AND age = 20);
constants.GVA_DB.Where("name = 'xiaoming'").Or(model.User{Name: "xiaohong", Age: 20}).Find(&user)

// Map
// SELECT * FROM user WHERE name = 'xiaoming' OR (name = 'xiaohong' AND age = 20);
constants.GVA_DB.Where("name = 'xiaoming'").Or(map[string]interface{}{"name": "xiaohong", "age": 20}).Find(&user)

查詢返回指定欄位:

如果你只要要查詢特定的欄位,可以使用 Select 來指定返回欄位:

// SELECT name, age FROM user;
constants.GVA_DB.Select("name", "age").Find(&user)

// SELECT name, age FROM user;
constants.GVA_DB.Select([]string{"name", "age"}).Find(&user)

// SELECT COALESCE(age,'20') FROM user;
constants.GVA_DB.Table("user").Select("COALESCE(age,?)", 20).Rows()

指定排序方式:

// SELECT * FROM users ORDER BY age desc, name;
constants.GVA_DB.Order("age desc, name").Find(&users)

// 多個 order
// SELECT * FROM users ORDER BY age desc, name;
constants.GVA_DB.Order("age desc").Order("name").Find(&users)

// SELECT * FROM users ORDER BY FIELD(id,1,2,3)
constants.GVA_DB.Clauses(clause.OrderBy{
  Expression: clause.Expr{SQL: "FIELD(id,?)", Vars: []interface{}{[]int{1, 2, 3}}, WithoutParentheses: true},
}).Find(&model.User{})

分頁查詢:

// SELECT * FROM user LIMIT 10;
constants.GVA_DB.Limit(10).Find(&user)

// SELECT * FROM user OFFSET 10;
constants.GVA_DB.Offset(10).Find(&user)

// SELECT * FROM user OFFSET 0 LIMIT 10;
constants.GVA_DB.Limit(10).Offset(0).Find(&user)

分組查詢-Group & Having:

// SELECT name, sum(age) as total FROM `users` WHERE name LIKE "ming%" GROUP BY `name`
constants.GVA_DB.Model(&model.User{}).Select("name, sum(age) as total").Where("name LIKE ?", "group%").Group("name").First(&result)


// SELECT name, sum(age) as total FROM `users` GROUP BY `name` HAVING name = "group"
constants.GVA_DB.Model(&model.User{}).Select("name, sum(age) as total").Group("name").Having("name = ?", "group").Find(&result)

Distinct 使用:

//SELECT distinct(name, age) from user order by name, age desc
constants.GVA_DB.Distinct("name", "age").Order("name, age desc").Find(&user)

事務操作

如同在 MySQL 中操作事務一樣,事務的開始是以 Begin 開始,以 Commit 結束:

//事務測試
func TestGormTx(user model.User) (err error) {
	tx := constants.GVA_DB.Begin()
	// 注意,一旦你在一個事務中,使用tx作為資料庫句柄
	if err := tx.Create(&model.User{
		Name:  "liliya",
		Age:   13,
		Sex:   0,
		Phone: "15543212346",
	}).Error; err != nil {
		tx.Rollback()
		return err
	}
	
	if err := tx.Updates(&model.User{
		Id:    user.Id,
		Name:  user.Name,
		Age:   user.Age,
		Sex:   user.Sex,
		Phone: user.Phone,
	}).Error; err != nil {
		tx.Rollback()
		return err
	}

	tx.Commit()
	return nil
}

以上就是關於 GORM 使用相關的操作說明以及可能會出現的問題,關於 Gorm 的使用還有一些高級特性,這裡就不做全面的演示,還是先熟悉基本 api 的操作等需要用到高級特性的時候再去看看也不遲。示例程式碼都已經上傳到 Github,大家可以下載下來練習一下。