WebSocket降级策略

712 阅读12分钟

WebSocket降级策略

问题背景

项目上前后端采用websocket通信,但是websocket连接经常会断开,虽然有重连机制,但是在重连的过程中,以及重连失败时,会影响前端数据的即时刷新。我们也不可能每次一出现问题就要求用户重启浏览器。因此需要设计一个websocket降级方案。

降级思路

前端处理:当前端websocket断开或者超过一定时间没有收到消息时,将会自动切换为轮询,主动查询服务器最近是否有发送给前端的websocket消息。当websocket重连成功并收到消息后,取消轮询。

后端处理:当后端发送websocket请求时,对发送的消息进行缓存,当前端进行查询时,返回发送给该前端的消息。同时将超过一定时长的消息(过期消息),或者前端已查询过的消息(确保已经收到了),从缓存中剔除,避免oom。

实现过程

这个方案前端的实现比较简单,不再赘述。下面着重写一下后端的实现。

首先我们要定义一个缓存服务接口,他需要实现的基本方法显然有三个,Get,Set,GetAll。

type CacheServer interface {
 Get(key string) []messageCache
 Set(receiver string, message string, time time.Time) error
 GetAll() []messageCache
}

复制

定义服务结构,包括服务名,服务缓存,缓存超时时间。

type cache struct {
    Name    string
    Cache   []messageCache
    Timeout time.Duration
}

复制

定义缓存结构。包含接收者,消息体,消息发送时间。

type messageCache struct {
    receiver string
    message  string
    time     time.Time
}

复制

其实定义完接口和结构体,任务就完成了一半了。接下来只要按照接口定义填一些实现。

Set方法实现,这里没有直接传入messageCache 类型的数据,也是因为不想把包内的数据类型扩散出去,外部调用不必知道包内的数据结构。每当读写数据时,先清理掉已经超时的数据。

func (s *cache) Set(receiver string, message string, sendtime time.Time) error {
	s.clearOutTimeCache()
	value := messageCache{
		receiver: receiver,
		message:  message,
		time:     sendtime,
	}
	s.Cache = append(s.Cache, value)
	return nil
}

复制

GetAll 方法实现,清理完超时数据后,直接返回剩余的全部缓存。

func (s *cache) GetAll() []messageCache {
	s.clearOutTimeCache()
	return s.Cache
}

复制

Get方法实现,在GetAll的基础上,加入了key值的判断,并且在用户获取完数据后,清理掉该key值的缓存。

func (s *cache) Get(key string) []messageCache {
	s.clearOutTimeCache()
	rlt := make([]messageCache, 1)
	newCache := make([]messageCache, 1)
	for i := 0; i < len(s.Cache); i++ {
		if s.Cache[i].receiver == key {
			rlt = append(rlt, s.Cache[i])
		} else {
			newCache = append(newCache, s.Cache[i])
		}
	}
	s.Cache = newCache
	return rlt
}

复制

clearOutTimeCache实现,把超时的缓存剔除出去。

func (s *cache) clearOutTimeCache() {
	for startindex := 0; startindex < len(s.Cache); startindex++ {
		if time.Now().Sub(s.Cache[startindex].time) < s.Timeout {
			s.Cache = s.Cache[startindex:]
			break
		} else if startindex == (len(s.Cache) - 1) {
			s.Cache = make([]messageCache, 0)
		}
	}
}

复制

我们的包还得暴露一个新建实例的方法给外部,一共两个参数,实例名,超时时间。

func NewCache(name string, timeout time.Duration) CacheServer {
	return &cache{
		Name:    name,
		Cache:   make([]messageCache, 1),
		Timeout: timeout,
	}
}

复制

好了这样我们的一个简单的cacheServer包就完成了。下面写一个测试代码来看一下效果。

直接把main文件贴上来了。两个goroutine,一个启动服务,一个模拟websocket消息发送。启动的服务三个接口,一个health页面,一个根据key获取缓存message,一个获取所有缓存。好运行下代码看看。

package main

import (
	"cacheServer/cacheServer"
	"fmt"
	"math/rand"
	"net/http"
	"strconv"
	"time"
)

func main() {
	MessageCache := cacheServer.NewCache("MessageCache", time.Second*5)
	go Start(":8080", MessageCache)
	go MockWebSocketMessage(MessageCache)
	select {}
}

func MockWebSocketMessage(cache cacheServer.CacheServer) {
	rand.Seed(time.Now().UnixNano())
	var i int = 0
	for {
		time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))
		var receiver string
		if i%2 == 0 {
			receiver = "HYC"
		} else {
			receiver = "ZMN"
		}
		message := "send message " + strconv.Itoa(i) + " times"
		cache.Set(receiver, message, time.Now())
		i = i + 1
	}
}

func Start(Port string, cache cacheServer.CacheServer) error {
	mux := http.NewServeMux()
	mux.HandleFunc("/", health)
	mux.HandleFunc("/syncCache", syncCache(cache))
	mux.HandleFunc("/getCache", getCache(cache))
	svr := &http.Server{Addr: Port, Handler: mux}
	err := svr.ListenAndServe()
	return err
}

func health(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "server work")
}

func getCache(cache cacheServer.CacheServer) func(w http.ResponseWriter, r *http.Request) {
	fmt.Printf("getCache Success\n")
	return func(w http.ResponseWriter, r *http.Request) {
		key := GetUrlArg(r, "key")
		fmt.Fprintf(w, "Cache Key:%s,%v", key, cache.Get(key))
	}
}

func syncCache(cache cacheServer.CacheServer) func(w http.ResponseWriter, r *http.Request) {
	fmt.Printf("syncCache Success\n")
	return func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "All Cache:%v \n", cache.GetAll())
	}
}

func GetUrlArg(r *http.Request, name string) string {
	var arg string
	values := r.URL.Query()
	arg = values.Get(name)
	return arg
}

复制

先查询全部缓存,等一会查询一次,确保缓存数据正常被清除。咱们NewCache时传入的超时时间是5秒,模拟随机发送消息是1-1000毫秒,因此预期查到的缓存数量在8-12条之间。随着刷新可以看到过期的缓存被清除。

查询全部缓存

再试试根据key查询,因为模拟了两个人在发消息,因此单个人预计在4-6条左右,并且清理掉已查询的数据,快速刷新两次应该第二次只能查到0-2条。

根据key查询

快速刷新两次第二次只查到两条

TODO

这样一个简单的cacheServer就完成了,测试下来数据也正确。那么还有哪些未完成的工作呢。

首先我们的服务大概率是集群部署的,在服务内部使用缓存不可避免的存在同步问题。之前可能大家会疑惑为啥通过key获取的缓存要清除掉,获取所有的缓存就不用清理掉。因为获取所有缓存的接口是准备留给服务器之间同步用的。我们不会允许用户去获取其他用户收到的消息。

其次是cacheServer里messageCache不是一个良好的定义,receiver和message与websocket消息的含义耦合太紧,可以换为更松散的定义。

messageCache定义修改

// type messageCache struct {
// 	receiver string
// 	message  string
// 	time     time.Time
// }

type cacheValue struct {
	key   string
	value interface{}
	time  time.Time
}

复制

把messageCache修改为更松散的key-value的结构,并且不再限定value的类型。

Set 方法修改

func (s *cache) Set(key string, kvalue interface{}) error {
	s.clearOutTimeCache()
	value := cacheValue{
		key:   key,
		value: kvalue,
		time:  time.Now(),
	}
	s.Cache = append(s.Cache, value)
	return nil
}

复制

把time生成移到包内部,不再由外部传入,以免出现外部传入时未按时间顺序牌序,可能导致缓存清理时保留过多的数据。

集群同步策略

这边采用redis来记录一下最后更新的集群ip。外面包一层服务简单处理一下。

package cacheServer

import (
	"github.com/go-redis/redis"
)

type redisManager struct {
	Name   string
	client *redis.Client
}

type RedisServer interface {
	Get(key string) (string, error)
	Set(key string, value string) error
}

func NewClient(name string, addr string, password string, db int) RedisServer {
	client := redis.NewClient(&redis.Options{
		Addr:     addr,
		Password: password,
		DB:       db,
	})
	return &redisManager{
		Name:   name,
		client: client,
	}
}

func (s *redisManager) Get(key string) (string, error) {
	return s.client.Get(key).Result()
}

func (s *redisManager) Set(key string, value string) error {
	return s.client.Set(key, value, 0).Err()
}

复制

同样我们的cacheServer在Get,Set方法中要加入对redis的读写,key取缓存服务名即可。

当set的时候,在redis中写入最后更新的缓存的IP,在get的时候,根据查出的ip更新缓存。

func (s *cache) SetLastWriterToRedis() error {
	return s.RedisClient.Set(s.Name, s.LocalIp)
}

复制

func (s *cache) GetLastWriterFromRedis() {
	IP, err := s.RedisClient.Get(s.Name)
	if err == nil {
		if IP != s.LocalIp {
			resp, err := http.Get(IP + "/syncCache")
			if err != nil {
				return
			}
			defer resp.Body.Close()
			body, _ := ioutil.ReadAll(resp.Body)
			var res []cacheValue
			json.Unmarshal([]byte(body), &res)
			s.Cache = res
		}
	}
}

复制

相应的在New我们的CacheServer的时候需要注入redisClinent实例,这里不传入redis参数在包内建立redis连接,因为可能会存在一个服务建立多个缓存服务的情况,不用在每个缓存服务里分别去新建连接。

func NewCache(name string, timeout time.Duration, localIp string, RedisClient RedisServer) CacheServer {
	return &cache{
		Name:        name,
		Cache:       make([]cacheValue, 0),
		Timeout:     timeout,
		RedisClient: RedisClient,
		LocalIp:     localIp,
	}
}

复制

同样我们在每次Get前先从redis get一下,在set之后也调用一下redis里的set,这里make切片的地方也做了一下修改,原来1的话会多出一条空的数据。需要注意的是get的时候也更新了缓存,所以也需要set一下redis。

func (s *cache) GetAll() ([]cacheValue, error) {
	s.GetLastWriterFromRedis()
	s.clearOutTimeCache()
	return s.Cache, nil
}

func (s *cache) Get(key string) ([]cacheValue, error) {
	s.GetLastWriterFromRedis()
	s.clearOutTimeCache()
	rlt := make([]cacheValue, 0)
	newCache := make([]cacheValue, 0)
	for i := 0; i < len(s.Cache); i++ {
		if s.Cache[i].Key == key {
			rlt = append(rlt, s.Cache[i])
		} else {
			newCache = append(newCache, s.Cache[i])
		}
	}
	s.Cache = newCache
	s.SetLastWriterToRedis()
	return rlt, nil
}

func (s *cache) Set(key string, kvalue interface{}) error {
	s.GetLastWriterFromRedis()
	s.clearOutTimeCache()
	value := cacheValue{
		Key:   key,
		Value: kvalue,
		Time:  time.Now(),
	}
	s.Cache = append(s.Cache, value)
	s.SetLastWriterToRedis()
	return nil
}

复制

开始测试!

缓存代码写完了那么接下来进行测试。直接把main贴上来,这边我们New两个Cache服务注册在两个端口上,模拟两个服务。

模拟消息全部发送在8080端口上,然后访问8081端口的接口,看看数据有没有正确同步。

package main

import (
	"cacheServer/cacheServer"
	"encoding/json"
	"fmt"
	"math/rand"
	"net/http"
	"strconv"
	"time"
)

func main() {
	RedisClient := cacheServer.NewClient("localhost:8080", "localhost:6379", "", 0)
	MessageCache1 := cacheServer.NewCache("MessageCache", time.Second*5, "http://localhost:8080", RedisClient)
	MessageCache2 := cacheServer.NewCache("MessageCache", time.Second*5, "http://localhost:8081", RedisClient)
	go Start(":8080", MessageCache1)
	go Start(":8081", MessageCache2)
	go MockWebSocketMessage(MessageCache1)
	select {}
}

func MockWebSocketMessage(cache cacheServer.CacheServer) {
	rand.Seed(time.Now().UnixNano())
	var i int = 0
	for {
		time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))
		var receiver string
		if i%2 == 0 {
			receiver = "HYC"
		} else {
			receiver = "ZMN"
		}
		message := "send message " + strconv.Itoa(i) + " times"
		if err := cache.Set(receiver, message); err != nil {
			fmt.Printf("cache Set Error:%s \n", err)
		}
		i = i + 1
	}
}

func Start(Port string, cache cacheServer.CacheServer) error {
	mux := http.NewServeMux()
	mux.HandleFunc("/", health)
	mux.HandleFunc("/syncCache", syncCache(cache))
	mux.HandleFunc("/getCache", getCache(cache))
	svr := &http.Server{Addr: Port, Handler: mux}
	err := svr.ListenAndServe()
	return err
}

func health(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "server work")
}

func getCache(cache cacheServer.CacheServer) func(w http.ResponseWriter, r *http.Request) {
	return func(w http.ResponseWriter, r *http.Request) {
		key := GetUrlArg(r, "key")
		value, err := cache.Get(key)
		if err != nil {
			fmt.Printf("cache Get Error:%s \n", err)
		}
		fmt.Fprintf(w, "Cache Key:%s,%v \n", key, value)
	}
}

func syncCache(cache cacheServer.CacheServer) func(w http.ResponseWriter, r *http.Request) {
	return func(w http.ResponseWriter, r *http.Request) {
		value, err := cache.GetAll()
		if err != nil {
			fmt.Printf("cache GetAll Error:%s \n", err)
		}
		res, err := json.Marshal(value)
		if err != nil {
			fmt.Printf("cache json Error:%s \n", err)
		}
		w.Write(res)
	}
}

func GetUrlArg(r *http.Request, name string) string {
	var arg string
	values := r.URL.Query()
	arg = values.Get(name)
	return arg
}

复制

当然我们也没有真的去启动一个redis服务。直接mock掉了redis的get set方法。因此在8081服务get之后,缓存不会同步回8080服务。

func (s *redisManager) Get(key string) (string, error) {
	return "http://localhost:8080", nil
	//return s.client.Get(key).Result()
}

func (s *redisManager) Set(key string, value string) error {
	return nil
	//return s.client.Set(key, value, 0).Err()
}

复制

同步的真不戳

好那么缓存同步也完成了。

TODO

这回加上了缓存同步,那么问题来了,对于咱们的websocket消息缓存来说,真的需要在不同服务间同步缓存吗?可不可以不同步。当然是可以的。我们在消息进来时,就可以根据key对消息进行划分,存进不同的缓存,查找的时候也直接去对应的缓存查找就行了啊。(比如key都是数字id的话,我们有十台服务器集群,那么可以直接id % 10 这样去存缓存)。

第一个问题的提出是表明我们可以去针对业务进行优化。那么第二个问题就是我们现有的cacheService里的get方法,自动清理掉已读内容的操作,又是与业务强耦合的。

我们可以进一步的把业务逻辑从cacheServer中提取出去,然后新增一层业务层来调用cacheServer。这样我们就可以有一层通用的缓存处理逻辑。然后在业务层中去写业务逻辑。

CacheServer优化

这时候我发现了SynCache这个接口并不需要被外部调用,在Get Set方法时,已经在内部处理了缓存。

因此我们可以直接把这个接口干掉。

针对问题二,我们可以抽象出一个Clear的接口,入参是key,清理掉cache中所有key值的消息。

type CacheServer interface {
	Get(key string) ([]cacheValue, error)
	Set(key string, kvalue interface{}) error
	Clear(key string) error
	GetAll() ([]cacheValue, error)
}

复制

func (s *cache) Clear(key string) error {
	s.GetLastWriterFromRedis()
	s.clearOutTimeCache()
	newCache := make([]cacheValue, 0)
	for i := 0; i < len(s.Cache); i++ {
		if s.Cache[i].Key != key {
			newCache = append(newCache, s.Cache[i])
		}
	}
	s.Cache = newCache
	s.SetLastWriterToRedis()
	return nil
}

复制

那我们在业务层当中的调用也可以修改为Get,然后Clear。这样做有没有问题呢?

当然抽象出这个方法是没有问题的。但是在业务层中连续调用就有问题了。因为业务层可能存在并发的读写,cache在读写时都是需要加锁的(当然我现在还没加上),在业务层调用时,在Get之后,Clear之前锁是会放开的。此时如果有消息写入,并且key正好是clear的key。那么消息就会在没有被get到的情况下clear掉。

好那么说到锁我们就把锁加上。一样加入cache,New的时候加上。对外提供的Get Set Getall,Clear四个方法里加上,defer解锁。需要注意一下不要加进GetLastWriterFromRedis这些内部互相调用的方法里去,不然就直接死锁了。

type cache struct {
	Name        string
	Cache       []cacheValue
	LocalIp     string
	Timeout     time.Duration
	RedisClient RedisServer
	Lock        sync.Mutex
}

复制

func NewCache(name string, timeout time.Duration, localIp string, RedisClient RedisServer) CacheServer {
	return &cache{
		Name:        name,
		Cache:       make([]cacheValue, 0),
		Timeout:     timeout,
		RedisClient: RedisClient,
		LocalIp:     localIp,
		Lock:        sync.Mutex{},
	}
}

复制

	s.Lock.Lock()
	defer s.Lock.Unlock()

复制

Config实现

既然上面说在业务层实现会有漏洞。那么我们可以想到在cacheServer内部引入config,在New的时候传入config,这样就能满足不同业务逻辑的需求。

为了满足可选Config传入的需求,我们定义了一种配置Config的函数。

type option func(*cache)

复制

在我们的NewCache时,必选参数还是用原来的方式传入,可选参数先给上一个缺省值,并由最后传入的options方法来修改。

func NewCache(name string, timeout time.Duration, localIp string, RedisClient RedisServer, options ...option) CacheServer {
	newCache := cache{
		Name:               name,
		Cache:              make([]cacheValue, 0),
		Timeout:            timeout,
		RedisClient:        RedisClient,
		LocalIp:            localIp,
		Lock:               sync.Mutex{},
		ClearAfterGet:      false,
		SyncFromOtherCache: false,
	}
	for _, option := range options {
		option(&newCache)
	}
	return &newCache
}

复制

这边我们有几个可选参数就定义几个方法供外部调用。

func DialClearAfterGet(sign bool) option {
	return func(c *cache) {
		c.ClearAfterGet = sign
	}
}

func DialSyncFromOtherCache(sign bool) option {
	return func(c *cache) {
		c.SyncFromOtherCache = sign
	}
}

复制

在main函数中修改

MessageCache := cacheServer.NewCache("MessageCache", time.Second*5, "http://localhost:8080", RedisClient, cacheServer.DialClearAfterGet(true), cacheServer.DialSyncFromOtherCache(true))

复制

这样我们的config配置就基本上完成了。在包内加上对config的判断。

func (s *cache) Get(key string) ([]cacheValue, error) {
	s.Lock.Lock()
	defer s.Lock.Unlock()
	s.getLastWriterFromRedis()
	s.clearOutTimeCache()
	rlt := make([]cacheValue, 0)
	if s.ClearAfterGet {
		newCache := make([]cacheValue, 0)
		for i := 0; i < len(s.Cache); i++ {
			if s.Cache[i].Key == key {
				rlt = append(rlt, s.Cache[i])
			} else {
				newCache = append(newCache, s.Cache[i])
			}
		}
		s.Cache = newCache
	} else {
		for i := 0; i < len(s.Cache); i++ {
			if s.Cache[i].Key == key {
				rlt = append(rlt, s.Cache[i])
			}
		}
	}
	s.setLastWriterToRedis()
	return rlt, nil
}

复制

这里直接把对配置项的处理写到最后调用的方法里了。避免配置项的判断都堆叠在一起。

func (s *cache) getLastWriterFromRedis() {
	if s.SyncFromOtherCache {
		IP, err := s.RedisClient.Get(s.Name)
		if err == nil {
			if IP != s.LocalIp {
				resp, err := http.Get(IP + "/syncCache")
				if err != nil {
					return
				}
				defer resp.Body.Close()
				body, _ := ioutil.ReadAll(resp.Body)
				var res []cacheValue
				json.Unmarshal([]byte(body), &res)
				s.Cache = res
			}
		}
	}
}

func (s *cache) setLastWriterToRedis() error {
	if s.SyncFromOtherCache {
		return s.RedisClient.Set(s.Name, s.LocalIp)
	} else {
		return nil
	}
}

复制

好了跑一下代码试了一下没有问题。

这样我们一个简单的Config配置的功能也实现了。