一个简单的Go session 实现
程序员文章站
2022-04-24 17:18:20
...
Go的net/http 包实现了http 编程,但是会话即session 还需应用自己实现。本文基于内存存储实现一个简单的session 管理,并解释一些语言重要的基础概念(如传值传址),基于Go 1.9.2。
分析
简单来说session 管理的难点在于过期的session 要能自动销毁以回收内存避免内存泄露,但是该销毁过程尽量不要影响其他非过期session 的正常使用。
通常所有session 存放在一个map,以session id 为key 这样可以快速由id 获取session。但是过期的session 要能及时清除,所以考虑在map 之外增加一个linked list 将所有session 按活跃程度链接起来:每次获取一个session 时将它提升到链头,这样链尾就逐步堆积了所有不活跃的session,过期清理从链尾可以快速执行。
如此将map value 改为list 的元素。使用Go container/list 双向列表:已知该列表的某个元素,可以快速将其移动而不需遍历整个列表。
设计
目录结构如下:
main.go 是主程序,session 目录是session 实现。
首先定义session类型:
Go 类似于Java 也可使用接口定义类型。session 有get/set/删除,可以主动过期。session id 一般固定不会修改。interface{} 类似于Java 的Object,但也可以是int/float32等。
session 管理器:
session 管理器用来获取一个session,传入session id。如果有就返回它,如果没有或者已经过期了,则新建一个再返回。
实现
一个session 所有需要的数据我们定义在一个struct 上:
session 的数据存储在smap 里。由于session 可能被并发使用(例如一个页面同时发起2个后台请求),所以通过sync RW锁来做访问同步。注意Go 语言通常建议通过channel/goroutine 来使得一个数据只会通过一个goroutine 来访问以规避多个goroutine 并发访问同一数据,但是该建议通常用于程序流程级的控制,当细化到对于一个map 的读写做并发控制时,简单的RW 锁看起来更合适。
lock 字段的类型是*sync.RWMutex 而非sync.RWMutex,区别何在?参见godoc faq#pass_by_value:Go 函数调用时所有参数、结果传递都是传值(by value)而非传址(by reference)。这样当调用ses.lock.XX方法时传递的是 *sync.RWMutex 的拷贝 - 指针拷贝不影响仍使用原始的lock 对象,而如果是 sync.RWMutex拷贝 则已经不是原始的lock 对象。RWMutex godoc 里要求“An RWMutex must not be copied after first use.”。
同样见faq#references,Go map|slice|channel 实际是指针:即一个map对象(非map指针)其内部实际存储的是指向实际数据的指针,所以map|slice|channel 传值也是可以的。但是注意Go array 是实际的值对象,当要传递一个较大的array 时最好改为传递其指针。
另外,小的struct 因为拷贝很'廉价' 所以仍可以传值。最后最好统一:同一对象的所有方法要么都传值要么都传址。
关于session 访问时间ses.time 我们实现上将简化为仅在通过manager 获取session 时更新它,后续session.get/set/.. 时不再更新。
ses.mgr 字段用于实现session.Invalidate 方法。
在Go 里一个类型如果实现了某个接口的所有方法则该类型也就实现了该接口,不需要类似Java里明确的'implements'。ses struct 实现了ISession 接口:
如上Get方法里由于要防止并发所以先Lock。defer Unlock 将在方法返回前(返回值已经得到)执行,这类似于Java 的try/finally。
然后是session manager 的实现struct:
在map 之外使用双向链表:map value 是链表的元素,链表元素的Value 是ses struct。已知id 从map 找到list element 其value 就是ses (ISession)。同样list element 可以快速在链表内移动及删除。同样用RW 锁控制并发。
ISessionManager 的Get 方法实现:
如果从smap 里找到并且尚未过期,则移动到链表头部后返回,否则删除过期的、新建一个后返回。
List.Element.Value 的类型是interface{}。“e.Value.(ses)”是一个type assertion “x.(T)”:x 需要是接口类型,当T 不是接口类型时x 的动态类型应该 = T。
new(xx)返回指针,make(xx)返回值。
清理时从链表的尾部开始。为避免耗时过长一次清理设置数量上限,超过而链表非空时下次清理将提前,但链表已经清空或剩余的未过期时,则要避免频繁启动下次清理:
这里的返回值=到下次清理的等待时间,在创建session manager 时启动一个goroutine 来持续清理:
go funcxx 就启动一个goroutine。goroutine 是更轻量级的线程,可以想象成一根Java线程可以调度多个goroutine。见faq#goroutines,一个goroutine 初始仅占几k 内存,一个程序可以使用成百上千goroutine。
单元测试
Go 提供了方便的单元测试编写,在session.go 相同目录创建session_test.go,里面的每个“func TestXX(*testing.T)”方法即为测试方法,通过go test 运行测试。
Go 里同一个包下的类型互相之间都可见,不存在private。由于test 类和session.go 在一个包里,这方便我们编写一个创建session manager 但不启动自动清理的工具方法:
简单的测试:
“t.Errorf”方法报告一次失败。
通过调用gcOnce方法来测试清理:
浏览器测试
需要在浏览器里实际验证一下会话:main.go 使用net/http 启动一个http server,接收到浏览器请求时(类似Java servlet)通过一个固定名称的cookie 存储产生的session 的id。该cookie 会发送到浏览器端,浏览器后续每次请求时会再发送给server:
注意以上getSession 方法仅用于测试,用于生产可能还有一些潜在问题(例如域名换ip)。
运行程序,访问展示页面确认每次刷新的session id 相同,另外还可以set 值、销毁当前session。
分析
简单来说session 管理的难点在于过期的session 要能自动销毁以回收内存避免内存泄露,但是该销毁过程尽量不要影响其他非过期session 的正常使用。
通常所有session 存放在一个map,以session id 为key 这样可以快速由id 获取session。但是过期的session 要能及时清除,所以考虑在map 之外增加一个linked list 将所有session 按活跃程度链接起来:每次获取一个session 时将它提升到链头,这样链尾就逐步堆积了所有不活跃的session,过期清理从链尾可以快速执行。
如此将map value 改为list 的元素。使用Go container/list 双向列表:已知该列表的某个元素,可以快速将其移动而不需遍历整个列表。
设计
目录结构如下:
$GOPATH/src/sample/memses main.go $GOPATH/src/sample/memses/session session.go
main.go 是主程序,session 目录是session 实现。
首先定义session类型:
type ISession interface { Id() string Get(key string) interface{} Set(key string, value interface{}) Remove(key string) Invalidate() }
Go 类似于Java 也可使用接口定义类型。session 有get/set/删除,可以主动过期。session id 一般固定不会修改。interface{} 类似于Java 的Object,但也可以是int/float32等。
session 管理器:
type ISessionManager interface { Get(id string) ISession // get or create a session (with new id) }
session 管理器用来获取一个session,传入session id。如果有就返回它,如果没有或者已经过期了,则新建一个再返回。
实现
一个session 所有需要的数据我们定义在一个struct 上:
type ses struct { mgr *sesmgr id string lock *sync.RWMutex // for smap,time smap map[string]interface{} time time.Time //access time }
session 的数据存储在smap 里。由于session 可能被并发使用(例如一个页面同时发起2个后台请求),所以通过sync RW锁来做访问同步。注意Go 语言通常建议通过channel/goroutine 来使得一个数据只会通过一个goroutine 来访问以规避多个goroutine 并发访问同一数据,但是该建议通常用于程序流程级的控制,当细化到对于一个map 的读写做并发控制时,简单的RW 锁看起来更合适。
lock 字段的类型是*sync.RWMutex 而非sync.RWMutex,区别何在?参见godoc faq#pass_by_value:Go 函数调用时所有参数、结果传递都是传值(by value)而非传址(by reference)。这样当调用ses.lock.XX方法时传递的是 *sync.RWMutex 的拷贝 - 指针拷贝不影响仍使用原始的lock 对象,而如果是 sync.RWMutex拷贝 则已经不是原始的lock 对象。RWMutex godoc 里要求“An RWMutex must not be copied after first use.”。
同样见faq#references,Go map|slice|channel 实际是指针:即一个map对象(非map指针)其内部实际存储的是指向实际数据的指针,所以map|slice|channel 传值也是可以的。但是注意Go array 是实际的值对象,当要传递一个较大的array 时最好改为传递其指针。
另外,小的struct 因为拷贝很'廉价' 所以仍可以传值。最后最好统一:同一对象的所有方法要么都传值要么都传址。
关于session 访问时间ses.time 我们实现上将简化为仅在通过manager 获取session 时更新它,后续session.get/set/.. 时不再更新。
ses.mgr 字段用于实现session.Invalidate 方法。
在Go 里一个类型如果实现了某个接口的所有方法则该类型也就实现了该接口,不需要类似Java里明确的'implements'。ses struct 实现了ISession 接口:
func (s ses) Id() string { return s.id } func (s ses) Get(key string) interface{} { s.lock.Lock() defer s.lock.Unlock() return s.smap[key] } func (s ses) Set(key string, value interface{}) { s.lock.Lock() defer s.lock.Unlock() s.smap[key] = value } func (s ses) Remove(key string) { 。。。 } func (s ses) Invalidate() { s.mgr.invalidate(s.id) }
如上Get方法里由于要防止并发所以先Lock。defer Unlock 将在方法返回前(返回值已经得到)执行,这类似于Java 的try/finally。
然后是session manager 的实现struct:
type sesmgr struct { lock *sync.RWMutex list *list.List // a list of ses Element, active (top) -> inactive (bottom) smap map[string]*list.Element // id => ses Element timeout time.Duration }
在map 之外使用双向链表:map value 是链表的元素,链表元素的Value 是ses struct。已知id 从map 找到list element 其value 就是ses (ISession)。同样list element 可以快速在链表内移动及删除。同样用RW 锁控制并发。
ISessionManager 的Get 方法实现:
func (sm sesmgr) Get(id string) ISession { sm.lock.Lock() defer sm.lock.Unlock() if e, ok := sm.smap[id]; ok { s := e.Value.(ses) if s.checkTimeout(sm.timeout) { sm.list.MoveToFront(e) // front means most active return s } else { sm.delete(e) } } // not exists or timed out s := ses{ mgr: &sm, id: genSesId(), lock: new(sync.RWMutex), smap: make(map[string]interface{}, 24), time: time.Now(), } e := sm.list.PushFront(s) sm.smap[s.id] = e return s }
如果从smap 里找到并且尚未过期,则移动到链表头部后返回,否则删除过期的、新建一个后返回。
List.Element.Value 的类型是interface{}。“e.Value.(ses)”是一个type assertion “x.(T)”:x 需要是接口类型,当T 不是接口类型时x 的动态类型应该 = T。
new(xx)返回指针,make(xx)返回值。
清理时从链表的尾部开始。为避免耗时过长一次清理设置数量上限,超过而链表非空时下次清理将提前,但链表已经清空或剩余的未过期时,则要避免频繁启动下次清理:
func (sm sesmgr) gcOnce() time.Duration { sm.lock.Lock() defer sm.lock.Unlock() for i := 0; i < 1000; i++ { // max 1000 del e := sm.list.Back() if e == nil { break } s := e.Value.(ses) if d := s.getLeftTimeout(sm.timeout); d >= 0 { sm.delete(e) } else { if -d < 2*time.Minute { // still valid, wait a bit longer return 2 * time.Minute } else { return -d } } } if sm.list.Len() > 0 { // assume more to gc, catch up return 1 * time.Second } else { return 2 * time.Minute } }
这里的返回值=到下次清理的等待时间,在创建session manager 时启动一个goroutine 来持续清理:
go func() { for { time.Sleep(sm.gcOnce()) } }()
go funcxx 就启动一个goroutine。goroutine 是更轻量级的线程,可以想象成一根Java线程可以调度多个goroutine。见faq#goroutines,一个goroutine 初始仅占几k 内存,一个程序可以使用成百上千goroutine。
单元测试
Go 提供了方便的单元测试编写,在session.go 相同目录创建session_test.go,里面的每个“func TestXX(*testing.T)”方法即为测试方法,通过go test 运行测试。
Go 里同一个包下的类型互相之间都可见,不存在private。由于test 类和session.go 在一个包里,这方便我们编写一个创建session manager 但不启动自动清理的工具方法:
func createMgr(d time.Duration) sesmgr { sm := sesmgr{ lock: new(sync.RWMutex), list: new(list.List), smap: make(map[string]*list.Element, 100), timeout: d, } return sm }
简单的测试:
func Test1(t *testing.T) { sm := createMgr(time.Minute) //1. one s := sm.Get("") if sm.list.Len() != 1 { t.Errorf("one: len != 1") } //。。。 }
“t.Errorf”方法报告一次失败。
通过调用gcOnce方法来测试清理:
sm = createMgr(10 * time.Second) //10s timeout id1 = sm.Get("").Id() sm.gcOnce() if sm.list.Len() != 1 { t.Errorf("gc: should gc none") }
浏览器测试
需要在浏览器里实际验证一下会话:main.go 使用net/http 启动一个http server,接收到浏览器请求时(类似Java servlet)通过一个固定名称的cookie 存储产生的session 的id。该cookie 会发送到浏览器端,浏览器后续每次请求时会再发送给server:
const TOKEN = "GSESSIONID" // session cookie name func getSession(w http.ResponseWriter, req *http.Request) session.ISession { var id = "" if c, err := req.Cookie(TOKEN); err == nil { id = c.Value } ses := sesmgr.Get(id) if ses.Id() != id { //new session http.SetCookie(w, &http.Cookie{ Name: TOKEN, Value: ses.Id(), }) } return ses }
注意以上getSession 方法仅用于测试,用于生产可能还有一些潜在问题(例如域名换ip)。
运行程序,访问展示页面确认每次刷新的session id 相同,另外还可以set 值、销毁当前session。