我们前面已经提到,nodejs是为Web应用开发而生的。所以,和一般的通用性开发平台不同,在设计之初,nodejs就内置了对标准网络协议的支持,包括了TCP、UDP、HTTP以及WS,当然其中的重点和核心就是HTTP,也就是我们在本章节中需要重点探讨的内容,nodejs的其他网络特性,笔者有机会另著文阐述。
HTTP协议和Nodejs
关于HTTP协议的内容,笔者另有一篇博文涉及的更加深入,这里就不再特别展开陈述。
这里想要特别说明的是,nodejs是将对http协议的支持,放在一个技术体系的核心的位置上的。所以其内置的不仅仅是简单的对http的支持,还包括了更加底层的网络协议TCP/UDP的支持。另外,考虑到Web应用的丰富性和多样性,还涉及到了扩展的网络协议如HTTPS、HTTP2和WebSocket等方面的内容。
在当前LTS版本(V20),相关http协议、网络和Web应用相关的功能模块包括(来自nodejs官方API文档):
- HTTP: http协议支持模块
- HTTPS: https协议支持模块
- HTTP/2: http/2协议支持模块
- TLS (SSL): 安全套件字层相关模块,支持HTTPS/2的底层技术
- WS(WebSocket): WebSocket协议支持模块
- URL: 标准URL结构
- Query Strings: 查询字符串模块
可以看到,其相关HTTP协议支持的功能设计还是比较完善的,对于最新的Web技术的跟踪也是比较快的。
HTTP模块和类
熟悉http协议和相关设计的开发者都了解,http使用经典的客户端/服务器部署模式和请求/响应业务模型。所以在nodejs中,http模块的设计和实现,就围绕这些内容展开。
首先我们来看看http模块中的内容,就是在其中定义的类,包括:
- Agent: 客户端代理对象
- ClientRequest: 客户端请求对象
- Server: http服务器类
- ServerResponse: 服务端响应对象
- IncomingMessage: 入站消息对象
- OutgoingMessage: 出站消息对象
- http: 主http类,包含一些常量和快速方法
笔者认为,这样的设计和规划,非常简洁清晰,贴合http协议的原理和定义,易于理解和使用。理论上而言,我们可以仅基于nodejs的http模块,构建一个完整的Web应用(Web部分)。但实际在工程实际上,我们一般会引入一些所谓的"框架"系统,它们会基于Web应有的类型模式、普通流程和最佳实践,提供更加规范和一致的开发体验,并增加常用和必要的扩展功能,熟练掌握和使用,通常可以大大提高开发的效率,并规避由于不规范和错误的技术应用所带来的,特别是涉及性能和安全性等方面问题的风险,我们后面会进一步讨论相关的内容。
但无疑基础也是非常重要的,下面我们就先以一些常用的使用场景为例,结合http模块和相关功能的实现,重点分析其中比较重要和常用的内容。
HTTP请求(客户端)
一般的Web应用开发都是针对作为服务器的角色的。比如Java的SpringBoot、PHP等等。如果需要作为HTTP客户端,则需要其他的组件或者技术。以前的Web应用需要作为客户端的场景并不多,但现在的Web应用系统越来越复杂,还有微服务概念和技术的流行,Web应用对其他HTTP服务的访问能力,也是HTTP服务本身的一个重要功能。
nodejs内置的HTTP模块,本身就内置了request对象,可以作为http客户端来使用。下面的示例代码展示了其常规应用方式:
js
const http = require('node:http');
const postData = JSON.stringify({
'msg': 'Hello World!',
});
const options = {
hostname: 'www.google.com',
port: 80,
path: '/upload',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
},
};
const req = http.request(options, (res) => {
console.log(`STATUS: ${res.statusCode}`);
console.log(`HEADERS: ${JSON.stringify(res.headers)}`);
res.setEncoding('utf8');
res.on('data', (chunk) => {
console.log(`BODY: ${chunk}`);
});
res.on('end', () => {
console.log('No more data in response.');
});
});
req.on('error', (e) => {
console.error(`problem with request: ${e.message}`);
});
// Write data to request body
req.write(postData);
req.end();
这段代码稍显繁复,是因为它展示了很多技术细节,也贴近真实的使用场景,要考虑很多实际的问题。我们简单的分析一下其中的要点:
- 完全基于HTTP协议的原理和过程
- http请求操作的核心,是先基于一些配置和选项,创建request对象
- 这些选项,主要包括请求的地址、端口、请求方式(此处为POST)、头信息(格式、内容类型长度等等)
- 同时需要编写一个响应处理程序,其参数就是响应对象
- 响应对象其实是一个读取流(read stream),可以使用数据流的方式来处理响应内容,这里是拼接chunk
- 请求的内容,也可以使用写入流的方式,依次写入并使用end方法结束
- 可能需要编写处理错误的程序,请求的过程如果出现错误,将会触发error事件
HTTPS
和互联网早期的蛮荒和简陋相比,现在Web应用的发展已经相当成熟。一个明显的标注就是网络传输的安全机制已经比较完善,体现在HTTPS协议的标准化和全面化。
和一般的想象不同,nodejs专门提供了HTTPS模块来实现和处理相关的功能。笔者觉得可能是由于其继承自TLS的体系,而且需要在里面实现很多密码学相关的处理。所以索性使用一个独立的模块。
但使用HTTPS来进行http请求的方式,却和http模块基本上没有什么差别。其他可能的差异在于:
- 需要提供客户端的证书和密钥(如果要对客户端进行验证的话)
- 可能需要处理和SSL相关的错误和问题,如可选的服务端签名和证书的验证等
所以,我们在使用HTTPS的请求功能是,最常见的错误信息就是证书类型的错误了,比如自签名证书,证书过期等等。特别是在开发和测试阶段,我们不需要那么严格的安全策略,可以选择暂时忽略这些错误。这个操作可以通过设置环境变量,或者修改进程参数的方式来实现:
js
// 启动前设置环境变量
set NODE_TLS_REJECT_UNAUTHORIZED = 0
// 在程序中修改进程参数
process.env.NODE_TLS_REJECT_UNAUTHORIZED = 0;
简单方法
前面已经看到,在nodejs中实现http请求的过程,虽然清晰明确,却略显繁复。比如对于非常简单的GET请求,也需要提供一个URL地址和相关的设置。为了方便代码编写,http模块提供了快捷的get方法,方便编写get请求的操作(注意简单方法没有POST请求)。
下面的参考代码,可以让我们很好的理解这一设定:
js
https
.get('https://encrypted.google.com/', (res) => {
console.log('statusCode:', res.statusCode);
console.log('headers:', res.headers);
res.on('data', (d) => {
process.stdout.write(d);
});
}).on('error', (e) => {
console.error(e);
});
fetch
如果我们熟悉前端开发,我们就知道浏览器环境中,编程异步发起HTTP请求(AJAX)的技术基础是XMLHttpRequest (XHR),后来又提供了一个较新的API名为fetch。相对XHR,fetch比较容易使用,最重要的是它可以使用promise方式来处理异步请求,支持HTTP2和更多的HTTP方法和请求头,已经接近于一个比较理想的标准的HTTP客户端模块了。
有趣的是,在nodejs环境中,也提供了一个fetch对象可以直接使用。但经过资料查证,笔者发现,它和浏览器中的fetch API并不是同一个事物。但是,它们都遵循WHATWG Fetch API规范,使用方式确实是基本相同的。nodejs中的fetch对象是基于Undici(Node.js社区开发的HTTP客户端),而浏览器中的则是由其开发者自行实现。nodejs fetch提供的功能稍稍多于标准fetch。但需要注意的是,fetch并不是标准nodejs模块,不需要引用,技术文档中也没有相关的章节。
相对而言,fetch的使用方式就比request或者XHR简单多了,下面是其参考示例代码:
js
const url = 'https://httpbin.org/post'
const data = {
x: 1920,
y: 1080,
};
const customHeaders = {
"Content-Type": "application/json",
}
fetch(url, {
method: "POST",
headers: customHeaders,
body: JSON.stringify(data),
})
.then((response) => response.json())
.then((data) => {
console.log(data);
})
.catch(error=>{
// something wrong
});
// 同步形式
async function logMovies() {
const response = await fetch("http://example.com/movies.json");
const movies = await response.json();
console.log(movies);
}
可以看到,在nodejs中使用fetch的要点如下:
- 可以直接使用fetch对象(应该是模块级变量)
- 可以使用基本相同的模式来使用Get和Post
- Post方法,直接将内容注入body参数
- 可以不分http和https
- 可以使用then/catch方式调用,也可以使用async/await方式调用
- 调用有几个阶段,它提供了内容编码的方法如json(),text()等等,并且可以链接处理
- 可以使用一致的错误处理方式
显然,相比http的request方法,fetch使用更加灵活方便。
HTTP服务(Server)
使用http模块,创建一个标准的http服务,基本上就是两个步骤:
1 使用一个函数创建一个http server实例,函数的参数只有两个,就是请求和响应对象
这个函数用于基于请求信息,进行业务处理,并且使用响应对象发送响应信息
2 使用网络和端口配置信息,启动实例侦听
参考代码如下:
js
const server = http.createServer((req, res) => {
const
ip = res.socket.remoteAddress,
port = res.socket.remotePort;
res.end(`Your IP address is ${ip} and your source port is ${port}.`);
}).listen(3000);
这样,一个最精简的Web应用服务程序,基本上就是这样架构的。其他各种丰富的功能和配置,都是根据业务和应用的需求,在这个架构上进行扩展开来的。
http模块
在了解了HTTP请求和服务的简单应用后,我们来了解初步了解一下http模块。作为Web应用开发支持的一个重要组成部分,nodejs提供了http模块,里面定义了很多和HTTP协议处理相关的对象和方法。
http对象结构
我们可以使用REPL来检查http对象的结构:
js
> http
{
_connectionListener: [Function: connectionListener],
METHODS: [
'ACL', 'BIND', 'CHECKOUT',
'CONNECT', 'COPY', 'DELETE',
'GET', 'HEAD', 'LINK',
'LOCK', 'M-SEARCH', 'MERGE',
'MKACTIVITY', 'MKCALENDAR', 'MKCOL',
'MOVE', 'NOTIFY', 'OPTIONS',
'PATCH', 'POST', 'PRI',
'PROPFIND', 'PROPPATCH', 'PURGE',
'PUT', 'REBIND', 'REPORT',
'SEARCH', 'SOURCE', 'SUBSCRIBE',
'TRACE', 'UNBIND', 'UNLINK',
'UNLOCK', 'UNSUBSCRIBE'
],
STATUS_CODES: {
'100': 'Continue',
'101': 'Switching Protocols',
'102': 'Processing',
'200': 'OK',
'201': 'Created',
'202': 'Accepted',
'203': 'Non-Authoritative Information',
'204': 'No Content',
'205': 'Reset Content',
'206': 'Partial Content',
'207': 'Multi-Status',
'208': 'Already Reported',
'226': 'IM Used',
'300': 'Multiple Choices',
'301': 'Moved Permanently',
'302': 'Found',
'303': 'See Other',
'304': 'Not Modified',
'305': 'Use Proxy',
'307': 'Temporary Redirect',
'308': 'Permanent Redirect',
'400': 'Bad Request',
'401': 'Unauthorized',
'402': 'Payment Required',
'403': 'Forbidden',
'404': 'Not Found',
'405': 'Method Not Allowed',
'406': 'Not Acceptable',
'407': 'Proxy Authentication Required',
'408': 'Request Timeout',
'409': 'Conflict',
'410': 'Gone',
'411': 'Length Required',
'412': 'Precondition Failed',
'413': 'Payload Too Large',
'414': 'URI Too Long',
'415': 'Unsupported Media Type',
'416': 'Range Not Satisfiable',
'417': 'Expectation Failed',
'418': "I'm a Teapot",
'421': 'Misdirected Request',
'422': 'Unprocessable Entity',
'423': 'Locked',
'424': 'Failed Dependency',
'425': 'Too Early',
'426': 'Upgrade Required',
'428': 'Precondition Required',
'429': 'Too Many Requests',
'431': 'Request Header Fields Too Large',
'451': 'Unavailable For Legal Reasons',
'500': 'Internal Server Error',
'501': 'Not Implemented',
'502': 'Bad Gateway',
'503': 'Service Unavailable',
'504': 'Gateway Timeout',
'505': 'HTTP Version Not Supported',
'506': 'Variant Also Negotiates',
'507': 'Insufficient Storage',
'508': 'Loop Detected',
'509': 'Bandwidth Limit Exceeded',
'510': 'Not Extended',
'511': 'Network Authentication Required'
},
Agent: [Function: Agent] { defaultMaxSockets: Infinity },
ClientRequest: [Function: ClientRequest],
IncomingMessage: [Function: IncomingMessage],
OutgoingMessage: [Function: OutgoingMessage],
Server: [Function: Server],
ServerResponse: [Function: ServerResponse],
createServer: [Function: createServer],
validateHeaderName: [Function: hidden],
validateHeaderValue: [Function: hidden],
get: [Function: get],
request: [Function: request],
maxHeaderSize: [Getter],
globalAgent: [Getter/Setter]
}
从这个结构,我们可以简单的分析一下nodejs如何来实现和处理http协议。可以分为以下几类。
HTTP协议相关
和HTTP协议相关的内容包括:
- METHODS: http方法常数,但好像内容比http标准方法多啊
- STATUS_CODES: 状态码常数,来自HTTP规范
- IncomingMessage: 入站消息,就是作为逻辑的接收方,接收到的数据
- OutgoingMessage: 出站消息,就是作为逻辑的发送方,发送的数据
- validateHeaderName: 方法,用于验证HTTP头项目的名称是否有效
- validateHeaderValue: 方法,用于验证HTTP头项目的值是否有效
- setMaxIdleHTTPParsers: 方法,用于设置 HTTP 解析器的最大空闲数量,通常用于调节HTTP数据处理性能
HTTP客户端相关
作为HTTP客户端相关的:
- Agent: 请求时使用的代理对象,也就是HTTP客户端对象
- ClientRequest: 创建请求的方法和所创建的请求对象,它继承了OutgoingMessage,用于处理发送数据
- request: 请求构造方法,可用于配置和发起HTTP请求,并直接通过回调函数处理响应信息
- get: 快速请求方法,实际上是一个request特例和简化
HTTP服务端相关
作为HTTP服务端相关包括以下内容:
- createServer:方法,用于创建服务器对象
- Server: HTTP服务器类
- ServerResponse: 在请求时,由服务器对象字段创建的,可以用于操作响应的响应对象
其中,Server类的核心属性和方法包括:
- listen: 侦听方法,此处使用给定的绑定地址和端口,启动网络侦听
- close/: 关闭连接,停止服务
- requestListener: 这是调用creatServer的时候,设置的请求侦听处理回调程序,它会自动传入两个参数:request和response
- request: 这是一个IncomingMessage对象实例,表示HTTP请求
- response: 这是一个ServerResponse(继承了OutgoingMessage)子类的实例,这是一个OutgoingMessage对象的扩展,表示HTTP响应
作为IncomingMessage类的实例,request在服务请求侦听回调参数中的核心属性和方法包括:
- headers: 请求的头信息
- method: 请求方法
- url: 请求地址
- on("data",data=>{}): 接收数据事件和处理程序,发送数据可能是分阶段或者信息流,数据的默认形式是buffer
- on("end", ()=>{}): 请求结束事件和处理程序,可以在请求结束时统一处理请求数据
其中,ServerResponse的核心属性和方法包括:
- socket: 请求和响应使用的网络插座对象
- socket.remoteAddress/remotePort: 远端的网络地址信息,即客户端IP地址和端口(请求端口可能是随机的)
- statusCode/statusMessage: 响应状态码和文本信息
- setHeader/setHeaders: 设置响应头信息
- writeHeader: 发送响应状态码和响应头信息
- write: 发送响应信息
- end: 结束响应,参数是结束时传输的内容
从http对象的结构和文档上来看,涉及到的相关内容是非常庞杂的。作为一般的Web应用开发,我们可能通常不会用到非常细节的特性,只需要处理上述几个核心环节就可以了。
而且,在实际的开发组织工作中,我们也不会直接nodejs提供的这些非常基础和底层的http特性,而是使用一些功能更加完善,应用更加成熟的Web框架技术,如Express、fastify等等。虽然这些框架的技术基础应该也是nodejs http模块,但它们在其上面做了很多扩展和封装工作,比如路由,内容解析,自动编码等等,方便更加复杂的Web应用开发。笔者另有其他的章节讨论相关议题。
HTTP Trailers
trailer的原意是拖车,在HTTP协议中,它通常用于在HTTP响应中添加额外的头部信息,而且是在响应主体发送完毕后进行操作,添加的信息可以包括如Cookie或其他状态信息等等。
要使用Trailer,需要先在HTTP头部声明 Transfer-Encoding: trailers。然后就可以在body尾部,或者发送数据之后,声明并且设置Trailer了。另外,要想获取trailer的内容,通常只能在onEnd事件中处理。在http模块的message类中,就有trailers的相关属性和addTrailers等相关方,例如下面的代码:
js
message.writeHead(200, { 'Content-Type': 'text/plain',
'Trailer': 'Content-MD5' });
message.write(fileData);
message.addTrailers({ 'Content-MD5': '7895bf4b8828b55ceaf47747b4bca667' });
message.end();
虽然HTTP协议设计了这个功能,看起来也可以使请求响应的过程处理更加灵活,但笔者尚未发现其很好的应用场景或者案例。
完整参考示例
为了让读者更好的理解前面的内容,和http模块的基本工作原理和相对完整的流程,笔者编写了下列的参考示例代码:
js
const
http = require("http"),
HOST = "127.0.0.1",
PORT = 8087;
const doRequest = async()=>{
const postData = JSON.stringify({
name: "John",
time: 0 | Date.now()/1000,
});
const options = {
hostname: HOST,
port: PORT,
path: '/upload',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
},
};
const req = http.request(options, (res) => {
console.log(`STATUS: ${res.statusCode}`);
console.log(`HEADERS: ${JSON.stringify(res.headers)}`);
res.setEncoding('utf8');
let data =[];
res.on('data', chunk => data.push(chunk));
res.on('end', () => {
console.log("Response:");
console.log(data.join(""));
console.log("Requet Ended.");
});
});
req.on('error', (e) => {
console.error(`problem with request: ${e.message}`);
});
// Write data to request body
req.write(postData);
req.end();
};
const handle = (req, res)=>{
console.log("Client:", req.socket.remoteAddress, req.socket.remotePort);
console.log("Url:", req.url);
let body = [];
req
.on("data", data=>body.push(data))
.on("end",()=>{
let qobject = JSON.parse(Buffer.concat(body).toString());
console.log("Request:\n",qobject);
res.write('hello!\n');
res.write("Welcome:" + qobject.name+ qobject.time);
res.end();
});
};
// start Server
setTimeout(()=>http
.createServer(handle)
.listen(PORT, HOST, null, ()=>{
console.log("Server Started:", HOST, PORT);
}),1000);
// do request
setInterval(doRequest,3000);
这里没有使用掘金的代码片段,是因为服务启动侦听后,端口会被占用,程序无法重复执行,:(。读者可以复制到自己的nodejs环境中直接执行,无需任何外部依赖。
HTTP/2
相对而言,HTTP/2是一个更新的协议,已经于2015年(基本上是和HTML5同时)发布。作为HTTP协议的第二个主要版本,笔者觉得它并不是一个HTTP协议的简单升级,而是从某种意义上是一个全新的设计,主要目的是解决一些HTTP协议的固有缺陷和问题,比如它包括下面的一些主要特性:
- 多路复用:允许客户端和服务器同时发送多个请求和响应,而无需等待前一个请求/响应完成,可以显著提高网络吞吐量。
- 头部压缩:使用HPACK算法来压缩HTTP头部,减少需要传输的数据量
- 流量控制:使用流量控制来防止客户端/服务器的无效数据发送,避免和减缓网络拥塞
- 优先级:允许客户端指定请求优先级,提供业务灵活性
- 双向通信:允许客户端/服务器进行双向通信,能够提供业务灵活性和简化应用开发
nodejs有专门的http2模块,可以用于处理客户端、服务端和session等方面的问题。由于在现阶段,这个特性的使用并不是特别主流和广泛,我们只做一些概念性的探讨,不会过于深入。
HTTP2客户端
下面是一个使用HTTP2协议客户端发起请求的示例代码:
js
const http2 = require('node:http2');
const fs = require('node:fs');
const client = http2.connect('https://localhost:8443', {
ca: fs.readFileSync('localhost-cert.pem'),
});
client.on('error', (err) => console.error(err));
const req = client.request({ ':path': '/' });
// 以下内容基本同标准HTTP请求
...
可以看到,主要修改的地方,就是相对明确的提出了一个"连接后请求"的概念,并且强化了对HTTPS协议的要求。另外,这种两阶段请求的方式,应该可以在后续,在同一个域中重复发起请求,并且可以共享已经建立的连接,这样可以提高网络请求的性能。
HTTP2 Session
和传统的HTTP类不同的是,HTTP2的类有一个新的Session子类用于处理HTTP2 Session相关的功能。这也体现出HTTP2协议的一个很重要的特性,就是面向连接提供了很多扩展的特性,例如可以承载多个stream来进行数据的请求和响应处理。笔者认为,也可以简单的将session就理解成一个连接,在HTTP2中的连接是可以复用的。
Session继承自EventEmitter,所以它的所有方法都是事件回调方法。下面的代码,应该能够帮助我们理解其在服务端和客户端的一些应用方式:
js
const http2 = require('node:http2');
// 服务端
const server = http2.createServer();
const expectedWindowSize = 2 ** 20;
server.on('connect', (session) => {
// Set local window size to be 2 ** 20
session.setLocalWindowSize(expectedWindowSize);
});
// 客户端
const client = http2.connect('https://example.org');
client.on('altsvc', (alt, origin, streamId) => {
console.log(alt);
console.log(origin);
console.log(streamId);
});
在比较简单的应用场景中,涉及到session的使用并不多,而是直接使用HTTP2Stream,比较直观和简单。
HTTP2 Stream
在HTTP/2协议中,流是 HTTP/2 的核心概念,HTTP/2中的连接可以包含多个流,每个流对应一个HTTP请求或响应,是数据传输的基本单位。基于这个新的理念和设计,nodejs提供了HTTP2Stream类,和相关一系列属性和方法,来帮助操作这个数据流。在HTTP/2对象中,请求和响应对象,都是某种流的实例。
我们来看一段简单的代码:
js
const http2 = require('node:http2');
const server = http2.createServer();
server.on('stream', (stream) => {
stream.respond({ ':status': 200 });
stream.pushStream({ ':path': '/' }, (err, pushStream, headers) => {
if (err) throw err;
pushStream.respond({ ':status': 200 });
pushStream.end('some pushed data');
});
stream.end('some data');
});
这段代码揭示了一些stream的特性和信息:
- stream是在server的onStream事件中创建和获取的
- HTTP2支持push数据,并使用pushStream实现
- stream的一般用法,其实是和http的response是相同的
小结
本文的主要讨论内容是nodejs作为一个主要用于Web应用开发的技术平台,提供的HTTP协议支持的相关技术和模块。这是nodejs的一个核心特性,每一个Web应用开发者,都应该比较好的理解和掌握这一部分的知识和技能。