本系列参考: 7天用Go从零实现ORM框架GeeORM
本系列源码: https://gitee.com/DaHuYuXiXi/geo-orm
对象关系映射(Object Relational Mapping,简称ORM)是通过使用描述对象和数据库之间映射的元数据,将面向对象语言程序中的对象自动持久化到关系数据库中。
那对象和数据库是如何映射的呢?
数据库 | 面向对象的编程语言 |
---|---|
表(table) | 类(class/struct) |
记录(record, row) | 对象 (object) |
字段(field, column) | 对象属性(attribute) |
举一个具体的例子,来理解 ORM。
CREATE TABLE `User` (`Name` text, `Age` integer);
INSERT INTO `User` (`Name`, `Age`) VALUES ("Tom", 18);
SELECT * FROM `User`;
第一条 SQL 语句,在数据库中创建了表 User,并且定义了 2 个字段 Name 和 Age;第二条 SQL 语句往表中添加了一条记录;最后一条语句返回表中的所有记录。
假如我们使用了 ORM 框架,可以这么写:
type User struct {
Name string
Age int
}
orm.CreateTable(&User{})
orm.Save(&User{"Tom", 18})
var users []User
orm.Find(&users)
ORM 框架相当于对象和数据库中间的一个桥梁,借助 ORM 可以避免写繁琐的 SQL 语言,仅仅通过操作具体的对象,就能够完成对关系型数据库的操作。
那如何实现一个 ORM 框架呢?
CreateTable
方法需要从参数 &User{}
得到对应的结构体的名称 User 作为表名,成员变量 Name, Age 作为列名,同时还需要知道成员变量对应的类型。Save
方法则需要知道每个成员变量的值。Find
方法仅从传入的空切片 &[]User
,得到对应的结构体名也就是表名 User,并从数据库中取到所有的记录,将其转换成 User 对象,添加到切片中。如果这些方法只接受 User 类型的参数,那是很容易实现的。但是 ORM 框架是通用的,也就是说可以将任意合法的对象转换成数据库中的表和记录。例如:
type Account struct {
Username string
Password string
}
orm.CreateTable(&Account{})
这就面临了一个很重要的问题:如何根据任意类型的指针,得到其对应的结构体的信息。这涉及到了 Go 语言的反射机制(reflect),通过反射,可以获取到对象对应的结构体名称,成员变量、方法等信息,例如:
typ := reflect.Indirect(reflect.ValueOf(&Account{})).Type()
fmt.Println(typ.Name()) // Account
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
fmt.Println(field.Name) // Username Password
}
除了对象和表结构/记录的映射以外,设计 ORM 框架还需要关注什么问题呢?
1)MySQL,PostgreSQL,SQLite 等数据库的 SQL 语句是有区别的,ORM 框架如何在开发者不感知的情况下适配多种数据库?
2)如果对象的字段发生改变,数据库表结构能够自动更新,即是否支持数据库自动迁移(migrate)?
3)数据库支持的功能很多,例如事务(transaction),ORM 框架能实现哪些?
4)…
数据库的特性非常多,简单的增删查改使用 ORM 替代 SQL 语句是没有问题的,但是也有很多特性难以用 ORM 替代,比如复杂的多表关联查询,ORM 也可能支持,但是基于性能的考虑,开发者自己写 SQL 语句很可能更高效。
因此,设计实现一个 ORM 框架,就需要给功能特性排优先级了。
Go 语言中使用比较广泛 ORM 框架是 gorm 和 xorm。除了基础的功能,比如表的操作,记录的增删查改,gorm 还实现了关联关系(一对一、一对多等),回调插件等;xorm 实现了读写分离(支持配置多个数据库),数据同步,导入导出等。
gorm 正在彻底重构 v1 版本,短期内看不到发布 v2 的可能。相比于 gorm-v1,xorm 在设计上更清晰。GeoORM的设计主要参考了 xorm,一些细节上的实现参考了 gorm。GeoORM的目的主要是了解 ORM 框架设计的原理,具体实现上鲁棒性做得不够,一些复杂的特性,例如 gorm 的关联关系,xorm 的读写分离没有实现。目前支持的特性有:
gorm 正在彻底重构 v1 版本,短期内看不到发布 v2 的可能。相比于 gorm-v1,xorm 在设计上更清晰。GeeORM 的设计主要参考了 xorm,一些细节上的实现参考了 gorm。GeeORM 的目的主要是了解 ORM 框架设计的原理,具体实现上鲁棒性做得不够,一些复杂的特性,例如 gorm 的关联关系,xorm 的读写分离没有实现。目前支持的特性有:
SQLite基本语法和Mysql等关系型数据库大体一致,无需耗费太多时间即可掌握
SQLite 是一款轻量级的,遵守 ACID 事务原则的关系型数据库。SQLite 可以直接嵌入到代码中,不需要像 MySQL、PostgreSQL 需要启动独立的服务才能使用。SQLite 将数据存储在单一的磁盘文件中,使用起来非常方便。也非常适合初学者用来学习关系型数据的使用。GeoORM的所有的开发和测试均基于 SQLite。
目前,几乎所有版本的 Linux 操作系统都附带 SQLite。所以,只要使用下面的命令来检查您的机器上是否已经安装了 SQLite。
在 Ubuntu 上,安装 SQLite 只需要一行命令,无需配置即可使用。 apt-get install sqlite3
接下来,连接数据库(geo.db),如若 geo.db 不存在,则会新建。如果连接成功,就进入到了 SQLite 的命令行模式,执行 .help 可以看到所有的帮助命令。
# sqlite3 geo.db
SQLite version 3.7.17 2013-05-20 00:56:22
Enter ".help" for instructions
Enter SQL statements terminated with a ";"
使用 SQL 语句新建一张表 User,包含两个字段,字符串 Name 和 整型 Age。
CREATE TABLE User(Name text, Age integer);
插入两条数据
INSERT INTO User(Name, Age) VALUES ("Tom", 18), ("Jack", 25);
执行简单的查询操作,在执行之前使用 .head on 打开显示列名的开关,.mode column让每一列左对齐显示,这样查询结果看上去更直观。
.head on
.mode column
# 查找 `Age > 20` 的记录
SELECT * FROM User WHERE Age > 20;
# 统计记录个数
SELECT COUNT(*) FROM User;
使用 .table 查看当前数据库中所有的表(table),执行 .schema < table > 查看建表的 SQL 语句。
SqlLite的基本CURD用法和Mysql等关系型数据库一致,这里不多介绍了,详细内容可以参考官方文档
Go 语言提供了标准库 database/sql 用于和数据库的交互,接下来我们写一个 Demo,看一看这个库的用法。
package main
import (
"database/sql"
"fmt"
_ "github.com/mattn/go-sqlite3"
)
func main() {
db, _ := sql.Open("sqlite3", "geo.db")
defer func() { _ = db.Close() }()
_, _ = db.Exec("DROP TABLE IF EXISTS User;")
_, _ = db.Exec("CREATE TABLE User(Name text);")
result, err := db.Exec("INSERT INTO User(`Name`) values (?), (?)", "Tom", "Sam")
if err == nil {
affected, _ := result.RowsAffected()
fmt.Println(affected)
}
row := db.QueryRow("SELECT Name FROM User LIMIT 1")
var name string
if err := row.Scan(&name); err == nil {
fmt.Println(name)
}
}
go-sqlite3 依赖于 gcc,如果这份代码在 Windows 上运行的话,需要安装 mingw 或其他包含有 gcc 编译器的工具包。
执行 go run .,输出如下。
> go run .
2020/03/07 20:28:37 2
2020/03/07 20:28:37 Tom
掌握了基础的 SQL 语句和 Go 标准库 database/sql 的使用,可以开始实现 ORM 框架的雏形了。
开发一个框架/库并不容易,详细的日志能够帮助我们快速地定位问题。因此,在写核心代码之前,我们先用几十行代码实现一个简单的 log 库。
为什么不直接使用原生的 log 库呢?log 标准库没有日志分级,不打印文件和行号,这就意味着我们很难快速知道是哪个地方发生了错误。
这个简易的 log 库具备以下特性:
编写自己的日志库设计到对Log标准库的相关操作,建议大家先熟悉一下标准库的操作:
package log
import (
"io/ioutil"
"log"
"os"
"sync"
)
var (
//参数: 日志输出到控制台, 日志输出的统一前缀设置(包括颜色设置) , 日志输出的额外选项: 输出日期,文件名和行号
//print( "\033[字背景颜色;字体颜色m字符串\033[0m" )
errorLog = log.New(os.Stdout, "\033[31m[error]\033[0m ", log.LstdFlags|log.Lshortfile)
warnLog = log.New(os.Stdout, "\033[33m[warn ]\033[0m ", log.LstdFlags|log.Lshortfile)
infoLog = log.New(os.Stdout, "\033[34m[info ]\033[0m ", log.LstdFlags|log.Lshortfile)
debugLog = log.New(os.Stdout, "\033[36m[debug]\033[0m ", log.LstdFlags|log.Lshortfile)
traceLog = log.New(os.Stdout, "\033[30m[trace]\033[0m ", log.LstdFlags|log.Lshortfile)
//存放所有日志记录器的数组
loggers = []*log.Logger{errorLog, warnLog, infoLog, debugLog, traceLog}
mu sync.Mutex
)
//日志输出方法
var (
Error = errorLog.Println
Errorf = errorLog.Printf
Warn = warnLog.Println
Warnf = warnLog.Printf
Info = infoLog.Println
Infof = infoLog.Printf
Debug = debugLog.Println
Debugf = debugLog.Printf
Trace = traceLog.Println
Tracef = traceLog.Printf
)
//日志级别
const (
TraceLevel = iota
DebugLevel
InfoLevel
WarnLevel
ErrorLevel
Disabled
)
//SetLevel 设置日志级别
func SetLevel(level int) {
mu.Lock()
defer mu.Unlock()
//ioutil.Discard,即不打印该日志
if ErrorLevel < level {
errorLog.SetOutput(ioutil.Discard)
}
if WarnLevel < level {
warnLog.SetOutput(ioutil.Discard)
}
if InfoLevel < level {
infoLog.SetOutput(ioutil.Discard)
}
if DebugLevel < level {
debugLog.SetOutput(ioutil.Discard)
}
if TraceLevel < level {
traceLog.SetOutput(ioutil.Discard)
}
}
package log
import (
"testing"
)
func TestPrintColor(t *testing.T) {
SetLevel(InfoLevel)
logAllLevel()
}
func logAllLevel() {
errorLog.Println("error日志输出测试")
warnLog.Println("warn日志输出测试")
infoLog.Println("info日志输出测试")
debugLog.Println("debug日志输出测试")
traceLog.Println("trace日志输出测试")
}
上面是极客兔兔给出的日志库Demo,下面给出一个我自己写的日志库demo:
package myLog
import (
"errors"
"io"
"log"
"os"
"sync"
)
type any interface{}
//日志级别
const (
TraceLevel = iota
DebugLevel
InfoLevel
WarnLevel
ErrorLevel
)
var mu sync.Mutex
type Log struct {
//日志级别名称
Log string
//日志级别
level int
//日志输出前缀
prefix string
//是否开启了当前日志级别的输出
logAble bool
//标准库
logger *log.Logger
//日志输出到哪里
out io.Writer
}
func (l *Log) log(v any) {
if l.logAble {
l.logger.Println(v)
}
}
func (l *Log) logf(str string, args interface{}) {
if l.logAble {
l.logger.Printf(str, args)
}
}
// 初始化相关log
var (
traceLog = &Log{Log: "trace", prefix: "\033[30m[trace]\033[0m ", out: os.Stdout, logAble: false, level: 0}
debugLog = &Log{Log: "debug", prefix: "\033[36m[debug]\033[0m ", out: os.Stdout, logAble: false, level: 1}
infoLog = &Log{Log: "info", prefix: "\033[34m[info ]\033[0m ", out: os.Stdout, logAble: true, level: 2}
warnLog = &Log{Log: "warn", prefix: "\033[33m[warn ]\033[0m ", out: os.Stdout, logAble: true, level: 3}
errorLog = &Log{Log: "error", prefix: "\033[31m[error]\033[0m ", out: os.Stdout, logAble: true, level: 4}
)
func init() {
traceLog.logger = log.New(traceLog.out, traceLog.prefix, log.LstdFlags|log.Lshortfile)
debugLog.logger = log.New(debugLog.out, debugLog.prefix, log.LstdFlags|log.Lshortfile)
infoLog.logger = log.New(infoLog.out, infoLog.prefix, log.LstdFlags|log.Lshortfile)
warnLog.logger = log.New(warnLog.out, warnLog.prefix, log.LstdFlags|log.Lshortfile)
errorLog.logger = log.New(errorLog.out, errorLog.prefix, log.LstdFlags|log.Lshortfile)
}
var logs = []*Log{traceLog, debugLog, infoLog, warnLog, errorLog}
type HandleLog func(l *Log)
//logsOp 对日志数组中每个日志log进行处理
func logsOp(logHandle HandleLog) {
for _, log := range logs {
logHandle(log)
}
}
//SetLogLevel 设置日志级别
func SetLogLevel(level int) {
mu.Lock()
defer mu.Unlock()
clearLevel()
if level < 0 {
for _, log := range logs {
log.logAble = false
}
} else if level >= len(logs) {
clearLevel()
} else {
for i := 0; i < level; i++ {
logs[i].logAble = false
}
}
}
//clearLevel 让所有日志级别都可以输出日志
func clearLevel() {
logsOp(func(l *Log) {
l.logAble = true
})
}
//SetGlobalLogOutPut 设置全局日志输出
func SetGlobalLogOutPut(out io.Writer) {
logsOp(func(l *Log) {
l.logger.SetOutput(out)
})
}
func SetGlobalLogMulOutPut(out ...io.Writer) {
logsOp(func(l *Log) {
l.logger.SetOutput(io.MultiWriter(out...))
})
}
//SetLogOutPut 设置某个级别的日志输出
func SetLogOutPut(logLevel int, out io.Writer) {
if logLevel < 0 || logLevel >= len(logs) {
panic(errors.New("logLevel is wrong"))
}
logs[logLevel].logger.SetOutput(out)
}
func SetLogMulOutPut(logLevel int, out ...io.Writer) {
if logLevel < 0 || logLevel >= len(logs) {
panic(errors.New("logLevel is wrong"))
}
logs[logLevel].logger.SetOutput(io.MultiWriter(out...))
}
func Info(v any) {
infoLog.log(v)
}
func Infof(str string, args interface{}) {
infoLog.logf(str, args)
}
func Trace(v any) {
traceLog.log(v)
}
func Tracef(str string, args interface{}) {
traceLog.logf(str, args)
}
func Debug(v any) {
debugLog.log(v)
}
func Debugf(str string, args interface{}) {
debugLog.logf(str, args)
}
func Warn(v any) {
warnLog.log(v)
}
func Warnf(str string, args interface{}) {
warnLog.logf(str, args)
}
func Error(v any) {
errorLog.log(v)
}
func Errorf(str string, args interface{}) {
errorLog.logf(str, args)
}
package myLog
import (
"fmt"
"os"
"testing"
)
func TestPrintColor(t *testing.T) {
SetLogLevel(TraceLevel)
logAllLevel()
SetLogLevel(DebugLevel)
logAllLevel()
SetLogLevel(InfoLevel)
logAllLevel()
SetLogLevel(WarnLevel)
logAllLevel()
SetLogLevel(ErrorLevel)
logAllLevel()
}
func TestLogOutput(t *testing.T) {
file, _ := os.OpenFile("log.txt", os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0755)
SetLogMulOutPut(ErrorLevel, os.Stdout, file)
SetLogLevel(TraceLevel)
logAllLevel()
}
func logAllLevel() {
Error("error日志输出测试")
Warn("warn日志输出测试")
Info("info日志输出测试")
Debug("debug日志输出测试")
Trace("trace日志输出测试")
fmt.Println("----------------------------")
}
我们在根目录下新建一个文件夹 session,用于实现与数据库的交互。今天我们只实现直接调用 SQL 语句进行原生交互的部分,这部分代码实现在 session/raw.go 中。
package session
import (
"database/sql"
"strings"
)
type Session struct {
db *sql.DB
sql strings.Builder
sqlVars []interface{}
}
func New(db *sql.DB) *Session {
return &Session{db: db}
}
func (s *Session) Clear() {
s.sql.Reset()
s.sqlVars = nil
}
func (s *Session) DB() *sql.DB {
return s.db
}
func (s *Session) Raw(sql string, values ...interface{}) *Session {
s.sql.WriteString(sql)
s.sql.WriteString(" ")
s.sqlVars = append(s.sqlVars, values...)
return s
}
接下来呢,封装 Exec()、Query() 和 QueryRow() 三个原生方法。
// Exec raw sql with sqlVars
func (s *Session) Exec() (result sql.Result, err error) {
defer s.Clear()
myLog.Infof(s.sql.String(), s.sqlVars)
if result, err = s.DB().Exec(s.sql.String(), s.sqlVars...); err != nil {
myLog.Error(err)
}
return
}
// QueryRow gets a record from db
func (s *Session) QueryRow() *sql.Row {
defer s.Clear()
myLog.Infof(s.sql.String(), s.sqlVars)
return s.DB().QueryRow(s.sql.String(), s.sqlVars...)
}
// QueryRows gets a list of records from db
func (s *Session) QueryRows() (rows *sql.Rows, err error) {
defer s.Clear()
myLog.Infof(s.sql.String(), s.sqlVars)
if rows, err = s.DB().Query(s.sql.String(), s.sqlVars...); err != nil {
myLog.Error(err)
}
return
}
Session 负责与数据库的交互,那交互前的准备工作(比如连接/测试数据库),交互后的收尾工作(关闭连接)等就交给 Engine 来负责了。Engine 是 GeeORM 与用户交互的入口。代码位于根目录的 geoorm.go。
package GeoORM
import (
"GeoORM/mylog"
"GeoORM/session"
"database/sql"
)
type Engine struct {
db *sql.DB
}
func NewEngine(driver, source string) (e *Engine, err error) {
db, err := sql.Open(driver, source)
if err != nil {
myLog.Error(err)
return
}
// Send a ping to make sure the database connection is alive.
if err = db.Ping(); err != nil {
myLog.Error(err)
return
}
e = &Engine{db: db}
myLog.Info("Connect database success")
return
}
func (engine *Engine) Close() {
if err := engine.db.Close(); err != nil {
myLog.Error("Failed to close database")
}
myLog.Info("Close database success")
}
func (engine *Engine) NewSession() *session.Session {
return session.New(engine.db)
}
Engine 的逻辑非常简单,最重要的方法是 NewEngine,NewEngine 主要做了两件事。
另外呢,提供了 Engine 提供了 NewSession() 方法,这样可以通过 Engine 实例创建会话,进而与数据库进行交互了。到这一步,整个 GeoORM 的框架雏形已经出来了。
package cmd_test
import (
"GeoORM"
"fmt"
#########导入对应的驱动实现别忘记了##########
_ "github.com/go-sql-driver/mysql"
"testing"
)
func TestCommonOp(t *testing.T) {
engine, _ := GeoORM.NewEngine("mysql", "root:xxx@tcp(xxx9:3306)/test")
defer engine.Close()
s := engine.NewSession()
_, _ = s.Raw("DROP TABLE IF EXISTS User;").Exec()
_, _ = s.Raw("CREATE TABLE User(Name text);").Exec()
_, _ = s.Raw("CREATE TABLE User(Name text);").Exec()
result, _ := s.Raw("INSERT INTO User(`Name`) values (?), (?)", "Tom", "Sam").Exec()
count, _ := result.RowsAffected()
fmt.Printf("Exec success, %d affected\n", count)
}
执行测试,将会看到如下的输出:
日志中出现了一行报错信息,table User already exists,因为我们在 main 函数中执行了两次创建表 User 的语句。可以看到,每一行日志均标明了报错的文件和行号,而且不同层级日志的颜色是不同的。
注意: 我们实现的日志框架的error输出仅仅只是调用标准库log的println方法进行输出,并没有调用painc等会抛出异常的日志输出