Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

当 Egg 遇到 TypeScript,收获茶叶蛋一枚 #27

Open
atian25 opened this issue Apr 5, 2018 · 9 comments
Open

当 Egg 遇到 TypeScript,收获茶叶蛋一枚 #27

atian25 opened this issue Apr 5, 2018 · 9 comments

Comments

@atian25
Copy link
Owner

atian25 commented Apr 5, 2018

slice

前言

TypeScript is a typed superset of JavaScript that compiles to plain JavaScript.

TypeScript 的静态类型检查,智能提示,IDE 友好性等特性,对于大规模企业级应用,是非常的有价值的。详见:TypeScript体系调研报告

然而,此前使用 TypeScript 开发 Egg ,会遇到一些影响 开发者体验 问题:

  • Egg 最精髓的 Loader 自动加载机制,导致 TS 无法静态分析出部分依赖。
  • Config 自动合并机制下,如何在 config.{env}.js 里面修改插件提供的配置时,能校验并智能提示?
  • 开发期需要独立开一个 tsc -w 独立进程来构建代码,带来临时文件位置纠结以及 npm scripts 复杂化。
  • 单元测试,覆盖率测试,线上错误堆栈如何指向 TS 源文件,而不是编译后的 js 文件。

本文主要阐述:

  • 应用层 TS 开发规范
  • 我们在工具链方面的支持,是如何来解决上述问题,让开发者几乎无感知并保持一致性。

具体的折腾过程参见:[RFC] TypeScript tool support


快速入门

通过骨架快速初始化:

$ npx egg-init --type=ts showcase
$ cd showcase && npm i
$ npm run dev

上述骨架会生成一个极简版的示例,更完整的示例参见:eggjs/examples/hackernews-async-ts


目录规范

一些约束:

  • Egg 目前没有计划使用 TS 重写。
  • Egg 以及它对应的插件,会提供对应的 index.d.ts 文件方便开发者使用。
  • TypeScript 只是其中一种社区实践,我们通过工具链给予一定程度的支持。

整体目录结构上跟 Egg 普通项目没啥区别:

  • typescript 代码风格,后缀名为 ts
  • typings 目录用于放置 d.ts 文件(大部分会自动生成)
showcase
├── app
│   ├── controller
│   │   └── home.ts
│   ├── service
│   │   └── news.ts
│   └── router.ts
├── config
│   ├── config.default.ts
│   ├── config.local.ts
│   ├── config.prod.ts
│   └── plugin.ts
├── test
│   └── **/*.test.ts
├── typings
│   └── **/*.d.ts
├── README.md
├── package.json
├── tsconfig.json
└── tslint.json

Controller

// app/controller/home.ts
import { Controller } from 'egg';

export default class HomeController extends Controller {
  public async index() {
    const { ctx, service } = this;
    const page = ctx.query.page;
    const result = await service.news.list(page);
    await ctx.render('home.tpl', result);
  }
}

Router

// app/router.ts
import { Application } from 'egg';

export default (app: Application) => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
};

Service

// app/service/news.ts
import { Service } from 'egg';

export default class NewsService extends Service {
  public async list(page?: number): Promise<NewsItem[]> {
    return [];
  }
}

export interface NewsItem {
  id: number;
  title: string;
}

Middleware

// app/middleware/robot.ts

import { Context } from 'egg';

export default function robotMiddleware() {
  return async (ctx: Context, next: any) => {
    await next();
  };
}

因为 Middleware 定义是支持入参的,第一个参数为同名的 Config,如有需求,可以用完整版:

// app/middleware/news.ts

import { Context, Application } from 'egg';
import { BizConfig } from '../../config/config.default';

// 注意,这里必须要用 ['news'] 而不能用 .news,因为 BizConfig 是 type,不是实例
export default function newsMiddleware(options: BizConfig['news'], app: Application) {
  return async (ctx: Context, next: () => Promise<any>) => {
    console.info(options.serverUrl);
    await next();
  };
}

Extend

// app/extend/context.ts
import { Context } from 'egg';

export default {
  isAjax(this: Context) {
    return this.get('X-Requested-With') === 'XMLHttpRequest';
  },
}

// app.ts
export default app => {
  app.beforeStart(async () => {
    await Promise.resolve('egg + ts');
  });
};

Config

Config 这块稍微有点复杂,因为要支持:

  • 在 Controller,Service 那边使用配置,需支持多级提示,并自动关联。
  • Config 内部, config.view = {} 的写法,也应该支持提示。
  • config.{env}.ts 里可以用到 config.default.ts 自定义配置的提示。
// app/config/config.default.ts
import { EggAppInfo, EggAppConfig, PowerPartial } from 'egg';

// 提供给 config.{env}.ts 使用
export type DefaultConfig = PowerPartial<EggAppConfig & BizConfig>;

// 应用本身的配置 Scheme
export interface BizConfig {
  news: {
    pageSize: number;
    serverUrl: string;
  };
}

export default (appInfo: EggAppInfo) => {
  const config = {} as PowerPartial<EggAppConfig> & BizConfig;

  // 覆盖框架,插件的配置
  config.keys = appInfo.name + '123456';
  config.view = {
    defaultViewEngine: 'nunjucks',
    mapping: {
      '.tpl': 'nunjucks',
    },
  };

  // 应用本身的配置
  config.news = {
    pageSize: 30,
    serverUrl: 'https://hacker-news.firebaseio.com/v0',
  };

  return config;
};

简单版:

// app/config/config.local.ts
import { DefaultConfig } from './config.default';

export default () => {
  const config: DefaultConfig = {};
  config.news = {
    pageSize: 20,
  };
  return config;
};

备注:

  • TS 的 Conditional Types 是我们能完美解决 Config 提示的关键。
  • 有兴趣的可以看下 egg/index.d.ts 里面的 PowerPartial 实现。
// {egg}/index.d.ts
type PowerPartial<T> = {
  [U in keyof T]?: T[U] extends {}
    ? PowerPartial<T[U]>
    : T[U]
};

Plugin

// config/plugin.ts
import { EggPlugin } from 'egg';

const plugin: EggPlugin = {
  static: true,
  nunjucks: {
    enable: true,
    package: 'egg-view-nunjucks',
  },
};

export default plugin;

Typings

该目录为 TS 的规范,在里面的 \*\*/\*.d.ts 文件将被自动识别。

  • 开发者需要手写的建议放在 typings/index.d.ts 中。
  • 工具会自动生成 typings/{app,config}/\*\*.d.ts ,请勿自行修改,避免被覆盖。(见下文)

现在 Egg 自带的 d.ts 还有不少可以优化的空间,遇到的同学欢迎提 issue 或 PR。


开发期

ts-node

egg-bin 已经内建了 ts-node ,egg loader 在开发期会自动加载 \*.ts 并内存编译。

目前已支持 dev / debug / test / cov

开发者仅需简单配置下 package.json

{
  "name": "showcase",
  "egg": {
    "typescript": true
  }
}

egg-ts-helper

由于 Egg 的自动加载机制,导致 TS 无法静态分析依赖,关联提示。

幸亏 TS 黑魔法比较多,我们可以通过 TS 的 Declaration Merging 编写 d.ts 来辅助。

譬如 app/service/news.ts 会自动挂载为 ctx.service.news ,通过如下写法即识别到:

// typings/app/service/index.d.ts
import News from '../../../app/service/News';

declare module 'egg' {
  interface IService {
    news: News;
  }
}

手动写这些文件,未免有点繁琐,因此我们提供了 egg-ts-helper 工具来自动分析源码生成对应的 d.ts 文件。

只需配置下 package.json :

{
  "devDependencies": {
    "egg-ts-helper": "^1"
  },
  "scripts": {
    "dev": "egg-bin dev -r egg-ts-helper/register",
    "test-local": "egg-bin test -r egg-ts-helper/register",
    "clean": "ets clean"
  }
}

开发期将自动生成对应的 d.tstypings/{app,config}/ 下,请勿自行修改,避免被覆盖。

后续该工具也会考虑支持 js 版 egg 应用的分析,可以一定程度上提升 js 开发体验。

Unit Test && Cov

单元测试当然少不了:

// test/app/service/news.test.ts
import * as assert from 'assert';
import { Context } from 'egg';
import { app } from 'egg-mock/bootstrap';

describe('test/app/service/news.test.js', () => {
  let ctx: Context;

  before(async () => {
    ctx = app.mockContext();
  });

  it('list()', async () => {
    const list = await ctx.service.news.list();
    assert(list.length === 30);
  });
});

运行命令也跟之前一样,并内置了 错误堆栈和覆盖率 的支持:

{
  "name": "showcase",
  "scripts": {
    "test": "npm run lint -- --fix && npm run test-local",
    "test-local": "egg-bin test -r egg-ts-helper/register",
    "cov": "egg-bin cov -r egg-ts-helper/register",
    "lint": "tslint ."
  }
}

Debug

断点调试跟之前也没啥区别,会自动通过 sourcemap 断点到正确的位置。

{
  "name": "showcase",
  "scripts": {
    "debug": "egg-bin debug -r egg-ts-helper/register",
    "debug-test": "npm run test-local -- --inspect"
  }
}

部署

构建

  • 正式环境下,我们更倾向于把 ts 构建为 js ,建议在 ci 上构建并打包。

配置 package.json :

{
  "egg": {
    "typescript": true
  },
  "scripts":  {
    "start": "egg-scripts start --title=egg-server-showcase",
     "stop": "egg-scripts stop --title=egg-server-showcase",
     "tsc": "ets && tsc -p tsconfig.json",
     "ci": "npm run lint && npm run cov && npm run tsc",
     "clean": "ets clean"
  }
}

对应的 tsconfig.json :

{
  "compileOnSave": true,
  "compilerOptions": {
    "target": "es2017",
    "module": "commonjs",
    "strict": true,
    "noImplicitAny": false,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "charset": "utf8",
    "allowJs": false,
    "pretty": true,
    "noEmitOnError": false,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "allowUnreachableCode": false,
    "allowUnusedLabels": false,
    "strictPropertyInitialization": false,
    "noFallthroughCasesInSwitch": true,
    "skipLibCheck": true,
    "skipDefaultLibCheck": true,
    "inlineSourceMap": true,
    "importHelpers": true
  },
  "exclude": [
    "app/public",
     "app/web",
    "app/views"
  ]
}

注意:

  • 当有同名的 ts 和 js 文件时,egg 会优先加载 js 文件。
  • 因此在开发期, egg-ts-helper 会自动调用清除同名的 js 文件,也可 npm run clean 手动清除。

错误堆栈

线上服务的代码是经过编译后的 js,而我们期望看到的错误堆栈是指向 TS 源码。
因此:

  • 在构建的时候,需配置 inlineSourceMap: true 在 js 底部插入 sourcemap 信息。
  • egg-scripts 内建了处理,会自动纠正为正确的错误堆栈,应用开发者无需担心。

具体内幕参见:


插件/框架开发指南

指导原则:

  • 不建议使用 TS 直接开发插件/框架,发布到 npm 的插件应该是 js 形式。
  • 当你开发了一个插件/框架后,需要提供对应的 index.d.ts
  • 通过 Declaration Merging 将插件/框架的功能注入到 Egg 中。
  • 都挂载到 egg 这个 module,不要用上层框架。

插件

可以参考 egg-ts-helper 自动生成的格式

// {plugin_root}/index.d.ts

import News from '../../../app/service/News';

declare module 'egg' {

  // 扩展 service
  interface IService {
    news: News;
  }

  // 扩展 app
  interface Application {

  }

  // 扩展 context
  interface Context {

  }

  // 扩展你的配置
  interface EggAppConfig {

  }

  // 扩展自定义环境
  type EggEnvType = 'local' | 'unittest' | 'prod' | 'sit';
}

上层框架

定义:

// {framework_root}/index.d.ts

import * as Egg from 'egg';

// 将该上层框架用到的插件 import 进来
import 'my-plugin';

declare module 'egg' {
  // 跟插件一样拓展 egg ...
}

// 将 Egg 整个 export 出去
export = Egg;

开发者使用的时候,可以直接 import 你的框架:

// app/service/news.ts

// 开发者引入你的框架,也可以使用到提示到所有 Egg 的提示
import { Service } from 'duck-egg';

export default class NewsService extends Service {
  public async list(page?: number): Promise<NewsItem[]> {
    return [];
  }
}

其他

TypeScript

最低要求 2.8+ 版本,依赖于新支持的 Conditional Types ,黑魔法中的黑魔法。

$ npm i typescript tslib --save-dev
$ npx tsc -v
Version 2.8.1

VSCode

由于 VSCode 自带的 TypeScript 版本还未更新,需手动切换:

F1 -> TypeScript: Select TypeScript Version -> Use Workspace Version 2.8.1

之前为了不显示编译后的 js 文件,会配置 .vscode/settings.json ,但由于我们开发期已经不再构建 js,且 js 和 ts 同时存在时会优先加载 js,因为 建议「不要」配置此项。

// .vscode/settings.json
{
  "files.exclude": {
    "**/*.map": true,
    // 光注释掉 when 这行无效,需全部干掉
    // "**/*.js": {
    //  "when": "$(basename).ts"
    // }
  },
  "typescript.tsdk": "node_modules/typescript/lib"
}

package.json

完整的配置如下:

{
  "name": "hackernews-async-ts",
  "version": "1.0.0",
  "description": "hackernews showcase using typescript && egg",
  "private": true,
  "egg": {
    "typescript": true
  },
  "scripts": {
    "start": "egg-scripts start --title=egg-server-showcase",
    "stop": "egg-scripts stop --title=egg-server-showcase",
    "dev": "egg-bin dev -r egg-ts-helper/register",
    "debug": "egg-bin debug -r egg-ts-helper/register",
    "test-local": "egg-bin test -r egg-ts-helper/register",
    "test": "npm run lint -- --fix && npm run test-local",
    "cov": "egg-bin cov -r egg-ts-helper/register",
    "tsc": "ets && tsc -p tsconfig.json",
    "ci": "npm run lint && npm run tsc && egg-bin cov --no-ts",
    "autod": "autod",
    "lint": "tslint .",
    "clean": "ets clean"
  },
  "dependencies": {
    "egg": "^2.6.0",
    "egg-scripts": "^2.6.0"
  },
  "devDependencies": {
    "@types/mocha": "^2.2.40",
    "@types/node": "^7.0.12",
    "@types/supertest": "^2.0.0",
    "autod": "^3.0.1",
    "autod-egg": "^1.1.0",
    "egg-bin": "^4.6.3",
    "egg-mock": "^3.16.0",
    "egg-ts-helper": "^1.5.0",
    "tslib": "^1.9.0",
    "tslint": "^4.0.0",
    "typescript": "^2.8.1"
  },
  "engines": {
    "node": ">=8.9.0"
  }
}

高级用法

装饰器

通过 TS 的装饰器,可以实现 依赖注入 / 参数校验  / 日志前置处理 等。

import { Controller } from 'egg';

export default class NewsController extends Controller {
  @GET('/news/:id')
  public async detail() {
    const { ctx, service } = this;
    const id = ctx.params.id;
    const result = await service.news.get(id);
    await ctx.render('detail.tpl', result);
  }
}

目前装饰器属于锦上添花,因为暂不做约定。
交给开发者自行实践,期望能看到社区优秀实践反馈,也可以参考下:egg-di

友情提示:要适度,不要滥用。

tegg

未来可能还会封装一个上层框架 tegg,具体 RFC 还没出,还在孕育中,敬请期待。

名字典故:typescript + egg -> ts-egg -> tea egg -> 茶叶蛋

Logo:image.png | left | 225x225


写在最后

早在一年多前,阿里内部就有很多 BU 在实践 TS + Egg 了。

随着 TS 的完善,终于能完美解决我们的开发者体验问题,也因此才有了本文。

本来以为只需要 2 个 PR 搞定的,结果变为 Hail Hydra,好长的 List:[RFC] TypeScript tool support

终于完成了 Egg 2.0 发布时的一大承诺,希望能通过这套最佳实践规范,提升社区开发者的研发体验。

@comeUpWithItLater
Copy link

comeUpWithItLater commented Apr 12, 2018

今天试了一下, 有两个问题:

  1. 在docker中开发时, egg-bin dev -r egg-ts-helper/registernpm run dev 在编辑器(vs code)保存 .ts文件后进程没有自动重启,意思是需要手动重启后才能看到.ts文件改动后的运行效果。

  2. 参照文档 https://eggjs.org/zh-cn/basics/schedule.html

import { Subscription } from 'egg';    //  [ts] Module ''egg'' has no exported member 'Subscription'.

npm run dev tsc 编译也失败

@atian25
Copy link
Owner Author

atian25 commented Apr 12, 2018

第一个问题,是 egg-watcher 那块,似乎在 docker 里面是拿不到 node 的 watch 事件的,加个 https://github.com/eggjs/egg-watcher-chokidar 看看。

@whxaxes
Copy link

whxaxes commented Apr 12, 2018

第二个问题,Subscription 这个是漏了,已经有人提了 PR: eggjs/egg#2321 ,等 merged 后就可以了

@LiJoah
Copy link

LiJoah commented May 22, 2018

在Linux 环境中、使用这个脚手架生成项目结构出现一个权限的问题,像config service 等文件是root的权限,普通用户不能对这些文件进行操作,
而不使用这个脚手架来搭项目结构,在import egg 这个module的时候,会出现[ts] Cannot find module 'egg'.
在ts 中使用import * as Egg from 'egg'; 这种方式引入

@atian25
Copy link
Owner Author

atian25 commented May 22, 2018

这属于 linux 的权限问题,自己看下 linux 相关资料吧。估计你是用 root 执行的 egg-init

@gaoziqi
Copy link

gaoziqi commented Dec 13, 2018

使用pnpm安装后执行npm run dev报错,default的plugin路径会出问题,找不到egg-onerror等基础plugin,建议可以修复一下egg-core的plugin.js的路径解析的机制,支持一下pnpm

@atian25
Copy link
Owner Author

atian25 commented Dec 13, 2018

我们日常用的 cnpm 的底层是 cnpm/npminstall,包路径跟 pnpm 应该是一致的才对,提供下可复现方案吧。

cc @whxaxes

@gaoziqi
Copy link

gaoziqi commented Dec 13, 2018

D:\code\npm\login\node_modules\.registry.npm.taobao.org\egg-core\4.12.0\node_modules\egg-core\lib\loader\mixin\plugin.js:356 throw new Error(Can not find plugin ${name} in "${lookupDirs.join(', ')}"); ^ Error: Can not find plugin egg-onerror in "D:\code\npm\login\node_modules, D:\code\npm\login\node_modules\.registry.npm.taobao.org\egg\2.14.1\node_modules\egg\node_modules, D:\code\npm\login\node_modules"

path中缺少了D:\code\npm\login\node_modules.registry.npm.taobao.org\egg\2.14.1\node_modules

@atian25

@atian25
Copy link
Owner Author

atian25 commented Dec 13, 2018

提供个最小可复现仓库

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

5 participants