开发一个 CLI 模板库可以点亮哪些技能点?

493 阅读7分钟

最近接手了一个“脚手架(CLI)支持远程模板库”功能的需求,实际开发难度不高,但在这个过程中笔者个人受益匪浅,认为有必要总结开发过程中的经验并和大家分享其中的一些个人思考。

本篇文章主要从五个方面逐一阐述,完成一个完整功能的开发,开发者会可能会需要考虑哪些问题,在平常的业务中,或许我们多做一步,就会获得更好的效果!

一、模板仓库的设计

模板仓库作为对 CLI 工具使用方来说是一个黑盒,但好的模板仓库设计,有助于简化在 CLI 中的流程,提升用户体验,以及提高模板仓库代码的可读性和可维护性

1.1 模板分组

模板仓库因为模板数量比较多,结合业务都是有分类分级的处理,在仓库中我们可以通过文件目录形式来实现分类分组。

├── category1
│   ├── templates1
│   ├── templates2
│   └── templates3
│   └── ...
│
├── category2
│   ├── templates1
│   ├── templates2
│   └── templates3
...
└── templates.init.config.js

这样的设计,有助于在 CLI 中只需要两步就可以选择用户想要的模板。

一个小细节,在 CLI 初始化流程中,如果用户选择的分类中只有一个模板,则我们直接默认选择唯一模板,就可以省掉用户一个选择步骤。

1.2 初始配置

可以看到在模板仓库下,有一个 tempaltes.init.config.js 的文件,该文件结合业务与描述,将仓库汇中模板的分布做了一个结构化描述并导出。

例如其结构可能是这样:

module.exports = {
  category1: [
    {
      name: 'XXX一号模板',
      path: 'category1/templates1',
      desc: '这个模板很好啊,这是描述...'
    },
    {
      name: 'XXX二号模板',
      path: 'category1/templates2',
      desc: '这个模板很好啊,这是描述...'
    }
  ],
  category2: [
    {
      name: 'YYY一号模板',
      path: 'category2/templates1',
      desc: '这个模板很好啊,这是描述...'
    },
  ]
}

其作用是当 CLI 工具下载更新了模板代码,然后通过读取 tempaltes.init.config.js 中的配置数据,就可以直接了解到当前模板仓库中的模板信息,也便于 CLI 工具直接将配置文件做其他计算排序等操作,而不需要通过 OS 能力读取文件目录、文件名称,效率更高。

1.3 .gitignore 的影响

为了方便,我们将每个模板相关的配置(.gitignore.eslintrc.json)等文件也放进去,发现 .gitignore 会影响子文件夹下的 Git 规则。

了解到 .gitignore 文件的作用域为:

  • .gitignore 只匹配其所在目录及子目录的文件。
  • 已经被 git track 的文件不受 .gitignore 影响。
  • 子目录的 .gitignore 文件规则会覆盖父目录的规则。

因此,简单的做法是,直接将 .gitignore 文件改名为 _gitignore,在 CLI 初始化的时候,再将其重命名为 .gitignore

.gitignore 文件里推荐语法:

  • 文件:文件名
  • 目录:目录名/
  • 单行注释:#

模板仓库搭建好了,接下来就是 CLI 工具从远程模板仓库获取功能的实现。

二、Git 下载远程模板仓库

本次的需求顾名思义,CLI(脚手架)工具需要一个远程模板仓库,其目的:实现模板动态更新,省去了因模板功能变化就需要升级发布 CLI 工具的麻烦。

那么首先就是得需要实现一个下载远程仓库到本地的功能。

2.1 download-git-repo 模块

一般来讲,有现成的,就不用自己动手,通过翻阅一些资料找到了 download-git-repo 这个三方模块,该模块可以从远程 Git 仓库(Github、Gitlab 和 Bitbucket)下载到本地文件系统中。

download-git-repo 模块使用时,Git 仓库下载地址参考这个格式:

<gitlab|github|Bitbucket>:[domain:]<owner/name>[#branch-name]
或:
direct:<url>

例如:

github:github.com:dyboy2017/DYPROXY#master
或:
direct:https://github.com/dyboy2017/DYPROXY.git

下载远程模板仓库代码大致如下:

import download from "download-git-repo";
import path from "path";

function downloadTemplates() {
  return new Promise<boolean>(resolve => {
    try {
      download(
        "github:example.com:dyboy/templates",
        path.resolve(__dirname, "./cache"),
        { clone: true },
        function (err) {
          resolve(Boolean(err));
        },
      );
    } catch {
      resolve(false);
    }
  });
}

结果下载 Github 上的公开仓库没问题,但下载私有仓库时候,却下载了一个压缩包,然后压缩包里是个需要登陆的网页,因此我们需要考虑授权问题。

2.2 服务账号

由于模板仓库是私有的,因此需要授权才行,但 CLI 工具又要提供给相关开发者使用,结合 download-git-repo 模块在第三个参数中可以传入 headers.PRIVATE-TOKEN 字段。

download(
  "gitlab:mygitlab.com:flippidippi/download-git-repo-fixture#my-branch",
  "test/tmp",
  { headers: { "PRIVATE-TOKEN": "1234" } },
  function (err) {
    console.log(err ? "Error" : "Success");
  },
);

在同事提示下,用了代码仓库服务账号的 GITLAB_SERVER_TOKEN,然后将第三个参数改为:

{ 
  clone: true, 
  headers: 
  { 
    Accept: '*/*', 
    'PRIVATE-TOKEN': GITLAB_SERVER_TOKEN 
  } 
}

这样虽然可以成功下载代码了,但这种方式存在一个安全问题,该服务账号的权限比较大,可以读写当前组织下的所有仓库内容。

20211029-010753.

这对于 CLI 工具来说是绝对不可行的方式,存在信息安全隐患,因此需要寻找另外的解决方式。

2.3 Deploy Tokens

通过查阅 Gitlab 的文档,找到了“Deploy Tokens(部署令牌)” 这个功能:

Deploy tokens allow you to download (git clone) or push and pull packages and container registry images of a project without having a user and a password.

image

拥有“部署令牌”的账号,限制了只有这个仓库的可读权限,收敛了权限范围,这就非常符合我们当前功能诉求。

但使用的时候只有通过命令行方式克隆仓库,用法如下:

git clone https://<username>:<deploy_token>@gitlab.example.com/tanuki/awesome_project.git

所以 download-git-repo 三方模块就没啥用了,需要我们自行通过 node 服务执行 Git clone 命令来实现:使用方无授权下载模板仓库内容。

通过 help 指令查看到 Git CLI 的使用帮助,得知只需要指定第二个参数作为存储文件夹即可

一个参考的实现代码如下:

const path = require('path');
const fs = require('fs-extra');
const { execSync } = require('child_process');

// 此部分常量推荐维护在单独的配置文件中
const REMOTE_REPO_ADDR = 'mygitlab.com/user/project.git';
const DEPLOY_USERNAME = 'gitlab+deploy-token-xxxx';
const DEPLOY_TOKEN = 'xxxxxxxxxxxxxxxx';
const AUTHORIZED_REMOTE_REPO = `https://${DEPLOY_USERNAME}:${DEPLOY_TOKEN}@${REMOTE_REPO_ADDR}`;
const TEMPLATES_SAVE_PATH = path.resolve(__dirname, '../.cache/templates')

async function gitCloneTemplates() {
  // 清空 git 模板库缓存文件
  fs.emptyDirSync(TEMPLATES_SAVE_PATH);
  try {
    // git clone 获取模板
    execSync(`git clone ${AUTHORIZED_REMOTE_REPO} ${TEMPLATES_SAVE_PATH}`, { stdio: 'ignore' });
    return true;
  } catch {
    return false;
  }
};

将模板文件放到了 .cache/templates/ 路径下主要有两个思考点:

  1. .cache/ 作为隐藏文件夹,不希望被用户感知和手动修改
  2. 多了一层 templates/ 子文件夹,设计希望还有扩展别的缓存都可放到 .cache/ 目录下,统一管理缓存内容

image

三、NodeJS API

在 CLI 的功能实现中,一般都需要借助一些 NodeJS 的 API,在 Node 环境中统一抹平一些操作系统上带来的差异。

3.1 路径处理

CLI 中通常会涉及到文件的读写,读写文件就涉及到文件路径问题。文件路径有“相对路径”和“绝对路径”之分,想要正确读写文件,首先就是得保证文件路径的正确性。

在 NodeJS 环境中,提供了 path 模块(nodejs.cn/api/path.ht…

该模块主要功能就是处理文件和目录的路径问题。

(1) path.basename()

该 API 是为了获得路径中的最后一部分

image

一般来说,我们会通过这个 API 去获取文件名。

按照获取路径中最后一部分,可以自己实现一个:

'/user/dyboy/desktop/workspace/project/index.html'.split('/').pop();

效率更高,但缺点明显,功能简单,没有平台兼容性,没有异常处理,但可以结合业务的特征来做取舍。

(2) path.join()

该 API 将多个路径片段组合在一个,模拟操作系统得到的最终路径,如果不传参数,则返回当前工作目录。

image

(3) path.resolve()

这是常用的 API 之一,功能也很强大,能将路径或路径片段的序列解析为绝对路径。

image

从上面两张图的对比可以总结出 resolve()join() 方法的差异:

  1. join() 是把各个 path 片段连接在一起, resolve()/ 当成根目录
  2. resolve() 在传入非 / 路径时,会自动加上当前目录形成一个绝对路径,而 join() 仅仅用于路径拼接

这里仅展示了三个 API,足以解决绝大部分场景下的路径问题了,其他的遇到问题了后,可自行再查阅 NodeJS中文文档

(4) 容易混淆的当前目录

在程序编写过程中,我们会被一些相对路径给迷惑,而不知道该如何写文件路径才能访问到正确的目标文件,例如可能会混淆当前文件目录和当前工作目录。

  • 当前文件目录: 当前代码所在的文件目录
  • 当前工作目录: 执行脚本的起始目录

例如,文件目录是这样:

├── scripts
│   ├── child_dictory
│   │   └── a.js

分别在 child_dictory/scripts/ 文件夹下执行 a.js 输出结果如下:

image

因此可知,想要获取准确的路径,可以把 __dirname 这个变量利用好,同时结合 path.resolve() 和其他路径片段,就可以得到准确的绝对路径

如果是想要获取脚本执行的起始(根)目录,推荐使用 process.cwd() 更符合语义一些。

3.2 文件 IO

搞定了文件的路径问题,那么文件读写,在 NodeJS 中也非常常见,比如读写配置文件、缓存数据等。

NodeJS 提供了文件系统,文档:nodejs.cn/api/fs.html

但使用起来参数太多,例如清空文件夹就得自己去判断是否文件夹为空,递归删除文件,再删除文件夹,属实有些麻烦。

既然文件系统使用这么麻烦,那肯定会有 Coder 造好了轮子,等着我去使用。于是发现了 fs-extra 模块。

模块作者介绍说:fs-extra 是原生 fs 的替代品,fs 中的所有方法都附加到 fs-extra

image

换句话说,其做了一系列上层封装,好用,你赶紧用起来。

在本次“模板库需求”中,就需要在 git clone 新内容之前,清空 ./cache/templates/ 目录下的历史文件。

借助 fs-extra 的 emptyDirSync() 清空文件夹同步方法即可,如果文件夹不存在,则会新建目标空文件夹,这就比较 nice 了,还有啥不满足你功能的,直接给作者提 issue!

image

四、Git 提交规范

代码写好了,得提交上去给大伙儿瞅瞅。

团队协作开发中,比较重要的一环就是给其他同事 CR(Code Review),CR 的目的是尽可能保证代码质量、格式化、功能正确性,要求高一点还会在语义化、可扩展性、性能方面做一些约束。

4.1 客观约束 - 插件脚本约束

为了实现代码风格上的统一,比如:语句加分号、纯文本用单引号包裹、未使用变量需删除等等,我们可以借助 eslint + prettier + lint-staged + husky 来实现在 git commit 的时候自动校验和格式化代码,保证团队提交的代码风格很大程度上的统一。

本次需求中,“模板库”的代码后续会给更多人来维护更新,因此需要在模板库中添加相应的代码提交规则,以及自动格式化规范。

现在成熟的方案是:借助 Git 钩子做代码自动格式化以及 TypeScript 的本地代码正误校验。

用到的三方库有:

"@commitlint/cli": "^13.2.1", // Git Commit 提示CLI
"@commitlint/config-conventional": "^13.2.0", // Git Commit Message 格式校验
"@typescript-eslint/eslint-plugin": "^5.1.0", // eslint ts 校验 插件
"@typescript-eslint/parser": "^5.1.0", // eslint ts 解析器
"eslint": "^8.0.1", // eslint
"husky": "3.1.0", // Git Hook
"lint-staged": "^11.2.3", // 在 git 暂存文件上运行 linters 的工具
"prettier": "^2.4.1", // 支持多类型代码格式化工具
"typescript": "^4.4.4" // ts

一个小坑: 在安装的时候发现 huskyhooks 不生效,怎么尝试都不生效,通过 github 找到一个 issue:github.com/typicode/hu…

image

因为新版的写法修改了(参考文档:husky document),然后安装了低版本的 husky@3.1.0 即可生效,浪费我好一阵的时间......怪自己,没有去看文档,直接把其他项目的配置拷贝过来,然而自己安装的 husky 又是最新的,最新版的 Husky 用法已经变了。

下次抄作业前,要先抄的一模一样才行!

给一个针对 TypeScript 开发环境的参考配置吧:

(1) .eslintrc.json

同时,eslint 的规则可以通过命令:yarn eslint --init 来快速生成。

{
  "env": {
    "browser": true,
    "es2017": true,
    "node": true
  },
  "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": 8,
    "sourceType": "module"
  },
  "plugins": ["@typescript-eslint"],
  "rules": {
    "@typescript-eslint/no-unused-vars": "off",
    "no-unused-vars": [
      "error",
      {
        "varsIgnorePattern": "^_"
      }
    ],
    "no-console": "error",
    "space-before-function-paren": "warn",
    "semi": "warn",
    "quotes": ["warn", "single"]}
}

(2) .prettierrc.json

用于代码格式化

{
  "printWidth": 100,
  "tabWidth": 2,
  "useTabs": false,
  "semi": true,
  "singleQuote": true,
  "bracketSpacing": true,
  "arrowParens": "avoid"
}

(3) package.json

package.json 中需要增加如下的包和字段:

"husky": {
  "hooks": {
    "pre-commit": "lint-staged",
    "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
  }
},
"commitlint": {
  "extends": [
    "@commitlint/config-conventional"
  ]
},
"lint-staged": {
  "*.{ts,js}": [
    "node --max_old_space_size=8192 ./node_modules/.bin/prettier -w",
    "node --max_old_space_size=8192 ./node_modules/.bin/eslint --fix --color",
    "git add"
  ]
},
"devDependencies": {
  "@commitlint/cli": "^13.2.1",
  "@commitlint/config-conventional": "^13.2.0",
  "@typescript-eslint/eslint-plugin": "^5.1.0",
  "@typescript-eslint/parser": "^5.1.0",
  "eslint": "^8.0.1",
  "husky": "3.1.0",
  "lint-staged": "^11.2.3",
  "prettier": "^2.4.1",
  "typescript": "^4.4.4"
}

这样在代码 commit 的时候,会检查 commit message 的格式以及 TypeScript 语法,并自动使用 prettier 模块的规则格式化暂存区文件,当 eslint 校验发生错误的时候会终止 commit 流程,直到修复完所有的 error,才可通过并提交 commit

commit messagetype 说明如下:

type: commit 的类型
feat: 新特性
fix: 修改问题
refactor: 代码重构
docs: 文档修改
style: 代码格式修改, 注意不是 css 修改
test: 测试用例修改
chore: 其他修改, 比如构建流程, 依赖管理.
scope: commit 影响的范围, 比如: route, component, utils, build...
subject: commit 的概述, 建议符合 50/72 formatting
body: commit 具体修改内容, 可以分为多行, 建议符合 50/72 formatting
footer: 一些备注, 通常是 BREAKING CHANGE 或修复的 bug 的链接.

4.2 主观约束 - 开发者编程习惯

客观约束不是万能的,计算机视角下无法理解一个函数的目的和应用场景是什么,也无法判断变量名、函数名是否人类可以理解,因此插件脚本的约束是有限制的约束。

如果是出现上述的情况,得依赖开发者的一些开发习惯和经验。

常见于文本,中文、英文、全/半角符号、数字、特殊符号的使用,这里推荐阅读文章:《程序员技术文档写作规范》。

例如在说明文档(readme.md)中就需要注意文档规范,那么写出来的文档阅读起来也非常舒适清晰。

与此同时,注意写好函数的注释,可以使用 JSDoc 有助于在使用函数时有较为友好的代码提示。

JSDoc 风格参考如下图:

image

另外推荐阅读《【PDF】代码整洁之道》一书,另外有网友分享的笔记可概览:《代码整洁之道》阅读笔记

4.3 如何提一个好的代码 MR

一个好的代码 MR(请求合并代码),可以总结为:职责单一,一个小功能对应一个 commit

(1) 常用 Git 命令

为了提好一个 MR,在发生合并代码时候很有可能会出现代码冲突、Code review 之后需要修改等情况,就会涉及到 commit 修改、增加的问题,为了减少不必要的 commit 信息,需要我们对 Git 命令有一定的掌握。

罗列了一些常用的 Git 命令:

// 图形化查看 git 提交日志
git log --pretty=oneline --graph

// 将 commit 剪切到当前分支最前
git cherry-pick <commit-id>

// 要修改 历史 commit message,获得到 commit-id 之前节点记录
git rebase -i [commit-id] 

// 修改最后一条 commit message
git commit --amend --author "DYBOY <dyboy2017@qq.com>" --message "fix: 🤔修改的信息"

// 查看远程仓库地址
git remote -v

// 删除远程分支
git push orgin --delete <branch-name>

// 删除本地分支
git branch -D <branch-name>

// 将当前工作内容存储,并将代码恢复到当前分支最新 commit 节点
git stash

// 将最近存储的工作内容恢复到当前分支
git stash pop

(2) 复用 commit 节点

问题: 发起 MR,代码被 CV 后,需要修改,还想复用当前 commit,应该咋办?

解决办法:

// 1. 切换到发起 MR 的分支 feat/xxx
git checkout feat/xxx

// 2. 修改并完善代码
...

// 3. 将修改好的代码存入暂存区
git add .

// 4. 合并本次修改到上一个 commit 节点中
git commit --amend

// 5. 强制提交远程分支
git push -f

// 代码没改好,持续完善,重复第 2~5 步

五、自动发包

CLI 工具结合远程模板库初始化功能写好了,升级 package.json 中的版本号,本地构建,然后 publish(发布)

发包的过程看起来是完全可以自动化处理的,结合 Gitlab 平台提供的 CI/CDContinuous Integration / Continuous Delivery)功能,能够有效提升研发工作效率,收益还是比较可观。

5.1 自动发包的流程策略

自动发包和手动发包的流程相似,对于 package.json 文件中 version 字段值的更新,个人认为还是交给开发者来手动修改比较好的两个原因:

  1. 版本概念在代码中直接体现和远程保持一致;
  2. 版本号可控。

在自动发布过程中,读取当前 package.json 中的 version 字段值即可,如果版本号已经被发布,重复发包也只是会出错,会终止掉整个自动发包流程开发者此时也可及时修改版本号,并再次触发“自动发包流程”。

自动发包流程图如下:

image

5.2 .gitlab-ci.yml 配置

需要在项目根目录配置一个 .gitlab-ci.yml 的描述文件,这个文件使用的配置方法和详细规则,可以参阅:

我们的目的是:实现有代码合并进 master 分支,就自动打包编译,并发包到内部平台上。

要想要 在 Gitlab CI/CD 过程中 commitpush,以及在内部 NPM 平台上发包,就需要设置好两者的权限 TOKEN。

(1) NPM TOEKN

创建 NPM 的 TOKEN 参考 npm-token

npm token create

NPM TOKEN 在 CI/CD 中的使用方式:Using private packages in a CI/CD workflow

然后我们可以把生成的 NPM TOKEN 放到 Gitlab 代码管理平台的“变量”设置中,这样就可以在 .gitlab-ci.yml 中使用这个变量

image

(2) 参考配置

stages:
  - publish

# build & publish
build-job:
  stage: publish
  only:
    - master
  tags:
    - fe
    - shared
    - xdev
  before_script:
    - nvm install 12.13.1
    - nvm use 12.13.1
    - npm install -g yarn --registry=https://repo.xxx.com
    - yarn install --registry=https://repo.xxx.com
    - echo "//repo.xxx.com/:_authToken=${NPM_TOKEN}" >> .npmrc
  script:
    - yarn jest
    - yarn build
    - npm publish
    - curl "WebHook URL 地址"

NPM_TOKEN 变量在 Gitlab 平台中的 Variables 中设置,这里主要用于设置类似密钥之类的值。

5.3 Runner

同时我们需要一个 Runner 来执行 .gitlab-ci.yml 中描述的命令操作,可以把这个 Runner 理解为一个无情的执行🤖️,开启共享 Runners 的方法如下图:

image

5.4 WebHook 通知

当构建并发包完成,为了及时将这个消息同步给其他同事或者 CLI 用户群,可以借助飞书提供的 WebHook,来通知大家最新的 CLI 包版本。

参考设置飞书 WebHook 机器人文档:自定义机器人指南

然后再结合字节内的“轻服务”平台,写一个 API 接口,加到 .gitlab-ci.yml 的最后一个 script 中,每次发包成功,都会在飞书群里收到一条消息。

image

整个链路似乎就完整了,搞起!

5.5 更多方式

还可以结合字节内的 SCM 平台,写一写 .sh 脚本文件,同样可以完成自动化发布,与此同时,SCM 平台还打通了机器人通知的流程,不需要写代码就可以完成主动消息通知。

另外有看到一个借助 semantic-release 三方库的方案,也是一种不错的实践方案。

当然还有更多暂时没用过的 CI/CD 流程方案,欢迎大家讨论

六、总结

这是一次简单的开发 CLI 模板库的需求,但从中可以发现需要例如 NodeJS、Git、CLI、NPM 等技术知识,以及后续我们针对自动发包的流程设计,使得开发和使用链路能够及时同步,代码提交规范化的一些扩展思考。

需求虽然简单,但是也看出需要多方面的技术知识,因此,我们可以在平时多积累相关知识,做好技术储备,以便能够满足产品经理的“天马行空”的需求🐶!

作为开发者,我们的除了能够完成功能开发,也可以在开发流程、使用流程上多思考一下,也许就会更进一步!

当然个人认为,学习的最快方式就是实践,从实践中才能遇到那些细节问题,解决问题,收获经验。

本次需求开发也离不开身边同事的帮助支持,可能这也是我为啥喜欢在字节干活儿的原因之一吧!

image