去中心化桌面版聊天软件开发揭秘

868 阅读5分钟

前言

    当下国内最为流行的聊天工具比如TX两兄弟,它们具有的共同特征之一——中心化,也是使我们的私密信息不再隐秘的原因之一。本文重点介绍一种基于免费开源库peerjs的p2p聊天桌面客户端的解决方案,希望能对感兴趣的童鞋有所帮助!

正文

    本文基于webRTC开源库peerjs、electron、umijs进行软件开发,electron具体相关开发方案可以参照基于github和jsdelivr文件服务探索一文第一节内容,这里不再赘述。

1 实施目标

(1)基本的文本消息; (2)文件传输; (3)截图以及预览功能实现;

2 基本理论

2.1 webRTC框架简介

    WebRTC (Web Real-Time Communications) 是一项实时通讯协议,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流和(或)音频流或者其他任意数据的传输。WebRTC包含的这些标准使用户在无需安装任何插件或者第三方的软件的情况下,创建点对点(Peer-to-Peer)的数据分享和电话会议成为可能。     在实际生活中,抛开防火墙问题,ISP (Internet Service Provider) 对网络终端的访问具有绝对控制权,它掌握的路由器可以决定我们能访问哪些网络,以及被哪些网络发现,因此,绝对意义上的点对点通讯实际上是难以实现的,此时通信双方需要借助一个名为STUN (Traversal Using Relays around NAT ) 的中继服务器实现消息转发,只需确保中继服务器允许被所有网络发现。下面来自一张MDN WEB DOCS的一张结构图:
    turn结构图
    上图中STUN (Session Traversal Utilities for NAT) 用于检测通讯双方是否能互相访问,并交换外网通讯地址。

2.2 peerjs简介

    peerjs是WebRTC协议的一种具体实现,支持传输数据(文本、blob对象、arraybuffer)和音视频,可以实现普通文本消息传递和音视频通话。peerjs官方提供了一个免费的TURN服务器用户消息转发,默认使用官方提供的TURN服务器进行消息转发,服务器源码见此,下面是简单消息通讯的示例代码:

// caller
const Peer = require('peerjs')
const peer = new Peer()
const conn = peer.connect('another-peers-id');
conn.on('open', () => {
  conn.send('hi!');
});

// receiver
const Peer = require('peerjs')
const peer = new Peer(''another-peers-id')
peer.on('connection', (conn) => {
  conn.on('data', (data) => {
    // Will print 'hi!'
    console.log(data);
  });
  conn.on('open', () => {
    conn.send('hello!');
  });
});

    本文消息传递正是借助官方提供的TURN服务器进行消息收发!

3 实施过程

3.1 生成唯一ID用于标识客户端

    本文利用网卡MAC加密生成唯一ID,经测试发现ID字符只允许数字和字母。在electron程序中,通过预加载脚本和contextBridge很容易实现获取并加密网卡MAC。

// preload.js
contextBridge.exposeInMainWorld('getPeerId', getPeerId);
// renderer.js
console.log(window.getPeerId())

    获取ID并复制到粘贴板

3.2 建立连接和消息收发

    这里提供核心部分代码:

  // 新建连接
  const appendChat = (chat: IChat) => {
    if (!chat || !chat.data ||  !currentContactRef.current) return
    const currentContact = currentContactRef.current
    const connMap = connMapRef.current
    const chatListSet = chatListSetRef.current
    const conn = connMap[currentContact.id]
    if (conn?.open) {
      const msg: IMsg = {
        type: chat.type,
        info: chat.info,
        data: chat.data
      }
      connMap[currentContact.id]?.send(msg)
      const targetChat: IChat[] = chatListSet[currentContact.id] || []
      targetChat.push(chat)
      const chatSet = {
        ...chatListSet,
        [currentContact.id]: targetChat,
      }
      updateChatListSet(chatSet)
      log.info('send data to peer ', currentContact.id)
    } else {
      messageService.error('连接已断开')
      log.error('connection to peer ', currentContact.id, ' disconnected!')
    }
  }
  // 新建连接处理
  const handleConnection = (conn: any) => {
    const contactList: IContact[] = JSON.parse(
      localStorage.getItem('contactList') || '[]'
    ) as IContact[] // 对联系人做了缓存
    let targetContact: IContact | undefined = contactList.find(
      (item: any) => item.id === conn.peer
    )
    if (!targetContact) {
      // 新建联系人
      targetContact = {
        id: conn.peer,
        nickname: '',
        avatar: getAvatar(), 
      }
      const list = [targetContact, ...contactList]
      updateContactList(list)
    }
    conn.on('close', () => { // 连接断开
      log.info('peer connection ', conn.peer, ' closed')
      const map = { ...connMapRef.current } // connMap用于缓存已经建立的连接
      delete map[conn.peer]
      updateConnMap(map)
    })
    conn.on('data', (msg: IMsg) => { // 接受消息
      log.info('get Data ', ' from ', conn.peer)
      const { type, info, data } = msg
      const chat: IChat = {
        ...msg,
        isMe: false,
        time: yxl_date.getDateTimeStr(),
      }
      if (type === 'file') {
        const file = new File([data], info) 
        chat.dataUrl = URL.createObjectURL(file);
        chat.dataSize = getFileSize(file.size);
      } else if (type === 'shotcut') { // 截图直接传送的是base64字符串
        chat.dataUrl = data;
      }
      const chatListSet: any = chatListSetRef.current
      const targetChat: any[] = chatListSet[conn.peer] || []
      targetChat.push(chat)
      const chatSet = {
        ...chatListSet,
        [conn.peer]: targetChat,
      }
      updateChatListSet(chatSet)
    })
    if (!currentContact && conn.open) setCurrentContact(targetContact) // 如果当前无会话,则设置当前会话
    const map = { ...connMapRef.current, [conn.peer]: conn }
    updateConnMap(map)
  }

    聊天主界面

3.3 截图以及图片预览功能

    这部分功能可以借助electron screen capture api实现,具体思路是:利用electron api desktopCapturer获取桌面视频流数据;然后利用html video api获得一帧图像并存到canvas里面;编写拖拽逻辑,实现截图效果,发送截图文件;点击截图文件时,将获取的截图展示到一个无边框透明窗体上实现预览。博主已经对相关功能做了封装并发布到npm,有兴趣的童鞋可自行查看源码,附上地址,或者直接mpm搜索yxlolxy-electron-plugins。效果图如下:

    截图功能

4 实施评估

经过反复测试,发现以下问题:
(1)跨网连接,通信双方双向通道有时只能单向连接成功,即接受方收到发送方连接请求,发送方收不到turn服务器回传的连接打开事件(只有连接打开,才能正常收发消息)
(2)上传文件只能10M以内,超过延时较高,或者无响应。

5 总结

    本文基于peerjs实现了消息收发,由于官方peerServer的不稳定性,导致有时建立连接失败,有云服务资源的童鞋可以自行搭建peerServer,通讯效果会相对较好。本文实施成果windows安装包已放百度网盘,附上地址, 提取码 2x69 ,欢迎各位体验,并留下您宝贵的意见!欢迎访问 原文链接!

补充

    2022.2.11,本文案例已集成去中心化移动版聊天软件开发揭秘一文案例,新版(V2.0)桌面版和移动版除细微界面差别外,其他保持一致,欢迎下载体验!网盘链接,提取码w3un