前言
上一篇文章,实现了一个基础的消息队列,事实上没啥用,我打算用它,开发一个能支持远程连接订阅的消息队列服务,这样才会有一点用
代码在这里
https://github.com/woshidajj/woshidajj-mq
大致的想法
1)建立连接就照搬RPC服务那一套,服务端启动一个HTTP服务,客户端发起一个HTTP的CONNECT方法的请求(请求带上订阅的topic),连接上以后,为这个客户在消息队列服务(msgqueue)进行订阅管理
2)服务端提供一个http的接口,方便发布消息,访问一个这个接口,就可以给某个topic发布消息,方便测试
3)连接建立起来就要通讯,通讯要有固定的消息格式,我希望他是二进制安全的,这样客户端可以不用必须是go或者什么域名,只要能自己解析出来即可,我决定模仿redis中RESP协议中批量字符串的实现,来设计这个消息
4)远程订阅者,是一个actor,他有自己的actorWorker,他的功能就是把消息发送给客户端
5)应该需要有一个保活机制,保证远程订阅者是可用的。有2种方案实现,1是服务端定时发送消息给客户端看下是不是活的,2是客户端定时告诉服务端它是活的。在服务端的角度,一个是用写流,一个是用读流。但是消息队列主要是发布消息给用户,所以写流会比较繁忙,而读流基本没用。所以,这个保活的责任应该交给客户端,客户端需要定期发送一个PONG消息给服务端,如果服务端一定时间没收到,就会主动关闭取消订阅,回收资源(包括停止actor的协程)
通讯
1)消息包含“命令Command”,“命令体Body”,例如,
命令Command=MSG,Body=123456,用户收到就知道这是服务端发布的消息,消息的内容是123456
命令Command=PONG,没有Body,这个是客户端保活消息,服务端收到知道这个客户端是“活的”
命令Command=ERR,Body=XXX,这个是表示错误消息,错误的内容是XXX
2)用一个Payload结构体承载一条消息
type Payload struct {
Command string
Bodylen int64
Body []byte
Err error
}
3)发送消息,会把Payload转成流,要二进制安全(主要是靠Bodylen字段),不知道怎么解释,直接上例子
假设Payload{Command : MSG,Bodylen : 6,Body : []byte("123456")}
转成流是MSG\r\n6\r\n123456\r\n
然后假设Payload{Command : PONG}
转成流是PONG\r\n
4)从流里获取消息
先从流里读取到\n就结束,获取到Command,然后根据Command的类型,知道它有没有Body,如果没有Body,得到一条完整的消息。如果有Body,就再从流读取到\n结束,获取到Bodylen,然后知道Bodylen以后,从流里固定读取这个多个字节,就是Body,Body是二进制安全的。
实现远程订阅者actorWorker
(1)远程订阅者有一个连接conn,调用Worker处理消息时,把消息转成字节数组,然后写入conn
type RemoteActorWorker struct {
conn net.Conn
}
func (w *RemoteActorWorker) Work(m interface{}) error {
v, ok := m.(Payload)
if ok {
b, err := v.ToBytes()
if err != nil {
fmt.Println("----------start-----------")
fmt.Println("CHANGE BYTE FAIL")
fmt.Println("----------end-----------")
return err
}
w.conn.Write(b)
} else {
fmt.Println("----------start-----------")
fmt.Println("INVALID M")
fmt.Println("----------end-----------")
}
return nil
}
服务端
服务端开启了一个http服务,有2个路由/sub,/pub,/sub用来让远程客户端订阅,/pub用来发布消息
1)订阅,客户端向/sub发起一个http请求(method=connect),需要带上query参数t,t表示订阅的topic。服务端接收到请求,建立连接以后,新建一个actor(他的actorWorker是remoteActorWorker),放入订阅列表,启动actor的协程。然后响应“200 Connected to Woshidajj Msg Queue“
2)建立连接,生成订阅者,放入订阅列表,以后,从流里持续读取客户端的消息(PONG消息),如果很久没收到,就要主动取消订阅,回收资源,结束连接
3)发布消息,请求/pub?t=topic&m=abc,服务端就会向订阅topic的人,发布消息abc
const (
pongDuration = time.Second * 5
pongLimit = 3
routeSub = "/sub"
routePub = "/pub"
queryTopic = "t"
queryMsg = "m"
connectSuccMSg = "200 Connected to Woshidajj Msg Queue"
)
type Server struct {
address string
mq *msgqueue.MsgQueue
exitC chan struct{}
}
func NewServer(address string, mq *msgqueue.MsgQueue) (*Server, error) {
s := &Server{address: address, mq: mq}
return s, nil
}
func (server *Server) handleHttpSub(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
select {
// wait for exit signal
case <-server.exitC:
io.WriteString(w, "503 MQ EXIT\n")
return
default:
}
if req.Method != "CONNECT" {
w.WriteHeader(http.StatusMethodNotAllowed)
io.WriteString(w, "405 NOT CONNECT METHOD\n")
return
}
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
log.Print("hijacking ", req.RemoteAddr, ": ", err.Error())
io.WriteString(w, "503 CAN'T CONNECT\n")
return
}
defer conn.Close()
query := req.URL.Query()
topic := query.Get("t")
if topic == "" {
io.WriteString(w, "503 NEED QUERY t\n")
return
}
actor := msgqueue.NewActor(10, &RemoteActorWorker{conn: conn})
_, err = server.mq.Subscribe(topic, actor)
if err != nil {
io.WriteString(w, fmt.Sprintf("503 SUB FAIL\n"))
return
}
defer server.mq.Unsubscribe(topic, actor)
io.WriteString(conn, "HTTP/1.0 "+connectSuccMSg+"\n\n")
runSuber(conn, actor.ExitC)
}
func (server *Server) handleHttpPub(w http.ResponseWriter, req *http.Request) {
query := req.URL.Query()
topic := query.Get(queryTopic)
msg := query.Get(queryMsg)
if topic == "" || msg == "" {
fmt.Fprintf(w, "NEED QUERY t AND m")
return
}
msgByte := []byte(msg)
msgPl := Payload{Command: cmdMsg, Bodylen: int64(len(msgByte)), Body: msgByte}
server.mq.Publish(topic, msgPl)
fmt.Fprintf(w, "publish-"+topic+"-"+msg)
}
func (server *Server) Start() error {
http.HandleFunc(routeSub, server.handleHttpSub)
http.HandleFunc(routePub, server.handleHttpPub)
listener, err := net.Listen("tcp", server.address)
if err != nil {
log.Fatal("启动服务监听失败:", err)
return err
}
err = http.Serve(listener, nil)
if err != nil {
log.Fatal("启动 HTTP 服务失败:", err)
return err
}
return nil
}
func runSuber(conn net.Conn, exitC chan struct{}) {
reader := bufio.NewReader(conn)
payloadC := make(chan *Payload)
stopC := make(chan struct{})
// 开启协程,从conn里读取数据,组装成payload,放进payloadC
go ParseStream(reader, payloadC, stopC)
// 定时器,定时检测上次接收到PONG的时间
idleDuration := pongDuration
idleDelay := time.NewTimer(idleDuration)
var pong int
for {
idleDelay.Reset(idleDuration)
select {
// 监听从conn读取的payload,进行相应处理,
case p, ok := <-payloadC:
// chan被关闭,退出
if !ok {
return
}
if p.Err != nil {
fmt.Printf("RECV CLIENT ERR %s \n", p.Err)
} else if p.Command == cmdPong {
// 如果是心跳消息PONG,要重置pong的数值
pong = 0
fmt.Printf("PONG \n")
}
case <-idleDelay.C:
// 超过5次没收到PONG,关闭parse协程
if pong > pongLimit {
fmt.Printf("CLIENT TIME UP \n")
// 关闭parse协程
close(stopC)
return
} else {
pong++
}
case <-exitC:
// mq服务主动关闭了订阅者actor的协程,要主动退出
fmt.Println("SUBER EXIT")
// 关闭parse协程
close(stopC)
return
}
}
}
启动服务
客户端
客户端就是建立连接,从流里获取消息,然后定时给服务端发送一个PONG消息告诉服务器自己还活着(主要看Run函数),比较简单
import (
"bufio"
"errors"
"fmt"
"io"
"net"
"net/http"
"time"
)
type client struct {
conn net.Conn
}
func NewClient(topic string, conn net.Conn) (*client, error) {
path := routeSub + "?t=" + topic
_, err := io.WriteString(conn, "CONNECT "+path+" HTTP/1.0\n\n")
if err != nil {
return nil, err
}
resp, err := http.ReadResponse(bufio.NewReader(conn), &http.Request{Method: "CONNECT"})
if err != nil {
return nil, err
}
if resp.Status != connectSuccMSg {
return nil, errors.New("unexpected HTTP response: " + resp.Status)
}
return &client{conn: conn}, nil
}
func (c *client) Run() {
reader := bufio.NewReader(c.conn)
payloadC := make(chan *Payload)
stopC := make(chan struct{})
go ParseStream(reader, payloadC, stopC)
// 定时器,定时检测上次接收到PONG的时间
idleDuration := pongDuration
idleDelay := time.NewTimer(idleDuration)
for {
idleDelay.Reset(idleDuration)
select {
case p, ok := <-payloadC:
// chan被关闭,退出
if !ok {
fmt.Printf("plC chan close \n")
close(stopC)
return
}
m, err := p.ToBytes()
if err != nil {
fmt.Printf("err pl %s \n", err)
} else {
fmt.Println("-----------print msg start---------")
fmt.Printf("recv %s \n", m)
fmt.Println("-----------print msg end---------")
}
case <-idleDelay.C:
fmt.Printf("PONG \n")
sp := Payload{Command: cmdPong}
b, _ := sp.ToBytes()
_, err := c.conn.Write(b)
if err != nil {
close(stopC)
fmt.Printf("sth wrong %s \n", err)
}
}
}
}
启动客户端
一些问题
(1)这个保活机制,其实是我自己想的,不知道有没有问题
(2)还是我非常怕的问题,就是会不会有协程泄露
(3)规范,我不懂规范,请大家指出问题
(4)这些设计,到底有没有问题,不知道