三 文章首页 实时留言 网络邻居 开往 虫洞
返回

不使用 Nginx 的网站开发指南

同时托管多个子域名站点并支持 https
2025-01-22 06:10:56
分类: Web开发 标签: node.js,Bun

现在主流的做法,一般情况下,无论使用什么 web 框架,在上线期间几乎绕不开的话题,也就是需要 Nginx / Apache 这些专门的 web 服务器托管我们的程序。它们能方便的管理我们的服务,例如做反向代理,给不同的域名站点配置 ssl 证书等等。

那有没有一种可能我们可以不用他们,而自己实现呢,那当然可以。

现在 web 开发平台 / 现代编程语言都会有 http 标准库,里面提供了各种各样的 api。

如果你只有一台服务器的情况下,并且用不到分布式和负载均衡。只需要一个快速并且轻量级的单体服务器,那么本文的思路可能会对你有所帮助。

网站服务的基本原理


有学过计算机的朋友应该懂,从浏览器访问网站的过程是基于 HTTP 协议的。当输入域名并回车时,浏览器会首先查询 DNS 服务器,解析域名对应的 IP 地址。随后,浏览器会向该 IP 地址发起 HTTP 请求,并在请求头中包含各种字段,包括 Host 等(即域名)。

如果服务器程序在该 IP 地址上运行并监听 80 端口,并且能够返回数据,那么浏览器在接收到服务器的响应后,会解析 HTTP 报文并显示内容给用户,从而完成一次网站服务。

接下来,我会用 Node.js 和 Bun 来编写程序来演示(当然,你可以选择自己喜欢的编程平台或语言,因为这些概念是通用的)。

实现一个简易的 HTTP Server


在 Bun 中实现一个 HTTP 服务器非常简单。以下代码监听 80 端口,并对所有请求返回 "Hello" 文本。

  • server.ts
Bun.serve({
    port: 80,
    fetch(){
        return new Response("Hello")
    }
})

执行 sudo bun run ./server.ts 后,在本地浏览器中访问 http://0.0.0.0http://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-typetext/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 解析)


我从前面有讲到,浏览器访问域名地址时,会在往 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
  • 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 扩展了。

什么是 SNI(Server Name Indication)


在 TLS/SSL 握手过程中,客户端和服务器在交换密钥之前,客户端一般会通过发送一个包含服务器名称指示(Server Name Indication)的 ClientHello 消息来告诉服务器它想要连接的域名。此时就可以根据 SNI 扩展中的域名信息确定客户端请求的是哪个域名服务。然后服务器可以根据这个域名选择合适的证书。像我们熟悉的 Nginx 就是需要 SNI 来支持这个功能。

实现多个子域名站点的 https 分发


一般主流高级语言都提供了对应的实现,而我熟悉的 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 分发问题。

强制 https(301重定向)

此时如果想要将 http 都重定向到 https,那也很简单。起一个 80 端口服务器,专门判断 Host 字段并且通过以下代码进行 301 重定向即可。

  • Bun(server.ts)实现方式
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})
    }
})
  • Node.js(server.cjs)实现方式
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 字段即可。 通常有 deflategzipbr 等。

格式DeflategzipBrotli(Br)
压缩算法LZ77 + 哈夫曼编码Deflate + 头部/校验LZ77 变种 + 上下文建模
压缩率
CPU 占用
兼容性极佳现代浏览器支持
用途ZIP/PNG 文件HTTP压缩/文件归档Web静态资源优化

下面将以 gzip 压缩为例子:

  • Bun(server.ts)实现方式
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'
    }})
}
  • Node.js(server.cjs)实现方式
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压缩。此贴将告一段落,如果有任何问题,欢迎留言和指正。