从零实现ORM框架GeoORM-database/sql基础-01

2022-09-27 15:56:48 浏览数 (1)

从零实现ORM框架GeoORM-database/sql基础-01

  • ORM 框架需要干什么
  • 关于 GeoORM
  • 初识 SQLite
  • database/sql 标准库
  • 实现一个简单的 log 库
  • 核心结构 Session
  • 核心结构 Engine
  • 测试

本系列参考: 7天用Go从零实现ORM框架GeeORM

本系列源码: https://gitee.com/DaHuYuXiXi/geo-orm


ORM 框架需要干什么

对象关系映射(Object Relational Mapping,简称ORM)是通过使用描述对象和数据库之间映射的元数据,将面向对象语言程序中的对象自动持久化到关系数据库中。

那对象和数据库是如何映射的呢?

数据库

面向对象的编程语言

表(table)

类(class/struct)

记录(record, row)

对象 (object)

字段(field, column)

对象属性(attribute)

举一个具体的例子,来理解 ORM。

代码语言:javascript复制
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 框架,可以这么写:

代码语言:javascript复制
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 框架是通用的,也就是说可以将任意合法的对象转换成数据库中的表和记录。例如:

代码语言:javascript复制
type Account struct {
    Username string
    Password string
}

orm.CreateTable(&Account{})

这就面临了一个很重要的问题:如何根据任意类型的指针,得到其对应的结构体的信息。这涉及到了 Go 语言的反射机制(reflect),通过反射,可以获取到对象对应的结构体名称,成员变量、方法等信息,例如:

代码语言:javascript复制
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
}
  • reflect.ValueOf() 获取指针对应的反射值。
  • reflect.Indirect() 获取指针指向的对象的反射值。
  • (reflect.Type).Name() 返回类名(字符串)。
  • (reflect.Type).Field(i) 获取第 i 个成员变量。

除了对象和表结构/记录的映射以外,设计 ORM 框架还需要关注什么问题呢?

1)MySQL,PostgreSQL,SQLite 等数据库的 SQL 语句是有区别的,ORM 框架如何在开发者不感知的情况下适配多种数据库?

2)如果对象的字段发生改变,数据库表结构能够自动更新,即是否支持数据库自动迁移(migrate)?

3)数据库支持的功能很多,例如事务(transaction),ORM 框架能实现哪些?

4)…


关于 GeoORM

数据库的特性非常多,简单的增删查改使用 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 的读写分离没有实现。目前支持的特性有:

  • 表的创建、删除、迁移。
  • 记录的增删查改,查询条件的链式操作。
  • 单一主键的设置(primary key)。
  • 钩子(在创建/更新/删除/查找之前或之后)
  • 事务(transaction)。

初识 SQLite

SQLite中文文档

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 可以看到所有的帮助命令。

代码语言:javascript复制
# 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。

代码语言:javascript复制
CREATE TABLE User(Name text, Age integer);

插入两条数据

代码语言:javascript复制
INSERT INTO User(Name, Age) VALUES ("Tom", 18), ("Jack", 25);

执行简单的查询操作,在执行之前使用 .head on 打开显示列名的开关,.mode column让每一列左对齐显示,这样查询结果看上去更直观。

代码语言:javascript复制
.head on
.mode column
代码语言:javascript复制
# 查找 `Age > 20` 的记录
SELECT * FROM User WHERE Age > 20;
代码语言:javascript复制
# 统计记录个数
SELECT COUNT(*) FROM User;

使用 .table 查看当前数据库中所有的表(table),执行 .schema < table > 查看建表的 SQL 语句。

SqlLite的基本CURD用法和Mysql等关系型数据库一致,这里不多介绍了,详细内容可以参考官方文档


database/sql 标准库

Go 语言提供了标准库 database/sql 用于和数据库的交互,接下来我们写一个 Demo,看一看这个库的用法。

代码语言:javascript复制
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 .,输出如下。

代码语言:javascript复制
> go run .
2020/03/07 20:28:37 2
2020/03/07 20:28:37 Tom
  • 使用 sql.Open() 连接数据库,第一个参数是驱动名称,import 语句 _ “github.com/mattn/go-sqlite3” 包导入时会注册 sqlite3 的驱动,第二个参数是数据库的名称,对于 SQLite 来说,也就是文件名,不存在会新建。返回一个 sql.DB 实例的指针。
  • Exec() 用于执行 SQL 语句,如果是查询语句,不会返回相关的记录。所以查询语句通常使用 Query() 和 QueryRow(),前者可以返回多条记录,后者只返回一条记录。
  • Exec()、Query()、QueryRow() 接受1或多个入参,第一个入参是 SQL 语句,后面的入参是 SQL 语句中的占位符 ? 对应的值,占位符一般用来防 SQL 注入。
  • QueryRow() 的返回值类型是 *sql.Row,row.Scan() 接受1或多个指针作为参数,可以获取对应列(column)的值,在这个示例中,只有 Name 一列,因此传入字符串指针 &name 即可获取到查询的结果。

掌握了基础的 SQL 语句和 Go 标准库 database/sql 的使用,可以开始实现 ORM 框架的雏形了。


实现一个简单的 log 库

开发一个框架/库并不容易,详细的日志能够帮助我们快速地定位问题。因此,在写核心代码之前,我们先用几十行代码实现一个简单的 log 库。

为什么不直接使用原生的 log 库呢?log 标准库没有日志分级,不打印文件和行号,这就意味着我们很难快速知道是哪个地方发生了错误。

这个简易的 log 库具备以下特性:

  • 支持日志分级(trace,info,debug,warn,error)。
  • 不同层级日志显示时使用不同的颜色区分。
  • 显示打印日志代码对应的文件名和行号。

  • 初始化项目结构如下:
  • log.go

编写自己的日志库设计到对Log标准库的相关操作,建议大家先熟悉一下标准库的操作:

GoLang的Log标准库介绍

print输出的字体颜色设置

代码语言:javascript复制
package log

import (
	"io/ioutil"
	"log"
	"os"
	"sync"
)

var (
	//参数: 日志输出到控制台, 日志输出的统一前缀设置(包括颜色设置) , 日志输出的额外选项: 输出日期,文件名和行号
	//print( "33[字背景颜色;字体颜色m字符串33[0m" )
	errorLog = log.New(os.Stdout, "33[31m[error]33[0m ", log.LstdFlags|log.Lshortfile)
	warnLog  = log.New(os.Stdout, "33[33m[warn ]33[0m ", log.LstdFlags|log.Lshortfile)
	infoLog  = log.New(os.Stdout, "33[34m[info ]33[0m ", log.LstdFlags|log.Lshortfile)
	debugLog = log.New(os.Stdout, "33[36m[debug]33[0m ", log.LstdFlags|log.Lshortfile)
	traceLog = log.New(os.Stdout, "33[30m[trace]33[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)
	}
}

  • log_test.go
代码语言:javascript复制
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:

代码语言:javascript复制
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: "33[30m[trace]33[0m ", out: os.Stdout, logAble: false, level: 0}
	debugLog = &Log{Log: "debug", prefix: "33[36m[debug]33[0m ", out: os.Stdout, logAble: false, level: 1}
	infoLog  = &Log{Log: "info", prefix: "33[34m[info ]33[0m ", out: os.Stdout, logAble: true, level: 2}
	warnLog  = &Log{Log: "warn", prefix: "33[33m[warn ]33[0m ", out: os.Stdout, logAble: true, level: 3}
	errorLog = &Log{Log: "error", prefix: "33[31m[error]33[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)
}
  • 测试
代码语言:javascript复制
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

我们在根目录下新建一个文件夹 session,用于实现与数据库的交互。今天我们只实现直接调用 SQL 语句进行原生交互的部分,这部分代码实现在 session/raw.go 中。

  • session/raw.go
代码语言:javascript复制
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
}
  • Session 结构体目前只包含三个成员变量,第一个是 db *sql.DB,即使用 sql.Open()方法连接数据库成功之后返回的指针。
  • 第二个和第三个成员变量用来拼接 SQL 语句和 SQL 语句中占位符的对应值。用户调用 Raw() 方法即可改变这两个变量的值。

接下来呢,封装 Exec()、Query() 和 QueryRow() 三个原生方法。

代码语言:javascript复制
// 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
}
  • 封装有 2 个目的,一是统一打印日志(包括 执行的SQL 语句和错误日志)。
  • 二是执行完成后,清空 (s *Session).sql 和 (s *Session).sqlVars 两个变量。这样Session 可以复用,开启一次会话,可以执行多次 SQL。

核心结构 Engine

Session 负责与数据库的交互,那交互前的准备工作(比如连接/测试数据库),交互后的收尾工作(关闭连接)等就交给 Engine 来负责了。Engine 是 GeeORM 与用户交互的入口。代码位于根目录的 geoorm.go。

  • engine.go
代码语言:javascript复制
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 主要做了两件事。

  • 连接数据库,返回 *sql.DB。
  • 调用 db.Ping(),检查数据库是否能够正常连接。

另外呢,提供了 Engine 提供了 NewSession() 方法,这样可以通过 Engine 实例创建会话,进而与数据库进行交互了。到这一步,整个 GeoORM 的框架雏形已经出来了。


测试

代码语言:javascript复制
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 affectedn", count)
}

执行测试,将会看到如下的输出:

日志中出现了一行报错信息,table User already exists,因为我们在 main 函数中执行了两次创建表 User 的语句。可以看到,每一行日志均标明了报错的文件和行号,而且不同层级日志的颜色是不同的。

注意: 我们实现的日志框架的error输出仅仅只是调用标准库log的println方法进行输出,并没有调用painc等会抛出异常的日志输出


0 人点赞