阅读 399

今天学:Koa 起服务搬砖语雀 API,偷懒建博客

前端早早聊大会,与掘金联合举办。加 codingdreamer 进大会技术群,赢在新的起跑线。


第二十九届|前端数据可视化专场,高强度一次性洞察可视化的前端玩法,7-17 全天直播,9 位讲师 9 个小时的知识轰炸(阿里云/蚂蚁/奇安信/小米等),报名上车👉 ):

大会海报  (3).png 所有往期都有全程录播,上手年票一次性解锁全部


正文如下

不想自己搭建数据库和后台编辑管理功能,如果把语雀当做是一个云数据库呢,有没有偷巧的办法?

Node.js(以下简称为 Node) 对前端的最大魅力,无外乎可以启动一个 HTTP 服务后,来提供网站的服务能力,这可以帮助前端工程师完成很多好玩的作品,如何起服务呢:

Node 起一个服务

在 Node 的网络请求这里,两个最神奇的东西就是请求和返回,也就是 request 和 response,我们所谓提供的 web 服务,都是在 req 和 res 上面做各种加工,原生 Node 提供了这样的能力,但所有的脏活累活我们都得自己干,不论请求类型的判断,还是是 url 的解析,还是状态码的返回,市面上的 Node 框架也都是在 req 和 res 的基础上做各种封装:

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 框架(现在已经比几年前轻巧很多了)帮你做好了很多事情,比如请求类型和路由都不需要你处理了,可以拎过来马上根据用户的访问来返回不同的内容,大二全的设计能让你充分偷懒:

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

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 的处理等等。

当然 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

文章分类
前端
文章标签