go开发一个简单消息队列(二)-支持远程连接

前言

上一篇文章,实现了一个基础的消息队列,事实上没啥用,我打算用它,开发一个能支持远程连接订阅的消息队列服务,这样才会有一点用

 代码在这里

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)这些设计,到底有没有问题,不知道

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值