Node.js 网络通信
Node 是一个面向网络而生的平台,它具有事件驱动、无阻塞、单线程等特性,具备良好的可伸缩性,使得它十分轻量,适合在分布式网络中扮演各种各样的角色。同时 Node 提供的 API 十分贴合网络,适合用它基础的 API 构建灵活的网络服务。本课程的内容就是给大家介绍 Node 在网络通信编程方面的具体能力。
利用 Node 可以十分方便的搭建网络服务器。在 Web 领域,大多数的编程语言需要专门的 Web 服务器作为容器,如 ASP、ASP.NET 需要 IIS 作为服务器,PHP 需要打在 Apache 或 Nginx 环境等,JSP 需要 Tomcat 服务器等。但对于 Node 而言,只需要几行代码即可构建服务器,无需额外的容器。
Node 提供了 net、dgram、http、https 这4个模块,分别用于处理 TCP、UDP、HTTP、HTTPS,适用于服务器端和客户端。
第1章 网络通信相关概念
我们每天使用互联网,你是否想过,它是如何实现的?
全世界几十亿台电脑,连接在一起,两两通信。上海的某一块网卡送出信号,洛杉矶的另一块网卡居然就收到了,两者实际上根本不知道对方的物理位置,你不觉得这是很神奇的事情吗?
互联网的核心是一系列协议,总称为"互联网协议"(Internet Protocol Suite)。它们对电脑如何连接和组网,做出了详尽的规定。理解了这些协议,就理解了互联网的原理。
网络七层模型
互联网的实现,分成好几层。每一层都有自己的功能,就像建筑物一样,每一层都靠下一层支持。
用户接触到的,只是最上面的一层,根本没有感觉到下面的层。要理解互联网,必须从最下层开始,自下而上理解每一层的功能。
如何分层有不同的模型,有的模型分七层
为了好理解,有的模型分五层
越下面的层,越靠近硬件;越上面的层,越靠近用户。
mac 地址
ping 127.0.0.1 能ping通即代表电脑网卡没有问题,网络正常就能上网
IP 地址
计算机在网络中的标识号码,好比每个人的电话号码。
ip地址的作用: 通过ip地址在网络中找到对应的设备,然后可以给这个设备发送数据
ip地址类型分为:ipv4、ipv6
Port 端口号
端口号分为知名端口号和动态端口号(知名端口号是系统使用的,动态端口号是程序员设置使用的)
知名端口号:范围从 0-1023
动态端口范围:1024-65535
,当程序关闭时,同时也就释放了所占用的端口号
查看端口号:netstat -an
查看端口号被哪个程序占用: lsof -i[tcp/udp]:端口号
(找不到时,使用管理员权限,加sudo)
根据进程编号杀死指定进程:kill -9 进程号
域名
方便记忆某台电脑的主机地址,域名能解析出来一个ip地址(DNS解析)
TCP 和 UDP
TCP和UDP: 都是数据传输方式的协议.比如说我要给你钱, 我是以手把手的方式拿给你呢还是以快递的方式寄给你呢.
TCP(传输控制协议)
- 需要建立连接(三次握手),形成一条传输通道,才能传输数据
- 传输数据的大小不受限制
- 是安全可靠的协议,但是速度稍慢
UDP(用户数据报协议)
概念:英文全拼(User Datagram Protocol)简称用户数据报协议,它是无连接的、不可靠的网络传输协议
核心特点:无连接、资源开销小、传输速度快、UDP每个数据包最大是64K
优点:不需要连接,传输速度快,资源开销小
缺点:传输数据不可靠,容易丢失数据包,没有流量控制,当对方没有及时接收数据,发送方一直发送数据会导致缓冲区数据满了,电脑出现卡死情况,所以接收方需要及时接收数据
- 不需要建立连接, 把数据封装成数据包扔给对面
- 每个数据包大小限制在64K内
- 因为不建立连接,所以对方可能收到也可能收不到数据(丢包),因此是不安全的协议, 但是速度比较快
什么时候用 TCP,什么时候用 UDP?
- 对速度要求比较高的时候使用UDP,例如视频聊天, QQ聊天
- 对数据安全要求比较高的时候使用TCP,例如数据传输,文件下载
- 假如对于视频聊天来说,如果画质优先那就选用TCP, 如果流畅度优先那就选用UDP
什么是 Socket
Socket应用于两个不同客户端之间的通信及数据传输.中文名字叫套接字.
编程源于生活. 打个活生生的例子来说, 汽车和加油机.我们如果想把加油机里面的油输到汽车上, 那么汽车这边需要有一个端口, 加油机这边也要有一个端口, 两边端口各加一个套接头套着(好比adaptor),然后中间连上管道来输油. 我认为Socket的角色就是这个套接头.
简单来说, 要想在两个客户端之间传数据, 那么两个客户端各自都要有一个Socket.
第2章 构建 TCP 服务
TCP 服务在网络应用中十分常见,目前大多数的应用都是基于TCP搭建而成的。
TCP 全名为传输控制协议,在 OSI 模型(由七层模型,分别为物理层、数据链路层、网络层、传输层、会话层、表示层、应用层)中属于传输层协议。许多应用层协议基于TCP构建,典型的是HTTP、SMTP、IMAP等协议。
七层协议示意图如下:
层级 | 作用 |
---|---|
应用层 | HTTP、SMTP、IMAP等 |
表示层 | 加密/解密等 |
会话层 | 通信连接/维持会话 |
传输层 | TCP/UDP |
网络层 | IP |
链路层 | 网络特有的链路接口 |
物理层 | 网络物理硬件 |
TCP 是面向连接的协议,其显著的特征是在传输之前需要3次握手形成会话,如下图所示
只有会话形成之后,服务器端和客户端之间才能互相发送数据。在创建会话的过程中,服务器端和客户端分别提供一个套接字,这个两个套接字共同形成一个连接。服务器端与客户端则通过套接字实现两者之间连接通信的操作。下面是一个基于 Socket 套接字编程的网络通信模型。
基本示例
服务端:
const net=require('net');
const server=net.createServer();
server.on('connection',clientSocket=>{
// console.log('有新的连接进来');
//发送消息给客户端
clientSocket.write('hello');
//监听客户端发送过来数据
clientSocket.on('data',data=>{
console.log('客户端说:',data.toString());
})
})
server.listen(3000);
客户端:
const net=require('net');
const client=net.createConnection({
host:'127.0.0.1',
port:3000
})
client.on('connect',()=>{
// console.log('连接成功');
client.write('world');
//接收终端输入
process.stdin.on('data',data=>{
//其实直接发送data即可,因为最终发送都是二进制,之所以这么干是为了去除换行符
client.write(data.toString().trim());
})
})
//接收服务端发送的数据
client.on('data',data=>{
console.log('服务端说:',data.toString());
})
相关 API
官方API文档:https://nodejs.org/dist/latest-v10.x/docs/api/net.html
服务端相关 API
- [new net.Server(options][, connectionListener]) 创建服务器
- Event: 'close' 当服务器关闭时,触发该事件
- Event: 'connection' 当有新的客户端连接进来时,触发该事件
- Event: 'error' 当服务器发生错误时,触发该事件
- Event: 'listening' 当调用 server.listen() 绑定端口后触发,简洁写法为 server.listen(port, listeningListener),通过 listen() 方法的第二个参数传入
-
server.address() 服务器创建侦听成功后,可以用来获取服务器地址相关信息,包含
{ port: 12346, family: 'IPv4', address: '127.0.0.1' }
信息 - server.close([callback]) 当服务器关闭时触发,在调用 server.close() 后,服务器将停止接受新的套接字连接,但保持当前存在的连接,等待所有连接都断开后,会触发该事件
-
server.connections 获取当前已建立连接的数量
- 注意:该 API 即将废弃,推荐使用
server.getConnections()
替换
- 注意:该 API 即将废弃,推荐使用
- server.getConnections(callback) 获取当前已建立连接的数量
-
server.listen() 绑定端口号启动服务,开始等待侦听
- [server.listen(handle, backlog][, callback])
- [server.listen(options, callback])
- [server.listen(path, backlog][, callback])
- [server.listen(port[, host[, backlog]]][, callback])
- server.listening 获取服务器的侦听状态
- server.maxConnections 在服务器连接数较高的时候,可以通过设置该属性用于拒绝超过最大数的连接
- server.ref() 恢复服务器侦听
-
server.unref() 暂停服务器侦听,可以使用
server.ref()
恢复服务器侦听
套接字 Socket 相关 API
- new net.Socket([options]) 创建 Socket 连接
- Event: 'close' 当套接字完全关闭时,触发该事件
- Event: 'connect' 该事件用于客户端,当套接字与服务端连接成功时会被触发
-
Event: 'data' 当一端调用
write()
发送数据时,另一端会触发data
事件,事件传递的数据即是write()
发送的数据 - Event: 'drain' 当任意一端调用 write() 发送数据时,当前这端会触发该事件
- Event: 'end' 当连接中的任意一端发送了 FIN 数据时,将会触发该事件
- Event: 'error' 当异常发生时,触发该事件
- Event: 'timeout' 当一定时间后连接不再活跃时,该事件将会被触发,通知用户当前连接已经被闲置了
-
socket.address() 获取套接字的连接信息,例如
{ port: 12346, family: 'IPv4', address: '127.0.0.1' }
- socket.localAddress 获取当前套接字地址
- socket.localPort 获取当前套接字端口号
- socket.remoteAddress 获取另一端套接字地址
- socket.remoteFamily 获取另一端套接字IP协议版本
- socket.remotePort 获取另一端套接字连接端口号
- socket.setEncoding([encoding]) 设置获取数据解析的编码格式,默认不处理
- socket.write(data[, encoding][, callback]) 通过套接字发送数据
其它 API
-
net.connect() 创建 Socket 客户端连接,和
net.createConnection()
作用相等- net.connect(options[, connectListener])
- [net.connect(port, host][, connectListener])
- net.createConnection() 创建 Socket 客户端连接
- [net.createServer(options][, connectionListener]) 创建Socket服务端,等价于
new net.Server()
- net.isIP(input) 判断是否是IP地址
- net.isIPv4(input) 判断是否是符合 IPv4协议的地址
- net.isIPv6(input) 判断是否是符合 IPv6 协议的地址
案例:聊天室
初始化
核心需求
-
用户第一次进来,提示用户输入昵称进行注册
- 昵称不允许重复
广播消息(群发)
用户昵称
点对点消息(私聊)
数据格式设计(语言协议)
- 什么是数据格式
- 数据格式(data format)是描述数据保存在文件或记录中的规则。可以是字符形式的文本格式,或二进制数据形式的压缩格式
- 为什么要进行数据格式设计
- 比较常见的数据传输格式
- JSON
- XML
- YAML
- ...
用户登录
客户端:
{
"type": "login",
"nickname": "xxx"
}
服务端:
{
"type": "login",
"success": true | false,
"message": "登录成功|失败",
"sumUsers": 10
}
广播消息
客户端:
{
"type": "broadcast",
"message": "xxx"
}
服务端:
{
"type": "broadcast",
"nickname": "xxx",
"message": "xxx"
}
点对点消息
客户端:
{
"type": "p2p",
"to": "xxx",
"message": "xxx"
}
服务端:
{
"type": "p2p",
"from": "xxx",
"to": "xxx",
"message": "xxx"
}
上线|离线通知日志
服务端:
{
"type": "log",
"message": "xxx 进入|离开了聊天室,当前在线人数:xx"
}
用户登录
- 客户端输入昵称发送到服务端
- 服务端接收数据,校验昵称是否重复
- 如果已重复,则发送通知告诉客户端
- 如果可以使用,则将用户昵称及通信 Socket 存储到容器中用于后续使用
群发消息
- 客户端输入消息发送到服务端
- 服务端将消息发送给所有当前连接(也就是存储客户端Socket的容器)的客户端
- 客户端收到消息,将消息输出到终端
私聊
- 客户端输入消息发送到服务端
- 服务端根据消息内容从容器中找到对应的通信客户端,然后将消息发送给该客户端
- 对应的客户端收到消息,将消息输入到终端
上线|离线通知
- 上线通知
- 离线通知
总结
- TCP 必须建立连接(三次握手建立连接)才能通信
- TCP 只是负责数据的传输,不关心传输的数据格式问题
{
"type": "xxx"
}
- 如果需要使用 TCP 通信完成某种功能,则需要制定数据格式协议,或者使用第三方协议
- Socket 就是与之通信的另一端,通过 Socket 接收或是发送数据
- Socket 通信模型
案例代码
- 服务端代码
const net = require('net');
const server = net.createServer();
const types = require('./type')
const users = [];
server.on('connection', clientSocket => {
clientSocket.on('data', data => {
data = JSON.parse(data.toString().trim());
switch (data.type) {
case types.login:
if (users.find(item => item.nickname === data.nickname)) {
return clientSocket.write(JSON.stringify({
type: types.login,
success: false,
message: '昵称重复'
}))
}
//给socket对象添加一个昵称属性
clientSocket.nickname = data.nickname;
users.push(clientSocket);
clientSocket.write(JSON.stringify({
type: types.login,
success: true,
message: '登录成功',
nickname: data.nickname,
sumUsers: users.length
}))
break;
case types.broadcast:
//群聊
users.forEach(item => {
//
if (item !== clientSocket) {
item.write(JSON.stringify({
type: types.broadcast,
nickname: clientSocket.nickname,
message: data.message
}))
}
})
break;
case types.p2p:
//私聊
const user= users.find(item=>item.nickname==data.nickname)
if (!user) {
return clientSocket.write(JSON.stringify({
type:types.p2p,
success:false,
message:'该用户不存在'
}))
}
user.write(JSON.stringify({
type:types.p2p,
success:true,
nickname: data.nickname,
message:data.message
}))
break;
}
})
clientSocket.on('end',()=>{
//此处针对mac系统,实际上windows系统,强制关闭客户端,则会走error事件
const index=users.findIndex(user=>user.nickname===clientSocket.nickname)
if (index!==-1) {
//移除用户
users.splice(index,1);
}
})
clientSocket.on('error',err=>{
console.log('异常',err);
})
})
server.listen(3000);
- 客户端代码
const net = require('net');
const types = require('./type')
let nickname = null;
const client = net.createConnection({
host: '127.0.0.1',
port: 3000
})
client.on('connect', () => {
process.stdout.write('请输入昵称: ')
//监听终端输入事件
process.stdin.on('data', data => {
data = data.toString().trim();
//第一次发送昵称信息
if (!nickname) {
//write只能发送二进制或者字符串
return client.write(JSON.stringify({
type: types.login,
nickname: data
}));
}
const matchs=/^@(\w+)\s(.+)$/.exec(data)
if (matchs) {
//匹配正则,则发送的是私聊消息
return client.write(JSON.stringify({
type:types.p2p,
nickname:matchs[1],
message:matchs[2]
}))
}
//群聊
client.write(JSON.stringify({
type:types.broadcast,
message:data
}))
})
})
client.on('data', data => {
data = JSON.parse(data.toString().trim());
switch (data.type) {
case types.login:
if (!data.success) {
console.log(`登录失败:${data.message}`);
process.stdout.write('请输入昵称: ')
}else{
nickname=data.nickname;
console.log(`登录成功,当前在线用户${data.sumUsers}`);
}
break;
case types.broadcast:
console.log(`${data.nickname}:${data.message}`);
break;
case types.p2p:
if (!data.success) {
return console.log(`发送失败: ${data.message}`);
}
console.log(`${data.nickname}对你说: ${data.message}`);
break;
}
})
- 类型
module.exports={
login:0,
broadcast:1,
p2p:2
}
第3章 构建 UDP 服务
内容安排:
- UDP 介绍
- Node 中的核心模块 dgram
- 使用 Node 实现 UDP 单播
- 使用 Node 实现 UDP 广播
- 使用 Node 实现 UDP 组播
UDP 简介
User Datagram Protocol,简称 UDP ,又称用户数据报协议
和 TCP 一样,位于网络传输层用于处理数据包
UDP 最大的特点是无连接
UDP 传输速度快
-
UDP 数据传输不可靠
- 不提供数据包分组、组装和不能对数据包进行排序的缺点,也就是说,当报文发送之后,是无法得知其是否安全完整到达的
- 可靠性由应用层负责
支持一对一通信,也支持一对多通信
-
许多关键的互联网应用程序使用 UDP
- 如 DNS 域名系统服务、TFTP 简单文件传输协议、DHCP 动态主机设置协议 等
-
UDP 适用于对速度要求比较高,对数据质量要求不严谨的应用
- 例如流媒体、实时多人游戏、实时音视频
TCP 和 UDP
TCP和UDP: 都是数据传输方式的协议.比如说我要给你钱, 我是以手把手的方式拿给你呢还是以快递的方式寄给你呢.
UDP | TCP | |
---|---|---|
连接 | 无连接 | 面向连接 |
速度 | 无需建立连接,速度较快 | 需要建立连接,速度较慢 |
目的主机 | 一对一,一对多 | 仅能一对一 |
带宽 | UDP 报头较短,消耗带宽更少 | 消耗更多的带宽 |
消息边界 | 有 | 无 |
可靠性 | 低 | 高 |
顺序 | 无序 | 有序 |
注:事实上,UDP协议的这种乱序性基本上很少出现,通常只会在网络非常拥挤的情况下才有可能发生。
什么时候用 TCP,什么时候用 UDP?
- 对速度要求比较高的时候使用UDP,例如视频聊天, QQ聊天
- 对数据安全要求比较高的时候使用TCP,例如数据传输,文件下载
- 假如对于视频聊天来说,如果画质优先那就选用TCP, 如果流畅度优先那就选用UDP
UDP 的三种传播方式
1、UDP 单播
- 单播是目的地址为单一目标的一种传播方式
- 地址范围:
0.0.0.0 ~ 223.255.255.255
2、UDP 广播
目的地址为网络中的所有设备
-
地址范围分为两种
- 受限广播:它不被路由器转发,IP 地址的网络字段和主机字段全为1就是地址
255.255.255.255
- 直接广播:会被路由器转发,IP地址的网络字段定义这个网络,主机字段通常全为1,如
192.168.10.255
- 受限广播:它不被路由器转发,IP 地址的网络字段和主机字段全为1就是地址
3、UDP 组播
多播(Multicast)也叫组播,把信息传递给一组目的地地址
地址范围:
224.0.0.0 ~ 239.255.255.255
224.0.0.0 ~ 224.0.0.255
为永久组地址,224.0.0.0.0
保留不分配,其它供路由协议使用224.0.1.0 ~ 224.0.1.255
为公用组播地址,可以用于 Internet224.0.2.0 ~ 238.255.255.255
为用户可用的组播地址(临时组),全网范围有效,使用需要申请239.0.0.0 ~ 239.255.255.255
为本地管理组播地址,仅在特定本地范围有效
UDP 一对多通信场景
单播传输(Unicast):在发送者和每一接收者之间实现点对点网络连接。如果一台发送者同时给多个的接收者传输相同的数据,也必须相应的复制多份的相同数据包。如果有大量主机希望获得数据包的同一份拷贝时,将导致发送者负担沉重、延迟长、网络拥塞;为保证一定的服务质量需增加硬件和带宽。
广播(Broadcast):是指在IP子网内广播数据包,所有在子网内部的主机都将收到这些数据包。广播意味着网络向子网每一个主机都投递一份数据包,不论这些主机是否乐于接收该数据包。所以广播的使用范围非常小,只在本地子网内有效,通过路由器和网络设备控制广播传输。在网络中的应用较多,如客户机通过DHCP自动获得IP地址的过程就是通过广播来实现的。但是与单播和多播相比,广播几乎占用了子网内网络的所有带宽
组播:组播解决了单播和广播方式效率低的问题。当网络中的某些用户需求特定信息时,组播源(即组播信息发送者)仅发送一次信息,组播路由器借助组播路由协议为组播数据包建立树型路由,被传递的信息在尽可能远的分叉路口才开始复制和分发。网上视频会议、网上视频点播特别适合采用多播方式。
1、单播面对 "一对多"
在单播通信中每一个数据包都有确切的目的IP地址
对于同一份数据,如果存在多个接收者,Server 需发送与接收者数目相同的单播数据包
当接收者成百上千时,将极大的加重 Server 的负担
2、广播面对 "一对多"
广播数据包被限制在局域网中
一旦有设备发送广播数据则广播域内所有设备都收到这个数据包,并且不得不消耗资源去处理,大量的广播数据包将消耗网络的带宽及设备资源
在 IPv6 中,广播的报文传输方式被取消
3、组播面对 "一对多"
组播非常适合一对多的模型,只有加入到特定组播组的成员,才会接收到组播数据。当存在多个组播组成员时,源无需发送多个数据拷贝,仅需发送一份即可,组播网络设备会根据实际需要转发或拷贝组播数据
数据流只发送给加入该组播组的接收者(组成员),而不需要该数据的设备不会收到该组播流量
相同的组播报文,在一段链路上仅有一份数据,大大提高了网络资源的利用率
Node 中的 dgram 模块
Node 为我们提供了 dgram 模块用于构建 UDP 服务。
使用该模块创建 UDP 套接字非常简单,UDP 套接字一旦创建,既可以作为客户端发送数据,也可以作为服务器接收数据。
const dgram = require('dgram')
const socket = dgram.createSocket('udp4')
Socket 方法
API | 说明 |
---|---|
bind() | 绑定端口和主机 |
address() | 返回 Socket 地址对象 |
close() | 关闭 Socket 并停止监听 |
send() | 发送消息 |
addMembership() | 添加组播成员 |
dropMembership() | 删除组播成员 |
setBroadcast() | 设置是否启动广播 |
setTTL() | 设置数据报生存时间 |
setMulticastTTL() | 设置组播数据报生存时间 |
Socket 事件
API | 说明 |
---|---|
listening | 监听成功时触发,仅触发一次 |
message | 收到消息时触发 |
error | 发生错误时触发 |
close | 关闭 Socket 时触发 |
使用 Node 实现 UDP 单播
服务端
const dgram = require('dgram')
const server = dgram.createSocket('udp4')
server.on('listening', () => {
const address = server.address()
console.log(`server running ${address.address}:${address.port}`)
})
server.on('message', (msg, remoteInfo) => {
console.log(`server got ${msg} from ${remoteInfo.address}:${remoteInfo.port}`)
server.send('world', remoteInfo.port, remoteInfo.address)
})
server.on('error', err => {
console.log('server error', err)
})
server.bind(3000)
客户端
const dgram = require('dgram')
const client = dgram.createSocket('udp4')
//如果下面的bind端口不写,则发送消息写在此处即可,如果绑定了,则发送消息必须在listening事件成功之后
// client.send('hello', 3000, 'localhost')
client.on('listening', () => {
const address = client.address()
console.log(`client running ${address.address}:${address.port}`)
client.send('hello', 3000, 'localhost')
})
client.on('message', (msg, remoteInfo) => {
console.log(`client got ${msg} from ${remoteInfo.address}:${remoteInfo.port}`)
})
client.on('error', err => {
console.log('client error', err)
})
//如果不绑定,则系统随机分配
client.bind(8000)
使用 Node 实现 UDP 广播
服务端
const dgram = require('dgram')
const server = dgram.createSocket('udp4')
server.on('listening', () => {
const address = server.address()
console.log(`server running ${address.address}:${address.port}`)
server.setBroadcast(true) // 开启广播模式
server.send('hello', 8000, '255.255.255.255')
// 每隔2秒发送一条广播消息
setInterval(function () {
//192.168.10/255.255.255就是网络字段 255就是主机字段
// 直接地址 192.168.10.255:可以跨网段通信
// 受限地址 255.255.255.255
server.send('hello', 8000, '192.168.10.255')
// server.send('hello', 8000, '255.255.255.255')
}, 2000)
})
server.on('message', (msg, remoteInfo) => {
console.log(`server got ${msg} from ${remoteInfo.address}:${remoteInfo.port}`)
server.send('world', remoteInfo.port, remoteInfo.address)
})
server.on('error', err => {
console.log('server error', err)
})
server.bind(3000)
客户端
const dgram = require('dgram')
const client = dgram.createSocket('udp4')
client.on('message', (msg, remoteInfo) => {
console.log(`client got ${msg} from ${remoteInfo.address}:${remoteInfo.port}`)
})
client.on('error', err => {
console.log('client error', err)
})
client.bind(8000)
使用 Node 实现 UDP 组播
广播在IPV6中已经被取消
组播和广播不同,组播可以根据ip分组同时存在很多组播组,对应客户端需要加入对应组播组才能收到数据
服务端
const dgram = require('dgram')
const server = dgram.createSocket('udp4')
server.on('listening', () => {
const address = server.address()
setInterval(function () {
server.send('hello', 8000, '224.0.1.100')
}, 2000)
})
server.on('message', (msg, remoteInfo) => {
console.log(`server got ${msg} from ${remoteInfo.address}:${remoteInfo.port}`)
server.send('world', remoteInfo.port, remoteInfo.address)
})
server.on('error', err => {
console.log('server error', err)
})
server.bind(3000)
客户端
const dgram = require('dgram')
const client = dgram.createSocket('udp4')
client.on('listening', () => {
const address = client.address()
console.log(`client running ${address.address}:${address.port}`)
//加入指定的ip组播组
client.addMembership('224.0.1.100')
})
client.on('message', (msg, remoteInfo) => {
console.log(`client got ${msg} from ${remoteInfo.address}:${remoteInfo.port}`)
})
client.on('error', err => {
console.log('client error', err)
})
client.bind(8000)
第4章 构建 HTTP 服务
内容安排:
- Node 中的 http 模块
- 使用 Node 构建 http 服务
- 实现一个静态文件服务器
- 使用模板引擎处理动态网页
- 结合数据库渲染动态页面
- 实现一个留言本案例
- 第三方 HTTP 服务框架
Node 中的 http 模块
TCP 和 UDP 都属于网络传输层协议,如果要构建高效的网络应用,就应该从传输层进行着手。但是对于经典的浏览器网页和服务端通信场景,如果单纯的使用更底层的传输层协议则会变得麻烦。
所以对于经典的B(Browser)S(Server)通信,基于传输层之上专门制定了更上一层的通信协议:HTTP,用于浏览器和服务端进行通信。由于 HTTP 协议本身并不考虑数据如何传输及其他细节问题,所以属于应用层协议。
Node 提供了基本的 http 和 https 模块用于 HTTP 和 HTTPS 的封装。
const http = require('http')
const server = http.createServer()
Server 实例
API | 说明 |
---|---|
Event:'close' | 服务关闭时触发 |
Event:'request' | 收到请求消息时触发 |
server.close() | 关闭服务 |
server.listening | 获取服务状态 |
请求对象
API | 说明 |
---|---|
request.method | 请求方法 |
request.url | 请求路径 |
request.headers | 请求头 |
request.httpVersion | 请求HTTP协议版本 |
响应对象
API | 说明 |
---|---|
response.end() | 结束响应 |
response.setHeader(name, value) | 设置响应头 |
response.removeHeader(name, value) | 删除响应头 |
response.statusCode | 设置响应状态码 |
response.statusMessage | 设置响应状态短语 |
response.write() | 写入响应数据 |
response.writeHead() | 写入响应头 |
使用 Node 构建 http 服务
Hello World
const http = require('http')
const hostname = '127.0.0.1'
const port = 3000
const server = http.createServer((req, res) => {
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.end('Hello World\n')
})
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`)
})
根据不同 url 处理不同请求
const http = require('http')
const hostname = '127.0.0.1'
const port = 3000
const server = http.createServer((req, res) => {
const url = req.url
if (url === '/') {
res.end('Hello World!')
} else if (url === '/a') {
res.end('Hello a!')
} else if (url === '/b') {
res.end('Hello b!')
} else {
res.statusCode = 404
res.end('404 Not Found.')
}
})
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`)
})
响应 HTML 内容
const http = require('http')
const fs = require('fs')
const hostname = '127.0.0.1'
const port = 3000
const server = http.createServer((req, res) => {
fs.readFile('./index.html', (err, data) => {
if (err) {
throw err
}
res.statusCode = 200
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.end(data)
})
// res.end(`
// <h1>Hello World!</h1>
// <p>你好,世界!</p>
// `)
})
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`)
})
处理页面中的静态资源
const http = require('http')
const fs = require('fs')
const hostname = '127.0.0.1'
const port = 3000
const server = http.createServer((req, res) => {
const url = req.url
if (url === '/') {
fs.readFile('./index.html', (err, data) => {
if (err) {
throw err
}
res.statusCode = 200
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.end(data)
})
} else if (url === '/assets/css/main.css') {
fs.readFile('./assets/css/main.css', (err, data) => {
if (err) {
throw err
}
res.statusCode = 200
res.setHeader('Content-Type', 'text/css; charset=utf-8')
res.end(data)
})
} else if (url === '/assets/js/main.js') {
fs.readFile('./assets/js/main.js', (err, data) => {
if (err) {
throw err
}
res.statusCode = 200
res.setHeader('Content-Type', 'text/javascript; charset=utf-8')
res.end(data)
})
} else {
res.statusCode = 404
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
res.end('404 Not Found.')
}
})
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`)
})
统一处理页面中的静态资源
const http = require('http')
const fs = require('fs')
const mime = require('mime')
const path = require('path')
const hostname = '127.0.0.1'
const port = 3000
const server = http.createServer((req, res) => {
const url = req.url
if (url === '/') {
fs.readFile('./index.html', (err, data) => {
if (err) {
throw err
}
res.statusCode = 200
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.end(data)
})
} else if (url.startsWith('/assets/')) {
// /assets/js/main.js
fs.readFile(`.${url}`, (err, data) => {
if (err) {
res.statusCode = 404
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
res.end('404 Not Found.')
}
const contentType = mime.getType(path.extname(url))
res.statusCode = 200
res.setHeader('Content-Type', contentType)
res.end(data)
})
} else {
res.statusCode = 404
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
res.end('404 Not Found.')
}
})
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`)
})
使用模板引擎处理动态页面
假如我们有一份数据 todos
需要展示到页面中
const todos = [
{ title: '吃饭', completed: false },
{ title: '睡觉', completed: true },
{ title: '打豆豆', completed: false }
]
如何将一组数据列表展示到一个页面中,最简单的方式就是字符串替换,但是如果有不止一份数据需要展示到页面中的时候就会变得非常麻烦,所以前人将此种方式整合规则之后开发了我们常见的模板引擎。
例如我们经常在网页源码中看到下面这样一段代码
<ul>
<% todos.forEach(function (item) { %>
<li><%= item.title %></li>
<% }) %>
</ul>
或者是
<ul>
{{ each todos }}
<li>{{ $value.title }}</li>
{{ /each }}
</ul>
无论如何,我们看到的这些语法都在模板引擎所指定的一些规则,目的就是让我们可以非常方便的在网页中进行字符串替换以达到动态网页的效果。
在 Node 中,有很多优秀的模板引擎,它们大抵相同,但都各有特点
基本使用
const template = require('art-template')
// const ret = template.render('Hello {{ message }}', {
// message: 'World'
// })
const ret = template.render(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Document</title>
</head>
<body>
<h1>Hello {{ message }}</h1>
<ul>
{{ each todos }}
<li>{{ $value.title }} <input type="checkbox" {{ $value.completed ? 'checked' : '' }} /></li>
{{ /each }}
</ul>
</body>
</html>
`, {
message: 'World',
todos: [
{ title: '吃饭', completed: false },
{ title: '睡觉', completed: true },
{ title: '打豆豆', completed: false }
]
})
console.log(ret)
结合 http 服务渲染页面
const http = require('http')
const template = require('art-template')
const fs = require('fs')
const hostname = '127.0.0.1'
const port = 3000
const server = http.createServer((req, res) => {
const url = req.url
if (url === '/') {
fs.readFile('./index2.html', (err, data) => {
if (err) {
throw err
}
const htmlStr = template.render(data.toString(), {
message: '黑马程序员',
todos: [
{ title: '吃饭', completed: true },
{ title: '睡觉', completed: true },
{ title: '打豆豆', completed: false }
]
})
res.statusCode = 200
res.setHeader('Content-Type', 'text/html')
res.end(htmlStr)
})
}
})
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`)
})
第6章 构建 WebSocket 服务
WebSocket 介绍
类似于 HTTP, WebSocket 是一种网络通信协议。
为什么需要 WebSocket
我们已经有了 HTTP 协议,为什么还需要另一个协议?它能带来什么好处?
答案很简单,因为 HTTP 协议有一个缺陷:通信只能由客户端发起,没有请求就没有响应。
举例来说,我们想了解今天的天气,只能是客户端向服务器发出请求,服务器返回查询结果。HTTP 协议做不到服务器主动向客户端推送信息。
这种单向请求的特点,注定了如果服务器有连续的状态变化,客户端要获知就非常麻烦。我们只能使用"轮询":每隔一段时候,就发出一个询问,了解服务器有没有新的信息。最典型的场景就是聊天室。
轮询的效率低,非常浪费资源(因为必须不停连接,或者 HTTP 连接始终打开)。因此,工程师们一直在思考,有没有更好的方法。WebSocket 就是这样发明的。
什么是 WebSocket
WebSocket 协议在2008年诞生,2011年成为国际标准。所有浏览器都已经支持了。
它的最大特点就是,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,属于服务器推送技术的一种。
其他特点包括:
建立在 TCP 协议之上,服务器端的实现比较容易。
与 HTTP 协议有着良好的兼容性。默认端口也是80和443,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。
数据格式比较轻量,性能开销小,通信高效。
可以发送文本,也可以发送二进制数据。
没有同源限制,客户端可以与任意服务器通信。
协议标识符是
ws
(如果加密,则为wss
),服务器网址就是 URL。
ws://example.com:80/some/path
客户端 WebSocket
基本示例
在浏览器中提供了 WebSocket
对象用于创建和管理 WebSocket 连接,以及可以通过该连接发送和接收数据的 API。
下面是一个简单示例
var ws = new WebSocket("wss://echo.websocket.org");
ws.onopen = function(evt) {
console.log("Connection open ...");
ws.send("Hello WebSockets!");
};
ws.onmessage = function(evt) {
console.log( "Received Message: " + evt.data);
ws.close();
};
ws.onclose = function(evt) {
console.log("Connection closed.");
};
常用 API
WebSocket 构造函数
WebSocket 对象作为一个构造函数,用于新建 WebSocket 实例。
var ws = new WebSocket('ws://localhost:8080');
执行上面语句之后,客户端就会与服务器进行连接。
事件: onopen
实例对象的onopen
属性,用于指定连接成功后的回调函数。
ws.onopen = function () {
// 发送消息一定要在建立连接成功以后
ws.send('Hello Server!');
}
如果要指定多个回调函数,可以使用addEventListener
方法。
ws.addEventListener('open', function (event) {
ws.send('Hello Server!');
});
事件: onclose
实例对象的onclose
属性,用于指定连接关闭后的回调函数。
ws.onclose = function(event) {
var code = event.code;
var reason = event.reason;
var wasClean = event.wasClean;
// handle close event
};
ws.addEventListener("close", function(event) {
var code = event.code;
var reason = event.reason;
var wasClean = event.wasClean;
// handle close event
});
事件: onmessage
实例对象的onmessage
属性,用于指定收到服务器数据后的回调函数。
ws.onmessage = function(event) {
var data = event.data;
// 处理数据
};
ws.addEventListener("message", function(event) {
var data = event.data;
// 处理数据
});
注意,服务器数据可能是文本,也可能是二进制数据(blob
对象或Arraybuffer
对象)。
ws.onmessage = function(event){
if(typeof event.data === String) {
console.log("Received data string");
}
if(event.data instanceof ArrayBuffer){
var buffer = event.data;
console.log("Received arraybuffer");
}
}
除了动态判断收到的数据类型,也可以使用binaryType
属性,显式指定收到的二进制数据类型。
// 收到的是 blob 数据
ws.binaryType = "blob";
ws.onmessage = function(e) {
console.log(e.data.size);
};
// 收到的是 ArrayBuffer 数据
ws.binaryType = "arraybuffer";
ws.onmessage = function(e) {
console.log(e.data.byteLength);
};
事件:onerror
实例对象的onerror
属性,用于指定报错时的回调函数。
socket.onerror = function(event) {
// handle error event
};
socket.addEventListener("error", function(event) {
// handle error event
});
方法:send()
实例对象的send()
方法用于向服务器发送数据。
ws.send('your message');
发送 Blob 对象的例子。
var file = document
.querySelector('input[type="file"]')
.files[0];
ws.send(file);
发送 ArrayBuffer 对象的例子。
// Sending canvas ImageData as ArrayBuffer
var img = canvas_context.getImageData(0, 0, 400, 320);
var binary = new Uint8Array(img.data.length);
for (var i = 0; i < img.data.length; i++) {
binary[i] = img.data[i];
}
ws.send(binary.buffer);
方法:close()
实例对象的 close()
方法用于关闭连接。
ws.close()
连接关闭之后会触发实例对象的
onclose
事件。
实例属性:bufferedAmount
实例对象的bufferedAmount
属性,表示还有多少字节的二进制数据没有发送出去。它可以用来判断发送是否结束。
var data = new ArrayBuffer(10000000);
socket.send(data);
if (socket.bufferedAmount === 0) {
// 发送完毕
} else {
// 发送还没结束
}
服务端 WebSocket 实现
WebSocket 服务器的实现,可以查看维基百科的列表。
常用的 Node 实现有以下三种。
- µWebSockets
-
Socket.IO
- 服务端实现
- 提供了对所有流行的服务端的支持,例如 Java、PHP、Python、Node.js 等
- 客户端实现
- 浏览器
- 服务端实现
- WebSocket-Node
具体的用法请查看它们的文档,这里我们以 Socket.IO 为例。
综合案例:聊天室
案例演示
开始
创建项目目录
chat
npm init -y
初始化package.json
文件npm install express
写入以下代码
const express = require('express')
const app = express()
const http = require('http').Server(app)
app.get('/', function(req, res){
res.send('<h1>Hello world</h1>');
})
http.listen(3000, () => {
console.log('listening on *:3000')
})
- 启动服务测试
- 服务静态网页
将 app.get(/)
代码替换为以下内容
app.use(express.static('./public'))
- 测试页面访问
- 使用 Socket.IO
- 安装
npm i socket.io
- 安装
- 服务端代码修改如下
const express = require('express')
const app = express()
const http = require('http').Server(app)
const io = require('socket.io')(http)
app.use(express.static('./public/'))
io.on('connection', socket => {
console.log('a user connected');
})
http.listen(3000, () => {
console.log('listening on *:3000')
})
- 在网页中
<script src="/socket.io/socket.io.js"></script>
<script>
// 默认链接当前网页地址,也就是 ws://localhost:3000
var socket = io()
</script>
- 刷新网页测试效果
- 每一个 socket 都有一个
disconnect
事件
io.on('connection', function(socket){
console.log('a user connected')
socket.on('disconnect', () => {
console.log('user disconnected')
})
})
- 刷新网页测试效果
- 客户端发送消息
socket.emit('chat message', 'hello');
- 服务端接收消息
socket.on('chat message', function(msg){
console.log('message: ' + msg);
});
- 测试
- 给当前连接 socket 发送消息
socket.emit('request', '消息');
- 服务端发送广播消息
如果你想向除了某个套接字以外的所有人发送消息
socket.broadcast.emit('hi');
将消息发送给所有人,包括发送消息的客户端
io.emit('chat message', msg)
- 客户端接收消息
socket.on('chat message', function(msg){
$('#messages').append($('<li>').text(msg));
});
测试
其它功能
当有人连接或断开连接时,向连接的用户广播消息
添加对昵称的支持
不要向发送它的用户发送相同的消息。而是在他按下回车后直接附加消息
添加“{user}正在输入”功能。 显示谁在线。 添加私人消息。 分享您的改进!