Hapi.js 起步 - 寫給前端開發的 Node Web 框架入門

KennyWho發表於2019-02-20

為什麼選擇 Hapi

或許你已經使用過 Express, Koa2 等 Node.js 的 WEB 框架,在構建 WEB 應用程式時,你的工作僅僅是產出 RESTFUL API,或者通過 Node 呼叫其他網路介面。你或許感覺到是不是有一種更簡單的方式來處理請求,或在構建專案初期,有沒有一種不必因為尋找使用哪個中介軟體而苦惱的 Node 框架。在對比多個框架後,我選擇使用 Hapi 來重構我的 Koa2 專案。

Hapi 目前 Github star 10653,最新版本 17.5,release 版本 18.x。issues 數目 6,對,你沒有看錯,個位數。可以看出 Hapi 的關注度與維護狀態都非常好。可以通過 Hapi 的官網來檢視 Hapi 的最新動態,包括提交,修改了哪些 issues,一個簡單介紹特性的教程,帶有示例的 API 文件,使用 Hapi 的社群,外掛和資源。Hapi 具有完整的構建 WEB 應用所需的外掛,一些是官方提供的,一些是社群貢獻的,而且通常這些外掛是可以在任何你想要的地方使用而不依賴於 Hapi,如 Boom, Joi, Catbox。

如果想了解 Hapi,或者它與其他框架的不同,可以在 Google 中搜尋相關資訊,本文不會過多涉及框架的介紹。

node-frameworks-to-use

框架對比

Hapijs

適合什麼樣的讀者

學習本教程,不需要你有任何的 Node 經驗,你可以把它當做 Node 的入門課。如果你是一名前端開發人員,本教程會讓你更清楚的瞭解 Node 可以做什麼,前後端是如何交付各自工作的。你也可能嘗試過其他 Node 框架的新手,你可以通過這個入門教程,來對比兩個框架的不同。如果你已經是一名有經驗的 Node 開發人員,那麼這個教程並不適合你。

這個教程涵蓋的概念較少,更多的是動手去嘗試,所以哪怕你沒有任何經驗,你也可以開始學習。

準備

  • 安裝 node
  • 建立專案
  • 初始化 package.json
  • 編輯器 推薦 vscode
  • 命令列工具 - Windows 推薦 cmder,Mac 推薦 iTerm2
npm init -y
// or
npm init
// -y 引數 以預設方式初始化 package.json
複製程式碼
  • 安裝 Hapi
npm i hapi
// or
npm install hapi -D
// i 為 install 的縮寫,不帶任何引數時,預設值為 -D
複製程式碼

一個服務

// server.js
const Hapi = require('hapi')

const server = Hapi.server({
    port: 3000,
    host: 'localhost'
})

const init = async () => {
    await server.start()
    console.log(`Server running at: ${server.info.uri}`)
}
init()
複製程式碼

在命令列中執行

node server.js
# Server running at: http://localhost:3000
# 說明我們的服務已經啟動了
# 如果 3000 埠已經被佔用了, 你可以修改 port 為其他埠
複製程式碼

現在我們訪問 http://localhost:3000,頁面會顯示 404,因為我們並沒有配置任何的路由

1. 路由

// server.js

const init = async () => {
    server.route({
        path: '/',
        method: 'GET',
        handler () {
            return 'Hapi world'
        }
    })
    await server.start()
    console.log(`Server running at: ${server.info.uri}`)
}

複製程式碼

現在重新啟動服務, 我們可以看到頁面上的內容了。

接下來我們建立一個 API 介面,可以返回一個 json 資料

// server.js
server.route({
    path: '/api/welcome',
    method: 'GET',
    handler () {
        return {
            code: 200,
            success: true,
            data: {
                msg: 'welcome'
            }
        }
    }
})
複製程式碼

重啟服務,我們訪問 http://localhost:3000/api/welcome

我們得到了一個 content-typeapplication/json 的資料,我們可以通過 XMLHttpRequest 的庫比如(jQuery Ajax、Axios、Fetch)來請求這個介面,得到一個 JSON 資料

2. 停一下

等等,你有沒有發現,我們在每次修改檔案之後,都要斷開服務,手動重啟,這樣太糟糕了,現在我們要解決這個問題。

npm i onchange
# 增加 onchange 模組
複製程式碼
// package.json
"scripts": {
    "dev": "node server.js",
    "watch": "onchange -i -k '**/*.js' -- npm run dev"
},
複製程式碼

我們在 package.json 檔案的 scripts 欄位中增加一個 dev 執行。這樣,我們執行 npm run dev 就相當於執行了之前 node server.js。使用 onchange 包,監控我的 js 檔案變動,當檔案發生改變時,重新啟動服務。

試一下

npm run watch
複製程式碼

然後我們修改一下 api/welcome 的返回結果

重新整理一下瀏覽器

看!不需要手動重啟服務了,每次改動,只需要重新重新整理瀏覽器就看到結果了

現在我們並不需要太早的引入 Nodemon,雖然它非常棒也很好用。

3. 引數

既然我們已經可以請求到伺服器的資料了,我們還要將客戶端的資料傳給伺服器,下面我們將介紹幾種傳遞引數的形式。

我們假設幾個場景,通過這些來理解如何獲取引數。

  1. /api/welcome 我們希望它能返回傳入的名字
// 修改路由
server.route({
    path: '/api/welcome',
    method: 'GET',
    handler (request) {
        return {
            code: 200,
            success: true,
            data: {
                msg: `welcome ${request.query.name}`
            }
        }
    }
})
// 請求 http://localhost:3000/api/welcome?name=kenny
// msg: "welcome kenny"
複製程式碼
  1. name 這個引數有些多餘,因為這個介面只接受這一個引數,那麼現在省略到這個 name
// 修改路由
server.route({
    path: '/api/welcome/{name}',
    method: 'GET',
    handler (request) {
        return {
            code: 200,
            success: true,
            data: {
                msg: `welcome ${request.params.name}`
            }
        }
    }
})
// http://localhost:3000/api/welcome/kenny
// msg: "welcome kenny"
// 結果是一樣的
複製程式碼
  1. 假設我們需要偶爾更換我們的歡迎詞,但不是每次都去修改程式碼,那麼我們需要一個替換歡迎詞的介面,通過提交介面來更換歡迎詞。
let speech = {
    value: 'welcome',
    set (val) {
        this.value = val
    }
}
server.route({
    path: '/api/welcome/{name}',
    method: 'GET',
    handler (request) {
        return {
            code: 200,
            success: true,
            data: {
                msg: `${speech.value} ${request.params.name}`
            }
        }
    }
})
server.route({
    path: '/api/speech',
    method: 'POST',
    handler (request) {
        speech.set(request.payload.word)
        return {
            code: 200,
            success: true,
            data: {
                msg: `speech is *${speech.value}* now`
            }
        }
    }
})
複製程式碼

驗證一下

# 使用 curl 來驗證一個 POST 介面,你也可以使用 Ajax,POSTMAN...等等 你所喜歡的方式。
curl --form word=你好 \
  http://localhost:3000/api/speech
# {"code":200,"success":true,"data":{"msg":"speech is *你好* now"}}%
curl http://localhost:3000/api/welcome/kenny
# {"code":200,"success":true,"data":{"msg":"你好 kenny"}}%
複製程式碼

這裡需要注意一下,content-type application/x-www-form-urlencodedmultipart/form-data區別

總結一下,可以使用 request.query 來獲取 url querystring 的資料,request.payload 獲取 POST 介面的 request body 資料,request.params 獲取 url 中的自定義引數。

4. 第二個服務

我們已經有了一個後端API服務,對應要有一個前端服務,可能這個服務是單頁面的,也有可能傳統的後端渲染頁面,但是通常都是和你後端服務不在同一個埠的。我們建立另一個服務,用來渲染前端頁面,為了更真實的模擬真實的場景。

+const client = Hapi.server({
+    port: 3002,
+    host: 'localhost'
+})
+

-    server.route({
+    client.route({

+    await client.start()
複製程式碼

增加一個新的服務,監聽埠啊 3002,並將之前首頁路由修改成 client 的首頁。

訪問 http://localhost:3002 檢視效果

5. 靜態檔案

之前,我們直接渲染頁面的方式是字串,這樣不利於編寫和修改,我們把返回 HTML 的方式改為”模板“渲染。

# 安裝所需依賴包
npm i inert
# 建立 public 資料夾
mkdir public
# 建立 index.html
touch public/index.html
# 建立 about.html
touch public/about.html
複製程式碼
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
    <title>Document</title>
</head>
<body>
    <h1>Hapi world</h1>
</body>
</html>
複製程式碼
// ...

const client = Hapi.server({
    port: 3002,
    host: 'localhost',
    routes: {
        files: {
            relativeTo: Path.join(__dirname, 'public')
        }
    }
})

// ... 
// const init = async () => {
await client.register(Inert)
client.route({
    path: '/{param*}',
    method: 'GET',
    handler: {
        directory: {
            path: '.',
            index: true,
        }
    }
})

// ...
複製程式碼

依次訪問檢視效果

/index.html 這種帶著副檔名的路徑看似不那麼專業,我們修改一下 directory 的配置

directory: {
+ defaultExtension: 'html'
複製程式碼

訪問 http://localhost:3002/index

6. 跨域請求

我們不過多介紹瀏覽器的同源策略,現在已有的客戶端(埠3002)在發起 XHRHttpRequest 請求服務端(埠3000)介面時,就會遇到 CORS 問題,接下來我們要在服務端允許來自客戶端的請求,通過設定 Access-Control-Allow-Origin 等響應頭,使跨域請求被允許。

// index.html
$.ajax({
    url: 'http://localhost:3000/api/welcome/kenny'
}).then(function (data) {
    console.log(data)
})
複製程式碼

訪問 http://localhost:3002/index 會報 js 的跨域錯誤

Access to XMLHttpRequest at 'http://localhost:3000/api/welcome/kenny' from origin 'http://localhost:3002' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

// server.js
const server = Hapi.server({
    port: 3000,
    host: 'localhost',
    routes: {
        cors: {
            origin: '*'
        }
    }
})
複製程式碼

儲存後,你會發現終端會有以下錯誤

[1] "origin" must be an array

這就是 Hapi 的另一個優勢,配置檢查,因為 Hapi 作為以配置先行的框架,做了很多配置的檢查,在你使用了不允許或不規範的配置時,會有相應的錯誤產生,方便你對於問題的捕捉和解決。

origin: ['*']
複製程式碼

然後重新整理頁面,你會發現跨域的錯誤已經沒有了。

關於跨域,我們還沒有提及:

  • 允許指定的域名和多個域名
  • 允許攜帶cookie [Access-Control-Allow-Credentials]
  • 允許獲取額外的頭部資訊 [Access-Control-Expose-Headers]
  • 允許攜帶的頭部資訊 [Access-Control-Allow-Headers]

7. 還缺什麼?

目前我們擁有了一個 web 渲染的前端服務,一個提供介面的後端服務,而且他們是在不同的”域“(埠),前端頁面或許有寫單調,沒有圖片和樣式,也沒有 favicon。

  • 下載一個你喜歡的 favicon
  • 引入一個本地的 CSS
  • 引入一個本地的影象

幫他們都放在放在 /public 目錄下

...
<head>
...
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="stylesheet" href="/bulma.min.css">
</head>
...
<html>
<img class="logo" src="/logo.svg" />
...
複製程式碼

8. Cookie

假設我們有一個登入 /login 介面,在登入成功後,設定一個 login 欄位在 cookie 中, 前端可以通過這個 login 來判斷你是否登入,並且可以通過 /logout 登出。

// ...
server.state('login', {
    ttl: null, // 時效
    isSecure: false, // https
    isHttpOnly: false, // http Only
    encoding: 'none', // encode
    clearInvalid: false, // 移除不可用的 cookie
    strictHeader: true // 不允許違反 RFC 6265
})
// ...
const init = async () => {
// ...
server.route({
    path: '/api/login',
    method: 'POST',
    handler (request, h) {
        let body
        let code
        // 獲取 cookie
        const isLogin = request.state.login
        if (isLogin) {
            body = {
                msg: '已登入'
            }
            code = 200
        } else if (request.payload && request.payload.email === 'kenny@gmail.com' && request.payload.password === '123456') {
            // 設定 cookie
            h.state('login', 'true')
            body = {
                msg: '登入成功'
            }
            code = 200
        } else {
            code = 100
            body = {
                msg: '登入資訊有誤'
            }
        }
        return {
            code,
            success: true,
            data: body
        }
    }
})
複製程式碼
server.route({
    path: '/api/logout',
    method: 'POST',
    handler (request, h) {
        // 取消 cookie
        h.unstate('login')
        return {
            code: 200,
            success: true
        }
    }
})
複製程式碼

這個例子並不適合實際的業務場景,只是為了更簡單的描述如何設定和取消cookie

9. 認證與授權

認證這個概念可能對於入門來說可能比較難以理解,比如比較常用的 JWT (JSON Web Token),這裡不浪費時間去解釋如何使用,如果想了解什麼是JWT,傳送門: Learn how to use JSON Web Tokens (JWT) for Authentication。在 Hapi 框架中,我們使用 hapi-auth-jwt2

這裡講一下 Hapi 中認證配置的方便之處。

在 Express/Koa2 中,你需要

  • 引入外掛
  • 中介軟體處理 401
  • 中介軟體中匹配需要認證的路由,和排除不需要的認證路由。

當你專案的路由足夠多時,這個匹配規則也會越來越複雜。或者你可以在路由的命名上做一些規劃,這讓完美主義者感覺很不好。在單個路由內做判斷呢,又是重複的操作。

下面看下 Hapi 的使用。

// 引入外掛
await server.register(require('hapi-auth-jwt2'))
// 自定義一個你的認證方法
const validate = async function (decoded, request) {
    return {
        isValid: true
    }
}
// 設定認證
server.auth.strategy('jwt', 'jwt', {
    key: 'your secret key',
    validate,
    verifyOptions: {
        algorithms: ['HS256']
    },
    cookieKey: 'token'
})

// 一個需要認證的路由
server.route({
    path: '/user/info',
    method: 'GET',
    options: {
        auth: 'jwt'
    },
    // ...
})
// 一個需要認證可選的路由
server.route({
    path: '/list/recommond',
    method: 'GET',
    options: {
        auth: {
            strategy: 'jwt',
            mode: 'optional'
      }
    },
    // ...
})
// 一個需要認證嘗試的路由
server.route({
    path: '/list/recommond',
    method: 'GET',
    options: {
        auth: {
            strategy: 'jwt',
            mode: 'try'
      }
    },
    // ...
})


複製程式碼

其中 try 與 optional 的區別在於認證錯誤後的返回, optional 的認證規則為你可以沒有,但是有那就必須是正確的。 try 則是無所謂,都不會返回 401 錯誤。

可以看出,Hapi 中關於認證是配置在路由上的,這使得在管理認證和非認證模組時,只需配置相應規則,而無需擔心是否錯改了全域性的配置。

10. 日誌

在接受到請求,或者在服務上發起請求時,並沒有可以讓我們檢視的地方,現在加入一個日誌系統。

npm i hapi-pino
複製程式碼
await server.register({
    plugin: require('hapi-pino'),
    options: {
        prettyPrint: true // 格式化輸出
    }
})
複製程式碼

重新服務,並且訪問 '/api/logout'

檢視一下終端的顯式

[1547736441445] INFO  (82164 on MacBook-Pro-3.local): server started
    created: 1547736441341
    started: 1547736441424
    host: "localhost"
    port: 3000
    protocol: "http"
    id: "MacBook-Pro-3.local:82164:jr0qbda5"
    uri: "http://localhost:3000"
    address: "127.0.0.1"
Server running at: http://localhost:3000

[1547736459475] INFO  (82164 on MacBook-Pro-3.local): request completed
    req: {
      "id": "1547736459459:MacBook-Pro-3.local:82164:jr0qbda5:10000",
      "method": "post",
      "url": "/api/logout",
      "headers": {
        "cache-control": "no-cache",
        "postman-token": "b4c72a2f-38ab-4c5c-9559-211e0669e6cf",
        "user-agent": "PostmanRuntime/7.4.0",
        "accept": "*/*",
        "host": "localhost:3000",
        "accept-encoding": "gzip, deflate",
        "content-length": "0",
        "connection": "keep-alive"
      }
    }
    res: {
      "statusCode": 200,
      "headers": {
        "content-type": "application/json; charset=utf-8",
        "vary": "origin",
        "access-control-expose-headers": "WWW-Authenticate,Server-Authorization",
        "cache-control": "no-cache",
        "set-cookie": [
          "login=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Strict"
        ],
        "content-length": 27
      }
    }
    responseTime: 16

複製程式碼

可以說非常全面的日誌,而且帶有著色效果。

11. 文件

隨著開發的時間,你的專案中加入了越來越多的介面,當你與其他人員配合,或者想找到一個介面的定義時,一個好的文件會讓你事倍功半

await server.register({
    plugin: require('lout')
})
複製程式碼

因為 Hapi 是以配置為中心的框架,所以文件也可以根據配置生成,只需要你對路由進行一定的描述,就會生成一個可用的文件。

訪問 http://localhost:3000/docs 檢視效果

12. 轉發介面

未完成

如何使用示例

本文提及的內容都已經上傳 github

你可以 clone 專案後檢視程式碼。同時你也可以切換到不同的步驟中(git checkout HEAD)


# 檢視commit
git log --pretty=online

51b2a7eea55817c1b667a34bd2f5c5777bde2601 part 9 api doc
fbb1a43f0f1bf4d1b461c4c59bd93b27aabc3749 Part8 cookies
00a4ca49f733894dafed4d02c5a7b937683ff98c Part7 static
ea2e28f2e3d5ef91baa73443edf1a01a383cc563 Part7 cors
a0caaedbf492f37a4650fdc33d456fa7c6ef46d3 Part6 html render
12fce15043795949e5a1d0d9ceacac8adf0079e8 Part5 client server
79c68c9c6eaa064a0f8c679ae30a8f851117d7e0 Part4 request.payload
e3339ff34d308fd185187a55f599feed1e46753e Part4 request.query
af40fc7ef236135e82128a3f00ec0c5e040d4b12 Part3 restart when file changed
2b4bd9bddfe565fd99c7749224e14cc7752525b1 Part2 route 2
99a8f8426f43fea85f98bc9a3b189e5e3386abfe Part2 route
047c805ca7fe44148bac85255282a4d581b5b8e1 Part1 server
# 切換至 Part5
git checkout 12fce15043795949e5a1d0d9ceacac8adf0079e8
複製程式碼

結尾

目前教程完成度為 80%,因為目前精力有限,暫時更新到這裡,後續根據讀者的意見和建議會持續更新到一個滿意的程度。

再次感謝你的閱讀,如果覺得這個教程對你有所幫助,歡迎轉發評論。當然也可以打賞一下。

如果你對本教程有更好的建議,請與我聯絡。

相關文章