探索 Deno 命令行开发解决方案 | 🏆 技术专题第一期征文

1,944 阅读7分钟

一、前言

我目前在掘金上正在写一个系列的关于我写的一个 Node 命令行开发框架 Semo 的介绍,对命令行方面的知识有一些研究,而对于 Deno 之前仅止于听说,安装和 Hello world。正巧昨天看到掘金上正在进行 Deno 方面的征文,我就想从命令行的角度做一些探索再分享出来,参加这次征文,所以,感谢掘金这次征文,让我有了深入研究 Deno 的动机,以及接下来要给大家分享的研究成果。

命令行这件事情说难不难,说简单也不简单,其实命令行应用和其他 Web 应用类似,如果基于框架开发会省一些事,但是要遵循一些框架的规范。用不用框架都能实现,就看你是想学造轮子还是想快速实现业务需求了。

Deno 开发命令行和 Node 命令行开发大体类似,但是又有一些区别,开发的时候,让我几乎感觉不到 Deno,但是在有的地方,又不得不去踩坑,填坑才行。尤其是,我受了之前开发 Semo 时的影响,想着是不是可以用 Deno 做出一个类似的方案出来,结果失败而走上了另一条路。。

这篇文章,我先说成品,再讲过程。

二、Denosh,Deno 命令行开发解决方案

更新: Deno 的第三方扩展提交方式发生变化,不再是用 PR 的方式,而是改为 webhook 的方式,只要名字不冲突,可以随意提交啦,提交以后不能删除,只能用更新的版本去覆盖。本项目已经提交: deno.land/x/denosh

2.1 什么是 denosh

命令行工具开发一般大家会关注几方面:

  • 参数和选项解析
  • 命令路由
  • 帮助信息展示
  • ...

基于我的研究成果,我起了一个项目,denosh, 使用 denosh 之后,大家就可以简化这个过程,直接写你想要实现的命令。

2.2 安装和使用

这里有两种方式可以体验,deno install 的方式和 依赖的方式,deno install 的方式是不推荐的方式,但是也可以说一说。

deno install 方式

deno install --allow-read --allow-write -f -n denosh https://deno.land/x/denosh/denosh.ts

目前,还没有通过 PR,所以,这里还是直接从我的项目上拉取。install 的时候要给相关的命令一些权限。

为什么说这种方式不推荐呢,因为这种方式,大家只能看我提供的几个命令,而我提供的命令又没有什么实际用途。虽然不推荐使用,但是也是有意义的,如果大家将来不是基于这个框架开发,而是直接 fork 这个项目,在里面开发,那么就可以用 deno install 的方式发布的。

开发依赖的方式

这种方式就是说,框架不提供入口,而是提供一些 API,然后你开发一个自己的命令行项目依赖 denosh 这个项目。

这里假设你想开发一个命令行,叫 cli

// ./cli.ts
import { launch, registerCommand } from 'https://deno.land/x/denosh/mod.ts'

import * as test1Command from './src/commands/test1.ts'
import * as test2Command from './src/commands/test2.ts'

registerCommand('test1', test1Command)
registerCommand('test2', test2Command)

if (import.meta.main) {
  launch(Deno.args, {
    scriptName: 'cli',
    commandDir: 'src/commands'
  })
}

代码很好理解,提供了一个启动命令的 API 和一个注册命令的 API。

一旦写了这些代码,就可以看看效果了:

$ deno run -A cli.ts --help     
cli [command]

Commands:
  cli help                              Show help
  cli version                           Show version
  cli generate <name> [desc]            Generate command
  cli test2                             test2
  cli test1                             test1

Options:
  -h, --help: Show help
  -v, --version: Show version
  
$ deno run -A cli.ts test1 --help     
test1

test1

Options:
  --opt1, --o1        opt1

其中,version, help, generate 是内置的命令,test1, test2 两个命令是当前项目定义的。那么这里的命令长什么样子呢?

// test1.ts
export const name = 'test1'
export const desc = 'test1'
export const aliases = ''

export const builder = (option: any) => {
  option.set('opt1', { desc: 'opt1', alias: 'o1'})
}

export const handler = async (argv: any) => {
  console.log('Hello world!')
}

了解 Semo 的同学就会知道,这个命令定义方式和 Semo 或者说和 yargs 的定义方式完全一样。

那么这种固定格式的代码我必须手打么?这就是 generate 命令的作用啦:

$ deno run -A cli.ts generate test3 desc
Done!
$ deno run -A cli.ts --help  
cli [command]

Commands:
  cli help                              Show help
  cli version                           Show version
  cli generate <name> [desc]            Generate command
  cli test3                             desc
  cli test2                             test2
  cli test1                             test1

Options:
  -h, --help: Show help
  -v, --version: Show version
  
$ deno run -A cli.ts test3
Hello world!

然后,大家就可以为所欲为了,不是嘛?这里要注意,开发的时候,我用的 -A 就是允许所有权限,真正要发布的时候还是要结合实际需要,只赋予最小权限。框架内部用到了 --allow-read--allow-write

一旦大家实现了自己的命令,就可以对外发布了,需要在你的 README.md 里告诉别人安装方式,一般是像上面第一种安装方式那样的 deno install 格式。

三、开发过程解析

开发框架的目的是为了使用简单,但是开发过程还是踩了一些坑的,这里给大家整理一些,希望对大家有所启发,尤其是我是为了这次征文现用现学的,基础还不太牢,哈哈。

3.1 命令行参数解析

Node 里有很多命令行参数解析的包可以用,我在开发 Semo 时用的是 yargs,而且 yargs 项目里也有 issue 在讨论是否要做一个 deno 版的问题。但是在我探索的过程中发现是没有必要的,deno 内置的命令行解析就很灵活了。

Deno.args 保存的是命令行输入的原始信息,数组形式,核心的 flags 包里还提供了一个 parse 方法,返回的结果跟 yargs 的很像,

import { parse } from "https://deno.land/std/flags/mod.ts";

const argv = parse(Deno.args)

3.2 启动命令的写法

Deno 里已经原生支持 async, await 了,另外查到建议用 import.meta.main 对入口进行保护。

async launch() {
    
}

if (import.meta.main) {
  launch(Deno.args, {
    scriptName: "cli",
    commandDir: "src/commands",
  });
}

3.3 命令参数的必填和选填与单元测试

这里参考 yargs,需要对命令实现必填参数和选填参数的格式:cmd <args1> [args2]args1 是必填的,args2 是选填的,本来想把 yargs 的解析逻辑搬过来,结果发现耦合太严重,所以就需要自己来实现了,为了解析正确,这里用了 deno test 进行单元测试,这里发一下测试部分的代码。

import {
  assertThrows,
  assertEquals,
} from "https://deno.land/std/testing/asserts.ts";

import * as Utils from "../src/common/utils.ts";

Deno.test("Utils.parseCommandName", () => {
  const parsed1 = Utils.parseCommandName("cmd <arg1> [arg2]", "cmd foo bar");
  assertEquals(parsed1, { arg1: "foo", arg2: "bar" });
  const parsed2 = Utils.parseCommandName("cmd  <arg1>  [arg2]", "cmd foo bar");
  assertEquals(parsed2, { arg1: "foo", arg2: "bar" });
  assertThrows(() => {
    Utils.parseCommandName("cmd <arg1> [arg2]", "cmd");
  });
});

执行测试

$ deno test tests                                                                                                       
running 1 tests
test Utils.parseCommandName ... ok (10ms)

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (11ms)

3.4 Typescript 的类型提示

由于是原生 Typescript,写的时候还是很爽的,而且身不由己的就必须要写类型,感觉自己的 ts 功力无形之中提升了那么一点点,我在里面定义了如下类型,在开发过程中能体会到类型提示的好处,尤其是在重构的时候,改错一点就一片飘红。

export type OptionStructure = {
  /** Option description */
  desc: string;

  /** Option alias, not working for now */
  alias?: string;

  /** Option default value, not working for now */
  default?: string;
};

export type OptionsStructure = {
  [key: string]: OptionStructure;
};

export interface OptionMangerInterface {
  /** Set option */
  set(key: string, value: OptionStructure): void;

  /** Get option */
  get(key: string): OptionStructure;

  /** Get all options */
  all(): OptionsStructure;

  /** Get all keys of options */
  keys(): string[];
}

export type CommandStructure = {
  /** Command name */
  name: string;

  /** Command description */
  desc: string;

  /** Command option builder */
  builder?(option: OptionMangerInterface): void;

  /** Command handler */
  handler?(argv: NormalArgvStructure): void;

  /** alias, for now it's not working */
  aliases?: string | string[];
};

export type CommandsStructure = {
  [key: string]: CommandStructure;
};

export type ConfigStructure = {
  [key: string]: string | number | boolean | undefined;
};

export type MatchStructure = {
  [key: string]: string | number | boolean | undefined;
};

export type NormalArgvStructure = {
  [key: string]: string | number | boolean | undefined;
};

export type LaunchOptionStructure = {
  /** entry script name, used in showing help info */
  scriptName?: string;

  /** Extra Commands Directory */
  commandDir?: string;
};

这些类型也都作为框架的一部分对外暴露了,所以大家在开发过程中可以引入里面的类型对输入输出进行约束,从而也能用到类型提示。

3.5 动态扫描和静态导入

一开始的时候我是想提供一个命令行工具的,像 Semo 一样,大家只需要按照规范格式写一个命令行的 ts 文件,就能识别和运行,但是实际开发才发现,这样行不通,虽然 Denoimport 支持动态导入,但是为了安全性限制,不允许动态导入所在项目之外的 ts 文件。所以我在开发中途转变了思路,改成了静态导入和注册的机制。

当然,动态导入的这个限制只是限制了框架不能调度具体业务项目,但是可以把动态扫描和导入放到业务项目中,所以在 cli 这个示例项目,我可以这么写来动态扫描和注册命令。

async function dynamicRegister() {
  const commandsDir = 'src/commands'
  const scannedCommands = []
  for (let entry of Deno.readDirSync(commandsDir)) {
    if (entry.isFile && path.extname(entry.name) == '.ts') {
      scannedCommands.push(entry)
    }
  }

  for (let entry of scannedCommands) {
    const command = await import(path.resolve(commandsDir, entry.name))
    registerCommand(path.basename(entry.name, '.ts'), command)
  }
}

await dynamicRegister()

3.6 最后再说说提交 PR

由于 PR 提交时有自动 CI 要求必须通过才会收录,所以除了写好 README 之外,还需要确保 deno lint, deno fmtdeno test 都要通过。

deno fmt 处理过的代码风格可能和你平时的写法不太一样,所以大家可以放心地让其去格式化,而不是一定要在写的时候就符合 deno 的风格。

deno lint 会对类型有一些基本的要求,比如消除 any,不管是隐式的还是显式的,这就倒逼我们必须优化类型声明。

deno test 我就写了一个,但是要保证有价值和通过测试也下了一番功夫。

小结

以上就是我用一天时间,从想参加这次征文开始,一路边学边做的收获和成果。

这个项目算是挖的一个坑,远没有达到生产级别,大家别见笑,觉得有启发的同学,求您给个 Star 哈,后面还会继续维护。一些方向:

  • 目前命令和 alias 和 选项的 alias 还没有生效
  • 选项的默认值还没有生效
  • 需要更多的单元测试
  • 内置的命令还可以再多一些,开发辅助
  • 内置的命令应该可以开关,业务项目可能不需要在发布的时候还显示这些命令。

🏆 技术专题第一期 | 聊聊 Deno的一些事儿......