今天學:Koa 起服務搬磚語雀 API,偷懶建博客

前端早早聊大會,與掘金聯合舉辦。加 codingdreamer 進大會技術羣,贏在新的起跑線。javascript


第二十九屆|前端數據可視化專場,高強度一次性洞察可視化的前端玩法,7-17 全天直播,9 位講師 9 個小時的知識轟炸(阿里雲/螞蟻/奇安信/小米等),報名上車👉 ):css

大會海報  (3).png 全部往期都有全程錄播,上手年票一次性解鎖所有html


正文以下

不想本身搭建數據庫和後臺編輯管理功能,若是把語雀當作是一個雲數據庫呢,有沒有偷巧的辦法?前端

Node.js(如下簡稱爲 Node) 對前端的最大魅力,無外乎能夠啓動一個 HTTP 服務後,來提供網站的服務能力,這能夠幫助前端工程師完成不少好玩的做品,如何起服務呢:java

Node 起一個服務

在 Node 的網絡請求這裏,兩個最神奇的東西就是請求和返回,也就是 request 和 response,咱們所謂提供的 web 服務,都是在 req 和 res 上面作各類加工,原生 Node 提供了這樣的能力,但全部的髒活累活咱們都得本身幹,不論請求類型的判斷,仍是是 url 的解析,仍是狀態碼的返回,市面上的 Node 框架也都是在 req 和 res 的基礎上作各類封裝:ios

const http = require('http')

const server = http.createServer((req, res) => {
  // 在這裏基於 req 的請求類型和參數完成各類業務處理
  res.statusCode = 200
  res.setHeader('Content-Type', 'text/plain')
  res.end('Hello ZaoZaoLiao')
})

server.listen(3000, '127.0.0.1', () => {})
複製代碼

Express 起一個服務

做爲上古經典框架,Express 框架(如今已經比幾年前輕巧不少了)幫你作好了不少事情,好比請求類型和路由都不須要你處理了,能夠拎過來立刻根據用戶的訪問來返回不一樣的內容,大二全的設計能讓你充分偷懶:git

mkdir iexpress && cd iexpress && npm init --yes && npm i express -Sgithub

const express = require('express')
const app = express()

app.get('/', (req, res) => {
  res.send('早')
})
app.get('/hi', (req, res) => {
  res.send('早聊')
})
app.listen(3001)
複製代碼

Koa 起一個服務

雖然 Express 很香,但它過重了,特別是早期基於 callback 的設計,讓很多團隊在 callback hell 的泥潭裏填坑填了好多年,而 Koa 更小而美,支持異步(雖然 1 代 Koa 的 generator 有點醜陋),它只作最純粹的部分,好比上下文的處理、流的處理、Cookie 的處理等等。web

固然 Koa 最吸引咱們的是它的洋蔥模型,請求能夠一層層的進去,再一層層的出來,若是洋蔥核心是咱們要處理的業務,那麼每一層皮均可以看做是外圍的一些業務處理,請求在 Koa 中進出要穿越的這些皮,就是 Koa 的中間件,這樣理論上咱們能夠爲一個應用擴展出三四十個中間件,處理安全的,處理緩存的,處理日誌的,處理頁面渲染的...來讓應用再次長得肥胖,不過中間件也要根據實際狀況作增刪,並非越多越好,越多意味着不肯定性越強(尤爲是三方中間件),性能也會受影響(社區的的代碼層次不齊,總體不必定可控,中間件多執行節點天然也多)。面試

不管如何,Koa 讓咱們能夠更精細的控制請求的進入和流出,這給開發者帶來了諸多便利:

mkdir ikoa && cd ikoa && npm init --yes && npm i koa -S

const Koa = require('koa')
const app = new Koa()
const indent = (n) => new Array(n).join(' ')
const mid1 = () => async (ctx, next) => {
  ctx.body = `<h3>請求 => 進入第一層中間件</h3>`
  await next()
  ctx.body += `<h3>響應 <= 從第一層中間件穿過</h3>`
}
const mid2 = () => async (ctx, next) => {
  ctx.body += `<h2>${indent(4)}請求 => 進入第二層中間件</h2>`
  await next()
  ctx.body += `<h2>${indent(4)}響應 <= 從第二層中間件穿出</h2>`
}
app.use(mid1())
app.use(mid2())
app.use(async (ctx, next) => {
  ctx.body += `<h1>${indent(12)}::處理核心業務 ::</h1>`
})
app.listen(2333)
複製代碼

Egg 起一個服務

Koa 雖然小而美,能夠集成大量中間件,但一個複雜的企業級應用,須要更嚴謹的約束,不管是功能模型上的設計(體如今目錄結構上),仍是框架自己的能力集成(體如今模塊的書寫方式、彼此暴露的接口和調用形式上),都須要有一個既有約束力又方便擴展的架構,這時候 Egg 就登場了,Egg 奉行『約定優於配置』,按照一套統一的約定進行應用開發,除了 service/controller/loader/context...的進一步抽象和改造外,還提供了強大的插件能力,如官方文檔所寫,一個插件能夠包含:

  • extend:擴展基礎對象的上下文,提供各類工具類、屬性。
  • middleware:增長一個或多箇中間件,提供請求的前置、後置處理邏輯。
  • config:配置各個環境下插件自身的默認配置項。

一個獨立領域下的插件實現,能夠在代碼維護性很是高的狀況下實現很是完善的功能,而插件也支持配置各個環境下的默認(最佳)配置,讓咱們使用插件的時候幾乎能夠不須要修改配置項。

mkdir iegg && cd iegg && npm init egg --type=simple && npm i && npm run dev

// app/controller/home.js
const Controller = require('egg').Controller

class HomeController extends Controller {
  async index() {
    const { ctx } = this
    ctx.body = 'hi, egg'
  }
}

module.exports = HomeController

// app/controller/router.js
module.exports = app => {
  const { router, controller } = app
  router.get('/', controller.home.index)
}

// config/config.default.js
module.exports = appInfo => {
  const config = exports = {}
  config.keys = appInfo.name + '_1598512467216_9757'
  config.middleware = []
  const userConfig = {
    // myAppName: 'egg',
  }

  return {
    ...config,
    ...userConfig,
  }
}

// config/plugin.js
module.exports = {
  // static: {
  // enable: true,
  // }
}

複製代碼

你們能夠前往 Egg 和 Koa 查看更多信息,官方寫的很是好了。

本地起一個簡單的博客服務

Egg 是基於 Koa 來封裝的,還能夠繼續基於 Egg 封裝更偏業務向的企業級框架,咱們把焦點回歸到 Koa,結合獲取語雀 API 的能力,咱們來用 Koa 搭建一個本地服務吧,本地不安裝數據庫,數據都從語雀上拿,模板引擎能夠用 Pug,目錄能夠這樣設計:

.
├── README.md
├── app                                     # 總體應用服務
│   ├── controllers                         # 控制器:處理業務邏輯
│   │   ├── article.js                      # 文章詳情業務處理
│   │   └── home.js                         # 路由跳轉至指定頁面業務處理
│   ├── router                              # 路由
│   │   └── routes.js                       # 路由信息配置												
│   ├── tasks                               # 對接第三方的一些服務任務
│   │   └── yuque.js                        # yuque 業務邏輯處理:獲取文檔列表、文檔詳情、保存文檔
│   └── views                               # 頁面
│       ├── includes
│       ├── layout.pug
│       └── pages
├── config                                  # 服務配置文件
│   └── config.js										
├── index.js                                # 入口文件
├── package-lock.json
├── package.json
└──  public                                 # 靜態資源
    ├── css
    │   ├── nav.css
    │   └── style.css
    └── images
        ├── logo.png
        ├── mobile-banner.png
        └── pc-banner.png

複製代碼

模塊能夠安裝這幾個:

  • axios:能夠用在瀏覽器和 Node.js 的基於 Promise 的 HTTP 客戶端
  • Koa:基於 Node.js 平臺的 Web 開發框架
  • koa-static:Koa 靜態文件服務中間件
  • koa-router:Koa 路由中間件
  • koa-views:Koa 模版渲染中間件
  • moment:JavaScript 日期處理類庫

獲取語雀的數據能夠這樣處理:

const fs = require('fs')
const { resolve } = require('path')
const axios = require('axios')
// 獲取配置信息
const config = require('../../config/config')
const { repoId, api, token } = config.yuque

// 把語雀拿來的文章存到本地
const saveYuque = (article, html) => {
  // 先檢查一下, pages 目錄的路徑是否存在
  // 路徑不存在就自動生成一個 pages 目錄(首次使用服務), 不然會報錯, 會一致沒法使用本地緩存
  // 路徑存在, 直接在該路徑保存語雀的博客文章
  const path = __dirname.substring(0, __dirname.length - 9) + 'public/pages'
  if (!fs.existsSync(path)) {
    fs.mkdirSync(path)
  }

  const file = resolve(__dirname, `../../public/pages/${article.id}.html`)
  if (!fs.existsSync(file)) {
    fs.writeFile(file, html, err => {
      if (err) console.log(err)
      console.log(`${article.title} 已寫入本地`)
    })
  }
}

// 封裝統一的請求
const _request = async (pathname) => {
  const url = api + pathname
  return axios.get(url, {
    headers: { 'X-Auth-Token': token }
  }).then(res => {
    return res.data.data
  }).catch(err => {
    console.log(err)
  })
}

// 獲取配置文件指定 repoId 下的全部文章
const getDocList = async () => {
  try {
    const res = await _request(`/repos/${repoId}/docs`)
    return res
  } catch (err) {
    console.log('獲取文章列表失敗: ', err)
    return []
  }
}

// 獲取配置文件指定 repoId 下的指定文章內容
const getDocDetail = async (docId) => {
  try {
    const res = await _request(`/repos/${repoId}/docs/${docId}?raw=1`)
    return res
  } catch (err) {
    console.log('獲取文章內容失敗: ', err)
    return {}
  }
}

module.exports = {
  // getYuqueUser,
  getDocDetail,
  getDocList,
  saveYuque
}
複製代碼

路由能夠添加幾個博客頁面:

// 頁面
const Home = require('../controllers/home')
const Article = require('../controllers/article')

module.exports = router => {
  // 網站前臺頁面
  // router.get(url, controller)
  router.get('/', Home.homePage)
  router.get('/about', Home.about)
  router.get('/joinus', Home.joinus)
  router.get('/contact', Home.contact)
  router.get('/article/:_id', Article.detail)
}
複製代碼

幾個頁面交給主控制器處理:

// 獲取配置文件指定 repoId 下的全部文章的方法
const { getDocList } = require('../tasks/yuque')
const { teamName } = require('../../config/config')

// 根據指定路徑, 用一個 controller 把頁面返回給客戶端
exports.homePage = async ctx => {
  const articles = await getDocList()

  // render(pug, pug 內須要的變量)
  ctx.body = await ctx.render('pages/index', {
    title: '首頁',
    teamName,
    articles
  })
}

exports.about = async ctx => {
  ctx.body = await ctx.render('pages/about', {
    teamName
  })
}

exports.joinus = async ctx => {
  ctx.body = await ctx.render('pages/joinus', {
    teamName
  })
}

exports.contact = async ctx => {
  ctx.body = await ctx.render('pages/contact', {
    teamName
  })
}
複製代碼

控制器的代碼能夠這樣處理:

const fs = require('fs')
const { resolve } = require('path')
const { getDocDetail, saveYuque } = require('../tasks/yuque')
const config = require('../../config/config')
const { root } = config

const streamEnd = fd => new Promise((resolve, reject) => {
  fd.on('end', () => resolve())
  fd.on('finish', () => resolve())
  fd.on('error', reject)
})

// 查看文章詳情
exports.detail = async ctx => {
  const _id = ctx.params._id
  const fileName = resolve(root, `${_id}.html`)
  const fileExists = fs.existsSync(fileName)

  // 首先去本地找是否緩存過資源,若是緩存過直接返回
  if (fileExists) {
    console.log('命中文章緩存,直接返回')
    // 拿到文件流,pipe 給 koa 的 res,讓它接管流的返回
    ctx.type = 'text/html; charset=utf-8'
    ctx.status = 200
    const rs = fs.createReadStream(fileName).pipe(ctx.res)
    await streamEnd(rs)
  } else {
    console.log('未命中文章緩存,從新拉取')
    // 若是沒緩存過,則從語雀 API 獲取後直接返回
    const article = await getDocDetail(_id)
    const body = article.body_html.replace('<!doctype html>', '')

    // 服務器返回新拿到的文章數據
    const html = await ctx.render('pages/detail', {
      body,
      article,
      siteTitle: article.title
    })
    // 本地文件緩存也寫一份
    saveYuque(article, html)

    ctx.body = html
  }
}
複製代碼

流程雖然簡單,但若是你們去面試的時候,被面試官問起這裏都緩存如何處理,以這種形式確定是過不了關的,這裏還須要考慮不少邊界條件和風險點,好比資源有無、權限、有效性、類型及安全檢查、流量判斷...等等等等,其中緩存的部分,每每會成爲一個考察重點,你們能夠在上面多花一些心思,以下僞代碼僅拋磚引玉:

// 304 緩存有效期判斷, 使用 If-Modified-Since,用 Etag 也能夠
const fStat = fs.statSync(filePath)
const modified = req.headers['if-modified-since']
const expectedModified = new Date(fStat.mtime).toGMTString()
if (modified && modified == expectedModified) {
  res.statusCode = 304
  res.setHeader('Content-Type', mimeType[ext])
  res.setHeader('Cache-Control', 'max-age=3600')
  res.setHeader('Last-Modified', new Date(expectedModified).toGMTString())
  return
}

// 文件頭信息設置
res.statusCode = 200
res.setHeader('Content-Type', mimeType[ext])
res.setHeader('Cache-Control', 'max-age=3600')
res.setHeader('Content-Encoding', 'gzip')
res.setHeader('Last-Modified', new Date(expectedModified).toGMTString())

// gzip 壓縮,文件流 pipe 回去
const stream = fs.createReadStream(filePath, {
  flags: 'r'
})
stream.on('error', () => {
  res.writeHead(404)
  res.end()
})
stream.pipe(zlib.createGzip()).pipe(res)

複製代碼

前端早早聊會時不時發一些面向技術小白的學習文章,你們能夠果斷關注本帳號,常年跟進新動態。


別忘了第二十九屆|前端數據可視化專場,高強度一次性洞察可視化的前端玩法,7-17 全天直播,9 位講師(阿里雲/螞蟻/奇安信/小米等),報名上車👉 ):

大會海報  (3).png

全部往期都有全程錄播,能夠購買年票一次性解鎖所有

👉更多活動


點贊,評論,求 Mark。

最後的效果

image.png image.png

image.png

相關文章
相關標籤/搜索