2-1 流媒体 - API模块
Sun, Jul 1, 2018
0x0 基础概念
API是什么
后端对外形成Service的接口
- REST是一种设计风格,不是任何架构标准
- RESTful API通常使用HTTP作为通信协议,JSON作数据格式
RESTful API特点
- 统一接口
- 无状态
- 无论什么时候调用,返回的都是我想要的东西
- API这个Service这个节点不具备状态
- 可缓存
- 分层(Layerd System)
- API的service可以分过好多层,每一层负责其中一部分功能
- CS模式
REST设计原则
- 以URL(统一资源定位符)风格设计API 资源路径类似本地文件系统风格
- 通过不同的method区分对资源的CURD
- get
- post
- put
- delete
- 返回码(Status Code)需要符合HTTP资源描述的规定
0x1 API模块设计 (RESTful风格)
分析实体及关系
三个实体
- 用户
- 资源
- 用户上传的视频
- 视频下面的评论
- 用户和资源发生的互动
实体间的关系
- 资源属于用户
- 评论属于资源
之后:把关系映射到刚才说的RESTful method里对应起来即可
用户
- 注册
- URL: /user
- Method: POST (创建用户)
- SC: 201(Created)/400/500(Internal Error)
- 返回值: sessionID
- 登录
- 实际上需要往内部系统上协调东西
- 内部校验后才能知道能不能登录
- URL: /user/:username
- Method: POST
- Body: 用户名、密码
- SC:200/400/500
- 返回值:sessionID, success
- 实际上需要往内部系统上协调东西
- 获取用户基本信息
- URL: /user/:username
- Method: GET
- SC:200/400/500
- 401
- 用户验证不通过返回的错误码
- 没验证
- 403
- 用户验证不通过返回的错误码
- 没权限
- 401
- 注销
- URL: /user/:username
- Method: DELETE
- SC:204(无内容)/400/401/403/500
用户资源
- List all videos(可能加分页)
- URL: /user/:username/videos
- Method: GET
- SC: 200/400/500
- Get one video
- URL: /user/:username/videos/:vid-id
- Method: GET
- SC:200/400/500
- Delete on video
- URL: /user/:username/videos/:vid-id
- Method:DELETE
- 204/400/401/403/500
评论
评论和Videos是强的从属关系,所以在设计的时候,它是属于Video的
- Show comments
- URL: /videos/:vid-id/comments
- Post a comment
- URL: /videos/:vid-id/comments
- POST
- Delete a comment
- URL: /videos/:vid-id/comment/:comment-id
- DELETE
- 需要做权限控制
RESTful API设计小结
三个实体之间,是一个美好的树形结构
使用URL设计的API,符合一种自然的描述
如果没有按照标准化设计API
- 任何一个地方出了任何一个小的问题,会造成非常庞大的资源浪费,去定位问题
- 市面上很多RESTful风格的API设计,其实不是严格的RESTful的
0x2 API分层设计
逻辑流程分层
根据Request处理流程,分为四个主要层
处理流程:request进来交给routine导航到正确的handler,校验鉴权,处理业务逻辑、返回response
- handler层
- validation层
- 校验request是否合法
- 校验user是否合法
- 基础设施
- 数据结构定义
- 返回的错误信息
- 逻辑处理层(business logic)
- 可以把business logic放在handlerl里面写
- 然后通过handler调db_ops
- (说明)整个API的逻辑处理流程:访问数据库、拿到信息、处理信息、经过校验
- (说明)一般API的操作,都是数据库的增删改查
- response层
代码层级设计
代码层级设计需要遵循上述分层结构
代码调用流程
- 从main进到handler
- handler会调用dbops,从dbops里拿到想要的信息做进一步处理
- 进一步处理过程中会用到defs中的一些东西 - 消息定义等
- 组装成response,调取response.go中提供的方法返回
分层设计 vs 所有逻辑写在同一个函数中
写在一个函数中
- 没有分层,不符合REST的建议
- 可读性差
- 扩展性差
- 比如:对所有请求增加一个校验步骤
- 如果没做分层,所有handler函数都需要逐一修改
分层架构
- 编写test case非常容易
- 工程效率高
0x3 User API代码框架
公共文件
api/defs
放一些配置和定义
- api的消息体结构(前台会发什么样的消息体进来)
- 返回的错误信息
apidef.go
用于声明model
type UserCredential struct { Username string `json:"user_name"` Pwd string `json:"pwd"` } |
知识点
Golang使用打tag的方式声明序列化/反序列化
代码中:tag名称为json,tag值为user_name
- 序列化和反序列化的时候,会自动解析为正确的json
errs.go
定义各种错误
type Err struct { Error string `json:"error"` ErrorCode string `json:"error_code"` } type ErrResponse struct { HttpSC int Error Err } var ( ErrorRequestBodyParseFailed = ErrResponse{HttpSC: 400, Error: Err{Error: "Request body is not correct", ErrorCode: "001"}} ErrorNotAuthUser = ErrResponse{HttpSC: 401, Error: Err{Error: "User authentication failed.", ErrorCode: "002"}} ErrorDBError = ErrResponse{HttpSC: 500, Error: Err{Error: "DB ops failed", ErrorCode: "003"}} ErrorInternalFaults = ErrResponse{HttpSC: 500, Error: Err{Error: "Internal service error", ErrorCode: "004"}} ) |
知识点
error_code是系统内部用来查问题的error编码,和http的status_code不是一个东西
api/dbops
处理和数据库交互的相关逻辑
- 库:"database/sql"
- 声明方法
- openConn() *sql.DB{}
- AddUserCredential(loginName string, pwd string) error {}
- GetUserCredential(loginName string) (string, error) {}
api/response.go
把它抽象出来,让API的分层处理更加清晰
方法定义
- sendErrorResponse(w http.ResonseWriter)
- sendNormalResponse(w http.ResonseWriter)
入口文件
api/main.go
main放一些简单的定义性的东西,逻辑处理的东西分发给别的文件
库们
- net/http
- github.com/julienschmidt/httprouter
- 会自动把RESTful的API按其请求方式、参数、URL格式,自动Routine到Handler上
初始化Routine
func RegisterHandlers() *httprouter.Router { router := httprouter.New() router.POST("/user", CreateUser) router.POST("/user/:user_name", Login) return router } func main() { r := RegisterHandlers() http.ListenAndServe(":8000", r) } |
Handler实现
需要重新开一个文件handlers.go
func CreateUser(w http.ResponseWriter, r *http.Request, p httprouter.Params) { io.WriteString(w, "Create User Handler") } func Login(w http.ResponseWriter, r *http.Request, p httprouter.Params) { uname := p.ByName("user_name") io.WriteString(w, uname) } |
小结Golang处理HTTP请求
http.ListenAndServer(":8000", handler)
- http的注册函数,会阻塞在这里等待人来连接
- handler我们上面用了httprouter这个库来做实现
一个Request进来之后
- listen → Registerhandlers → handlers
- 每一个handler都是用不同Goroutine处理的
- 每个Goroutine只占4k大小
- 能同时创建成千上万个
- 不用我们手动考虑多线程的情况,http框架已经默认实现了
- 每一个handler都是用不同Goroutine处理的
0x4 数据库层设计和实现
数据库设计
数据模型的设计、数据表的设计
对照API模型,需要三个表:用户、视频、评论
建表参考:http://www.runoob.com/mysql/mysql-create-tables.html
用户
mysql> CREATE TABLE IF NOT EXISTS users(
-> id INT UNSIGNED AUTO_INCREMENT,
-> login_name VARCHAR(64) UNIQUE KEY,
-> pwd TEXT,
-> PRIMARY KEY ( id )
-> );
资源
CREATE TABLE video_info( id VARCHAR(64) NOT NULL, author_id INT UNSIGNED, name TEXT, display_ctime TEXT, create_time DATETIME, PRIMARY KEY( id ) );
id的类型为varchar,防止id溢出
author_id 和用户表的id映射
- 为什么不搞外键?
- 外键有「外键约束」等东西,在业务处理的时候有很多不方便
- 把这些逻辑写到业务代码里,就很灵活
name:资源的名字
display_ctime TEXT:显示在页面上的创建时间
create_time DATETIME:video入库的时间
评论
CREATE TABLE comments( id VARCHAR(64) NOT NULL, video_id VARCHAR(64), author_id INT UNSIGNED, content TEXT, time DATETIME, PRIMARY KEY( id ) );
id类型为varchar
video_id 就是上一张表的id,应该起到「外键」的作用。
author_id 通过这个找user
这三个表的设计:
- 没有任何信息冗余
- 三个ID在互相的表里面起到「主键」和「外键」的作用
- 这三个表完全符合「第三范式」
- 好的设计理念:
- 不能让每一个Entity里面有冗余 - 和另一张表有重复信息
- 能保证每一张表的「原子性」
sessions
什么是sessions?
在登录网站的时候,只需要登录一次就好,之后再打开,还是登录状态。
session:标识用户还在validate状态下的标识符
- web应用会把它写在浏览器的cookie里面
- 会有过期时间
- cookie里做检查
- server端做检查
- 用户登录之后,server端返回一个sessionID
- 用户每次再打开页面,server端会通过sessionID去查当前用户的登录状态
- 如果sessionID是validate,通过sessionID找到用户本身信息
- 可以判定这个用户是登录的合法用户,而非匿名用户
- 如果sessionID是validate,通过sessionID找到用户本身信息
- 用户每次再打开页面,server端会通过sessionID去查当前用户的登录状态
session表 - 专门用来记录session的信息
CREATE TABLE sessions(session_id VARCHAR(64) NOT NULL, TTL TINYTEXT, login_name VARCHAR(64), PRIMARY KEY( session_id ) );
session_id:varchar也可以,因为登录的次数是无限的,所以要用一个范围非常大的类型表示它
TTL(time to live):sessionID的过期时间戳,如果过期,需要返回一个错误
login_name:这里没有用户ID,因为想做的简单一点。有了login_name后,就知道用户是谁了,证明它是合法的。
session的主要问题:在机制的处理上
- 如何完成session过期?
- session校验?
数据库设计小结
任意两张表,不会出现除了id之外的冗余信息。
当你在表中没有重复信息出现的时候,你的表的扩展性才是最大的
- 加字段的时候,不用考虑这个字段会不会影响别的表的关联字段
- 缺点:
- 想获得比较多的信息的时候,需要做「多表关联」
mysql操作
课程mysql环境: 5.7.21
mysql指令大全:→
当然,为了偷懒不想记指令,可以使用Navicat的呀!
- 起起来:brew services start mysql
- 找到mysql的bin目录 cd
/usr/local/mysql/bin//usr/local/opt/mysql/bin/ - 登录:./mysql -u root -p 输入密码
- 创建db:create database <数据库名字>
- 列举dbs:show databases
- 切到db里:use video_server
- 创建表:create table <表名> ( <字段名1> <类型1> [,..<字段名n> <类型n>]);
- 看表们:show tables:
- 看表:describe xxx:
- 修改密码:ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '新密码';
- 刷新权限:FLUSH PRIVILEGES
Golang操纵数据库
mysql的Driver:github.com/go-sql-driver/mysql
使用示例:https://github.com/go-sql-driver/mysql/wiki/Examples
Golang-SQL文档:https://github.com/golang/go/wiki/SQLInterface
- db.Ping(): 检查DSN是否正确的
- DSN: datasource name, 表明了连接数据库的时候,用什么样的数据库、那个user、哪个psw连接那个库
- Ping()方法:会提前检查连接可不可用,而非等到Query的时候再检查。
Database Usage
打开数据库
sql.Open(driver, dataSourName)
db, err = sql.Open("mysql", "root:密码@tcp(localhost:3306)/库名?charset=utf8")
db.Ping():检测连接是否正常
操纵数据库
不需要返回值:db.Exec(SQL语句, 参数)
需要返回值:db.Query(SQL语句, 参数)
rows, err := db.Query("SELECT name FROM users WHERE age = $1", age) if err != nil { log.Fatal(err) } defer rows.Close() for rows.Next() { var name string if err := rows.Scan(&name); err != nil { log.Fatal(err) } fmt.Printf("%s is %d\n", name, age) } if err := rows.Err(); err != nil { log.Fatal(err) } |
只返回一个Row:db.QueryRow
var age int64 row := db.QueryRow("SELECT age FROM users WHERE name = $1", name) err := row.Scan(&age) |
声明(statements)一个SQL:db.Prepare (推荐,这样的查询方式会有预编译机制,更安全)
age := 27 stmt, err := db.Prepare("SELECT name FROM users WHERE age = $1") defer stmt.Close() // 需要关闭 if err != nil { log.Fatal(err) } rows, err := stmt.Query(age) // stmt.Exec // stmt.QueryRow // process rows |
代码
连数据库
func openConn() *sql.DB { dbConn, err = sql.Open("mysql", "root:123!@#@tcp(localhost:3306)/video_server?charset=utf8") if err != nil { panic(err.Error()) // 只有在确实无法正常完成业务逻辑的时候,才panic } return dbConn } |
说明:
OpenDSN的这个地方,实际上需要抽出来,作为配置文件。
生产环境都会这样做的,把数据库的配置、Cache连接的配置都抽出来,作为配置文件。
部署的时候,有类似CMDB的配置数据库,把配置下发过去。代码会根据配置,连它应该连的数据库,这样配置和业务可以解耦。
我们为了简便起见,抽为配置的这一步省略掉了。
操作数据库的常规思路:
func AddUserCredential(loginName string, pwd string) error { db := openConn() } |
这样写有问题:
- Golang的sql Interface是为长期存在的连接而创建的,每次openConn会造成connection的浪费
- 如果每个查询请求都open一次,查询完成之后需要加一个db.Close
- 当并发量非常大的时候,会出现「半连接」和「半关闭」的状态
- Server端会出现很多个Close Wait
- 不能每次都open一个Connection,而是「应该复用起来」
复用Connection
添加 api/dbops/conn.go
package dbops import ( "database/sql" _ "github.com/go-sql-driver/mysql" ) var ( dbConn *sql.DB err error ) func init() { dbConn, err = sql.Open("mysql", "root:123!@#@tcp(localhost:3306)/video_server?charset=utf8") if err != nil { panic(err.Error()) // 只有在确实无法正常完成业务逻辑的时候,才panic } } |
init方法:包一旦被加载,Init方法会第一个就执行。
执行之后,开启一个连接,赋给包内的全局变量dbConn
User API逻辑处理
增加用户
func AddUserCredential(loginName string, pwd string) error { // 先写一个SQL SQLQuery := `INSERT INTO users (login_name, pwd) VALUES (?, ?)` stmtIns, err := dbConn.Prepare(SQLQuery) // *2 if err != nil { retuen err } _, err := stmtIns.Exec(loginName, pwd) if err != nil { return err } defer stmtIns.Close() return nil } |
提示:
- 不要用拼接的方式(如「+」)连接query的各个部分,非常不安全,容易被撞库攻击。
- Prepare:所有Driver都有的好用的功能「预编译」,预编译之后不会出现安全上的数据库攻击
获取用户密码
func GetUserCredential(loginName string) (string, error) { pwdQuery := `SELECT pwd FROM users WHERE login_name = ?` stmtOut, err := dbConn.Prepare(pwdQuery) defer stmtOut.Close() // *3 if err != nil { log.Printf("%s", err) return "", err } // 先声明需要放的东西 var pwd string err = stmtOut.QueryRow(loginName).Scan(&pwd) // *1、2 if err != nil && err != sql.ErrNoRows { // *4 retuen "", err } retuen pwd, nil } |
提示:
- QueryRow: 只Queyr一个数据
- Scan: 写入
- stmtOut.Close()方法实际上可以放在defer里面
- 但是defer会在栈退出的时候才会调用,对性能有些许损耗
- 这里由于我们的函数有多个退出的口子,不可能每次退出都写一次Close,所以即使有一些性能损耗,我们也要用defer统一处理之
- 如果第一个QueryRow取不到东西(返回空值)的时候,一样会返回一个Object叫Raw;Object下面的scan会把这个Object的Error带出来,就是ErrNoRaws
删除用户
func DeleteUser(loginName string, pwd string) error { deleteQuery := `DELETE FROM users WHERE login_name = ? AND pwd = ?` stmtDel, err := dbConn.Prepare(deleteQuery) if err != nil { log.Printf("Delete User error: %s", err) return err } _, err = stmtDel.Exec(loginName, pwd) if err != nil { return err } stmtDel.Close() return nil } |
测试
基本的逻辑弄完了,下面,写几个UT测试业务函数
api/dbops/api_test.go
业务处理都在api里面,为了更清楚的区分其他逻辑和api,所以test统一写在api里面
package dbops import ( "testing" ) func clearTables() { dbConn.Exec(`truncate users`) dbConn.Exec(`truncate video_info`) dbConn.Exec(`truncate comments`) dbConn.Exec(`truncate sessions`) } func TestMain(m *testing.M) { clearTables() m.Run() clearTables() } func TestUserWorkFlow(t *testing.T) { // *1 t.Run("Add", testAddUser) t.Run("Get", testGetUser) t.Run("Del", testDeleteUser) t.Run("Reget", testRegetUser) } func testAddUser(t *testing.T) { err := AddUserCredential("azen", "123") if err != nil { t.Errorf("Error of AddUser: %v", err) } } func testGetUser(t *testing.T) { pwd, err := GetUserCredential("azen") if pwd != "123" || err != nil { t.Errorf("Get User Error) } } func testDeleteUser(t *testing.T) { err := DeleteUser("azen", "123") if err != nil { t.Errorf("Delete User Error : %v", err) } } func testRegetUser(t *testing.T) { // Delete完之后check是否正确 pwd, err := GetUserCredential("azen") if err != nil { t.Errorf("Get User Error) } if pwd != "" { t.Errorf("Delete User Error") } } |
写UT的最佳实践
- init一下(由于我们的包已经有init方法了,所以不需要在test文件中做Init)
- truncate tables:保证每次跑测试表都是空的,初始状态
- run test
- clear data(truncate tables)
提示:
- 因为我们的测试顺序是固定的,为了保证顺序,必须用subTest
- 这样写的好处:
- 代码规整
- 可读性非常好
- mock data本来是应该写在Fixture里面的(表格驱动测试吗...)
每次修改完代码,在提交之前先跑Test
Video API逻辑处理
定义VideoModel
type VideoInfo struct { Id string AuthorId int // 上传者ID Name string DisplayCtime } |
添加Video
func AddNewVideo(aid int, name string) (*defs.VideoInfo, error) { // create uuid vid, err := utils.NewUUID() if err != nil { return nil, err } // creatime: 进到这个函数里的时间 - 以这个作为display ctime // 把creatime写到db里面,还有一个写库的时间戳,用来以后做排序之类的事情 t := time.Now() ctime := t.Format("Jan 02 2006, 15:04:05") // *2 stmtIns, err := dbConn.Prepare(`INSERT INTO video_info (id, author_id, name, display_ctime) VALUES(?, ?, ?, ?)`) defer stmtIns.Close() if err != nil { return nil, err } _, err = stmtIns.Exec(vid, aid, name, ctime) if err != nil { return nil, err } res := &defs.VideoInfo{Id: vid, AuthorId: aid, Name: name, DisplayCtime: ctime} return res, nil } |
提示:
- 有现成的算法可以生成uuid
- f.Format函数,通过示例描述时间格式
- 时间原点:这串字符必须使用这个,不然就没办法正确格式化,但是可以添加自己的各种符号
- 没人知道这个时间原点是什么鬼...可能是Golang的彩蛋
附件:
生成uuid
package utils import ( "crypto/rand" "io" "fmt" ) func NewUUID() (string, error) { uuid := make([]byte, 16) n, err := io.ReadFull(rand.Reader, uuid) if n != len(uuid) || err != nil { return "", err } // variant bits; see section 4.1.1 uuid[8] = uuid[8]&^0xc0 | 0x80 // version 4 (pseudo-random); see section 4.1.3 uuid[6] = uuid[6]&^0xf0 | 0x40 return fmt.Sprintf("%x-%x-%x-%x-%x", uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:]), nil } |
提示:
创建video、comment的时候都需要用到相关方法,所以抽成一个包
Video的其他操作
func GetVideoInfo(vid string) (*defs.VideoInfo, error) { stmtOut, err := dbConn.Prepare("SELECT author_id, name, display_ctime FROM video_info WHERE id=?") var aid int var dct string var name string err = stmtOut.QueryRow(vid).Scan(&aid, &name, &dct) if err != nil && err != sql.ErrNoRows{ return nil, err } if err == sql.ErrNoRows { return nil, nil } defer stmtOut.Close() res := &defs.VideoInfo{Id: vid, AuthorId: aid, Name: name, DisplayCtime: dct} // 不能把struct直接传进去吗...封个方法吧 return res, nil } func DeleteVideoInfo(vid string) error { stmtDel, err := dbConn.Prepare("DELETE FROM video_info WHERE id=?") defer stmtDel.Close() if err != nil { return err } _, err = stmtDel.Exec(vid) if err != nil { return err } return nil } |
Video的相关测试
var tempvid string // 这个参数是在AddNewVideo函数的返回值里的,但是测试中没办法拿到返回值,所以用一个全局变量引着它 func TestVideoWorkFlow(t *testing.T) { clearTables() t.Run("PrepareUser", testAddUser) // 需要把用户准备好才能测Video t.Run("AddVideo", testAddVideoInfo) t.Run("GetVideo", testGetVideoInfo) t.Run("DelVideo", testDeleteVideoInfo) t.Run("RegetVideo", testRegetVideoInfo) } func testAddVideoInfo(t *testing.T) { vi, err := AddNewVideo(1, "my-video") if err != nil { t.Errorf("Error of AddVideoInfo: %v", err) } tempvid = vi.Id // 赋全局变量,解决无法传参的问题 } func testGetVideoInfo(t *testing.T) { _, err := GetVideoInfo(tempvid) if err != nil { t.Errorf("Error of GetVideoInfo: %v", err) } } func testDeleteVideoInfo(t *testing.T) { err := DeleteVideoInfo(tempvid) if err != nil { t.Errorf("Error of DeleteVideoInfo: %v", err) } } func testRegetVideoInfo(t *testing.T) { vi, err := GetVideoInfo(tempvid) if err != nil || vi != nil{ t.Errorf("Error of RegetVideoInfo: %v", err) } } |
Comments API逻辑处理
添加评论
func AddNewComments(vid string, aid int, content string) error { id, err := utils.NewUUID() if err != nil { return err } stmtIns, err := dbConn.Prepare(`INSERT INTO comments (id, video_id, author_id, content) values (?, ?, ?, ?)`) if err != nil { return err } _, err := stmtIns.Exec(id, vid, aid, content) if err != nil { return err } defer stmtIns.Close() return nil } |
展示评论
type Comment struct { Id string VideoId string Author string Content string } |
func ListComments(vid string, from, to int) ([]*defs.Comment, error) { query := `SELECT comments.id, users.Login_name, comments.content FROM comments INNER JOIN users ON comments.author_id = users.id WHERE comments.video_id = ? AND comments.time > FROM_UNIXTIME(?) AND comments.time <= FROM_UNIXTIME(?)` stmtOut, err := dbConn.Prepare(query) defer stmtOut.Close() rows, err := stmtOut.Query(vid, from, to) if err != nil { return res, err } for rows.Next() { var id, name, content string if err := rows.Scan(&id, &name, &content); err != nil { return res, err } c := &defs.Comment{Id: id, VideoId: vid, Author: name, Content: content} res = append(res, c) } return res, nil } |
评论测试
func TestComments(t *testing.T) { clearTables() t.Run("AddUser", testAddUser) t.Run("AddCommnets", testAddComments) t.Run("ListComments", testListComments) } func testAddComments(t *testing.T) { vid := "12345" aid := 1 content := "I like this video" err := AddNewComments(vid, aid, content) if err != nil { t.Errorf("Error of AddComments: %v", err) } } func testListComments(t *testing.T) { vid := "12345" from := 1514764800 to, _ := strconv.Atoi(strconv.FormatInt(time.Now().UnixNano()/1000000000, 10)) res, err := ListComments(vid, from, to) if err != nil { t.Errorf("Error of ListComments: %v", err) } for i, ele := range res { fmt.Printf("comment: %d, %v \n", i, ele) } } |
Session API逻辑处理
概念
Session不是业务entity,而是系统的entity
在使用RESTful API的时候,状态是不会保持的。为了记录用户在服务端的状态,需要有个东西保存它。
为什么用session
如果不存状态,用户之前操作的东西就会丢掉。如果每次刷网页都要用户登录一遍,是不可用的。
session vs cookie
session是一种在服务端为用户保存状态的机制
cookie是在客户端保存用户状态的机制
使用session的时候需要sessionID,当客户端为了方便访问session,会把sessionID放到cookie里。
我们今天的课程,session不会存复杂的东西,只负责用户的登录状态
- 如果session中没有sessionID,说明用户没登录
- 如果有sessionID,说明登录了
流程图
当系统初始化的时候,Cache会load所有sessionID
当更新用户session的时候,需要往Cache和DB里面写两次
写两次原因:
- DB在网页访问量、并发量大的时候,压力很大
- DB的操作对IO消耗非常大,要尽量减少DB操作
- Cache机制能保证:在多读少写的情况下以最快的速度返回想要的结果
代码实现
session是系统逻辑,需要单独拎出来 api/session
session的操作:
- 服务起来的时候load所有session
- 新用户登录的时候,需要分配一个sessionID,需要一个产生sessionID的方法
- 校验的时候session过期,需要返回一个是否过期的状态
package session import ( "time" // *1 "sync" // *2 "xxxxxx/dbops" // 自己写的数据库操作文件 "xxxxxx/utils" // 生成uuid用的 ) // type SimpleSession struct { // Username string // login name // TTL int64 // session过期时间戳,检查用户是否过期 // } // 放到dbops的def里 var sessionMap *sync.Map // *3 func init() { sessionMap = &sync.Map{} } // 从数据库拿出来所有的session,放入Cache中 func loadSessionsFromDB() { r := dbops.RetrieveAllSessions() if err != nil { return } r.Range(func(k, v interface{} bool) { ss := v.(*defs.SimpleSession) sessionMap.Store(k, ss) return true }) } func GenerateNewSessionId(username string) (sessionID string) { id, _ := utils.NewUUID() ct := time.Now().UnixNano()/1000000 // 毫秒表示 ttl := ct + 30 * 60 * 1000 // serverside session valid time : 30 min ss := &defs.SimpleSession{Username: un, TTL: ttl} sessionMap.Store(id, ss) dbops.InsertSession(id, ttl, un) return id } func IsSessionExpried(sid string) (un string, bool) { ss, ok := sessionMap.Load(sid) if ok { ct := time.Now().UnixNano() / 100000 // 毫秒级表示 if ss.(*defs.SimpleSession).TTL < ct { // 删除session deleteExpiredSession(sid) return "", true } return ss.(*defs.SimpleSession).Username, false } return "", true } func deleteExpireSession(sid string) { sessionMap.Delete(sid) dbops.DeleteSession(sid) } |
提示:
- session是否过期 - 需要time
- 需要一个存session的地方 - sync
- Cache:为什么不用一个web的Redis这种类型的Cache?
- 当一个系统中增加一个模块的时候,系统的复杂度必然增加。
- 当系统复杂度超出了业务增量、能带给你的好处之后,会增加你的不必要负担。综合考虑,没必要
- 使用Go的map做内部缓存,在每个节点上都会缓存全量数据
- 有关用户的数据量非常有限,不会特别多,使用内建Cache - Sync.map就完全足够了
- sync.Map vs 内建map
- sync.Map是Golang 1.9之后加入的,是一个线程安全的map
- 自己实现了一套线程安全的map
- 在「读」上面,优化的非常极致
- 「写」上有一些问题,内部每次写都要加一个全局锁
- 内建map:不能支持并发的读写
- 两个以上的协程读写的话,会panic
- sync.Map是Golang 1.9之后加入的,是一个线程安全的map
dbops中session交互的操作,放在/api/dbops/internal.go
package dbops func InsertSession(sid string, ttl int64, uname string) error { ttlstr := strconv.FormatInt(ttl, 10) stmtIns, err := dbConn.Prepare("INSERT INTO session (session_id, TTL, login_name) VALUES (?, ?, ?)") defer stmtIns.Close() if err != nil { return err } _, err := stmtIns.Exec(sid, ttlstr, login_name) if err != nil { return err } return nil } func RetrieveSession(sid string) (*defs.SimpleSession, error) { ss := &defs.SimpleSession({} stmt, err := dbConn.Prepare(`SELECT TTL, login_name FROM sessions WHERE session_id=?`) defer stmt.Close() if err != nil { return nil, err } var ttl string var unane string stmt.QueryRow(sid).Scan(&ttl, &uname) if err != nil && err != sql.ErrNoRows { return nil, err } if res, ttlint := strconv.ParseInt(ttl, 10, 64); err == nil { ss.TTL = res ss.Username = uname } else { return nil, err } return ss, nil } func RetrieveAllSessions() (*sync.Map, err) { } func DeleteSession(sid string) error { } |
API前端部分
API前端部分包括:
- API入口 - main函数
- 注册Router之前需要做一些通用性的东西 - 术语:http middleware
- 校验
- 鉴权
- 流控
- 其他处理
- 一些定义
- 消息体message
- error
- 继续写handler
- response
HTTP中间件
HTTP 中间件提供了一个方便的机制来过滤进入应用程序的 HTTP 请求,例如,Auth 中间件验证用户的身份,如果用户未通过身份验证,中间件将会把用户导向登录页面,反之,当用户通过了身份验证,中间件将会通过此请求并接着往下执行。
当然,除了身份验证之外,中间件也可以被用来运行各式各样的任务,如:CORS 中间件负责替所有即将离开程序的响应加入适当的标头;而日志中间件则可以记录所有传入应用程序的请求。
实现MiddleWare
原理:duck type - 组合
httprouter.Router,实际实现的是httpHandler中的ServeHTTP函数。我们先劫持这个函数,然后自己实现一下httpHandler,实现的时候先写自己的逻辑,再调用一下它的默认实现就好
type middleWareHandler struct { r *httprouter.Router } func NewMiddleWareHandler(r *httprouter.Router) http.Handler { m := middleWareHandler{} m.r = r return m } // 实现协议方法 func (m middleWareHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // 劫持它本身的这个函数,自己实现一些通用的功能 validateUserSession(r) // 添加自定义鉴权 m.r.ServeHTTP(w,r) } func RegisterHandlers() *httprouter.Router { router := httprouter.New() router.POST("/user", CreateUser) router.POST("/user/:user_name", Login) return router } func main() { r := RegisterHandlers() // http.ListenAndServe(":8000", r) // 旧写法 mh := NewMiddleWareHandler() http.ListenAndServe(":8000", mh) // 实现MiddleWare的写法 } |
MiddleWare功能 - 鉴权
添加api/auth.go文件
package main import ( "net/http" "xxx/api/session" ) var HEADER_FIELD_SESSION = "X-Session-Id" var HEADER_FIELD_UNAME = "X-User-Name" // *1 // 检查用户是否合法 func validateUserSession(r *http.Request) bool { sid := r.Header.Get(HEADER_FIELD_SESSION) if len(sid) == 0 { return false } uname, ok := session.IsSessionExpired(sid) if ok { return false } r.Header.Add(HEADER_FIELD_UNAME, uname) // 如果鉴权成功,则把username加入到Header中,方便请求的后续处理 return true } func ValidateUser(w http.ResponseWriter, r *http.Response) bool { uname := r.Header.Get(HEADER_FIELD_UNAME) if len(uname) == 0 { sendErrorResponse() return false } return true } |
提示:
- X开头的字段,都是HTTP协议中的自定义Header
- 我们将这两种Header加入到HTTP的原生headers中,构成鉴权过程
添加类型定义
type SignedUp struct { Success bool `json:"success"` SessionId string `json:"session_id"` } |
继续完成Handler部分
func CreateUser(w http.ResponseWriter, r *http.Request, p httprouter.Params) { res, _ := ioutil.ReadAll(r.Body) // 从request里面拿出Body - POST方法嘛,必有body ubody := &defs.UserCredential{} if err := json.Unmarshal(res, ubody); err != nil { // *1 sendErrorResponse(w, defs.ErrorRequestBodyParseFailed) return } if err := dbops.AddUserCredential(ubody.Username, ubody.Pwd); err != nil { sendErrorResponse(w, defs.ErrorDBError) } // 写入正确,创建session id := session.GenerateNewSessionId(udody.Username) su := &defs.SignUp{Success: true, SessionId: id} if resp, err := json.Marshal(su); err != nil { sendErrorResponse(w, defs.ErrorInternalFaluts) } else { sendNormalResponse(w, string(resp), 201) } } func Login(w http.ResponseWriter, r *http.Request, p httprouter.Params) { uname := p.ByName("user_name") io.WriteString(w, uname) } |
实现sendErrorResponse
func sendErrorResponse(w http.ResponseWriter, errResp defs.ErrResponse) { w.WriteHeader(errResp.HttpSC) resStr, _ := json.Marshal(&errResp.Error) io.WriteString(w, string(resStr)) } func sendNormalResponse(w http.ResonseWriter, resp string, sc int) { w.WriterHeader(sc) io.WriteString(w, resp) } |
提示:
- json.Unmarshal(xxx, yyy) - 把xxxjson序列化为yyy结构体
以上,一套API已经完成了。可以编译一下,测下能不能跑通。
其他API和这个相同,自己实现之即可
TODOs
- 数据库DSN作为配置文件下发