koa源碼閱讀[3]-koa-send與它的衍生(static)

来源:https://www.cnblogs.com/jiasm/archive/2018/08/24/9527536.html
-Advertisement-
Play Games

koa源碼閱讀的第四篇,涉及到向介面請求方提供文件數據。 第一篇:koa源碼閱讀-0第二篇:koa源碼閱讀-1-koa與koa-compose第三篇:koa源碼閱讀-2-koa-router 處理靜態文件是一個繁瑣的事情,因為靜態文件都是來自於伺服器上,肯定不能放開所有許可權讓介面來讀取。各種路徑的校 ...


koa源碼閱讀的第四篇,涉及到向介面請求方提供文件數據。

第一篇:koa源碼閱讀-0
第二篇:koa源碼閱讀-1-koa與koa-compose
第三篇:koa源碼閱讀-2-koa-router

處理靜態文件是一個繁瑣的事情,因為靜態文件都是來自於伺服器上,肯定不能放開所有許可權讓介面來讀取。
各種路徑的校驗,許可權的匹配,都是需要考慮到的地方。
koa-sendkoa-static就是幫助我們處理這些繁瑣事情的中間件。
koa-sendkoa-static的基礎,可以在NPM的界面上看到,staticdependencies中包含了koa-send

koa-send主要是用於更方便的處理靜態文件,與koa-router之類的中間件不同的是,它並不是直接作為一個函數註入到app.use中的。
而是在某些中間件中進行調用,傳入當前請求的Context及文件對應的位置,然後實現功能。

koa-send的GitHub地址

原生的文件讀取、傳輸方式

Node中,如果使用原生的fs模塊進行文件數據傳輸,大致是這樣的操作:

const fs      = require('fs')
const Koa     = require('koa')
const Router  = require('koa-router')

const app     = new Koa()
const router  = new Router()
const file    = './test.log'
const port    = 12306

router.get('/log', ctx => {
  const data = fs.readFileSync(file).toString()
  ctx.body = data
})

app.use(router.routes())
app.listen(port, () => console.log(`Server run as http://127.0.0.1:${port}`))

 

或者用createReadStream代替readFileSync也是可行的,區別會在下邊提到

這個簡單的示例僅針對一個文件進行操作,而如果我們要讀取的文件是有很多個,甚至於可能是通過介面參數傳遞過來的。
所以很難保證這個文件一定是真實存在的,而且我們可能還需要添加一些許可權設置,防止一些敏感文件被介面返回。

router.get('/file', ctx => {
  const { fileName } = ctx.query
  const path = path.resolve('./XXX', fileName)
  // 過濾隱藏文件
  if (path.startsWith('.')) {
    ctx.status = 404
    return
  }

  // 判斷文件是否存在
  if (!fs.existsSync(path)) {
    ctx.status = 404
    return
  }

  // balabala

  const rs = fs.createReadStream(path)
  ctx.body = rs // koa做了針對stream類型的處理,詳情可以看之前的koa篇
})

 

添加了各種邏輯判斷以後,讀取靜態文件就變得安全不少,可是這也只是在一個router中做的處理。
如果有多個介面都會進行靜態文件的讀取,勢必會存在大量的重覆邏輯,所以將其提煉為一個公共函數將是一個很好的選擇。

koa-send的方式

這就是koa-send做的事情了,提供了一個封裝非常完善的處理靜態文件的中間件。
這裡是兩個最基礎的使用例子:

const path = require('path')
const send = require('koa-send')

// 針對某個路徑下的文件獲取
router.get('/file', async ctx => {
  await send(ctx, ctx.query.path, {
    root: path.resolve(__dirname, './public')
  })
})

// 針對某個文件的獲取
router.get('/index', async ctx => {
  await send(ctx, './public/index.log')
})

 

假設我們的目錄結構是這樣的,simple-send.js為執行文件:

.
├── public
│   ├── a.log
│   ├── b.log
│   └── index.log
└── simple-send.js

 

使用/file?path=XXX就可以很輕易的訪問到public下的文件。
以及訪問/index就可以拿到/public/index.log文件的內容。

koa-send提供的功能

koa-send提供了很多便民的選項,除去常用的root以外,還有大概小十個的選項可供使用:

optionstypedefaultdesc
maxage Number 0 設置瀏覽器可以緩存的毫秒數
對應的HeaderCache-Control: max-age=XXX
immutable Boolean false 通知瀏覽器該URL對應的資源不可變,可以無限期的緩存
對應的HeaderCache-Control: max-age=XXX, immutable
hidden Boolean false 是否支持隱藏文件的讀取
.開頭的文件被稱為隱藏文件
root String - 設置靜態文件路徑的根目錄,任何該目錄之外的文件都是禁止訪問的。
index String - 設置一個預設的文件名,在訪問目錄的時候生效,會自動拼接到路徑後邊 (此處有一個小彩蛋)
gzip Boolean true 如果訪問介面的客戶端支持gzip,並且存在.gz尾碼的同名文件的情況下會傳遞.gz文件
brotli Boolean true 邏輯同上,如果支持brotli且存在.br尾碼的同名文件
format Boolean true 開啟以後不會強要求路徑結尾的//path/path/表示的是一個路徑 (僅在path是一個目錄的情況下生效)
extensions Array false 如果傳遞了一個數組,會嘗試將數組中的所有item作為文件的尾碼進行匹配,匹配到哪個就讀取哪個文件
setHeaders Function - 用來手動指定一些Headers,意義不大

參數們的具體表現

有些參數的搭配可以實現一些神奇的效果,有一些參數會影響到Header,也有一些參數是用來優化性能的,類似gzipbrotli的選項。

koa-send的主要邏輯可以分為這幾塊:

  1. path路徑有效性的檢查
  2. gzip等壓縮邏輯的應用
  3. 文件尾碼、預設入口文件的匹配
  4. 讀取文件數據

在函數的開頭部分有這樣的邏輯:

const resolvePath = require('resolve-path')
const {
  parse
} = require('path')

async function send (ctx, path. opts = {}) {
  const trailingSlash = path[path.length - 1] === '/'
  const index = opts.index

  // 此處省略各種參數的初始值設置

  path = path.substr(parse(path).root.length)

  // ...

  // normalize path
  path = decode(path) // 內部調用的是`decodeURIComponent`
  // 也就是說傳入一個轉義的路徑也是可以正常使用的

  if (index && trailingSlash) path += index

  path = resolvePath(root, path)

  // hidden file support, ignore
  if (!hidden && isHidden(root, path)) return
}

function isHidden (root, path) {
  path = path.substr(root.length).split(sep)
  for (let i = 0; i < path.length; i++) {
    if (path[i][0] === '.') return true
  }
  return false
}

 

路徑檢查

首先是判斷傳入的path是否為一個目錄,(結尾為/會被認為是一個目錄)
如果是目錄,並且存在一個有效的index參數,則會將index拼接到path後邊。
也就是大概這樣的操作:

send(ctx, './public/', {
  index: 'index.js'
})

// ./public/index.js

 

resolve-path 是一個用來處理路徑的包,用來幫助過濾一些異常的路徑,類似path//file/etc/XXX 這樣的惡意路徑,並且會返回處理後絕對路徑。

isHidden用來判斷是否需要過濾隱藏文件。
因為但凡是.開頭的文件都會被認為隱藏文件,同理目錄使用.開頭也會被認為是隱藏的,所以就有了isHidden函數的實現。

其實我個人覺得這個使用一個正則就可以解決的問題。。為什麼還要分割為數組呢?

function isHidden (root, path) {
  path = path.substr(root.length)

  return new RegExp(`${sep}\\.`).test(path)
}

 

已經給社區提交了PR

壓縮的開啟與文件夾的處理

在上邊的這一坨代碼執行完以後,我們就得到了一個有效的路徑,(如果是無效路徑,resolvePath會直接拋出異常)
接下來做的事情就是檢查是否有可用的壓縮文件使用,此處沒有什麼邏輯,就是簡單的exists操作,以及Content-Encoding的修改 (用於開啟壓縮)

尾碼的匹配:

if (extensions && !/\.[^/]*$/.exec(path)) {
  const list = [].concat(extensions)
  for (let i = 0; i < list.length; i++) {
    let ext = list[i]
    if (typeof ext !== 'string') {
      throw new TypeError('option extensions must be array of strings or false')
    }
    if (!/^\./.exec(ext)) ext = '.' + ext
    if (await fs.exists(path + ext)) {
      path = path + ext
      break
    }
  }
}

 

可以看到這裡的遍歷是完全按照我們調用send是傳入的順序來走的,並且還做了.符號的相容。
也就是說這樣的調用都是有效的:

await send(ctx, 'path', {
  extensions: ['.js', 'ts', '.tsx']
})

 

如果在添加了尾碼以後能夠匹配到真實的文件,那麼就認為這是一個有效的路徑,然後進行了break的操作,也就是文檔中所說的:First found is served.

在結束這部分操作以後會進行目錄的檢測,判斷當前路徑是否為一個目錄:

let stats
try {
  stats = await fs.stat(path)

  if (stats.isDirectory()) {
    if (format && index) {
      path += '/' + index
      stats = await fs.stat(path)
    } else {
      return
    }
  }
} catch (err) {
  const notfound = ['ENOENT', 'ENAMETOOLONG', 'ENOTDIR']
  if (notfound.includes(err.code)) {
    throw createError(404, err)
  }
  err.status = 500
  throw err
}

 

一個小彩蛋

可以發現一個很有意思的事情,如果發現當前路徑是一個目錄以後,並且明確指定了format,那麼還會再嘗試拼接一次index
這就是上邊所說的那個彩蛋了,當我們的public路徑結構長得像這樣的時候:

└── public
    └── index
        └── index # 實際的文件 hello

 

我們可以通過一個簡單的方式獲取到最底層的文件數據:

router.get('/surprises', async ctx => {
  await send(ctx, '/', {
    root: './public',
    index: 'index'
  })
})

// > curl http://127.0.0.1:12306/surprises
// hello

 

這裡就用到了上邊的幾個邏輯處理,首先是trailingSlash的判斷,如果以/結尾會拼接index,以及如果當前path匹配為是一個目錄以後,又會拼接一次index
所以一個簡單的/加上index的參數就可以直接獲取到/index/index
一個小小的彩蛋,實際開發中應該很少會這麼玩

最終的讀取文件操作

最後終於來到了文件讀取的邏輯處理,首先就是調用setHeaders的操作。

因為經過上邊的層層篩選,這裡拿到的path和你調用send時傳入的path不是同一個路徑。
不過倒也沒有必要必須在setHeaders函數中進行處理,因為可以看到在函數結束時,將實際的path返回了出來。
我們完全可以在send執行完畢後再進行設置,至於官方readme中所寫的and doing it after is too late because the headers are already sent.
這個不需要擔心,因為koa的返回數據都是放到ctx.body中的,而body的解析是在所有的中間件全部執行完以後才會進行處理。
也就是說所有的中間件都執行完以後才會開始發送http請求體,在此之前設置Header都是有效的。

if (setHeaders) setHeaders(ctx.res, path, stats)

// stream
ctx.set('Content-Length', stats.size)
if (!ctx.response.get('Last-Modified')) ctx.set('Last-Modified', stats.mtime.toUTCString())
if (!ctx.response.get('Cache-Control')) {
  const directives = ['max-age=' + (maxage / 1000 | 0)]
  if (immutable) {
    directives.push('immutable')
  }
  ctx.set('Cache-Control', directives.join(','))
}
if (!ctx.type) ctx.type = type(path, encodingExt) // 介面返回的數據類型,預設會取出文件尾碼
ctx.body = fs.createReadStream(path)

return path

 

以及包括上邊的maxageimmutable都是在這裡生效的,但是要註意的是,如果Cache-Control已經存在值了,koa-send是不會去覆蓋的。

使用Stream與使用readFile的區別

在最後給body賦值的位置可以看到,是使用的Stream而並非是readFile,使用Stream進行傳輸能帶來至少兩個好處:

  1. 第一種方式,如果是大文件,在讀取完成後會臨時存放到記憶體中,並且toString是有長度限制的,如果是一個巨大的文件,toString調用會拋出異常的。
  2. 採用第一種方式進行讀取文件,是要在全部的數據都讀取完成後再返回給介面調用方,在讀取數據的期間,介面都是處於Wait的狀態,沒有任何數據返回。

可以做一個類似這樣的Demo:

const http      = require('http')
const fs        = require('fs')
const filePath  = './test.log'

http.createServer((req, res) => {
  if (req.url === '/') {
    res.end('<html></html>')
  } else if (req.url === '/sync') {
    const data = fs.readFileSync(filePath).toString()

    res.end(data)
  } else if (req.url === '/pipe') {
    const rs = fs.createReadStream(filePath)

    rs.pipe(res)
  } else {
    res.end('404')
  }
}).listen(12306, () => console.log('server run as http://127.0.0.1:12306'))

 

首先訪問首頁http://127.0.0.1:12306/進入一個空的頁面 (主要是懶得搞CORS了),然後在控制台調用兩個fetch就可以得到這樣的對比結果了:

sync-timeline
pipe-timeline

可以看出在下行傳輸的時間相差無幾的同時,使用readFileSync的方式會增加一定時間的Waiting,而這個時間就是伺服器在進行文件的讀取,時間長短取決於讀取的文件大小,以及機器的性能。

koa-static

koa-static是一個基於koa-send的淺封裝。
因為通過上邊的實例也可以看到,send方法需要自己在中間件中調用才行。
手動指定send對應的path之類的參數,這些也是屬於重覆性的操作,所以koa-static將這些邏輯進行了一次封裝。
讓我們可以通過直接註冊一個中間件來完成靜態文件的處理,而不再需要關心參數的讀取之類的問題:

const Koa = require('koa')
const app = new Koa()
app.use(require('koa-static')(root, opts))

 

opts是透傳到koa-send中的,只不過會使用第一個參數root來覆蓋opts中的root
並且添加了一些細節化的操作:

  • 預設添加一個index.html
    if (opts.index !== false) opts.index = opts.index || 'index.html'

     

  • 預設只針對HEADGET兩種METHOD
    if (ctx.method === 'HEAD' || ctx.method === 'GET') {
    // ...
    }

     

  • 添加一個defer選項來決定是否先執行其他中間件。
    如果deferfalse,則會先執行send,優先匹配靜態文件。
    否則則會等到其餘中間件先執行,確定其他中間件沒有處理該請求才會去尋找對應的靜態資源。
    只需指定root,剩下的工作交給koa-static,我們就無需關心靜態資源應該如何處理了。

小結

koa-sendkoa-static算是兩個非常輕量級的中間件了。
本身沒有太複雜的邏輯,就是一些重覆的邏輯被提煉成的中間件。
不過確實能夠減少很多日常開發中的任務量,可以讓人更專註的關註業務,而非這些邊邊角角的功能。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 文章來源:公眾號-智能化IT系統。 貝葉斯的原理類似於概率反轉,通過先驗概率推導出後驗概率。其公式如下: 在大數據分析中,該定理可以很好的做推導預測,很多電商以及用戶取向可以參照此方式,從已有數據推導出未知數據,以歸類做後續操作。 例如,在一個購房機構的網站,已有8個客戶,信息如下: 這時來了一個新 ...
  • 幫助類: using System; using System.Collections.Generic; using System.Data; using System.Linq; using System.Text; using System.Threading.Tasks; using MySq... ...
  • 1.前言 react-natvie中文網地址:https://reactnative.cn/ 現在前端的開發環境基本都需要先搭建環境.react-natvie環境搭建跟著官網的文檔流程走就行了。但是有些是沒有必要的。 2.搭建環境 2.1選擇平臺 這裡需要註意的就是windows的電腦只能開發and ...
  • 首先,為什麼我們要定義一個新類呢?按照我的理解,就是為了抽象出來一個新的東西(也就是類),用來存儲更多的數據變數和方法,一切類都直接或間接繼承與NSObject。 在類的頭文件里我們可以定義成員變數、屬性變數、和方法,在方法里又分為實例方法和類方法。 1.成員變數 成員變數可以以三個關鍵詞來修飾,即 ...
  • 程式中資料庫文件路徑在/data/data/<package name>/databases/ adb shell 進入adb命令行 ls 列出當前文件夾下所有文件 假設有一個名為angle.db的資料庫 下麵通過這個資料庫說明資料庫的操作方式 進入資料庫 sqlite3 angle.db 查看所有 ...
  • 解決方案類似: Android項目實戰(四十):Andoird 7.0+ 安裝APK適配 解決方法: 一、在AndroidManifest.xml 文件中添加 四大組件之一的 <provider> 註意這裡的 android :authorities 屬性的值 中的 com.xxx.xxxx 是你的 ...
  • 歡迎大家前往 "騰訊雲+社區" ,獲取更多騰訊海量技術實踐乾貨哦~ 本文由 "WeTest質量開放平臺團隊 " 發表於 "雲+社區專欄" 一款app除了要有令人驚嘆的功能和令人髮指交互之外,在性能上也應該追求絲滑的要求,這樣才能更好地提高用戶體驗。 以下是本人在工作中對經歷過的性能優化的一些總結,依 ...
  • 參考資料: 開發環境:win10 64bit 開發工具:webstorm node.js npm: node.js下的包管理器。 webpack: 它主要的用途是通過CommonJS的語法把所有瀏覽器端需要發佈的靜態資源做相應的準備,比如資源的合併和打包。 vue-cli: 用戶生成Vue工程模板。 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...