现在主流的做法,一般情况下,无论使用什么 web 框架,在上线期间几乎绕不开的话题,也就是需要 Nginx / Apache 这些专门的 web 服务器托管我们的程序。它们能方便的管理我们的服务,例如做反向代理,给不同的域名站点配置 ssl 证书等等。
那有没有一种可能我们可以不用他们,而自己实现呢,那当然可以。
现在 web 开发平台 / 现代编程语言都会有 http 标准库,里面提供了各种各样的 api。
如果你只有一台服务器的情况下,并且用不到分布式和负载均衡。只需要一个快速并且轻量级的单体服务器,那么本文的思路可能会对你有所帮助。
有学过计算机的朋友应该懂,从浏览器访问网站的过程是基于 HTTP 协议的。当输入域名并回车时,浏览器会首先查询 DNS 服务器,解析域名对应的 IP 地址。随后,浏览器会向该 IP 地址发起 HTTP 请求,并在请求头中包含各种字段,包括 Host 等(即域名)。
如果服务器程序在该 IP 地址上运行并监听 80 端口,并且能够返回数据,那么浏览器在接收到服务器的响应后,会解析 HTTP 报文并显示内容给用户,从而完成一次网站服务。
接下来,我会用 Node.js 和 Bun 来编写程序来演示(当然,你可以选择自己喜欢的编程平台或语言,因为这些概念是通用的)。
在 Bun 中实现一个 HTTP 服务器非常简单。以下代码监听 80 端口,并对所有请求返回 "Hello" 文本。
Bun.serve({
port: 80,
fetch(){
return new Response("Hello")
}
})
执行 sudo bun run ./server.ts
后,在本地浏览器中访问 http://0.0.0.0
或 http://localhost
, 可以看到 "Hello" 的输出。此时,Bun 响应的 HTTP 报文如下:
HTTP/1.1 200 OK
content-type: text/plain;charset=utf-8
Date: Mon, 20 Jan 2025 19:22:02 GMT
Content-Length: 5
Hello
如果要返回一个网页,只需读取 HTML 文件内容,并在响应头中设置 content-type
为 text/html
,以告知浏览器这是一个 HTML 网页。具体可以了解一下:MIME (Multipurpose Internet Mail Extensions) 描述了消息内容类型的标准,用来表示文档、文件或字节流的性质和格式。可参见 MDN.
Bun.serve({
port: 80,
fetch(){
return new Response(Bun.file('./index.html'),{
headers: {
'content-type': 'text/html;charset=utf-8'
}
})
}
})
此时响应报文会像这样:
HTTP/1.1 200 OK
Content-Type: text/html;charset=utf-8
content-length: 208
Date: Tue, 21 Jan 2025 07:10:57 GMT
<!DOCTYPE html>
<html lang="cn">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
你好世界
</body>
</html>
浏览器在接收到这个报文后,会解析 HTML 并渲染内容给用户。
已经了解网站服务如何提供,那么域名是怎么绑定在这个服务上的呢?
我从前面有讲到,浏览器访问域名地址时,会在往 DNS 服务器去查询并解析域名的 ip 来路。
当你从 IDC 或域名注册商购买域名时,他们通常会提供域名解析服务。IDC 会运行自己的权威 DNS 服务器,这些解析记录会被同步到全球的 DNS 系统中。其他 DNS 服务器在解析你的域名时,会向这些权威 DNS 服务器查询。
通俗来讲,你需要告诉 DNS 服务器,你的域名应该指向哪个 IP 地址。这一般在域名提供商的后台面板中完成。
在完成域名解析后,我们就可以通过域名代替 ip 来访问服务器了,而接下来就是关于如何应对多个子域名分发的问题。假如我们只是绑定了主域名如: xxx.cn ,但我们可能还有很多 xxx.xxx.cn 这样的子域名需要提供服务。
在使用 Nginx 服务器时,通常使用反向代理来实现不同子域名的内容分发。客户端首先请求 Nginx,Nginx 根据请求信息将请求转发给内部对应的 Web 应用服务器(通常占用非 80 端口,如 localhost:8888、localhost:3000 等)。应用服务器处理完请求后,将结果返回给 Nginx,Nginx 再返回给客户端。
这是一个标准的 "反向代理" 过程。
然而,如果我们仍然采用这种方式,那这篇文章就没有意义了。我们可以编写一个类似的分发逻辑,但不需要额外的 TCP 开销。关键在于,浏览器在发起请求时会携带 Host 字段。
我们可以监听所有请求,并根据 Host 字段自定义分发不同的项目。(这里将用我的域名来举例)
目录结构如下:
├─ projects
│ ├─ demo1
│ │ └─ index.html
│ └─ demo2
│ └─ index.html
└─ server.ts
Bun.serve({
port: 80,
fetch(req: Request){
let headers = {'content-type': 'text/html;charset=utf-8'}
if(req.headers.get('host')=='glumi.cn'){
return new Response(Bun.file('./projects/demo1/index.html'),{ headers })
}
if(req.headers.get('host')=='blog.glumi.cn'){
return new Response(Bun.file('./projects/demo2/index.html'),{ headers })
}
return new Response(null,{status: 404})
}
})
如果访问了 http://glumi.cn 会去加载 demo1 的网页,而 http://blog.glumi.cn 是加载 demo2 的网页,都不成立的话就返回一个 404。
为了易于理解,上面这是一个非常极简的例子,实际具体取决于你怎么融会贯通。
能够自定义分发后此时是不是就已经够了?那当然不是,也就是这仅仅只对 http 协议有效,我们还没解决 https 问题。
因为咱们域名不同,证书也不同,而 443 端口也只能一个服务占用。并且 https 必须要 server 返回一张证书才能和浏览器建立连接。服务器无法在握手阶段之前知道客户端请求的具体域名,因此无法直接根据域名动态分发证书。
此时就需要了解什么是 SNI(服务器名称指示)这个 TLS 扩展了。
在 TLS/SSL 握手过程中,客户端和服务器在交换密钥之前,客户端一般会通过发送一个包含服务器名称指示(Server Name Indication)的 ClientHello 消息来告诉服务器它想要连接的域名。此时就可以根据 SNI 扩展中的域名信息确定客户端请求的是哪个域名服务。然后服务器可以根据这个域名选择合适的证书。像我们熟悉的 Nginx 就是需要 SNI 来支持这个功能。
一般主流高级语言都提供了对应的实现,而我熟悉的 Bun 还有 Node.js 也都支持。不过 Bun 实现起来会更容易,Node.js 的代码会稍多一些。(这就是我为什么会使用 Bun,个人非常推荐)
在 Node.js 下,需要这样:
const https = require('https');
const tls = require('tls')
const fs = require('fs');
// 定义域名和对应的证书路径
const domains = {
'glumi.cn': {
key: fs.readFileSync('ssl/main_ssl.key'),
cert: fs.readFileSync('ssl/main_ssl.cert')
},
'blog.glumi.cn': {
key: fs.readFileSync('ssl/blog_ssl.key'),
cert: fs.readFileSync('ssl/blog_ssl.cert')
}
};
// 创建 HTTPS 服务器
const server = https.createServer({
SNICallback: (domain, cb) => {
// 根据域名动态选择证书
if (domains[domain]) {
const context = tls.createSecureContext({
key: domains[domain].key,
cert: domains[domain].cert
})
cb(null,context);
}
}
});
// 处理请求
server.on('request', (req, res) => {
// 分发对应的站点(为了演示直观这里是写死的,但你可以自己定制)
res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' });
if(req.headers.host=='glumi.cn'){
res.end(fs.readFileSync('./projects/demo1/index.html','utf-8'))
}
if(req.headers.host=='blog.glumi.cn'){
res.end(fs.readFileSync('./projects/demo2/index.html','utf-8'))
}
});
// 启动服务器
server.listen(443, () => {
console.log('服务器已启动');
});
在 Bun 下的实现代码:
Bun.serve({
port: 443,
tls: [
{
key: Bun.file(`ssl/main_ssl.key`),
cert: Bun.file("ssl/main_ssl.cert"),
serverName: "glumi.cn"
},
{
key: Bun.file("ssl/blog_ssl.key"),
cert: Bun.file("ssl/blog_ssl.cert"),
serverName: "blog.glumi.cn"
}
],
fetch(req: Request){
let headers = {'content-type': 'text/html;charset=utf-8'}
if(req.headers.get('host')=='glumi.cn'){
return new Response(Bun.file('./projects/demo1/index.html'),{ headers })
}
if(req.headers.get('host')=='blog.glumi.cn'){
return new Response(Bun.file('./projects/demo2/index.html'),{ headers })
}
return new Response(null,{status: 404})
}
})
这样一来就解决了不同子域名站点下 https 分发问题。
此时如果想要将 http 都重定向到 https,那也很简单。起一个 80 端口服务器,专门判断 Host 字段并且通过以下代码进行 301 重定向即可。
Bun.serve({
port: 80,
fetch(req: Request){
if(req.headers.get('host')=='glumi.cn'){
return new Response(null,{status: 301,headers: {Location: 'https://glumi.cn'}})
}
if(req.headers.get('host')=='blog.glumi.cn'){
return new Response(null,{status: 301,headers: {Location: 'https://blog.glumi.cn'}})
}
return new Response(null,{status: 404})
}
})
const httpServer = http.createServer()
httpServer.on('request', (req,res)=>{
if(req.headers.host=='glumi.cn'){
res.writeHead(301,{location: 'https://glumi.cn'}).end()
}
if(req.headers.host=='blog.glumi.cn'){
res.writeHead(301,{location: 'https://blog.glumi.cn'}).end()
}
})
httpServer.listen(80, ()=>{
console.log('http 服务已启动')
})
一般现代浏览器会在请求的时候发送 accept-encoding
头,会告诉服务器支持哪种压缩编码格式,此时服务器可以返回一个特定格式并在响应头写一个 Content-Encoding
字段即可。
通常有 deflate
、gzip
、br
等。
格式 | Deflate | gzip | Brotli(Br) |
---|---|---|---|
压缩算法 | LZ77 + 哈夫曼编码 | Deflate + 头部/校验 | LZ77 变种 + 上下文建模 |
压缩率 | 低 | 中 | 高 |
CPU 占用 | 低 | 中 | 高 |
兼容性 | 差 | 极佳 | 现代浏览器支持 |
用途 | ZIP/PNG 文件 | HTTP压缩/文件归档 | Web静态资源优化 |
下面将以 gzip 压缩为例子:
if(req.headers.get('accept-encoding')?.split(',').indexOf('gzip') != -1){
let buff: any = Buffer.from(page_html)
let compressed = Bun.gzipSync(buff)
return await new Response(compressed,{status:200,headers: {
'Content-Encoding': 'gzip',
'Content-Type': 'text/html'
}})
}
let html = fs.readFileSync('./projects/demo1/index.html','utf-8')
if(req.headers['accept-encoding'].split(',').indexOf('gzip') != -1){
let buf = Buffer.from(html)
res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8','Content-Encoding': 'gzip' });
let decodeBuffer = zlib.gzipSync(buf)
res.end(decodeBuffer)
}else {
res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' });
res.end(html)
}
至此,我们已经不需要借助 Nginx 这样的服务器并自己解决了多个不同子域名和站点下的分发问题,并且还支持了 https、301重定向、gzip压缩。此贴将告一段落,如果有任何问题,欢迎留言和指正。