2019-01-31

Websocket 聊天室

聊天室是通过 Websocket 协议进行通信的一个小案例,对用户的连接状态控制和信息同步转发等操作进行练习,最终使不同用户间的信息能够同步共享。


案例界面

登录、注册界面
聊天界面


数据库设计

聊天室用户数据持久化是用 mysql 数据库,相对应的 npm 包就是 mysql,本案例只用到了该包的少许 API,查看更多 mysql 相关信息

创建一个名为 chatroom 的数据库,和一个名为 room 的表,表的数据结构如下:

Filed Type Null Key Default Extra
id int NO PRI NULL auto_increment
username varchar(32) NO NULL
password varchar(32) NO NULL
online bit(1) NO b'0'


聊天室设计

  1. 注册和登录接口处于同一页面。

  2. 客户端未登录账户访问聊天室页面时,直接跳转回登录/注册页面。

  3. 已登录的用户不可再次登录,因此不会造成强制下线或同一用户同时出现在两个窗口。

  4. 一个客户端可以登录多个账户,在线的聊天内容同步化。

  5. 对于一些错误或非服务器服务范围内的操作,皆返回 404.html 页面。



以上叙述是该服务器主要服务的响应部分,详细内容请看案例源码

说明:main.js、test_main.html 两文件与聊天室案例的运行无关。


功能实现

这里将所有的服务器逻辑都写在单文件 ws.js 里,登录/注册页面为 test_ws.html,聊天室页面为 index.html


服务器

导入必要库

引入几个必要的库,利用它们提供的底层支持和简洁、易用的接口,加快开发速度。

const http = require('http')
const fs = require('fs')
const mysql = require('mysql')
const io = require('socket.io')
// 引入封装了验证账号和密码的正则表达式接口
const regs = require('../libs/regs')


部署外层服务

连接数据库服务接口,创建并开启服务器,再用 websocket 监听 http 服务器。

// 默认 10 个服务器连接池,控制连接与断开的频率
let db = mysql.createPool({host: 'localhost', user: 'siri', password: 'siripassword', database: 'chatroom'})
// 存储已登录的用户名,用于页面跳转后的验证
let userList = []
let httpServer = http.createServer((req, res) => {
  // TODO 服务路由以及其它相关操作
})

httpServer.listen(3000, () => {
  console.log('listening on *:3000')
})

// 每个连接的 sock 的不一样
let sockList = []
let wsServer = io.listen(httpServer)
wsServer.on('connection', sock => {
  // TODO 客户端连接服务器时所有的监听接口
})


服务路由

有了服务器,我们来设置路由,避免客户端页面无响应(转圈圈)。路由只有一个分支,第一个即是特殊对待聊天室页面(index.html)的访问,也就是:想要进入聊天室,得看看你有没有登录,没有的话得回到登录页面登录。否则就记录用户信息,并允许其进入聊天室。检查有没有登录主要依靠一个标识符 online ,将 online 为开(b'1')的所有用户与顶层用户列表进行匹配过滤,找出已登录但未在聊天室的用户。

客户端输入账号登录成功后服务端将数据库中相应用户的 online 字段更新为 1,断开时重置为 0。



另一个则是其它页面的通用接口,访问时则要看看给的文件路径在不在当前目录下,若不在则 404 错误,否则读取全文,并将其内容 toString 返回给客户端。

let httpServer = http.createServer((req, res) => {
    if (req.url === '/index' || req.url === '/') {
    // 原理为遍历 name 和 userList,找出已登录但未在聊天室的用户,允许为其层现页面。
    // 注意:这里是异步操作
    db.query('SELECT name FROM room WHERE online=1', (err, data) => {
      if (err) {
        res.setHeader('Content-Type', 'text/plain; charset=utf-8')
        res.writeHead(404)
        res.write('服务器读取数据失败')
        res.end()
      } else {
        // 得到已登录但未渲染页面给该用户的名字,即未记录入全局列表 userList
        let who = data.filter((user, i) => !userList.includes(user.name))
        let online = who.length > 0 ? false : true
        if (data.length < 1 || online) {
          // 没登录则直接跳回登录页面
          res.setHeader('Location', '/test_ws.html')
          res.statusCode = 302
          res.end()
        } else {
          fs.readFile(__dirname + '/index.html', (err, data) => {
            if (err) {
              res.setHeader('Content-Type', 'text/plain; charset=utf-8')
              res.writeHead(404)
              res.write('服务器读取数据失败')
            } else {
              res.write(data.toString())
            }
            // 记录入列表
            userList.push(who[0].name)
            res.end()
          })
        }
      }
    })
  } else {
    fs.readFile(__dirname + req.url, (err, data) => {
      if (err) {
        // 也可以在这里读入 404.html 的内容返回给客户端
        res.writeHead(404)
        res.write('Not Found')
      } else {
        res.write(data.toString())
      }
      res.end()
    })
  }
}


核心逻辑处理

监听了 http 服务器,我们就拥有了事件触发 或者说是状态变更时的行为控制机制,在此我们于用户访问页面时(连接服务器)接收到一个 sock,进而在该 sock 上注册一些事件,主要包括登录,注册,广播,下线四个事件,事件对应有一个状态码,非 0 即 1,0 代表成功,1 反之。因为 Websocket 是双向通信的一个协议,要使服务器主动向客户端发送数据,客户端这边还得接受才行,现在先写好服务器这边的逻辑。

下面的 online、cur_user 标识符是用来区分开登录页面和聊天室页面的,因为即使使用 location 跳转到同一页面,也会触发disconnect。致使这里的 online 状态有点别扭,但这样实现比 cookie 方便得多,只需要在聊天室页面主动发送 scan 即可知道是谁在登录(虽然也需要借助客户端访问 cookie 一下),cookie 还需要在req.headers.cookie 里面解析分析以及登录时的键值设置,不好实现同一客户端登录多个用户的情况。

wsServer.on('connection', sock => {
  sockList.push(sock)
  let cur_user = null
  let online = false

  // 注册接口
  sock.on('reg', (user, passwd) => {
    if (!regs.username.test(user)) {
      sock.emit('reg_ret', 1, '用户名不符合规范')
    } else if (!regs.password.test(passwd)) {
      sock.emit('reg_ret', 1, '密码不符合规范')
    } else {
      db.query(`SELECT * FROM room WHERE name='${user}'`, (err, data) => {
        if (err) {
          sock.emit('reg_ret', 1, '数据库出错')
        } else if (data.length > 0) {
          sock.emit('reg_ret', 1, '用户名已存在')
        } else {
          db.query(`INSERT INTO room (name, password) VALUES('${user}', '${passwd}')`, err => {
            if (err) {
              sock.emit('reg_ret', 1, '服务繁忙,请稍后重试')
            } else {
              sock.emit('reg_ret', 0, '注册成功')
            }
          })
        }
      })
    }
  })

  // 登录接口
  sock.on('login', (user, passwd) => {
    if (!regs.username.test(user)) {
      sock.emit('login_ret', 1, '用户名不符合规范')
    } else if (!regs.password.test(passwd)) {
      sock.emit('login_ret', 1, '密码不符合规范')
    }
    else {
      db.query(`SELECT id,password FROM room WHERE name='${user}'`, (err, data) => {
        if (err) {
          sock.emit('login_ret', 1, '数据库出错')
        } else if (data.length < 1) {
          sock.emit('login_ret', 1, '该用户不存在')
        } else if (data[0].password !== passwd) {
          sock.emit('login_ret', 1, '密码不正确')
        } else {
          // 如果已经登录了,则提示已登录信息
          let isLogin = userList.some(name => user === name)
          if (isLogin) {
            sock.emit('login_ret', 1, '该用户已经登录')
          } else {
            db.query(`UPDATE room SET online=1 WHERE id=${data[0].id}`, err => {
              if (err) {
                sock.emit('login_ret', 1, '服务繁忙,请稍后重试')
              } else {
                online = true
                sock.emit('login_ret', 0, '登录成功')
              }
            })
          }
        }
      })
    }
  })

  // 广播接口
  sock.on('chat', (name, msg) => {
    if (!msg) {
      sock.emit('chat_ret', 1, '信息不能为空')
    } else {
      sockList.forEach((socket, i) => {
        if (socket !== sock) {
          socket.emit('everyone', name, msg)
        }
      })
      sock.emit('chat_ret', 0, '发送成功')
    }
  })

  // 登录后跳转页面前在服务端输出一下,可以不要
  sock.on('online', (name, msg) => {
    console.log(`${name} ${msg}`)
  })

  sock.on('scan', username => {
    cur_user = username
  })

  // 断开连接
  sock.on('disconnect', () => {
    if (!online) {
      db.query(`UPDATE room SET online=0 WHERE name='${cur_user}'`, err => {
        if (err) {
          console.log('数据库出错', err)
        }
      })

      // 将断开的 sock 和离开聊天室的用户过滤掉
      sockList = sockList.filter(item => item !== sock)
      userList.splice(userList.indexOf(cur_user), 1)
    }

    // 在聊天室里离开时才会触发
    if (cur_user) {
      console.log(cur_user, '断开连接')
      cur_user = null
    }
  })
}



socket.io 服务端 API

好啦,服务端的业务已经全部实现,接下来实现客户端的部分。


客户端

登录/注册

客户端需要引入 websocket,才能使用该协议的特性,在这里使用 `<script src="http://localhost:3000/socket.io/socket.io.js"></script>进行链入,或者也可以引入其它路径的在线资源。

登录时,将表单中的信息发送给服务端验证,类似于发送 ajax。登录成功后临时设置 cookie 键值对,随后跳转页面。注册的逻辑也是相似的,就不多啰嗦了。

<script>
  window.onload = function () {
    // 必须连接服务端才行
    const sock = io.connect('ws://localhost:3000/')
    let name = document.getElementById('username')
    let passwd = document.getElementById('password')
    let login = document.getElementById('login')
    let register = document.getElementById('register')

    // 登录
    login.onclick = () => {
      sock.emit('login', name.value, passwd.value)
      sock.once('login_ret', (code, msg) => {
        if (code) {
          console.log('登录失败 ' + msg)
        } else {
          alert('登录成功')
          // 主动发请求,表示本客户是刚刚登陆跳转过来的
          sock.emit('online', name.value, '请求上线')
          // 页面跳转,需保存已登录的用户状态,这里用 cookie,暂时先简单处理
          document.cookie = 'username=' + name.value
          setTimeout(() => {
            window.location = 'http://localhost:3000/index'
          }, 1000)
        }
      })
    }

    // 注册
    register.onclick = () => {
      sock.emit('reg', name.value, passwd.value)
      sock.once('reg_ret', (code, msg) => {
        if (code) {
          console.log('注册失败 ' + msg)
        } else {
          console.log('注册成功')
        }
      })
    }
  }
</script>

注意避免多次注册事件。


聊天室页面

承接在登录页面跳转过来的情境,获取 cookieusername 键值,主动发送给服务端表明身份,此时服务端的 cur_user 就是该键值。然后对应的一个是主动群发消息事件,另一个是接受别人的消息事件。对于自己发送的消息在当前页面显示的是样式 .mine 的颜色,而另一个是样式 li 的颜色,从而区分开是谁发了消息。

<script src="http://localhost:3000/socket.io/socket.io.js"></script>
<script>
  window.onload = () => {
    const sock = io.connect('ws://localhost:3000/')
    let oText = document.getElementById('msg')
    let submit = document.querySelector('input[type="button"]')
    let chat = document.getElementById('chat')
    let username = document.cookie.split('=')[1]
    document.querySelector('.wrapper > h1').innerText = 'I am ' + username

    // 主动给服务器表名身份
    sock.emit('scan', username)
    submit.onclick = () => {
      sock.emit('chat', username, oText.value)
      sock.once('chat_ret', (code, msg) => {
        if (code) {
          alert('发送失败,' + msg)
        } else {
          let oLi = document.createElement('li')
          oLi.innerHTML = `<h4>${username}</h4><p>${oText.value}</p>`
          oLi.className = 'mine'
          chat.appendChild(oLi)
          oText.value = ''
        }
      })
    }

    sock.on('everyone', (sender, msg) => {
      let oLi = document.createElement('li')
      oLi.innerHTML = `<h4>${sender}</h4><p>${msg}</p>`
      chat.appendChild(oLi)
    })
  }
</script>
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,794评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,050评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,587评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,861评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,901评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,898评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,832评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,617评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,077评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,349评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,483评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,199评论 5 341
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,824评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,442评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,632评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,474评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,393评论 2 352

推荐阅读更多精彩内容