使用WebSocket通讯时如何设计应用层的数据协议

为何选择WebSocket


我们知道,在B/S系统里,基本上都是使用 RESTful 协议来传输数据,虽然没有明文规定要这么做,但是经过多年的实践, RESTful 协议已经成为了B/S系统数据传输的事实标准。


但是我们知道,在有些场合的应用,我们不能使用B/S模式,系统需要用C/S模式来实现,这时数据通讯上我们的选择就有几种了,你可以继续选择使用 RESTful 传数据, 也可以不用它,其中有一个选项就是我们熟知的WebSocket。 实际上,当我们的后台系统不需要提供HTTP访问时,我们完全可以不用RESTful, 而使用WebSocket是一个很好的选择,使用WebSocket功能上更契合C/S的使用场景,并且设计框架可以做得更简洁紧凑。


实现同样的功能,用 WebSocket比用 RESTful 要节约一半的代码,更重要的是,它能让程序结构更清晰,简单明了,没有那么多的HTTP方面的条条框框,什么(GET/PUT/POST/DELETE、HTTP Header,Content-Type,JWT, Filter 啥的),使用WebSocket协议的程序维护和扩展也更简单方便。


数据结构设计

这里我们要感谢一下WebSocket的发明人,将纷繁复杂的Socket通讯浓缩成了一个很简单的协议。这样对上层开发人员屏蔽了实现细节,让我们可以更专注自己的业务逻辑,不用为底层通讯费脑细胞。至于这个协议怎么实现的,世界上有大把的大神为我们做好了协议框架。

WebSocket传输的协议比较简单,直来直去,打开连接、收、发、关闭。我们用到的主要就是收和发,可以是文本数据,也可以是二进制。
二进制的场景我们先不谈,这里只谈使用最广泛的文本数据。
因为协议本身就是一坨数据文本传来传去,没有应用含义,所以我们需要进一步定义应用层的协议。
要让程序知道 一坨文本是干嘛的,我们首先要定义一个命令的名字,我们叫它action,放在最前面,然后请求方给出一个交易号,便于回传后识别是哪一次请求的并做相应的处理,这个交易号我们称之为txNo, 后面就跟消息体data了, data一般都是json字符串。这3段数据我们用一个特殊的符号分隔,比如我们用两个美元符号$$,一般的数据很少有2个美元符号并排的吧。
整体数据结构就是这样:(因为CSDN显示问题,这里用全角的符号)

action$$txNo&$$data

比如一个登录请求,我们可以这样传数据:

LOGIN$​​​​​​​$1725876358993$​​​​​​​${"username":"003","password":"123456"}

如果登录通过,服务端的回应可以是这样:

LOGIN$​​​​​​​$1725876358993$​​​​​​​${"code":200,"msg":"Success","data":{"userId":4, "username":"003", "realName":"王老五"}}

如果没通过,服务端的回应可以是这样:

LOGIN$​​​​​​​$1725876358993$​​​​​​​${"code":500, "msg":"用户名或密码错误"}

注意,按照我们的设计要求,回应里的action和txNo应该与请求时的相同。

接收到消息后,我们把这3部分解析出来,根据action名称分发到各自的处理程序里去。这样就从框架上把具体的处理逻辑切割开了。

有了这样的思路后,我们开始程序框架设计了。

服务端实现


WebSocket是中性的协议,意思是与语言无关,你可以用的市面上的任何主流语言实现它,这里服务端我们选择C#语言,实际上用其它的语言也可以,大同小异。
同样的,我们也不想重复造轮子,不想从协议底层开始实现,我们只是要使用协议,怎么快速把项目搞出来。既然WebSocket是通用协议,而且已经推出很多年了,世界上一定有成千上万的人在用,这其中一定有各种各样的大神活雷锋给我们做出了很好的轮子,
每种语言应该都有,我们查一下,C#就有很多,.NET 4.5框架也自带了,第三方的有Fleck,WebsocketSharp等,综合考虑这里我们选择使用Fleck, 因为它使用起来最简单。简单到不能再简单了。
我们只需要关注自己的业务逻辑就好了。


我们把启动服务放到MainForm里,当然,你也可以把这块代码独立处理做成一个独立的Windows服务都可以。服务启动:

 public partial class MainForm : Form
 {
  private static readonly ILog log = LogManager.GetLogger(typeof(MainForm));
  private readonly List<IWebSocketConnection> connections = [];//连接会话
  public MainForm()
  {
      InitializeComponent();

      var server = new WebSocketServer("ws://0.0.0.0:5000");
      
      server.Start(socket =>
      {
          socket.OnOpen = () =>
          {
              log.Info("Client Open!");
              connections.Add(socket);
          };
          socket.OnClose = () =>
          {
              log.Info("Client Closed");
              connections.Remove(socket);
          };
          socket.OnMessage = message =>
          {
              log.Info(message);
              string response = APIService.Process(message);
              log.Info(response);
              socket.Send(response);
          };
          socket.OnBinary = binary =>
          {
              log.Info("==Binary data length=" + binary.Length);
              //you can process binary data here.
          };
      });
  }
}


是不是简单明了? 主要方法就是OnMessage, 我们得到一个message然后处理完后得到一个response回应串,回传给请求方。
下面我们进一步说明代码中的这个APIService.Process具体干了什么。

数据对象定义

我们上面规划的协议里,第3段是具体的Json数据,需要把它转为对象后才能方便使用。
这里说的对象其实就是一些数据对象,不带任何业务逻辑的。一般我们称他们为Value Object, 简称VO。
请求信息就是一个个单独的业务VO对象,而对于回应信息,为了让数据和程序更规整,我们先设计一个统一的范式。


什么叫统一的范式,看一下下面的设计范例。 

public class BaseResult
 {
     public int code;
     public string msg;
 }

 public class ObjResult<T> : BaseResult
 {
     public T data;
 }

 public class ListResult<T> : BaseResult
 {
     public List<T> data;
 }

 public class PageResult<T> : ListResult<T>
 {
     public long total;
     public int pageIndex;
     public int pageSize;
 }

先有一个顶层的VO, 或者叫基类对象,BaseResult, 带一个code 一个msg,因为任何请求的回应都首先需要知道成功没,如果没成功有什么错误信息。

对于查询请求,如果成功了,就带上具体的VO对象,因为是设计框架,这个具体的对象我们作为泛型<T>放到这些通用的回应对象里。


 回应无非就是几种,一种是增删改,回应只要一个BaseResult就够了,对于查询,如果是请求的单个对象信息,我们就用ObjResult ,如果是对象列表,我们就用ListResult, 如果是分页信息,我们就用PageResult。

这里的VO属性我们使用了C#里的public Field 而不是Property, 主要是为了让程序更简洁,因为我们就是当纯VO使用的,并不打算在get/set方法里写逻辑。另外C#中的Property的语法第一个字母要大写,这跟Java一类的客户端对接不方便,容易混淆。

这个设计范式用任何语言都是相通的,可以原封不动拿到Java里也能用。

到这一步,我们的程序整体框架就基本成型了。下面我们回到上面的那行  string response = APIService.Process(message); 看看具体怎么做的。

流程处理框架

这个Process就是具体切割内容后,分发的具体的业务方法处理。   

public static string Process(string request)
        {
            object responseObj = null;
            string[] str = request.Split(new[] { "$$" }, StringSplitOptions.None);
            string action = str[0];
            string txNo = str[1];
            string content = str[2];

            if (action == "LOGIN")
            {
                var loginRequest = JsonConvert.DeserializeObject<LoginRqData>(content);
                responseObj = Login(loginRequest);
            }
        //else if(action=="XXX")
        //{
        // process other action
        //}
           
            string jsonResponse = "";
            if (responseObj != null)
            {
                jsonResponse = JsonConvert.SerializeObject(responseObj);
                log.Debug("===API response: " + jsonResponse);
            }
            return action + "$$" + txNo + "$$" + jsonResponse;
        }


比如这个登录操作,我们收到客户端信息,切割完数据发现action是LOGIN, 就分发到业务方法Login处理,当然分发前,需要把那段json数据反序列化为VO后作为参数传入业务方法。

处理完我们把action txNo会回应对象VO组合起来回传给客户端。

如果你的业务逻辑非常之多有几十上百个,一起写在这就不太合适了,不方便维护,这种情况你可以它拆开。然后用反射进行整合,就像Java里的Spring一样。笔者这个项目很小,就不搞那么复杂了,避免过度设计。

业务逻辑具体实现

接下来就是实现具体的业务方法了,这个纯粹就是自己的业务逻辑了。该怎么搞就怎么搞。
       

 static ObjResult<LoginRpData> Login(LoginRqData rq)
        {
            ObjResult<LoginRpData> r = new()
            {
                code = 402,
                msg = "用户名或密码不对"
            };
            DataRow dr = BizDB.GetUserByName(rq.username);
            try
            {
                if (dr != null)
                {
                    string hashedPassword = dr["password"].ToString();
                    bool isVerified = BCrypt.Net.BCrypt.Verify(rq.password, hashedPassword);
                    if (isVerified)
                    {
                        r.code = 200;
                        r.msg = "Success";
                        int userId = (int)dr["user_id"];                      
                        r.data = new LoginRpData()
                        {
                            userId = userId,
                            username = rq.username,
                            realName = dr["real_name"].ToString(),
                            //token = token,// we don't need a token on websocket.
                        };
                    }
                }
                log.Info(string.Format("==User {0} login success, device={1}", rq.username, rq.client));
            }
            catch (Exception e)
            {
                r.code = 500;
                r.msg = e.Message;
            }
            return r;
        }

对于LOGIN请求来说,请求VO和回应VO分别如下:

public class LoginRqData
   {
       public string username;
       public string password;
       public string client;//设备标识
       public string attrs;
   }

public class LoginRpData
  {
      public int userId;
      public string username;
      public string realName;
      public string token;// C/S模式不用这个属性
  }

如果要加其它的操作,只要定义一个action和具体的VO就可以新增一个访问操作了。
要加一个信息操作,action和具体的VO定义本来就是我们该干的事情,可以轻松完成。

到这里,我们服务端的实现框架就已经完整了。


看到这里,有的聪明的小伙伴会提出一个问题:我能不能在分割的那里,最开始就把对象解析好,或者说做成一个统一的对象呢,那样是不是更简洁呢?

回答是,理论上那样程序会更规整,但实际上并不可行,因为客户端传过来的一坨文本,我们事先并不知道它是啥,只有知道了它是啥我们才决定把它送到哪个工厂加工。
一定要知道action是啥才能决定如何走后面的加工程序。所以我们只能老老实实先解析出action.切割步骤是必不可少的。

总结

这篇文章我们讲解了使用WebSocket时如何设计应用层数据协议及基本的实现框架。对于中小型项目,这个框架已经足够使用了。

如果不需要服务端提供HTTP服务,系统可以不使用RESTful传输数据,使用WebSocket是一个较好的选择。它能让程序框架设计简洁规整,便于扩展和维护。
WebSocket应用层的协议设计要点是设计好ACTION和通用回应对象及具体的业务VO。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值