软件技术学习笔记

个人博客,记录软件技术与程序员的点点滴滴。

Golang服务常用组件—GORM, Sqlx, MySQL, MongoDB

在分布式系统中考虑7x24h不间断服务时,相比无状态的应用服务,有状态的数据存储服务是一个非常重要又比较麻烦的部分。

在云原生中,无状态服务与有状态服务的区别有:

事件 无状态服务 有状态服务
升级 直接部署新版本,删/减老版本实例 升级Schema; 新增表,双表操作,到下个版本才能完全迁移
POD异常 拉起新的POD实例 选举副本做Master,再拉起新的副本实例;在客户端中熔断,写操作切换到新的Master
负载均衡 Service Mesh或IPVS负载均衡,非侵入式 分片选择:SQL协议Proxy模式;客户端侵入式

数据库的升级/维护,选择GitOps比较合适,避免人工直接操作环境。

本文中,我们不演示如何实现高可用、高并发的数据库集群的Master选举、客户端熔断,只演示:

  1. 如何连接数据库单例或集群
  2. 如何完成增、删、改、查等基本操作

1. GORM

GORM是GoLang中最出色的ORM框架,支持MySQL、PostgreSQL、Sqlite、SQL Server,功能非常强大,也可以直接执行SQL并获取结果集。

其中的数据库自动迁移AutoMigrate()需要特别注意,虽然创建Schema很便捷,但是在多应用服务实例与数据库集群中不适用。升级云原生中微服务与数据库,还是“先GitOps自动升级数据库,再部署新版本微服务”的方案比较保险。

// product_db.go
package main

import (
    "log"
    "os"

    "github.com/jinzhu/gorm"
    _ "github.com/jinzhu/gorm/dialects/mysql"
)

var db *gorm.DB

// Product Model
type Product struct {
    gorm.Model
    Code  string
    Name  string
    Price float64
}

// Book Model
type Book struct {
    gorm.Model
    ProductID uint
    Product   Product
    AuthorID  uint
    ISBN      string
    Comment   string
}

// Author Model
type Author struct {
    gorm.Model
    FirstName string
    LastName  string
    PenName   string
    Birthday  string
    Books     []Book
}

// UserBuy Model
type UserBuy struct {
    gorm.Model
    UserID    uint
    ProductID uint
}

// UserBook Query result
type UserBook struct {
    ID      string
    Code    string
    Name    string
    Price   float64
    ISBN    string
    Comment string
}

func init() {
    driverName := os.Getenv("SQL_DRIVER_NAME")
    dataSourceName := os.Getenv("SQL_DATA_SOURCE_NAME")

    var err error
    db, err = gorm.Open(driverName, dataSourceName)

    if err != nil {
        log.Println("ERROR    Gorm open DB failed")
    } else {
        log.Println("INFO    Gorm open DB OK")
    }

    // Migrate the schema
    db.AutoMigrate(&Product{}, &Book{}, &Author{}, &UserBuy{})

    if isEmptyBooks() {
        log.Println("INFO    Gorm DB books is Empty")
        createUserBooks()
    }
}

func getUserBooks() (userBooks *[]UserBook, err error) {
    userID := uint(10001)
    var userBooksGot []UserBook

    err = db.Raw(`SELECT C.id, C.code, C.name, C.price, B.isbn, B.comment
        FROM (SELECT product_id FROM user_buys WHERE user_id = ?) AS A
        INNER JOIN books B ON A.product_id = B.product_id
        LEFT JOIN products C ON B.product_id = C.id`, userID).Scan(&userBooksGot).Error

    if err == nil {
        userBooks = &userBooksGot
    }

    return
}

func isEmptyBooks() bool {
    var count int
    err := db.Model(&Book{}).Count(&count).Error
    return err == nil && count == 0
}

func createUserBooks() {
    userID := uint(10001)
    authors := []Author{
        {
            FirstName: "Ken",
            LastName:  "Bench",
            PenName:   "KB",
            Birthday:  "1953-2-11",
            Books: []Book{
                {
                    Product: Product{
                        Code:  "P-10001",
                        Name:  "Book Name 1",
                        Price: 46.33,
                    },
                    ISBN:    "543-233-33",
                    Comment: "c",
                },
                {
                    Product: Product{
                        Code:  "P-10002",
                        Name:  "Book Name 2",
                        Price: 26.67,
                    },
                    ISBN:    "543-233-34",
                    Comment: "abc",
                },
            },
        },
        {
            FirstName: "Martin",
            LastName:  "Fowler",
            PenName:   "MF",
            Birthday:  "1973-12-13",
            Books: []Book{
                {
                    Product: Product{
                        Code:  "P-10021",
                        Name:  "Book Name 21",
                        Price: 76.33,
                    },
                    ISBN:    "543-233-43",
                    Comment: "a",
                },
                {
                    Product: Product{
                        Code:  "P-10022",
                        Name:  "Book Name 22",
                        Price: 76.67,
                    },
                    ISBN:    "543-233-44",
                    Comment: "123",
                },
            },
        },
    }

    for i := range authors {
        db.Create(&authors[i])
    }

    // UserBuys
    var products []Product
    db.Find(&products)

    userBuys := make([]UserBuy, len(products))

    for i, product := range products {
        userBuys[i] = UserBuy{
            UserID:    userID,
            ProductID: product.ID,
        }
    }

    for i := range userBuys {
        db.Create(&userBuys[i])
    }
}

func giveUserFreeBooks(userInfo *UserInfo) error {
    var books []Book
    db.Where("isbn in (?)", []string{"543-233-33", "543-233-43"}).Find(&books)

    log.Printf("INFO   give %d books to user ID %d\n", len(books), userInfo.ID)

    userBuys := make([]UserBuy, len(books))

    for i, book := range books {
        userBuys[i] = UserBuy{
            UserID:    uint(userInfo.ID),
            ProductID: book.ProductID,
        }
    }

    for i := range userBuys {
        db.Create(&userBuys[i])
    }

    return nil
}

2. Sqlx

Sqlx是对GoLang标准database/sql的扩展。其特点是:

  1. 把SQL执行的结果集转化成数据结构(Struct、Maps、Slices)。
  2. 支持问号(?)或命名的Prepared Statements,避免SQL注入的安全问题

连接MySQL与增/查代码:

// user_db.go
package main

import (
    "log"
    "os"

    _ "github.com/go-sql-driver/mysql"
    "github.com/jmoiron/sqlx"
)

const schema = `
CREATE TABLE user (
    id int AUTO_INCREMENT PRIMARY KEY,
    name text,
    email text
);

CREATE TABLE place (
    country text,
    city text NULL,
    telcode integer
);

ALTER TABLE user AUTO_INCREMENT = 10001;`

var db *sqlx.DB

func initDB() {
    driverName := os.Getenv("SQL_DRIVER_NAME")
    dataSourceName := os.Getenv("SQL_DATA_SOURCE_NAME")

    if dataSourceName == "" {
        log.Println("ERROR   evn SQL_DRIVER_NAME and SQL_DATA_SOURCE_NAME required")
        return
    }

    // MYSQL DSN format: username:password@protocol(address)/dbname?param=value
    var err error
    db, err = sqlx.Open(driverName, dataSourceName)

    if err != nil {
        log.Println("ERROR    Fail to open to the USER DB, ", err)
    }

    err = db.Ping()

    if err != nil {
        log.Println("ERROR    Fail to ping to the USER DB, ", err)
        return
    }

    _, err = db.Exec(schema)

    if err != nil {
        log.Println("ERROR    Fail to create schema to the USER DB, ", err)
        return
    }

    log.Println("INFO    Success to create schema to the USER DB")
}

func closeDB() {
    if db != nil {
        db.Close()
    }
}

func createUserInfoToDB(userInfo *UserInfo) {
    sql := "INSERT INTO user (name, email) VALUES (:name, :email)"

    res, err := db.NamedExec(sql, userInfo)

    if err != nil {
        return
    }

    newID, err := res.LastInsertId()

    if err != nil {
        return
    }

    userInfo.ID = int(newID)
}

func getUserInfoFromDB(userID int) *UserInfo {
    userInfo := UserInfo{}
    err := db.Get(&userInfo, "SELECT id, name, email FROM user WHERE id = ?", userID)

    if err != nil {
        return nil
    }

    return &userInfo
}

3. MySQL

MySQL是线程模型,PostgreSQL是进程模型。MySQL比较适合处理高并发、大流量的互联网数据,节省资源。GoLang中已有标准database/sql,为了支持MySQL,我们只需在项目中引入MySQL Driver:

import "database/sql"
import _ "github.com/go-sql-driver/mysql"

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

MySQL Cluster包含以下3中节点:

  1. 管理节点:用于给整个集群其他节点提供配置、管理、仲裁等功能。
  2. 数据节点:MySQL Cluster的核心,存储数据、日志,提供数据的各种管理服务。
  3. SQL节点(API):用于访问MySQL Cluster数据,提供对外应用服务。

4. MongoDB

MongoDB 是一个基于分布式文件存储的数据库。其存储关系是Database > Collection > Document -> Field。

Sharded Cluster配置时,以下3中节点:

  1. shard节点:存储分片数据。
  2. mongos节点:提供访问入口。
  3. config server节点:存储分片元信息与配置数据(自动同步给shard与mongos)。

MongoDB Sharded Cluster

在GoLang中使用MongoDB时,只需使用官方提供的mongo-go-driver。用法如下:

// mongo_demo.go
package main

import (
    "context"
    "log"
    "time"

    "go.mongodb.org/mongo-driver/bson"
    "go.mongodb.org/mongo-driver/mongo"
    "go.mongodb.org/mongo-driver/mongo/options"
)

var mongoURI = "mongodb://my-root:my-root-pw@mongo:27017"

type numberValue struct {
    Found bool
    Name  string
    Value float64
}

func mongoDemo() numberValue {
    client, err := mongo.NewClient(options.Client().ApplyURI(mongoURI))

    if err != nil {
        log.Println("Error   Invalid mongo URI, error: ", err)
    }

    ctx, cancelConnect := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancelConnect()
    err = client.Connect(ctx)

    if err != nil {
        log.Println("Error   Can't connect to mongo server, error: ", err)
    }

    ctx, cancelPing := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancelPing()
    err = client.Ping(ctx, nil)

    if err != nil {
        log.Println("Error   Can't ping to mongo server, error: ", err)
    } else {
        log.Println("INFO   ping to mongo server OK")
    }

    collection := client.Database("testing").Collection("numbers")

    if OK, value := findPI(collection); OK {
        return numberValue{true, "pi", value}
    }

    return insertPI(collection)
}

func findPI(collection *mongo.Collection) (bool, float64) {
    var result struct {
        Value float64
    }

    filter := bson.M{"name": "pi"}
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    err := collection.FindOne(ctx, filter).Decode(&result)

    if err != nil {
        log.Println("INFO   Can't find 'pi', error: ", err)
        return false, 0
    }

    log.Println("INFO   Found 'pi': ", result.Value)
    return true, result.Value
}

func insertPI(collection *mongo.Collection) numberValue {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    value := 3.14159
    res, err := collection.InsertOne(ctx, bson.M{"name": "pi", "value": value})

    if err != nil {
        log.Println("ERROR   Insert 'pi', error: ", err)
        return numberValue{false, "", 0}
    }

    id := res.InsertedID
    log.Println("INFO   Inserted 'pi' ID: ", id)

    return numberValue{false, "pi", value}
}

5. 总结

在GORM和Sqlx之间做选择,需要根据团队的编程风格来确定。如果想干净的使用SQL,就选择Sqlx。如果偏重于ORM,就选择GORM。GORM存储关系数据时,比Sqlx要少写许多代码。不管使用哪个,在头脑中的概念一定要清楚:在整洁架构中,领域模型是核心,GORM和Sqlx只是外围存储部分。

MySQL与MongoDB之间,不需要做选择,它们面对的问题不同。MySQL属于关系数据库,在需要JOIN查询时,传统的SQL数据库时一个很好的选择。虽然New SQL也能干这个活,但是目前New SQL仍没有传统SQL数据库成熟。MongoDB是文档型数据库,它有良好的分布式能力。经常按文档修改就选MongoDB。同时,MongoDB不需传统SQL的Schema。

MySQL与MongoDB的Cluster部署时,它们的节点要求非常相似:Router(API) + Shard(Data) + Config(Mgr)。