react搭建websocket通信架构

3,531 阅读13分钟

前言

随着跨端技术的发展,前端开发职能不再局限于浏览器,而是具备了很多客户端开发的能力,比如桌面应用框架Electorn,移动App框架React native.

一般而言,前端同学对http协议非常熟悉,在平时的工作中使用http与后端通信居多.但在原生客户端领域,比如Java语言开发的安卓应用,与后端通信的方式大多采用socket.

众所周知,http连接是一种短连接,即客户端向服务器端发送一次请求,服务器端响应后连接即会断掉.而socket连接是一种长连接,理论上客户端和服务器端一旦建立起连接将不会主动断掉.

前端领域存在一个和socket连接功能相似的通信协议,即WebSocket.WebSocket创建了一种持久性的连接,后端不仅能正常处理客户端发送的消息,还能主动向客户端推送消息.

后端主动推送消息的能力在一些特殊的场景中太重要了,比如App接受到的信息通知,即时通讯接受的好友信息,另外面板上实时展现波动的金融数据.

不管是桌面应用框架Electron,还是App开发框架React native,它们都拥有基于原生平台封装的WebSocket.比起浏览器端开放的WebSocket,原生平台提供的协议要稳定很多.

因此在使用前端技术开发客户端应用时,完全可以使用WebSocket协议作为前后端通信的主要方式,不再需要往项目中引入http,因为http拥有的能力WebSocket同样也能找到替代方案.

本文接下来将详细介绍用react hook开发一款客户端应用时,如何在项目中搭建有效的通信机制,让WebSocketredux有机结合,在不影响前端习惯的编程风格下,建立起客户端与服务器的全双工通信.

实现

数据格式

前后端约定连接建立后,使用WebSocket协议通信的数据格式(参考如下).

{
   request_id,
   command,
   data,  
}
  • request_id是一段随机生成的字符串,用来标识本次客户端向服务器请求的id值.
  • command是命令关键词,类似于接口中的url,后端根据此标识来决定返回接口数据.
  • data是发送的参数.

前端向后端发起请求时,以上3个参数都得携带,其他参数可根据业务需要增添.

后端主动向前端推送消息时,只需要发送commanddata参数即可.客户端监听command,根据值的不同进行相应的操作.

在整个项目的通信架构下,前端需要搭建好以下两种通信机制.

  • 客户端向服务器发送请求,服务器处理请求并返回响应结果,客户端接受响应结果再做后续处理.这种机制模拟了类似于前端ajax的通信方式,客户端除了发送请求,还要负责接受该请求的响应.

  • 服务器主动向客户端推送消息,客户端接受消息.

以上两种机制基本满足了开发的需要,接下来在项目实战中实现以上两种机制(源代码贴在了文章结尾).

登录功能

登录页如下,页面内容很简答,两个输入框,输入账号和密码.还有一个登录按钮.

鼠标点击登录按钮时,dispatch触发LoginAction,此时客户端向服务器发起登录请求,请求成功后进入then的回调函数,打印出登录成功并且路由跳转到首页home.

import { LoginAction } from "../../redux/actions/login"; 

export default function Login() {

  const dispatch = useDispatch();
  const history = useHistory();
  
  //省略
  ...
  
  //登录
  const login = ()=>{
    dispatch(LoginAction()).then(()=>{
      console.log("登录成功!");
      history.push("/home");
    })
  }

  return (
          <div>
            <ul>
              <li>登录账号:</li>
              <li>
                  <div><input onChange={updateUser} type="text" placeholder="请输入账号" /></div>
              </li>
              <li>登录密码:</li>
              <li>
                  <div><input onChange={updatePwd} type="text" placeholder="请输入密码" /></div>
              </li>
            </ul>
            <button onClick={login}>立即登录</button>
          </div>  
  )
}

现在进入LoginAction函数的内部实现,探索它是如何实现发起请求 - 接受响应(代码如下).

LoginAction内部调用了fetch函数,actiontype值为"PUSH_MESSAGE".

从这里大概可以推断出fetch内部调用了dispatch,派发了一个type"PUSH_MESSAGE"action.

另外fetch函数返回了一个Promise,在then回调函数里接受后端返回的响应.

import { fetch } from "./global";

// 组装action数据类型
const loginType = (username,password)=>{
    return {
        type:"PUSH_MESSAGE", // 实际开发中这里应该用变量替代
        value:{
            command:"login",
            data:{
                username,
                password
            }
        }
    }
}

export const LoginAction = ()=>(dispatch,getState)=>{
    const { username,password } = getState().login;
    return fetch({
        dispatch,
        action:loginType(username,password)
    }).then((response:any)=>{
       console.log("接受后端响应,请求成功!");
    })
}

由此可见,fetch函数它能实现向后端发起请求,并且在返回的then回调函数里获取响应的能力.

fetch函数代码如下,调用fetch时如果传递了第三个参数loading,就调用openLoading(dispatch),从而修改reducer中定义的一个全局状态loading,页面就可以根据loaidng值做加载中的样式变换.

fetch函数内部主要返回了一个Promise,核心代码便是将resolve赋予了action,那么这将意味着何时调用action.resolve(),fetch函数返回的then回调函数将何时执行.

代码接下来执行dispatch(action),将传递给fetch函数的参数action派发了.

从上面代码可知,派发的action.type的值为PUSH_MESSAGE,那么肯定在redux中有一个地方会监听到派发的action,并触发对后端的请求.


/**
 *  loading 决定本次请求需不需要让页面出现加载中的样式 
 */
export const fetch = ({dispatch,action,loading = false}) =>{
    loading && openLoading(dispatch); // 加载中
    return new Promise((resolve,reject)=>{
        action.resolve = resolve;
        action.reject = reject;
        dispatch(action);    
    }).finally(()=>{ // 异步请求完成后关闭loading
        closeLoading(dispatch);
    })
}

//修改全局reducers/global.ts定义的loading状态 
const openLoading = (dispatch)=>{
   dispatch({
       type:"UPDATE_GLOBAL_STATE",
       value:{
           type:"loading",
           value:true
       }
   })
}

const closeLoading = (dispatch)=>{
    dispatch({
        type:"UPDATE_GLOBAL_STATE",
        value:{
            type:"loading",
            value:false
        }
    })
}

中间件函数

在哪个地方去监听type值为PUSH_MESSAGEaction呢?这部分代码封装在redux中间件函数里非常合适.

中间件函数不仅能解析action的具体参数,它还能将全局要使用的WebSocketredux绑定在一起,最终实现通过派发action达到运用WebSocket向后端发起请求的目的.

观察下面中间件函数,即wsMiddleware()的返回值.redux的中间件函数会在应用每一次派发action后,都会执行一遍.

登录action派发后,线程会进入下面的中间件函数运行.中间件函数里编写了一个switch,分别监听type值为CONNECT_READYPUSH_MESSAGE以及DIS_CONNECT,分别对应建立WebSocket连接、向后端推送数据以及断开连接三种操作.

登录操作派发的action.type正是等于PUSH_MESSAGE,因此会进入第二个case结构,代表向后端发起请求.

后面我们会设置应用启动时派发type值为CONNECT_READYaction,即创建WebSocket连接.假设进行登录操作时,WebSocket连接已经创建好了,变量名为socket.

case 'PUSH_MESSAGE'包裹的代码里,代码首先解析action的参数commanddata,并使用uuid随机生成一个不重复的字符串request_id.

commanddatarequest_id组装成参数对象message.接下来关键的一步代码,如果发现action携带resolve参数,说明本次请求是由调用上面fetch函数发起的,因此需要将该action缓存到callback_list.最后调用socket.send将请求发送给后端.

前后端商议好,凡是带有request_id的请求,后端处理完后也要将request_id联合响应返回给前端.这样前端就能知道返回的响应对应着哪一次发起的请求.

现在前端请求已经结束了,后端一旦处理完就会向前端发起推送通知,这时候就会触发onMessage函数.

onMessage函数拿到后端推送的消息,取出其中的request_id,并检查callback_list是否缓存过该action,如果缓存了说明该请求是通过fetch发起,那么此时调用action.resolve(response.data),就能触发fetch返回的回调函数执行并将响应结果一同传递过去.

这样整个过程串联起来就会发现,页面组件先调用action,而action调用fetch,进而fetch又触发中间件函数使用Websocket向后端发送数据,并将请求的action缓存在callback_list.后端返回响应后,中间件的监听函数从callback_list里取出缓存的action,并调用action.resolve从而顺利触发了fetch的回调函数执行.因此整个环节便实现了发起请求 - 接受响应.

const WS = window.require('ws');// 安装基于node构建的websocket库ws,并使用window.require引入
import { messageResolve } from './common';
import { v1 as uuid } from 'uuid';

//请求缓存列表
const callback_list = {};

const wsMiddleware = () => {
  let socket = {}; // 存储websocket连接

  /**
   * 连接成功了
   */
  const onOpen = (store) => {
    store.dispatch({
      type: 'UPDATE_GLOBAL_STATE',
      value: {
        type: 'connected',
        payload: true,
      },
    });
  };

  /**
   * 收到发送过来的消息
   */
  const onMessage = (store, response) => {
    if(typeof response === "string"){
      response = JSON.parse(response);
    }
    let action;
    if (response.request_id && (action = callback_list[response.request_id])) {
      delete callback_list[response.request_id];
      // 该请求缓存过了
      action.resolve(response.data);
    }
    messageResolve(store, response);
  };

  /**
   * 连接断开了
   */
  const onClose = (store) => {
    store.dispatch({
      type: 'UPDATE_GLOBAL_STATE',
      value: {
        type: 'connected',
        payload: false,
      },
    });
  };
  
  //定时器
  let timer = null;

  //返回中间件函数
  return (store) => (next) => (action) => {
    switch (action.type) {
      // 建立连接
      case 'CONNECT_READY':
        timer = setInterval(() => {
          if (socket != null && (socket.readyState == 1 || socket.readyState == 2)) {
            //已经连接成功了
            timer && clearInterval(timer);
            timer = null;
            return;
          } 
          socket = new WS('ws://localhost:8080');     
          socket.on('open', () => {
            onOpen(store);
          });
          socket.on('message', (data: any) => {
            onMessage(store, data);
          });
          socket.on('close', () => {
            onClose(store);
          });
        }, 1000);

        break;
      // 向后台推送消息
      case 'PUSH_MESSAGE':
        const { command, data } = action.value;
        const message = {
          command,
          data,
          request_id: uuid(),
        };
        if (action.resolve) {
          callback_list[message.request_id] = action;
        }
        socket.send(JSON.stringify(message)); // 想后端推送消息
        break;
      // 应用主动发起断开连接的操作
      case 'DIS_CONNECT':
        socket.close();
        onClose(store);
        break;
      default:
        next(action);
    }
  };
};

export default wsMiddleware();

建立连接

上面中间件函数还监听了两种操作,分别是'CONNECT_READY'对应的建立连接和'DIS_CONNECT'对应的断开连接.

在看上述操作之前,先在reducers下面创建一个存储全局通用的状态文件global.js(代码如下).文件分别定义了四个状态,分别是connectedtokenis_login以及loading.

connected用来标记当前Websocket有没有处于连接上,比如突然断网connected的值会变成false,那么界面上就可以根据该状态值做相应的视图展现.

tokenis_login是登录成功后赋予的值,下一次客户端再发起请求时就可以将token值塞到data中一起发送给后端做校验.

const defaultState = {
  connected: false, // 是否连接上
  token: '', // 登录成功返回的token
  is_login:false, // 已经登录了吗
  loading:false //页面是否显示加载中的样式
};

export default (state = defaultState, action: actionType) => {
  switch (action.type) {
    case 'UPDATE_GLOBAL_STATE': // 修改全局状态
      const { type, payload } = action.value;
      return { ...state, [type]: payload };
    default:
      return state;
  }
};

全局状态定义了四个,而与中间件函数密切相关的属性是connected.

case 'CONNECT_READY' 负责监听建立Websocket连接的操作(代码如下),代码块里首先定义了一个定时器,每过一秒连接一次,直到与后端连接成功为止.

连接建立后,socket分别监听了三个函数openmessageclose.open函数会在连接建立成功后触发,成功后将全局状态connected置为true.

close断开连接时触发,断开时将全局状态connected置为false.

message监听后端推送的过来的消息.这里的消息分为两种类型.一种是前端发起请求,后端返回响应,另一种是后端主动推送消息.

那何时何地派发type值为'CONNECT_READY'action来建立Websocket连接呢?


 /**
   * 连接成功了
   */
  const onOpen = (store) => {
    store.dispatch({
      type: 'UPDATE_GLOBAL_STATE',
      value: {
        type: 'connected',
        payload: true,
      },
    });
  };

  /**
   * 收到发送过来的消息
   */
  const onMessage = (store, response) => {
    if(typeof response === "string"){
      response = JSON.parse(response);
    }
    let action;
    if (response.request_id && (action = callback_list[response.request_id])) {
      delete callback_list[response.request_id];
      // 该请求缓存过了
      action.resolve(response.data);
    }
    messageResolve(store, response);
  };

  /**
   * 连接断开了
   */
  const onClose = (store) => {
    store.dispatch({
      type: 'UPDATE_GLOBAL_STATE',
      value: {
        type: 'connected',
        payload: false,
      },
    });
  };

//省略
...

case 'CONNECT_READY':
        timer = setInterval(() => {
          if (socket != null && (socket.readyState == 1 || socket.readyState == 2)) {
            //已经连接成功了
            timer && clearInterval(timer);
            timer = null;
            return;
          }
          
          socket = new WS('ws://localhost:8080');   
         
          socket.on('open', () => {
            onOpen(store);
          });
          socket.on('message', (data: any) => {
            onMessage(store, data);
          });
          socket.on('close', () => {
            onClose(store);
          });
        }, 1000);

文章其实上面已经提及,建立连接应该发生在应用启动之时,因为只有当Websocket连接成功了,后面所有的操作才有意义.

新建一个组件WsConnect执行连接操作(代码如下).组件先判断全局状态connected值,如果发现没有连接上,随即派发CONNECT_READY,触发中间件的函数执行创建Websocket连接的操作.

const WsConnect = (props) => {
    const dispatch = useDispatch();
    const { connected } = useSelector((state)=>state.global);
    //建立websocket连接
    if(!connected){
        dispatch({
            type:"CONNECT_READY"
        });
    }
    return (
        <div>
            {props.children}
        </div>
    );
}
export default WsConnect;

最后将WsConnect塞入到react的根组件App中,这样就能确保应用在启动之时就会派发action建立Websocket连接.

export default function App() {
  return (
    <Provider store={store}>
      <WsConnect>
        <Router />
      </WsConnect>
    </Provider>
  );
}

登录完成

我们再回到最初讲解的LoginAction(代码如下),中间件函数内监听到后端响应回来时会执行action.resolve(response.data).

这句代码一执行就会触发fetch返回的then回调函数执行.

回调函数将后端返回的值赋予了全局状态token,并将全局状态is_login设置为true,代表登录成功了.

const updateGlobalType = (type,value)=>{
    return {
        type:"UPDATE_GLOBAL_STATE",
        value:{
            type,
            value
        }
    }
}

export const LoginAction = ()=>(dispatch,getState)=>{
    const { username,password } = getState().login;
    return fetch({
        dispatch,
        action:loginType(username,password)
    }).then((response)=>{
        dispatch(updateGlobalType("token",response.token)); // 存储token值
        dispatch(updateGlobalType("is_login",true)); //将全局状态is_login置为true
    })
}

由于上面fetch函数前面加了一个return返回自己的执行结果,因此界面上调用dispatch(LoginAction())也能返回一个then回调函数(代码如下).

在回调函数里引用react-router-dom提供的api,登录成功后页面立马跳转到首页,至此整个登录流程完结.

import { useHistory } from "react-router-dom";
import { LoginAction } from "../../redux/actions/login"; 

export default function Login() {

  const dispatch = useDispatch();
  const history = useHistory();
  
  //省略
  ...
  
  //登录
  const login = ()=>{
    dispatch(LoginAction()).then(()=>{
      console.log("登录成功!");
      history.push("/home");
    })
  }

  return (
          <div>
            <ul>
              <li>登录账号:</li>
              <li>
                  <div><input onChange={updateUser} type="text" placeholder="请输入账号" /></div>
              </li>
              <li>登录密码:</li>
              <li>
                  <div><input onChange={updatePwd} type="text" placeholder="请输入密码" /></div>
              </li>
            </ul>
            <button onClick={login}>立即登录</button>
          </div>  
  )
}

接受通知

登录功能实践了发起请求 - 接受响应的整体环节,接下来实现服务器主动推送消息的机制.

Demo最终实现效果图如下.登录成功后,页面跳转到首页.在客户端没发起请求的条件下,应用会连续收到后端发送过来的推送通知,并将推送的数据渲染到首页视图上.

12.gif

通过上面对中间件函数讲解可知,onMessage专门负责处理后端推送过来的消息(代码如下).如果是后端主动推送的消息通知,代码会进入messageResolve函数执行.

  import { messageResolve } from './common';

  /**
   * 收到发送过来的消息
   */
  const onMessage = (store, response) => {
    if(typeof response === "string"){
      response = JSON.parse(response);
    }
    let action;
    if (response.request_id && (action = callback_list[response.request_id])) {
      delete callback_list[response.request_id];
      // 该请求缓存过了
      action.resolve(response.data);
    }
    messageResolve(store, response);
  };

messageResolve函数(代码如下)一方面会派发typeMESSAGE_INCOMMINGaction,触发某些页面上定义的监听逻辑.

另一方面它会解析出响应的command字段,用来判端是否触发一些公共功能.比如全局的消息通知以及版本升级的操作.

/**
 *  消息处理
 */
export const messageResolve = (store, response) => {
  //将推送的消息广播全局,因为可能某些页面需要监听消息
  store.dispatch({
    type: 'MESSAGE_INCOMMING',
    value: response,
  });
  //公共功能的开发
  switch (response.command) {
    case 'message_inform': //消息通知,可以用弹框提醒
      console.log(`后端推送一条通知:${JSON.stringify(response.data)}`);
      break;
    case 'software_upgrading'://版本升级
      console.log("触发版本升级的窗口");
      break;
  }
};

首页reducer一旦监听到messageResolve派发的action(代码如下),会解析出command字段的值,如果发现command值与"home/add_item"相等,说明后端想在首页上实时动态添加数据.

最终首页视图会获取reducer定义的list状态渲染列表,当后端主动推送一条数据时,页面就会触发重新渲染.

至此后端主动推送的机制便已实现.

const defaultState = {
  list: []
};

export default (state = defaultState, action: actionType) => {
  switch (action.type) {
    case 'MESSAGE_INCOMMING': //监听后端推送过来的消息
      if(action.value.command === "home/add_item"){ // 添加一条数据
        return {...state,list:[...state.list,action.value.data]};
      }
      return state;
      break;
    default:
      return state;
  }
};

源码

源码地址