Koa+mongoDb手把手实战「前端发布平台」后端!值得收藏~

4,626 阅读20分钟

声明:文章为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

纯前端不了解全栈开发但想学?赶紧看过来!本文通过node server实战分享,带你快速上手全栈开发,掌握后端开发实战技巧。通过koa2 + mongo实现服务端功能以完成前端发布平台的业务需求。小前端也有一个全栈梦!!!

本文是《实战前端发布平台,打开CICD黑盒》专栏的第二篇——实战前端发布平台后端。文章之间存在关联,对整个系列感兴趣的朋友可以关注专栏把其余文章也一起看看~

系列文章:

  1. 总览前端自动化部署流程,如何实现前端发布平台?文章链接
  2. 前端发布平台 node server 实战!
  3. 前端发布平台 jenkins 实战!如何实现前端自动化部署?文章链接
  4. 前端发布平台全栈实战(前后端开发完整篇)!开发一个前端发布平台。文章链接
  5. websocket 全栈实战,实现唯一构建实例 + 日志同步文章链接

本文为第二篇「前端发布平台 node server 实战」的实战记录分享,主要内容是通过 Koa + mongoDb 实现 项目构建配置CRUD 功能,如项目的仓库信息、构建分支、打包命令等...为后续的前端自动化部署奠定基础

快速看源码

一、Koa

在上一篇文章中,已经用 koa2 + @koa/router 搭建了一个初始化的后端项目了,本文将在之前的基础上进行扩展和完善,let's go!

1. 路由模块

回顾上一篇文章,笔者已经用 @koa/router 实现了简单的路由,并且可以通过 postman 中发送 getpost 请求成功响应。接下来,我们需要把 route 模块进行系统化的整理和抽离,总不能都把路由信息写到入口文件吧。

// 入口js
const Koa = require('koa')
const Router = require('@koa/router')

const router = new Router()
const app = new Koa();

router.get('/test', (ctx, next) => {
  ctx.body = {
    code: 0,
    data: { name: '井柏然-get' }
  }
}) // 待抽离整理

router.post('/test', (ctx, next) => {
  ctx.body = {
    code: 0,
    data: { name: '井柏然-post' }
  }
}) // 待抽离整理

app.use(router.routes()).use(router.allowedMethods())

app.listen(3000);

整理思路:

  • 路由分类。按照功能模块进行路由分类,如案例的 test 模块,一会要用的存放配置的 config 模块。 image.png
  • 命名统一。清晰整个 mvc 链路,如请求 routestest 模块,其执行函数是在 controller 目录 中的 testimage.png

接下来,开始整理,将原首页的内容放到 routes/test.js 文件中,包进执行函数 initTestRoute 中,并导出。

// test路由文件 routes/test.js
function initTestRoute (router) {
  router.get('/test', (ctx, next) => {
    ctx.body = {
      code: 0,
      data: { name: '井柏然-get' }
    }
  })

  router.post('/test', (ctx, next) => {
    ctx.body = {
      code: 0,
      data: { name: '井柏然-post' }
    }
  })
}

module.exports = {
  initTestRoute // 导出 test 模块的路由初始化函数
}

routes/index.js 中导入 每个模块的init函数,放在一个全局 init 的函数中统一执行,最后导出 全局路由init函数。(就是将每个模块的init统一管理调用,在routes的入口中汇集而已)

// 路由入口文件 routes/index.js
const { initTestRoute } = require('./test')
const { initConfigRoute } = require('./config')

function initGlobalRoute (router) {
  initTestRoute(router) // 调用 test 模块路由注册
  initConfigRoute(router) // 调用 config 模块路由注册
}

module.exports = {
  initGlobalRoute // 导出全局路由注册
}

最后,在 koa 入口中调用 initGlobalRoute 函数,如下代码:

const Koa = require('koa')
const Router = require('@koa/router')
const { initGlobalRoute } = require('./routes/index')

const router = new Router()
const app = new Koa();

initGlobalRoute(router) // 注册全局路由

app.use(router.routes()).use(router.allowedMethods())

app.listen(3000);

紧接着,我们再使用 postman 对 test 模块进行请求:

image.pngimage.png

可以看到,postget 请求都有了正确的返回。接下来,我们把整个后端处理链路(每一层都按照这个模块划分规范)统一整理一波!

2. 整理controller+services+model

从路由层的“抛砖引玉”,我们把这种模块划分的整理思路套到每个分层中!

回顾一下上一篇中提到的后端分层。 mvc.png 用户发起请求 -> route层 -> controller层 -> service层 -> db

我们依照这个分层,将上文中的按模块整理的思路放到每一个分层中。

首先是 controller 层(处理业务逻辑):

// test路由文件 routes/test.js
const controller = require('../controller/test')

function initTestRoute (router) {
  router.get('/test', controller.get)

  router.post('/test', (ctx, next) => {
    ctx.body = {
      code: 0,
      data: { name: '井柏然-post' }
    }
  })
}

module.exports = {
  initTestRoute
}

很简单,对比上文实现,笔者只是将原本处理 get 请求的函数抽离到了 controller/test 中,在 routes层 中去调用 controller层 的方法。

// controller层的实现 controller/test.js
function get (ctx, next) {
  ctx.body = {
    code: 0,
    data: { name: '井柏然-get(放在controller里啦!)' } // 这里改了文案跟上面形成对比
  }
  next()
}

module.exports = {
  get
}

这时候看 postman 的请求结果。跟预想中的返回一样: image.png

好,按照这样的思路,对整个后端的分层、模块进行整理。如 services 层,也是按照当前模块划分,进行相应的数据库数据查询操作; model 层按照模块划分,定义不同模块中的数据模型。因为思路都是类似的,笔者就不再展开赘述了,只要按照这个思路将各层按模块划分好即可。

3. 中间件

Koa中间件大家可能多多少少都有听过,但是可能没自己玩过!这里笔者借着实战的场景,把中间件也用上,通过场景更加加深大家对中间件的使用理解。

中间件使用场景:

  • 需要对服务器处理的每个请求的返回-response做一层拦截,以便后续拓展,如需要对一些错误信息进行统一收集等。
  • 每个进入的请求都要进行登陆校验权限校验,以统一进行相应的逻辑处理。

讲到Koa中间件,一定要讲一下洋葱模型: 洋葱模型.webp

光看图可能很难 get 到要点,笔者这里根据官网的 解释 外加一个 demo 来跟大家一起体会一下这幅图的含义。首先看看官网的一段解释: image.png 重点看笔者划线的部分:调用 next() 该函数暂停执行,控制传递给下一个中间件,并且没有其他中间件时,恢复执行。

这么说可能还是有点懵,直接上demo。官网的案例还是复杂了点,直接撸个简单的demo。笔者直接在上述的入口文件中添加如下代码,通过 console.log 来输出 1-4 的数字。

app.use(function fn1 (ctx, next) {
  console.log(1)
  next()
  console.log(2)
})
app.use(function fn2 (ctx, next) {
  console.log(3)
  next()
  console.log(4)
})

这里大家不妨先试着想一下输出结果,结合 Koa 官网对 next函数 的解释(当前暂停执行,控制传递下一中间件),应该都能想到答案。

image.png 如上图蓝色圈,输出的结果:1-3-4-2。毫无意外是不是!细心的童鞋可能已经发现,笔者在传入中间件的 function 中都加了函数名 fn1fn2,为的就是把 next() 机制转换成伪代码方便大家理解。如下:

function fn1 () {
    console.log(1)
    fn2() // 把原本的 next 替换成 fn2
    console.log(2)
}
function fn2 () {
    console.log(3)
    next() // 如果还有 fn3 那就一直这样嵌套调用下去而已
    console.log(4)
}

换成这样的写法,是不是就很清晰了。其实 next 就是实现了一个函数嵌套调用。这样一来,再结合上文提到的洋葱模型的图片,应该就能很好的理解中间件的执行机制了。最后,笔者再撸个请求-响应流的图跟大家一起巩固一下洋葱模型!

请求-响应流.png

紧接着,笔者通过实现一个中间件对所有的响应进行拦截,来完善整个请求-响应流的返回结果。根据 Koa 官网推荐的命名空间,笔者在这里对整个 controller层 的返回结果进行一个约定,约定请求处理的结果按照规定字段放在 ctx.state.apiResponse 中。

image.png

中间件代码实现如下:

import { RESPONSE_CODE } from '../constant'

export function handleResponse () {
  return async function (ctx, next) {
    await next()
    // controller 层的结果处理结果放置 ctx.state.apiResponse 中
    const { code, data, msg } = ctx.state.apiResponse
    ctx.body = getResult(code, data, msg)
  }
}

function getResult (code, data, msg) {
  const result = {
    code,
    data: null,
    msg: null
  }
  if (code === RESPONSE_CODE.SUC) {
    result.data = data // 响应成功
  }

  if (code === RESPONSE_CODE.ERR) {
    result.msg = msg // 响应失败的msg
  }

  return result
}

最后,我们需要在 入口app 中使用我们的中间件 handleResponse (权限的就不演示了,每个童鞋的场景都不一样,只要按照这个思路,自己整个也是没问题的~),这里需要注意一点就是,我们这个中间件是需要对所有的返回做拦截的,按照洋葱模型的请求流向,handleResponseuse 的位置应该在 koa-router 中间件的前面。代码如下:

// index 入口文件
const Koa = require('koa')
const Router = require('@koa/router')
const { initGlobalRoute } = require('./routes/index')
const { handleResponse } = require('./middleware')

const router = new Router()
const app = new Koa();

initGlobalRoute(router)

app.use(handleResponse()) // 中间件实现返回拦截

app.use(router.routes()).use(router.allowedMethods()) // 路由相关

app.listen(3000);

紧接着,我们对 test 模块的 get请求按照刚才的约定,进行一定的代码改造,再通过 postman 进行请求,看看返回的结果。

const { RESPONSE_CODE } = require('../constant')

function get (ctx, next) {
 // 按照约定,把返回的内容包裹在 ctx.state.apiResponse 中
  ctx.state.apiResponse = {
    code: RESPONSE_CODE.SUC, // 约定的字段 code
    data: { name: '井柏然-get(放在controller里啦!)' } // 约定的字段 data
  }
  next()
}

module.exports = {
  get
}

通过 get 请求可以得到我们期望的返回: image.png

ok,讲到这里,整个 Koa 的一些基础开发工作就算是差不多了,基本可以在这个基础上进行相应的业务开发了。紧接着我们进入到下一个阶段——数据库。上手完数据库~我们就能进行愉快的 crud 操作了!!!

二、mongoDb

到了这个阶段,我们首先要做的事情就是各种安装配置!配置的话是因为我们 devprod 环境中所需要连接的数据库地址不一样,所以趁着这次的配置,顺便在这里把项目的一些配置都给整一整。

1. nodemon

"scripts": {
  "dev": "node ./src/index.js" // 这样每次修改文件都需要重新启动
},

当前项目仅是通过 node 入口文件 的方式去执行的,所以每当代码修改,都需要重启服务。这时候你一定很想念开发前端项目时,打包工具给我们提供的 HMR 功能!这个时候不用我多说了, nodemon 就是 nodeHMR !修改代码后会自动重启服务进程。

接着我们安装、配置一下 nodemon 就ok啦。配置啥的就不展开往下说啦,毕竟不是这个章节的重点,想详细了解的童鞋可以看笔者的 github源码~

"scripts": {
  "dev": "nodemon ./src/index.js --watch server --exec babel-node" // 自动重启香
},

细心的伙伴可能发现启动命令后面带了一大段参数,作用是让我们能在 koa 中就可以用 es6 模块导入。哈哈哈,由于笔者之前都没写过 commonjs 模块化,所以这次 demo 特地写了一下 cjs,结果发现自己还是写习惯了 esm ,趁着这次机会,换回来换回来,不得不说啊,笔者真的喜欢折腾~

2. 安装、连接数据库

首先得安装个 mongoDb 。安装这一块就不演示了,毕竟大家系统不一样,安装的方式都不同,笔者这里自己撸一个~安装成功后,通过 mongod -version 能看到相关的信息。(当然也可以通过 docker 下载安装mongo,笔者是用 docker 下的) image.png 这里说多一句,刚开始笔者下的是 mongodb 6.0+ 折腾了一会,发现遇到一些问题网上都没找到解决的办法,可能是版本比较新吧,毕竟是才发布没多久的,所以后来笔者久用回5版本的了~

现在安装好数据库,先把数据库服务进程给启起来,然后要为这次发布平台项目新建一个数据库,就命名为 cicd 吧。

启动的方式各异,笔者也不对这个点进行展开说了。可以用命令的,或者像笔者一样通过 docker 去启动也是可以的。在镜像中找到 mongodb ,然后运行。 image.png

首次运行完后,后续要启动数据库可以直接在 容器 中去启动,非常方便。 image.png

浏览器访问 http://localhost:27017/ 出现如下界面证明数据库启动成功了。需要注意的是,端口 27017 为默认端口,如果使用了自定义的端口记得要换过来。 image.png

接着通过 shell 命令,可以在命令行窗口执行 新建、切换数据库 等各种操作:

# 输入以下命令进入 docker root
docker exec -it cicd-mongo bash
# 输入 mongo 后进入交互式程序
mongo
# 查看数据库
show dbs 
# 创建、切换到 cicd 数据库
use cicd 

讲到这里,数据库的安装就算完了,此时我们还需要再装一个 mongoose 的包,配合着 Koa 项目使用爽得很!详细了解可以戳下他的github。笔者自己先安装了,大家自己动动手吧~

安装好之后,我们可以在项目层面进行数据库的连接了!回到项目代码中,开始进行简单的数据库连接。等连通成功后,再去接着搞一波环境配置。整个的代码非常简单,核心就是通过 mongoose.connect 去连接数据库,传入对应的 mongodb 地址即可,笔者直接贴出来代码:

import mongoose from 'mongoose'

const db = mongoose.connection

export const connect = function () {
  mongoose.connect('mongodb://localhost:27017/cicd') // 暂时写死数据库uri
  db.on('error', console.error.bind(console, 'mongodb connect error'))
  db.once('open', console.log.bind(console, 'mongodb connect success'))
}

现在,我们在入口中使用上面这段代码进行数据库连接,然后 pnpm dev 试试! image.png ok,控制台完美输出,那接下来就到下一步搞个环境配置,完了就可以开始令人心动的实战了~

3. 环境变量配置

之前有提到,数据库配置在不同的环境下肯定也是不同的,所以我们这里还要顺带把数据库的各环境配置也给准备好,后续的开发、部署上线就能省点事了。

首先,在之前预留的 config 文件中添加几个 js 文件,如下图所示: image.png 我们分别把需要针对环境进行不同配置的数据配置到 .env.js 文件中。

// 开发环境 development
export default {
  db: {
    uri: 'mongodb://localhost:27017/cicd'
  }
}

// 生产环境 production
export default {
  db: {
    uri: 'mongodb://xxx_xxx_xxx_prod/cicd'
  }
}

然后,我们只需要根据 process.env.NODE_ENV 去取当前环境的配置即可,直接给出需要改动的地方吧,详细的也不展开说了,这一块大家应该也比较熟悉了。

// packge.json 启动命令添加 环境变量
"scripts": {
  "dev": "export NODE_ENV=development && nodemon ./src/index.js --exec babel-node"
}

// 连接数据库中写死的地址改为变量
export const connect = function () {
  mongoose.connect(config.db.uri) // 将写死的 uri 该成变量,跟随环境变量变化
  db.on('error', console.error.bind(console, 'mongodb connect error'))
  db.once('open', console.log.bind(console, 'mongodb connect success'))
}

搞定了 mongodb 的基础工作之后,就可以进入业务阶段的开发了。接下来通过实战:构建配置crud 的方式跟大家一起熟悉整个后端的开发模式,并为后续实现 前端发布平台 + jenkins 实现自动化部署功能做一个基础的铺垫。

三、实战构建配置CRUD

进入实战阶段,首先需要一个明确的目标,当然也就是提需求阶段!有了需求,才好去落实。那么回顾上一篇文章中提到的一些构建需要的配置,笔者在此再进行一个罗列:

  1. 项目名称
  2. 项目源码(git仓库地址)
  3. 需要构建的分支
  4. 需要执行的打包命令
  5. 上传到文件服务器的目录

上述这5点,就是本次实战中需要实现的构建配置,我们需要实现配置的 保存 、 修改 、 删除 操作以满足我们发布平台的业务需求。

1. 定义Schema

Schema 大概就是定义一个数据的基本格式,是一个集合。笔者是这么理解的,好比一张表有一些字段,每个字段是什么类型的、如何定义它而已,就是一个静态的数据格式。比如,笔者就为上述的配置定义个 Schema

import mongoose from 'mongoose'

const configSchema = new mongoose.Schema({
  projectName: {
    type: String // 项目名称
  },
  gitUrl: {
    type: String // 项目源码(git仓库地址)
  },
  gitBranch: {
    type: String // 需要构建的分支
  },
  buildCommand: {
    type: String // 需要执行的打包命令
  },
  uploadPath: {
    type: String // 上传到文件服务器的目录
  }
})

通过定义个这么个 schema ,把笔者需要的配置信息的数据模型就已经定义好了,接下来就是通过 crud 去操作数据库的这张表了。

我们把表名命为 jobConfig ,并导出 mongoose.model(表名, Schema)

export default mongoose.model('jobConfig', configSchema)

对于 mongoose.Schemamongoose.model,笔者理解他们的区别就是 Schema 是定义数据结构的,而 model 是根据这个结构去操作数据的,我们的 crud 就是通过 model 这一层去实现的。

2. 实现配置保存

定义好数据模型后,我们首先来尝试实现一个保存配置的功能。我们一步一步来实现,按照之前约定,一层一层去实现。我们可以通过下图回顾一下分层,然后就要开始开工啦: mvc.png

  1. routes 层。新建 jobConfig.js 文件,提供一个 post 的路由入口,调用 controller 层的 save 方法

    export function initConfigRoute (router) {
      router.post('/job/save', controller.save)
    }
    
  2. controller 层。也是新建 jobConfig 文件,实现保存相关的业务逻辑。

    这里补充说明一下,由于需要解析 post 请求的 request body,所以我们还需要安装个 koa-body 的包来帮助我们~ pnpm i koa-body 跑起来。安装完成后,在入口文件中使用该中间件。

    // 入口文件
    app.use(KoaBody({
      multipart: true
    }));
    

    那么,接下来就是 controller 层的逻辑了,我们直接看代码(每一步都有注释)

    export async function save (ctx, next) {
      // 首先拿到 request body
      const requestBody = ctx.request.body
    
      try {
        // 调用 services 层的 save 方法。这个下面会展开代码
        await services.save(requestBody)
        // 这里是保存成功后的处理
        ctx.state.apiResponse = {
          code: RESPONSE_CODE.SUC,
          data: null
        }
        // 还记得之前写返回拦截中间件时定义的 apiResponse 的返回规范吗?这里就这样用了
      } catch (e) {
        // 处理保存失败的返回
        ctx.state.apiResponse = {
          code: RESPONSE_CODE.ERR,
          msg: '配置数据保存失败'
        }
      }
      // koa-router 也是一种中间件模式,所以我们这里要加个next
      /** 
       * 比如路由是这样写的 router.get('/test', conttoller1, controller2)
       * 那 controller2 就需要 controller1 提供一个 next 才会执行到了
      **/
      next()
    }
    
  3. services 层。上面 controller 层调用了 services 层的 save,那我们就需要在 services 层提供一个 save 方法。save 的实现按照 mongosse 的用法来写即可,代码实现如下:

    import JobModel from '../model/jobConfig'
    
    /**
     * 这里导入的 JobModel 就是我们在 model 层定义 Schema 时,导出的 model
     * 没错,就是 export default mongoose.model('jobConfig', configSchema) 这句代码
    **/
    // params 就是保存的参数,在 request body 中获得的
    export function save (params) {
      // 这里就时 mongoose 保存数据,操作 model 的用法。
      return new JobModel(params).save()
    }
    

ok,以上就是所有我们要实现的 save 的业务代码了,很简单有没有。那这个时候,我们打开 postman 来验证一下,看看能不能把配置成功保存到数据库里吧。

这里贴出需要保存的配置参数:

{
    "projectName": "cicd-project",
    "gitUrl": "git://xxx",
    "gitBranch": "master",
    "buildCommand": "pnpm run build",
    "uploadPath": "/static"
}

话不多说,直接用 postman post 一下!看起来好像成功了 image.png

接着,我们通过命令去查看一下,是否真的把我们的配置信息存到数据库里面了。

# 切换数据库
use cicd
# 查看所有数据集合
show collections 
# 查看集合中的所有数据
db.jobConfigs.find()

结果如下(为了演示效果,笔者先通过 db.jobConfigs.drop()jobConfigs 的集合删除): image.png 根据结果可以看到,我们通过 postman 发送的 post 请求,保存项目名称 projectName"cicd-project" 的配置信息已经成功保存到数据库中了!

3. 实现配置更新 & 删除

由于我们已经把保存功能实现了,配置的更新、删除也就是在数据库操作那一层会有点不一样而已,所以笔者这里不会详细的展开,只放上核心的实现代码。大家如果想详细了解的话,可以到 github 上去看整个后端的源码~废话不多说,我们接着撸起来,就快完工啦~

  1. routes 层。添加 updatedelete 的路由。
    router.post('/job/update', controller.update)
    router.post('/job/delete', controller.del)
    
  2. controller 层。实现 updatedelete 的业务逻辑,并调用 services 层实现数据库操作。这一层的实现基本跟 save 是一样的,所以不展开啦~
  3. services 层。这里稍有不同,就是我们需要调用的数据库操作的方法不一样。我们可以通过 mongoose 中的 findByIdAndUpdatefindByIdAndDelete 实现更新、删除。
    export function update (id, params) {
      // 这里需要2个参数:一个id(到时候前端是能拿到的),一个新的配置
      return JobModel.findByIdAndUpdate(id, params) 
    }
    
    export function deleteById (id) {
      // 通过 id 删除配置
      return JobModel.findByIdAndDelete(id)
    }
    

好了,代码都撸完了,捣鼓一下 postman 去。首先来试试 update 的: 参数除了笔者圈出来的都一致,当然还多了一个 id 的参数哈哈~ image.png 看到 postman 的返回结果是成功的,接着我们去数据库那里查询一下。 image.png 没有意外,重新查询后,库里的 projectName 的数据值已经更新了。删除的功能也是可以的,笔者就不再演示啦,结果都符合预期!

到这里,整个实战Koa + mongoDb后端的篇章就完结啦,接下来笔者会接着分享 node server 打通 jenkins 实现自动化部署核心流程的文章,主要是通过后端调 jenkins 的openapi 实现 freestyle job 的新建、替换、构建执行,完成整个前端发布平台的自动化部署核心功能。大家可以关注笔者或者关注下该专栏,笔者一定快马加鞭的撸文章给大家~

写在最后

有些时候技术这东西真的不是难不难的说,更多可能是机遇的问题。像笔者做了几年了都是纯前端,也就今年才开始接触 node server。工作实战中没有机会接触后端,也就只能一时想学 node server 就去学学,写个 koaexpress 案例 这样子。但是效果并不好,隔段时间不接触,又什么都不会了。所以,机遇还是很重要的,但是话说回来,有时候工作中真的没有机会搞node server,那就只能靠自己了,其实可以跟着这篇文章的思路走,自己给自己出个需求,做个发布平台啊啥的,一定是有实战意义的,然后需要从0-1去搭建一个后端,使用多种技术栈~最关键是自己提需求,自己实现需求。在实现需求的过程中,就跟实战场景很相似了,会遇到很多坑,要用各种方法、技术不断地解决问题。当通过折腾把问题解决了,印象就很深刻了,等过了这个阶段,你会发现自己已经具备了一定的 node server 开发能力。所以,还是得自己多动手去实现,实在没项目可做的,照着笔者的方向自己去做一个发布平台也是会有收获的,大家一起加油吧~