另辟蹊径的Go语言的web框架(下)

原创
2021/12/05 21:09
阅读数 213

书接上文,进入代码细节。

socket.go 服务接口

socket.go是程序的入口,从main()函数开始:

......
//Socket侦听参数
const tcp_url = "0.0.0.0:21231"

func main() {

	//初始化数据库连接
	db := service.InitDB()

	//TCP listen
	listener, err := net.Listen("tcp", tcp_url)
	if err != nil {
		log.Fatal("listen error:", err)
	}
	log.Println("TCP server start..., listen: ", tcp_url)
	//tcp accept loop
	for {
		socket, err := listener.Accept()
		if err != nil {
			log.Println("tcp accept error:", err)
			continue
		}

		go process(socket, db)
	}
}
......

在main函数中首先初始化数据库连接,代码service.InitDB()中service是包名,InitDB()函数在db_mysql.go文件中实现。

然后创建TCP服务,在for循环中通过Accept()侦听每一次客户端的请求,并把请求socket连同db交给process()函数去处理,这里注意是通过go关键字调用process()的,在Go语言中表示这里启用了“协程”。协程也称之为轻量级进程,所谓轻量是因为调度发生在用户空间,不像传统进程或线程是由内核调度。协程的优点是并发数远大于进程和线程,这也是Go语言的靓点之一。

process()函数完成了以下几件事:

  1. 接收socket传过来的Json数据
  2. 解析数据结构中的业务方法名称和业务数据
  3. 反射调用业务方法
  4. Json封装业务方法返回的数据
  5. 将数据通过socket返还
func process(socket net.Conn, db service.DB) {

	//关闭socket
	defer socket.Close()
	//捕获goroutine可能抛出的panic
	defer func() {
		if err := recover(); err != nil {
			log.Println("goroutine error: ", err)
		}
	}()
......

process()函数一开始执行了两个defer,defer是延迟操作的意思,defer socket.Close()表示在process()函数运行结束时自动调用socket关闭,defer func()中recover()相当于Java中的try...catch,用来捕获process()及业务方法可能出现的异常,否则业务方法抛出的错误会导致整个服务进程的崩溃。

defer这种设计,初看要比try...catch方便,但实际使用确达不到try...catch细腻的控制粒度,这也是Go被很多人吐槽的原因之一。

......
	//读取req json长度
	buf := make([]byte, 12) //长度行总是12字节
	readN, readErr := socket.Read(buf)
	if readErr != nil {
		log.Println("socket read json error", readErr)
	}
	bufStr := string(buf[:readN])
	lenStr := strings.Trim(bufStr, "\r\n") //去除行尾的回车换行
	lenStr = strings.Trim(lenStr, " ")     //去除行左的空格
	jsonLen, _ := strconv.Atoi(lenStr)     //获取json的字节长度

	//读取req json
	jsonBuf := make([]byte, jsonLen)
	_, readErr = socket.Read(jsonBuf)
	if readErr != nil {
		log.Println("socket read json error", readErr)
		return
	}
......

Module Proxy 中间件将Json数据转发给后端Go服务时,在Json数据前加了12字节的单元,用来表示Json数据的长度。这是因为如果Json数据很大的话TCP会拆包传输,socket接收数据就不容易判断数据是否已完成,这也是socket编程常用的模式:先发一个长度单元,再发送数据。

同样的,socket将数据返回时,也是在数据前加了12字节的长度单元。

......
	//解析req json
	var jsonMap map[string]interface{}
	if err := json.Unmarshal([]byte(jsonBuf), &jsonMap); err != nil {
		log.Println("Request json error: ", err)
		return
	}
	methodName := jsonMap["head"].(map[string]interface{})["method"].(string) //方法名称
	reqMap := jsonMap["data"].(map[string]interface{})                        //方法参数
	log.Println("method: ", methodName)
	log.Println("reqMap: ", reqMap)

	//反射方法名称
	method := reflect.ValueOf(db).MethodByName(methodName)
	if !method.IsValid() {
		log.Println("Method not found!")
		return
	}
	//反射方法参数
	args := []reflect.Value{reflect.ValueOf(reqMap)}
	//反射方法调用
	reflectRsp := method.Call(args)
	//反射方法返回值
	rsp := reflectRsp[0].Interface()
	//返回值转json
	rspBytes, err := json.Marshal(rsp)
	if err != nil {
		log.Println("json.Marshal error: ", err)
		return
	}
......

客户端请求的Json数据结构分两部分:head和data。head中封装了业务方法名称,data是传递给业务方法的数据参数。上面代码解析出这两部分后,通过反射机制调用对应的业务方法。

因此框架中的所有业务方法需要满足以下规范:

  • 方法能够被反射获取(框架中定义为struct DB的实现方法)
  • 方法必须要接收唯一的参数,参数符合Json格式
  • 方法有唯一的返回值(Go语言允许有多返回值,也不能无返回值),返回值符合Json格式

看到这里,大家应该明白了为什么文章一开始说本框架解决了Go语言Web架构的青涩问题,因为是“绕道”了啊!什么GET、POST、DELETE等都扔到一边去,name=value、?、&这种数据结构怎比得上Json,从此一切和HTTP无关,超简单,Perfect!

db_mysql.go 数据源

package service

import (
	"database/sql"
	"log"
)

//数据库连接参数
const db_name = "mysql"
const db_url = "video:video@tcp(127.0.0.1:3306)/golang_test?parseTime=true"

//数据库连接struct
type DB struct {
	Conn *sql.DB //数据库连接对象
}

//初始化数据库连接
func InitDB() DB {

	var db DB
	var err error
	//初始化数据库连接
	db.Conn, err = sql.Open(db_name, db_url)
	if err != nil {
		log.Fatal(err)
	}
	log.Println("db open OK")
	//测试数据库连接
	err = db.Conn.Ping()
	if err != nil {
		log.Fatal(err)
	}
	log.Println("db connection OK")

	return db
}

db_mysql.go实现mysql数据源,逻辑简单无需多讲,要强调的是:

  • type DB struct : 所有业务方法都是这个struct的方法,DB有唯一的属性Conn的类型是*sql.DB,是Go语言的数据库规范。Go语言不能称之为面向对象语言,但也区分函数和方法,区别如下:
//这是函数 
func foo() string{
  return "hello"
}

//这是方法, DB是truct类型
func (db DB) foo() string{
  return "hello"
}

service_dept.go 增删改查

service_dept.go 包含了部门相关的业务方法,我们先看添加部门:

///添加部门
func (db DB) AddDept(reqMap map[string]interface{}) bool {
	//从json中提取参数
	name := reqMap["name"].(string)
	parentId := int(reqMap["parentId"].(float64))
	var houseNo interface{}
	if reqMap["house_no"] != nil {
		houseNo = reqMap["house_no"].(string)
	}
	var tel interface{}
	if reqMap["tel"] != nil {
		tel = reqMap["tel"].(string)
	}

	//SQL预处理
	sqlstr := "INSERT INTO dept(name,parent_id,house_no,tel)VALUES(?,?,?,?)"
	stmt, err := db.Conn.Prepare(sqlstr)
	if err != nil {
		log.Println("SQL Prepare error: ", err)
		return false
	}
	//SQL执行
	_, err = stmt.Exec(name, parentId, houseNo, tel)
	if err != nil {
		log.Println("SQL Exec error: ", err)
		return false
	}

	//返回
	return true
}

在Go语言中方法名称首字母大写表示方法是public的,在此package外部可以访问,反之首字母小写只能在package内部访问。

参数reqMap的类型map[string]interface{}表示这是一个key-val的结构,相当于其他语言的hashmap类型。方括号中的string表示key的类型,值类型interface{}表示可以是任何类型,相当于Java或C#中的Object。所以 map[string]interface{} 写法在Java中就是Map<String, Object>,写法诡异吧,这也是Go语言不支持泛型被吐槽的地方。

方法开始部分是从参数reqMap中获取部门的各个属性值,值得说的是houseNo和tel,因为这两个数据库对应字段可以为空,客户端发来的Json中可能没有它俩,所以这里获取时先定义类型为interface{},当他们不为空时才获取值,这样在insert时既可以插入string值也可以插入null。

数据连接Conn是通过方法func (db DB) 带进来的,insert、update、delete操作过程是一样的,先Prepare()预处理,再Exec()执行。Exec(name, parentId, houseNo, tel)会根据name、parentId等他们的数据类型自动转换到相应的数据库字段类型,比Java JDBC的stmt.setInt(),stmt.setString()用起来方便一些。

SQL执行出错时方法返回false,成功返回true,true和false本身就是Json类型,process()简单的封装长度单元后就通过socket返给了客户端。

修改部门、删除部门过程相似,无需多讲,下面说说查询操作:

//获得部门信息
func (db DB) GetDept(reqMap map[string]interface{}) map[string]interface{} {
	//从json中提取参数
	queryId := int(reqMap["id"].(float64))

	//查询SQL
	sqlstr := "SELECT * FROM dept WHERE id=?"
	rows, err := db.Conn.Query(sqlstr, queryId)
	if err != nil {
		log.Println("Query error: ", err)
		rspEmpty := make(map[string]interface{})
		return rspEmpty
	}

	defer rows.Close() //方法返回时rows将关闭

	//查询结果集
	var (
		id       int
		name     string
		parentId int
		houseNo  sql.NullString
		tel      sql.NullString
	)
	if !rows.Next() {
		log.Println("Can't get the dept, id=", id)
		rspEmpty := make(map[string]interface{})
		return rspEmpty
	}
	if err = rows.Scan(&id, &name, &parentId, &houseNo, &tel); err != nil {
		log.Println("rows scan err: ", err)
		rspEmpty := make(map[string]interface{})
		return rspEmpty
	}

	//返回
	rsp := make(map[string]interface{})
	rsp["id"] = id
	rsp["name"] = name
	rsp["parentId"] = parentId
	if houseNo.Valid {
		rsp["houseNo"] = houseNo.String
	} else {
		rsp["houseNo"] = nil
	}
	if tel.Valid {
		rsp["tel"] = tel.String
	} else {
		rsp["tel"] = nil
	}

	return rsp
}

查询操作函数是Query(还提供了其他的一些查询方法,这里不介绍了),Query()返回的rows表示结果集,和JDBC的ResultSet类似需要next、next的一行行从数据库获取数据。因为是通过ID获取部门,结果只可能有一行或压根没有,所以这里用 !rows.Next()而没有用for。

rows必须关闭否则这次用的连接Conn不能被释放,对应延时语句 defer rows.Close()。回想下前面的添加部门方法好像没有释放Conn的操作啊,因为不需要,stmt在执行Exec()后内部会自动关闭Conn。

数据获取不到或异常时返回空的map,获取成功时将各字段封装进map返回,map在process()中被转换成Json对象,并添加长度单元后返给客户端。

注意houseNo和tel两个允许空的字段的值转换,变量类型必须定义为sql.Nullstring,表示可能为null的字符串,转换时也必须先通过 houseNo.Valid 来判断是否为null,不为空才能取值,否则会发送错误。相对应的还有 sql.Nullint,sql.Nulltime等类型。

获得组织树方法,通过递归调用将部门组装成上下级的树形结构:

//获取组织树
func (db DB) Deptree(_nil interface{}) map[string]interface{} {
	//递归获取组织树
	list := deptree(db, 0)

	//返回包装为map
	m := make(map[string]interface{})
	m["id"] = 0
	m["name"] = "组织树"
	m["sub"] = list
	return m
}

//递归获取组织树
func deptree(db DB, queryParentId int) []map[string]interface{} {
	//返回集合
	var list []map[string]interface{}
......

注意 Deptree() 和 deptree() 的区别:Deptree()是公开的方法,deptree()是私有的函数。

Deptree()方法不需要输入参数,但基于当前框架规范必须要有一个参数,因此这里的 _nil interface{} 表示空的参数,客户端也必须发送一个空Json对象 {} 。

deptree()函数的返回值类型 []map[string]interface{},初学Go语言的童鞋可能看着有点懵,无他,就是 List<Map<String, Object>>,带泛型的语言看着就是那么徐福。

service_transaction.go 中的事务操作

service_transaction.go演示了数据库事务的用法,一起来看看添加一个临时部门和其下人员的业务方法(虽然在业务上并无事务的必要性):

//添加临时部门及其人员
func (db DB) AddTempDeptAndPeoples(reqMap map[string]interface{}) bool {
	......

	//开启事务
	tx, err := db.Conn.Begin()
	if err != nil {
		log.Println("transaction error: ", err)
		return false
	}
	defer tx.Rollback() //回滚

	//插入部门
	......
    res, err := stmt.Exec(deptName, parentDeptId, houseNo, tel)
    ......

	//获得刚插入的部门id
	deptId, err := res.LastInsertId()
	if err != nil {
		log.Println("res.LastInsertId error: ", err)
		return false
	}

	//人员插入 SQL预处理
	sql := "INSERT INTO employee(dept_id,name,position,phone,office,gender,birthday,bak)VALUES(?,?,?,?,?,?,?,?)"
	stmt, err = tx.Prepare(sql)
	if err != nil {
		log.Println("SQL Prepare error: ", err)
		return false
	}

	//插入部门中所有人员
	for _, item := range peopleList {
		m := item.(map[string]interface{})
		//人员各字段
		......

		//SQL执行
		_, err = stmt.Exec(deptId, name, position, phone, office, gender, birthday, bak)
		if err != nil {
			log.Println("SQL Exec error: ", err)
			return false
		}
	}

	//事务提交
	if err = tx.Commit(); err != nil {
		log.Println("tx.Commit() error: ", err)
		return false
	}
	return true
}

通过 Conn.Begin() 获得事务对象 tx,并立即定义 defer tx.Rollback() 在方法非正常结束时回滚,而正常时在return前调用 tx.commit() 进行事务提交。

前面的示例在调用Exec()时我们忽略了方法的第一个返回值,这里我们通过这个值res得到刚插入的部门id。

因为人员有多个,添加人员预处理语句写在for循环之前,这样预处理只需做一次。

最后

以上就是Go语言的Web后端框架的介绍,最后说下我的看法。

先说结论:总的来说可用

优点:1)通过 Module Proxy 中间件避开了Go语言开发Web框架的“青涩”问题,规范、简单、实用。2)通过一系列的增删改查和事务示例,充分展示了Go在实际项目中业务后端的能力。3)对于从C++、Python转Go的程序员,Go语言相对来说学习门槛低,容易上手;对于从Java转过来的程序员,估计是受不了了Java那一层套一层的封装、模式,想找一个简单明快的语言实现,这点Go也基本满足。4)示例中的Go变量和数据库字段的转换,仍显得啰嗦低能,但这些是可以进行反射封装的,但我个人不希望像Java那样去封装,还是要保留语言本身的活力。

缺点:网上已经被人吐槽的,如异常、defer、泛型等,文章中也吐过了,这里要吐的是Go定义的数据库规范 database/sql。在数据库操作方面,Java JDBC已经耕耘了超过20年时间,Connection、Statement/PreparedStatement、ResultSet三对象深入人心,但Go语言却视而不见,另起炉灶重搞了一套。Go的语言设计者很可能是几个有严重偏执观念的,这点也被网上吐过了,但想想眼高于顶的牛顿牛爵爷当年都说过“我的成就是站在了巨人的肩膀上”,何况一个Go呢。

展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
0 评论
0 收藏
0
分享
返回顶部
顶部