Skip to main content

Go语言的'大道至简':从Session源码说起

· 11 min read
ayanami

看了一下Go的Session源码——这就是我们的"大道至简"啊(笑)。

Go框架源码的可读性

觉得Go有一点是好的:由于框架很简陋,所以源码可读性非常高,能够让人对框架在做什么有稍微深一点的印象(因为不做的要自己手做了)。

对比一下:

  • 比Go核心语法更简单的C,底层全是编译器特化的东西和直接对应汇编的hack
  • 比Go核心语法复杂得多的其他语言(C++、Java、Python等),源码里面都大量使用黑魔法,又或者就是套了N层抽象

怪不得Go Web的文档写这么少——鉴定为"具体请看源码"是吧。Go是这样的,只有库没有真正意义的框架。框架只需要轮椅就好了,调库要想的可就多了。

Session的实现原理

gorilla/sessions为例,看看Session在Go中是怎么实现的。

基本使用

package main

import (
"fmt"
"net/http"

"github.com/gorilla/sessions"
)

var sessionStore *sessions.CookieStore

func initSession() {
sessionStore = sessions.NewCookieStore([]byte("secret-key"))
}

func validation(username string, password string) bool {
fmt.Printf("username: %s, password: %s\n", username, password)
return true
}

func login(w http.ResponseWriter, r *http.Request) {
session, err := sessionStore.Get(r, "cookie-name")
if err != nil {
fmt.Println(err)
}
// validation
if validation(r.FormValue("username"), r.FormValue("password")) {
session.Values["authenticated"] = true
session.Save(r, w) // write back to store, == store.Save(r, w, session)
fmt.Fprintf(w, "Login successfully\n")
return
}
http.Error(w, "Authentication failed", http.StatusUnauthorized)
}

func secret(w http.ResponseWriter, r *http.Request) {
authSession, _ := sessionStore.Get(r, "cookie-name")
// 可能是nil, nil时转换会err
auth, err := authSession.Values["authenticated"].(bool)
if !auth || !err {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
fmt.Fprintln(w, "Secret Area")
}

func logout(w http.ResponseWriter, r *http.Request) {
authSession, _ := sessionStore.Get(r, "cookie-name")
authSession.Values["authenticated"] = false
authSession.Save(r, w)
}

func main() {
initSession()
http.HandleFunc("/login", login)
http.HandleFunc("/secret", secret)
http.HandleFunc("/logout", logout)
http.ListenAndServe(":8080", nil)
}

CookieStore的结构

一个CookieStore就是一个Option+一堆密钥:

// CookieStore stores sessions using secure cookies.
type CookieStore struct {
Codecs []securecookie.Codec
Options *Options // default configuration
}

type Codec interface {
Encode(name string, value interface{}) (string, error)
Decode(name, value string, dst interface{}) error
}

关键设计:Session与CookieStore的反转

有意思的是,Cookie/Session本身不存放在CookieStore里面,而是反过来——Session里面存了CookieStore的引用。有了这个指针,Session就可以用CookieStore里面的密钥组进行encode/decode:

// EncodeMulti encodes a cookie value using a group of codecs.
//
// The codecs are tried in order. Multiple codecs are accepted to allow
// key rotation.
//
// On error, may return a MultiError.
func EncodeMulti(name string, value interface{}, codecs ...Codec) (string, error) {
if len(codecs) == 0 {
return "", errNoCodecs
}
var errors MultiError
for _, codec := range codecs {
encoded, err := codec.Encode(name, value)
if err == nil {
return encoded, nil
}
errors = append(errors, err)
}
return "", errors
}

多个Codec的设计是为了支持密钥轮换(key rotation)——新旧密钥并存,编码用新密钥,解码时依次尝试。

Session与Cookie的定义

type Session struct {
// The ID of the session, generated by stores. It should not be used for
// user data.
ID string
// Values contains the user-data for the session.
Values map[interface{}]interface{}
Options *Options
IsNew bool
store Store
name string
}

// A Cookie represents an HTTP cookie as sent in the Set-Cookie header of an
// HTTP response or the Cookie header of an HTTP request.
//
// See https://tools.ietf.org/html/rfc6265 for details.
type Cookie struct {
Name string
Value string

Path string // optional
Domain string // optional
Expires time.Time // optional
RawExpires string // for reading cookies only

// MaxAge=0 means no 'Max-Age' attribute specified.
// MaxAge<0 means delete cookie now, equivalently 'Max-Age: 0'
// MaxAge>0 means Max-Age attribute present and given in seconds
MaxAge int
Secure bool
HttpOnly bool
SameSite SameSite
Raw string
Unparsed []string // Raw text of unparsed attribute-value pairs
}

可以看到,Session的核心是一个KV存储,有自己的名字,根据名字定位。同时Session有Store的信息。Cookie则是一个单独的KV加上其他的HTTP信息。

读取Session的过程

func (s *CookieStore) Get(r *http.Request, name string) (*Session, error) {
return GetRegistry(r).Get(s, name)
}

func GetRegistry(r *http.Request) *Registry {
var ctx = r.Context()
registry := ctx.Value(registryKey)
if registry != nil {
return registry.(*Registry)
}
newRegistry := &Registry{
request: r,
sessions: make(map[string]sessionInfo),
}
*r = *r.WithContext(context.WithValue(ctx, registryKey, newRegistry))
return newRegistry
}

这个registryKey就是一个常数0——约定http.ContextValue[0]是session信息(实际上传入的request初始不会带ctx,是框架在处理的时候给这个request加上了context信息)。

如果这个registry已经有东西了(说明被框架处理过,已经有了约定的session信息),就读取;否则新建一个空的registry(可以带多个session),绑定到request的context里面。

然后GetRegistry(r).Get(s, name)里面的Get也就是简单的map[name]有没有东西,没有东西就用store新建一个空的,有就返回。

多个Request如何共享Session

同个host发出的多个request如何共享一个session呢?我们得先看Save:

// Save adds a single session to the response.
func (s *CookieStore) Save(r *http.Request, w http.ResponseWriter,
session *Session) error {
encoded, err := securecookie.EncodeMulti(session.Name(), session.Values,
s.Codecs...)
if err != nil {
return err
}
http.SetCookie(w, NewCookie(session.Name(), encoded, session.Options))
return nil
}

可以看到,Save将创建的session作为Set-Cookie返回给了浏览器,新建的cookie name和session的name相同,值为session encode的结果。

也就是说,原始的session信息不需要继续保留,只需要根据store里面的密钥decode cookie就行。Registry的Get也体现了这一点:

func (s *Registry) Get(store Store, name string) (session *Session, err error) {
if !isCookieNameValid(name) {
return nil, fmt.Errorf("sessions: invalid character in cookie name: %s", name)
}
if info, ok := s.sessions[name]; ok {
session, err = info.s, info.e
} else {
session, err = store.New(s.request, name)
session.name = name
s.sessions[name] = sessionInfo{s: session, e: err}
}
session.store = store
return
}

新建session时,如果请求中带了对应的cookie,就decode出来;否则新建一个空的session:

func (s *CookieStore) New(r *http.Request, name string) (*Session, error) {
session := NewSession(s, name)
opts := *s.Options
session.Options = &opts
session.IsNew = true
var err error
if c, errCookie := r.Cookie(name); errCookie == nil {
err = securecookie.DecodeMulti(name, c.Value, &session.Values,
s.Codecs...)
if err == nil {
session.IsNew = false
}
}
return session, err
}

CookieStore全流程

我在各种struct里面转了半天,一直在找它session存哪了,最后才意识到是类似JWT的方法。整理一下CookieStore全流程:

  1. 建立一个密钥组的CookieStore cs
  2. 第一个request,创建一个{request: []session}的map(Registry),包回request的context之中,Get(r, name)根据name创建session,其中值为使用cs encode后的结果
  3. 之后,如果在原始request上继续Get,会直接从request上面拿
  4. 第一个request response的时候,从request的context里面提取出registry,然后返回Set-Cookie让浏览器设置cookie为{name: session.name(), value: encode后的session}
  5. 后续request会带上cookie,然后使用cs即可进行解码,得到相关信息

对外暴露的API为:

func sessions.NewCookieStore(keyPairs ...[]byte) *sessions.CookieStore
func (s *sessions.CookieStore) Get(r *http.Request, name string) (*sessions.Session, error)
func (s *sessions.Session) Save(r *http.Request, w http.ResponseWriter) error

对比Java Spring:因为Go的net/http作为基础库,势必要将req/res的API暴露出来,作为外部库引入的session不能有强侵入性,故而必须在session相关的API之中保留req、res和name,实际上要求用户对session/cookie的流程有一定认知。而Spring因为已经接管了从Java Object到HTTP req/res的转换处理过程,故而可以进一步接管Session的创立,使用IoC和注入的方法输入session,用户不需要对session底层实现有任何认知,只需要把它当成一个KV存储就行:

@GetMapping("/")
public String function(HttpSession session) // 会自动注入session

实际上达到了更加解耦的效果,也更加"轮椅"。

如果想要在服务器端持久化会话数据,或者在分布式系统中共享会话数据,gorilla/sessions提供了FilesystemStore来使用文件系统存储session,对外暴露的API是相同的。

不过确实有助于了解底层实现——有种原始的美感。

架构基础知识

今天了解了一些架构的基础知识,还挺有意思的。翻来覆去讲的高可用、高并发、高拓展,各种中间件似乎都是把某个领域特定的东西(比如搜索的倒排索引)相关的功能做好,再加上切片和分配、主从机制、分布式节点管理,就变成"三高"了。Elasticsearch、Kafka、Redis似乎都是这样。

关于语言选择

感觉个人全栈的话,TypeScript可能会是一个好选择?tRPC、Prisma、Next.js、Socket.IO,各种基础库好全,同一套配置也非常简单,API设计简直就是怎么让程序员舒服怎么来。

但国内后端的话估计还得滚回去看Java。

Loading Comments...