当前位置: 首页 > 后端技术 > Node.js

作为一个前端工程师,难道你不知道如何用Node写一个静态文件服务器吗?

时间:2023-04-03 23:26:54 Node.js

背??景作为前端工程师,想必大家对于静态文件服务器一定不陌生。所谓静态文件服务器的工作就是将我们的前端静态文件(.js/.css/.html)传给浏览器,然后由浏览器渲染我们的页面。我们常用的webpack-dev-server是本地开发的静态文件服务器,一般线上环境我们都会用nginx,因为它更稳定高效。现在静态文件服务器无处不在,它们是怎么做到的呢?本文将带您完成高效静态文件服务器的实现。功能介绍我们的静态服务器包括以下两个功能:当用户请求的内容是文件夹时,显示当前文件夹的结构信息;当用户请求的内容是文件时,返回文件的内容看看实际效果吧,服务端的静态文件目录是这样的:static└──index.html访问localhost:8080获取根目录信息:根目录下只有一个index.html文件。我们可以点击index.html文件来获取这个文件的具体内容:代码实现根据上面的需求描述,我们先用流程图来设计一下我们的逻辑如何实现:其实就是实现思路静态文件服务器还是很简单的:先判断资源不存在,不存在直接报错。如果资源存在,则根据资源的类型返回相应的结果给客户端。基本代码实现看完上面的流程图,相信大家的思路基本都清楚了,接下来我们看具体的代码实现:consthttp=require('http')consturl=require('url')constfs=require('fs')constpath=require('path')constprocess=require('process')//获取服务器的工作目录,即代码运行的目录constROOT_DIR=process.cwd()常量服务器=http。createServer(async(req,resp)=>{constparsedUrl=url.parse(req.url)//删除前导'/'以获取资源的相对路径,例如:`/static`变为`static`constparsedPathname=parsedUrl.pathname.slice(1)//获取资源在服务器上的绝对路径constpathname=path.resolve(ROOT_DIR,parsedPathname)try{//读取资源信息,fs.Stats对象conststat=awaitfs.promises.stat(pathname)if(stat.isFile()){//如果请求的资源是文件,则将其发送给sendFile函数处理sendFile(resp,pathname)}else{//如果请求的资源是一个文件夹,将其发送到sendDirectory函数处理sendDirectory(resp,pathname)}}catch(error){//访问的资源不存在if(error.code==='ENOENT'){resp.statusCode=404resp.end('文件/目录不存在')}else{resp.statusCode=500resp.end('somethingwrongwiththeserver')}}})server.listen(8080,()=>{console.log('serverisupandrunning')})在上面的代码中我使用了http模块A服务器实例被创建,它定义了一个处理所有HTTP请求的处理函数。处理函数的实现比较简单。读者可以根据上面的代码注释来理解。这里我想解释一下为什么我使用fs.promises.stat来获取资源的元信息(fs.Stats类,包括资源类型和变化时间等),而没有使用可以实现相同功能的fs.stat和fs.statSyncfunction:fs.promises.statvsfs.stat:fs.promises.stat是promise风格的,可以使用async和await来实现异步逻辑,代码非常干净。但是fs.stat是callback-style的,在这个API中写异步逻辑最终可能会变成意大利面条,给后期维护带来困难。fs.promises.statvsfs.statSync:fs.promises.stat读取文件信息是一个异步操作,不会阻塞主线程的执行。而fs.statSync是同步的,也就是说执行这个API时,JS主线程会卡住,无法处理其他资源请求。这里我也建议,当需要在服务器端读写文件系统时,一定要优先使用异步API,避免使用同步API。然后我们看一下sendFile和sendDirectory这两个函数的具体实现:constsendFile=async(resp,pathname)=>{//使用promise风格的readFileAPI异步读取文件的数据,然后返回constdata=awaitfs.promises.readFile(pathname)resp.end(data)}constsendDirectory=async(resp,pathname)=>{//使用promise风格的readdirAPI异步读取目录信息文件夹,然后返回ForclientconstfileList=awaitfs.promises.readdir(pathname,{withFileTypes:true})//这里保存子资源相对于根目录的相对路径,以便客户端继续访问子资源constrelativePath=path。relative(ROOT_DIR,pathname)//构造返回的html结构letcontent='

    'fileList.forEach(file=>{content+=`
  • ${file.name}${file.isDirectory()?'/':''}
  • `})content+='
'//返回当前目录给客户端resp.end(`

Contentof${relativePath||'rootdirectory'}:

${content}`)}sendDirectory通过fs.promises获取其下的目录信息。readdir,然后以列表的形式返回一个html结构给客户端。这里值得一提的是,由于客户端需要根据返回的子资源信息进一步访问子资源,所以我们需要记录子资源相对于根目录的相对路径。sendFile函数的实现比sendDirectory简单。它只需要读取文件的内容并返回给客户端。上面的代码写完之后,其实我们已经实现了上面提到的需求,但是这个服务器是不能用于生产的,因为它还有很多潜在的问题没有解决,那我们就看看如何解决这些问题来优化我们的服务器代码。大文件优化让我们看看在当前实现下当客户端请求大文件时会发生什么。首先,我们在静态文件夹中准备一个大文件test.txt。HelloWorld有1000万行!在这个文件中,文件大小为124M:然后我们启动服务器,在服务器启动后查看Node的内存使用情况:可以看到现在Node服务只占用了8.5M内存,我们访问test.txt中的浏览器:浏览器疯狂输出HelloWorld!这时候再看看Node的内存占用情况:内存占用突然从8.5M增加到132.9M,增加的资源差不多是文件大小124M,这是为什么呢?我们再看一下sendFile文件的实现:constsendFile=async(resp,pathname)=>{//readFile会读取文件的数据,存入数据变量constdata=awaitfs.promises。readFile(pathname)resp.end(data)}在上面的代码中,其实我们会读取一次文件的内容,并保存在data变量中,也就是说,我们会将124M的文本信息保存在记忆!试想一下,如果多个用户同时访问大量资源,我们的程序肯定会因为内存爆炸而OOM(OutofMemory)。那么如何解决这个问题呢?其实node提供的stream模块可以很好的解决我们的问题。Stream先来看看官方对stream的介绍:Stream是Node.js中处理流数据的抽象接口。Node.js提供了许多流对象。例如,对HTTP服务器和进程的请求。stdout都是流实例。流可以是可读的、可写的或两者兼而有之。所有的流都是EventEmitter的实例,简单来说,stream就是给我们流式传输数据的,那什么是流处理呢?用最简单的话来说:与其一次处理所有数据,不如一点一点地处理它们。使用stream,我们要处理的数据会一点一点的加载到内存的某个固定大小的区域(buffer)中,供其他消费者消费。由于保存数据的缓冲区的大小一般是固定的,在旧数据处理完后才会加载新数据,这样可以避免内存崩溃。话不多说,我们用stream来重构上面的sendFile函数:constsendFile=async(resp,pathname)=>{//为要读取的文件创建一个可读流readableStreamconstfileStream=fs.createReadStream(pathname)fileStream.pipe(resp)}在上面的代码中,我们为要读取的文件创建一个可读流(ReadableStream),然后把这个流连接(pipe)到resp对象上,这样文件的数据就会源源不断发送给客户端。看到这里,你可能会问,为什么resp对象可以和fileStream连接起来呢?原因是resp对象底层是一个可写流(WritableStream),可读流的pipe函数接收的是可写流。优化之后我们再去请求那个大的test.txt文件,浏览器会疯狂输出,但是此时Node服务的内存占用是这样的:Node的内存基本稳定在9.0M,只比服务刚启动时多了0.5M!由此可见,我们通过流优化确实取得了不错的效果。由于文章篇幅所限,关于如何使用stream的API就不做详细介绍了。需要了解的同学可以自行查看官方文档。减少文件传输带宽使用stream确实可以减少服务端的内存使用,但是并没有减少服务端和客户端传输数据的大小。换句话说,如果我们的文件大小是2M,实际上我们会把2M的数据传给客户端。如果客户端是手机或者其他移动设备,这么大的带宽消耗肯定是不可取的。这时候我们就需要对传输的数据进行压缩,然后在客户端进行解压,这样可以大大减少传输的数据量。服务器端有很多数据压缩算法。这里我使用一种比较常用的gzip算法。让我们看看如何更改sendFile以支持数据压缩://引入zlib包constzlib=require('zlib')constsendFile=async(resp,pathname)=>{//通过header告诉客户端:服务器使用gzip压缩算法resp.setHeader('Content-Encoding','gzip')//创建可读流constfileStream=fs.createReadStream(pathname)//文件流先经过zip处理后发送给resp对象一个转换流(TransformStream),这个流是可读可写的(ReadableandWritableStream),所以它就像一个转换器,处理输入的数据,然后输出到下游的可写流。我们请求index.html文件看看优化后的效果:上图中,第一行的请求是没有经过gzip压缩的请求大小,大约2.6kB,经过gzip压缩后传输的数据一下子变成了373B。优化效果非常显着!使用浏览器缓存数据压缩虽然解决了服务端与客户端数据传输的带宽问题,但并没有解决数据重复传输的问题。我们知道,一般来说,服务器的静态文件是很少变化的。在服务端资源没有变化的前提下,同一个客户端多次访问同一个资源,服务端会传输相同的数据。这种情况下比较有效的办法是:服务端告诉客户端资源没有变化,可以直接使用缓存。浏览器缓存有多种方式,包括协商缓存和强缓存。关于这两种缓存的区别,我想网上很多文章都说的很清楚了,这里就不多说了。这篇文章主要想说说如何实现强缓存的Etag机制。什么是Etag其实Etag(Entity-Tag)可以理解为文件内容的指纹。如果文件内容发生变化,指纹很可能会发生变化。这里注意,我用的是大概率而不是绝对,因为HTTP1.1协议并没有规定具体的etag生成算法是什么,完全由开发者决定。通常,对于一个文件,etag是由文件长度+变化时间生成的。其实会出现浏览器无法读取最新文件内容的情况,但这不是本文的重点。有兴趣的同学可以参考网上的其他资料。下面举例说明基于etag的协商缓存过程:具体过程如下:当浏览器第一次请求服务器的资源时,服务器会在Response中设置当前资源的etag信息,例如,Etag:5d-1834e3b6ea2第二次请求服务器资源时,会在请求头的If-None-Match字段中加入最新的etag信息5d-1834e3b6ea2。服务器收到请求,解析出If-None-Match字段,与最新的服务器etag进行比较。如果相同,则向浏览器返回304,表示资源未更新。如果资源发生变化,则将最新的etag设置到头部并将最新的资源返回给浏览器。然后我们看看sendFile函数是如何支持etag的://这个函数会根据文件的fs.Stats信息计算出etagconstcalculateEtag=(stat)=>{//文件的大小constfileLength=stat.size//文件结束变化时间constfileLastModifiedTime=stat.mtime.getTime()//数字以十六进制表示return`${fileLength.toString(16)}-${fileLastModifiedTime.toString(16)}`}constsendFile=async(req,resp,stat,pathname)=>{//文件的最新etagconstlatestEtag=calculateEtag(stat)//客户端的etagconstclientEtag=req.headers['if-none-match']//客户端可以使用缓存if(latestEtag==clientEtag){resp.statusCode=304resp.end()return}resp.statusCode=200resp.setHeader('etag',latestEtag)resp.setHeader('Content-Encoding','gzip')constfileStream=fs.createReadStream(pathname)fileStream.pipe(zlib.createGzip()).pipe(resp)}在上面的代码中,我添加了一个函数calculateEtag来计算etag,它会根据文件的大小而变化,lastTime计算出文件最新的etag信息。然后我也修改了sendFile的函数签名,接收两个新的参数:req(HTTP请求体)和stat(文件信息,fs.Stats类)。sendFile会先判断客户端的etag和服务端的etag是否相同,如果相同则返回304给客户端;否则,返回文件的最新内容,并在header中设置最新的etag信息。同样我们再次访问index.html文件验证优化效果:上图中我们可以看到浏览器在第一次请求资源的时候并没有缓存资源,服务器返回的是最新的内容文件和200状态代码。本次请求的实际带宽为396B。第二次请求,由于浏览器有缓存,服务器资源没有更新,服务器返回了304状态码,但没有返回实际的文件内容。此时文件的实际带宽为113B!可见优化效果明显。我们稍微改动一下index.html的内容,验证一下客户端是否会拉取最新的数据:从上图可以看出,当index.html更新后,旧的etag失效,浏览器可以拿到最新的数据.最后,我们来看看这三个请求的细节。下面是第一次请求,服务端返回etag信息给浏览器:然后是第二次请求,客户端在请求服务端资源时带上etag信息:第三次请求,etag无效,获取新数据:值得一提的是这里我们仅通过etag实现浏览器缓存,不完整,实际静态服务器可能会添加数据基于Expires/Cache-Control的强缓存和基于Last-Modified/Last-Modified-Since协商缓存的优化。总结一下这篇文章,我首先实现了一个最简单好用的静态文件服务器,然后通过解决实际使用中会遇到的三个问题来优化我们的代码,最终完成了一个简单高效的静态文件服务器。如上所述,由于篇幅所限,我们在实现中还遗漏了很多东西,比如MIME类型设置、对deflate等更多压缩算法的支持以及对Last-Modified/Last-Modified等更多缓存方式的支持——sinetc.其实这些内容在掌握了以上方法之后就可以很容易的实现了,就留给大家真正需要的时候自己去实现吧。创造个人技术动力并不容易。如果你从这篇文章中学到了什么,请给我点赞或关注。您的支持是我继续创作的最大动力!同时欢迎关注公众号攻略葱一起学习成长