写给前端的自动化部署攻略

​ 对于前端开发而言,静态页面的部署是容易的。只需要将打包好的页面,放入已经配置好的 nginx 或者 apache 的静态资源目录下即可。然而,重复的、机械化的打包、上传、部署工作,是无意义且令人厌烦的。

​ 自动化部署就是为了解决这一问题而存在的。

​ 很多重量级的软件 DockerJenkins 等,都提供了自动化部署的相关功能。但也为 自动化部署 流程,蒙上了一层神秘的面纱。其实,自动化部署并不是什么高深的技术,只要你掌握了一些基本的工具和原理,就可以轻松地实现自动化部署。

​ 因此,本文仅以前端开发最熟悉的 NodeJS 为基础,攻略 自动化部署 ,并使之能够运用到实际的项目中。希望能对大家有所帮助。

部署流程

​ 现在来回顾一下,前端页面的手动部署流程。

git 拉取最新代码 => npm 更新依赖 => 打包生成 dist => 登录服务器 => 上传文件到指定目录

​ 打包生成 dist , 通常是缓慢耗时、需要等待的。如果,打包出的文件数很多,我们还可能需要将文件 zip 压缩, 上传到服务器后再解压。

​ 手动部署通常是涉及本机文件和服务器交互的。自动化部署 中,我们可以将上述步骤全权交由服务器。

服务器部署流程:

git 拉取最新代码 => npm 更新依赖 => 打包生成 dist => 移动 dist 内容到 nginx 静态资源

服务器相关依赖:

  • git: 拉取源码
  • node/npm: 更新依赖与打包
  • nginx: 静态资源目录

Webhooks

​ 你可能已经发现了,如果服务器实现了上述流程。开发者只需要告诉服务器,什么时候跑这个流程。这就是全部。

​ 幸运的是 git 对于每个仓库,都提供了 webhooks 功能。简而言之,就是如果你对仓库做了某些操作,git 将通过 http 向你提供的地址发送操作信息(json).

要确保 git 平台 与 部署服务器 之间是可连通的,webhooks 才具有可行性

gitlab 为例:

  • trigger: 这里选中了 push events , pattern 填入了 master. 当 master 分支 提交事件发生时,触发该 webhook

  • url: 发送信息到 http://192.168.110.130:3000/deploy/zz-platform-config

  • secret token: 可以输入一个和部署服务器约定的 hash 值, 用作校验。

这里演示使用的 gitlab、部署服务器 都使用的是内网。当然,你也可以使用公网的配置,只要确保 git 平台 与 部署服务器 之间可连通。

如果你使用的也是内网环境,需要打开 webhooks 对本机地址的允许

服务端应用程序

​ 接下来创建服务端应用程序,监听 3000 端口,接受 webhooks 将会发送的信息。

​ 这里以 NestJS 为例

Controller

​ 这个应用程序将部署在 192.168.110.130 服务器上

webhooks 操作信息将发送到 http://192.168.110.130:3000/deploy/zz-platform-config

整理一下思路之后,一个 DeployController 的实现如下:

ts 复制代码
@Controller('deploy')
export class DeployController {
  constructor (private readonly deployService: DeployService) {}

  @Post('zz-platform-config')
  async deployZzPlatformConfig (
    @Body() body: GitlabPushEvent,
    @Headers('X-Gitlab-Token') token: string,
  ) {

    // 1. 校验 Secret token 是否正确
    if (token !== '123456') return {
      code: 401,
      message: 'Unauthorized',
    }

    // 2. 通过 git 下载代码到本地 git clone
 	const { dir: codeDir } = await this.deployService.cuCode(body)


    // 3. 更新代码的依赖 pnpm i 
    await this.deployService.uDependencies(codeDir)
        
    // 4. 获取部署信息,如 打包命令名称、产物部署目录 等,
    const deployConfigs = await this.deployService.rDeployConfigs(codeDir, body)


    // 5. 根据部署信息,运行打包脚本,将打包产物拷贝至静态资源目录
    for (const deployConfig of deployConfigs) {
      await this.deployService.deploy(codeDir, deployConfig)
    }

    return {
      code: 200,
      message: 'ok',
    }
  }

}

Service

逐一分析实现之前,介绍一些重要的变量 \ 方法 \ 接口

ts 复制代码
export const workRoot = '[当前部署应用程序的根目录]'
// 代码下载到 codes 目录下
export const codesRoot = path.resolve(workRoot, 'codes')

// webhook 发送的信息 
export interface GitlabPushEvent {
  project: {
    // @example rd/platform/zz-platform-config
    path_with_namespace: string
  }
  repository: {
    // @example http://192.168.110.138:30080/rd/platform/zz-platform-config.git
    git_http_url: string
  }
}
ts 复制代码
import { spawn } from 'node:child_process'

export const run = async (
  script: string, 
  cwd?: string,
) => { // 使用这个方法, 调用脚本命令
  return new Promise((resolve) => {
    const [cmd, ...args] = script.split(' ')

    // 在node中使用子进程运行脚本
    const app = spawn(cmd, args, {
      cwd,
      stdio: 'inherit',
      shell: true,
      
    })

    app.on('close', resolve)
  })
}

run 是实现程序中运行 cmd 命令的重要方法。

更新代码

ts 复制代码
/**
   * 创建/更新代码 
   * @param body 
   */
  async cuCode (body: GitlabPushEvent) { 
    // 如果 codesRoot 不存在, 创建
    if (!fs.existsSync(codesRoot)) {
      fs.mkdirSync(codesRoot, { recursive: true })
    }

    // 检查仓库是否已经拉取过
    const codeDir = path.resolve(
      codesRoot,
      
      body.project.path_with_namespace,
    )

    if (
      // 文件夹存在
      fs.existsSync(codeDir)
      // 并且文件夹为空
      && !fs.readdirSync(codeDir).length
    ) {
      // 清除该文件夹
      fs.rmdirSync(codeDir)
    }


    if (
      // 文件夹不存在
      !fs.existsSync(codeDir)
    ) {
      /**
       * git clone 
       * @example
       * git clone http://192.168.110.138:30080/rd/platform/zz-platform-config.git rd/platform/zz-platform-config
       */
      await run(`git clone ${body.repository.git_http_url} ${body.project.path_with_namespace}`, codesRoot)
    } else {
      // 文件夹存在, 且不为空
      // 放弃当前修改
      await run('git reset --hard', codeDir)
      // 拉取最新代码
      await run('git pull', codeDir)
    }

    return {
      dir: codeDir,
    }

    
  }

上述函数将会把目标仓库的源码下载到本地指定的目录中。函数返回了 dir , 即源码的根目录,方便我们后续使用。

这是一个执行 cuCode 之后的工作目录,在这个示例中 dir = codes/rd/platform/zz-platform-config .

更新依赖

使用 pnpm 下载/更新依赖,需要部署环境中里提前安装 pnpm

ts 复制代码
  /**
   * 更新依赖
   */
  async uDependencies (codeDir: string) {
    // 安装依赖
    await run('pnpm i', codeDir)
  }

部署配置

为了使,部署服务的通用性更强,考虑在目标仓库(源码)中,放入一个json ,来描述如何部署这个项目

json 复制代码
{
    "scripts": [
      // 依次执行命令,最终生成打包产物
      "build:skzz" // npm run build:skzz
    ],
    "path": {
       // 打包产物所在目录
      "dist": "./app/dist/zz/zz-platform-config",
        
       // 静态资源目录
      "html": "/home/nginx/html/zz/zz-platform-config"
    }
}
ts 复制代码
  /**
   * 获取部署配置
   */
  async rDeployConfigs (codeDir: string, body: GitlabPushEvent) {

    let deployList = [{}] as DeployConfig[]
    if (fs.existsSync(
      path.resolve(codeDir, 'deploy.config.json'),
    )) {
      const jsonObj = JSON.parse(
        fs.readFileSync(
          path.resolve(codeDir, 'deploy.config.json'),
          'utf-8',
        ),
      )
      if (Array.isArray(jsonObj)) {
        deployList = jsonObj
      } else {
        deployList = [jsonObj]
      }
    }
    
    return deployList.map(deployConfig => {
      const scripts = deployConfig.scripts || []
      const pathDist = deployConfig.path?.dist ?? './dist'

      // prod: deployConfig.path?.html ??
      const pathHtml = deployConfig.path?.html ?? path.resolve(workRoot, 'nginx/html', body.project.path_with_namespace)

      return {
        scripts,

        path: {
          // 从 dist 中取出所有文件
          dist: path.resolve(codeDir, pathDist),

          // 将取出的文件放入 nginx 的指定目录下
          html: pathHtml,
        },
      }
    })
 
  }

可能一个目标仓库中,存在多个应用需要部署,例如文档和应用本身。这里处理成数组,增强健壮性。

部署

ts 复制代码
  async deploy (codeDir: string, deployInfo: DeployConfig) {
    // 如果 dist 文件夹存在,清空文件夹
    if (fs.existsSync(deployInfo.path.dist)) {
      await fs.promises.rm(
        deployInfo.path.dist, 
        { recursive: true },
      )
    }
  
  
    // 执行构建命令
    for (const script of deployInfo.scripts) {
      await run(`npm run ${script}`, codeDir)
    }
  
    // 如果 html 文件夹存在,重名文件夹【自动备份】
    if (fs.existsSync(deployInfo.path.html)) {
      await fs.promises.rename(
        deployInfo.path.html,
        deployInfo.path.html + '.' + new Date().toISOString(),
      )
    }
  
  
    // 将 dist 下所有文件放入 html 下
    consola.log('开始拷贝文件')
    await fs.promises.cp(
      deployInfo.path.dist,
      deployInfo.path.html,
      {
        recursive: true,
      },
    )
    consola.success('部署成功')
  }

根据部署配置,执行命令,转移产物,即部署成功。

:::warning

这里展示了一个最基础的部署实现。当然,可以根据实际的需求,去调整代码。这一过程是自由的。

举个例子,如果认为 deployInfo.path.html ,完全由目标仓库决定是有安全风险的,则使用 静态 或者 静态+动态拼接 的方式决定。

:::

服务器中启动

安装必要依赖

  • git

  • node >= 18.0.x

  • pnpm - npm i pnpm -g

配置git免登

推荐使用 SSH 密钥或访问令牌(Personal Access Token)来进行身份验证。

也有一种简单的设置,运行以下命令,设置全局凭证存储:

shell 复制代码
git config --global credential.helper store

这会在用户主目录下的 .git-credentials 文件中存储明文的用户名和密码。请注意,这并不是最安全的方式,因为凭证以明文形式存储在文件中。

执行一次 Git 操作(如克隆或拉取),并在提示输入用户名和密码时输入你的 GitLab 用户名和密码。

接下来的 Git 操作将不再要求输入用户名和密码,因为它们已经存储在 .git-credentials 文件中。

但由于密码以明文形式存储在文件中,它并不是最安全的方式。

后台启动 nest 服务

shell 复制代码
# 服务器中拉取 部署应用 安装依赖

# 后台启动 nest 服务
# 在命令末尾添加 & 可以将命令放入后台运行 日志默认放入当前文件夹
$ nohup npm run start &

# 查看日志 
$ tail -f nohup.out

# 查看进程
$ ps aux | grep "npm"

服务启动后,接受到提交事件,开始打包部署。

​ 本文涉及的代码@simple-deploy

​ 通过本文的实践,我们可以看到,自动化部署并不是一个遥不可及的技术,而是通过合理利用现有工具和技术,结合适当的编程实践就能实现的过程。通过配置 webhooks 触发部署事件,服务器端应用程序能够自动处理从代码更新到部署的整个流程,包括代码拉取、依赖安装、打包、以及将打包好的文件放入资源目录。

​ 这些理解和技能的转移,可以帮助我们更深入地理解自动化部署的本质,以及如何在更复杂的系统中实施自动化部署策略。对将来接触和使用 DockerJenkins 也有着重要的启示和补充意义。

相关推荐
拉不动的猪2 分钟前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
Asthenia041237 分钟前
Spring扩展点与工具类获取容器Bean-基于ApplicationContextAware实现非IOC容器中调用IOC的Bean
后端
FreeCultureBoy44 分钟前
macOS 命令行 原生挂载 webdav 方法
前端
bobz9651 小时前
ovs patch port 对比 veth pair
后端
Asthenia04121 小时前
Java受检异常与非受检异常分析
后端
uhakadotcom1 小时前
快速开始使用 n8n
后端·面试·github
uhakadotcom1 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom1 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom1 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
JavaGuide1 小时前
公司来的新人用字符串存储日期,被组长怒怼了...
后端·mysql