eggjs / egg

🥚 Born to build better enterprise frameworks and apps with Node.js & Koa
https://eggjs.org
MIT License
18.9k stars 1.82k forks source link

[RFC] egg-core增加应用启动阶段 #2520

Closed killagu closed 6 years ago

killagu commented 6 years ago

背景描述

有的插件可能需要异步操作后才能加载, 需要使用beforeStart来执行异步操作, 挂载到app上。

同时应用启动前, 又在beforeStart中使用了这样的插件, 就可能不能正常启动。

例如:

// pluginA/index.js

app.beforeStart(async ()=>{
  const bar = await pull();
  app.addSingleton('foo', (config,app) => {
    return new Foo(bar);
  });
});
// app.js

app.beforeStart(async ()=>{
  await app.foo.foooo(); // app.foo 可能还没注入
});

解决方案

EggCore上增加appReady。

beforeAppStart(scope) {
  const done = this.appReady.readyCallback(name);

  process.nextTick(() => {
    this.ready()
    .then(()=>utils.callFn(scope))
    .then(() => done(), done);
  });
}

服务器启动时也应该修改为app.appReady.ready(startServer);

定义一个新目录, 暂时命名为app/checker

在应用启动阶段(所有的beforeStart都执行完毕),

调用app/checker目录下的checker实现, 调用完成后调用通过app.ready注册的回调。

现征集该目录的命名, 暂有两个选项:

cc: @gxcsoccer @XadillaX @popomore @coolme200

egg-bot commented 6 years ago

Translation of this issue:


[RFC] egg-core increase appReady

Background description

Some plugins may need to be asynchronous before they can be loaded. They need to use beforeStart to perform asynchronous operations. Mount it to ʻapp`.

At the same time, before the application starts, if such a plugin is used in beforeStart, it may not start normally.

E.g:

// pluginA/index.js

app.beforeStart(async ()=>{
  Const bar = await pull();
  app.addSingleton('foo', (config,app) => {
    Return new Foo(bar);
  });
});
// app.js

app.beforeStart(async ()=>{
  Await app.foo.foooo(); // app.foo may not be injected yet
});

solution

Add appReady to EggCore.

beforeAppStart(scope) {
  Const done = this.appReady.readyCallback(name);

  process.nextTick(() => {
    This.ready()
    .then(()=>utils.callFn(scope))
    .then(() => done(), done);
  });
}

The server should also be modified to ʻapp.appReady.ready(startServer);`

coolme200 commented 6 years ago

app.appReady 下面还有几个 API?

killagu commented 6 years ago

app.appReady这个就是个ready-callback对象。

tong3jie commented 6 years ago

@killagu 你可以在应用启动后执行相关的服务,例如:app的API接口已经提供了ready方法,例如:

module.exports = app => {
  app.ready(() => {
    console.log('app is ready!');
    const ctx = app.createAnonymousContext();
    ctx.service.send.abc();
    ctx.service.send.abd();
    ctx.service.send.acd();
  });
};
killagu commented 6 years ago

@tong3jie 你讲的没错。ready是应用启动后执行相关的服务。

但是我讲的情况都是在应用启动前的, beforeStart的情况。

atian25 commented 6 years ago

addSingleton had support async since last month

popomore commented 6 years ago

两个想法

  1. 提供 afterReady,等现在的 ready 后出发 afterReady 再触发现在的 ready(也就是启动)

  2. 提供一个启动检查模式,在 app/start_checker 中定义一个类,实现 check 方法(实现和第一条类似)。

killagu commented 6 years ago

提供 afterReady,等现在的 ready 后出发 afterReady 再触发现在的 ready(也就是启动)

afterReady这个名字有歧义。其实是在ready之前触发的。

start_checker +1

popomore commented 6 years ago

可能还可以有 health checker

killagu commented 6 years ago

boot +1

XadillaX commented 6 years ago

boot + 1

popomore commented 6 years ago

starter 呢?

dead-horse commented 6 years ago

定义一个新目录, 暂时命名为app/checker

直觉让我反对新加一个目录

popomore commented 6 years ago

新加目录比新加 API 友好些,比如 schedule,notify 都是新增目录的

dead-horse commented 6 years ago

这个功能比较反对新加目录的原因主要还是这些功能都是应用开发者基本用不到的。

egg 的启动,有点像 react 的生命周期的意思了。是否应该再完善一点,把启动时期的各个生命周期给定义下来:

- appDidLoad
- appWillStart
- appDidStart
- appWillClose
atian25 commented 6 years ago

life cycle +1

dead-horse commented 6 years ago

而且 issue 中描述的这个问题,通过新增一个应用启动阶段,看似可以解决当下的问题,但是其实是在埋坑,最多只能算是一个临时解决方案。现在如果 B 依赖 A,把 B 放在启动阶段,A 放在 beforeStart 是可以解决了,那如果出现了 C 依赖 B, B 依赖 A 呢?C 放哪里?

本质上这还是执行顺序的问题,想清楚之前不建议加新的

popomore commented 6 years ago

如果 C 和 B 都是应用定义的话,那在应用里面可以写在一起。

本质还是应用开发者对 beforeStart 的真正启动顺序不理解,不改变 beforeStart 的前提下,需要提供应用启动的环节。增加 API 和增加目录都是具体的解决方案,应用启动的这个环节是要真正增加的。

dead-horse commented 6 years ago

现在给应用层的 app.js 实在是太尴尬了。90% 的人是用做应用启动阶段的,但是还有部分的人在里面操作 middleware,这个又不能算启动阶段。

popomore commented 6 years ago

我本身是不太建议在应用层用 app.js 的,因为对传统开发者来说这就是入口文件,感觉对顺序还是比较难理解的。

killagu commented 6 years ago

关于生命周期这个, 我觉得是这样的:

- app.beforeStart // 代表load的过程
- appDidStart: app.ready
- appWillClose: app.beforeClose

现在appDidLoadappWillStart是没有对应的实现, 但是这两个生命周期是APP层面就可以实现的。

要把原来的这些方法改造相比于增加一个目录来说成本太大了。

@dead-horse

gxcsoccer commented 6 years ago

egg 的启动,有点像 react 的生命周期的意思了。是否应该再完善一点,把启动时期的各个生命周期给定义下来:

同意,但是觉得没必要像 react 那么复杂

gxcsoccer commented 6 years ago

我能接受的三个方案

1. 不改 API

应用里面的 app.beforeStart 放到所有其他 beforeStart 执行完以后再执行

2. 加启动目录

如上描述

3. 规范插件,保证 app 上的实例挂载是同步的,通过 app.xxx.ready 来管理依赖

之前有问题的 case 是:

app.beforeStart(async function() {
  // 异步操作
  await fetchPWD();

  app.addSingleton('mysql', () => {
      // ...
      return instance;
  });
});

可以改成如下,确保 app.mysql 一开始就有

app.beforeStart(async function() {
  app.addSingleton('mysql', async () => {
      // 异步操作
      await fetchPWD();
      // ...
      return instance;
  });
});
gxcsoccer commented 6 years ago

偏向我提的方案 1

gxcsoccer commented 6 years ago

如果在 加 API vs 加目录 里选的话,稍微偏向加目录一点。原因是:

  1. 感觉目前 API 还没有想的很清楚,其实原来 beforeStart 的表述也有点问题
  2. 目录容易规范开发者
atian25 commented 6 years ago

@popomore: 本质还是应用开发者对 beforeStart 的真正启动顺序不理解,不改变 beforeStart 的前提下,需要提供应用启动的环节。增加 API 和增加目录都是具体的解决方案,应用启动的这个环节是要真正增加的。

@dead-horse: 现在给应用层的 app.js 实在是太尴尬了。90% 的人是用做应用启动阶段的,但是还有部分的人在里面操作 middleware,这个又不能算启动阶段。

现在生命周期这块确实有点不清晰,我有时候也晕晕的,这块建议是补充下文档,画一张生命周期的图 @popomore

至于 加API vs 加目录,后者我觉得更像是 加文件,如 app/extend/boot.js,但后者本质其实是在 加 API 的基础上加一个目录规范而已,我更倾向于讨论清楚生命周期,把已知的加了,并把文档清晰化。

killagu commented 6 years ago

那我先来把egg现有的生命周期画一下,然后讨论一下需要增加的生命周期。

issue先hold

dead-horse commented 6 years ago

通过约定 app.js 来实现可能更加容易理解,而且考虑到 app 和 agent 都有这个过程,如果定义目录就要拆两个目录,对用户太复杂了

// app.js

module.exports = class AppBoots {
  constructor(app) {
    this.app = app;
  }

  async afterLoad() {

  }

  async beforeReady() {

  }

  async ready() {

  }

  async beforeClose() {

  }
};
atian25 commented 6 years ago

感觉现在只需要解决异步挂 API 这个问题,就够了。

甚至不加 API 的话,通过文档,建议开发者,在 beforeStart 或者 configWillReady 之类钩子,让开发者去获取远程配置。

然后开发者的 API 挂载,还是放在 app/extend/application.js 里面,用 getter 做延迟初始化。 这样是不是就可以了?

// app.js
app.beforeStart(async () => {
  app.config.mysql.client = await app.curl('http://');
});

// app/extend/application.js
module.exports = {
  get mysql() {
    if (!this[MYSQL]) {
      this[MYSQL] = new MYSQL(this.config.mysql.client);
    }
    return this[MYSQL];
  }
}
killagu commented 6 years ago
class Boot {
  // config文件加载完成
  // 可以用来修改中间件顺序
  configDidLoad() {}

  // 文件都加载完成
  // 可以做一些client的异步加载
  async appDidLoad() {}

  // 所有的插件已经加载完毕
  // 可以正常的使用插件
  async appWillReady() {}

  // 当前进程ready
  // 与app.ready相同
  async appDidReady() {}

  // 所有的进程ready
  async serverDidReady() {}
}

cc: @atian25 @gxcsoccer @dead-horse @popomore

大家可以套用一下场景,看看能不能吻合。

将会替换 app.js/agent.js, 作为启动点。

atian25 commented 6 years ago

@killagu 加下注释,这几个函数的描述

  1. 画张图,补下函数名,和描述
  2. 列出目前常用的场景,按这个方式去写伪代码,看看能否满足
  3. 确定最终的 API 方式,包括今天没讨论清楚的,app 和 agent 的区别
popomore commented 6 years ago

确定最终的 API 方式,包括今天没讨论清楚的,app 和 agent 的区别

讨论清楚了,就是 app.js 和 agent.js 的差别

killagu commented 6 years ago

egg-lifecyle

atian25 commented 6 years ago
  1. app.js/boot.js 这里要改下,不清晰。
  2. beforeStart 是 deprecate 了?
  3. 异步 API 的注册是推荐在 appDidLoad 阶段?
  4. appDidLoad 这个有没有其他名字可以替换? 在 agent 里面有一个 app 的名词,会有点混淆
killagu commented 6 years ago

egg-lifecyle

把方法前缀 app 都去掉了。

异步API的注册推荐在 didLoad 阶段使用。

dead-horse commented 6 years ago

beforeStart 会有很长一段时间和声明周期共存,需要确定清楚 beforeStart 的明确执行时间。是不是放在 willReady 之后执行比较合适?

popomore commented 6 years ago

beforeStart 的功能保持原状,刚是从 DidLoad 开始到 DidReady 之前,这样在不改的前提下,应用在 DidReady 注册的方法还是可以在插件完成后执行。

okoala commented 6 years ago

我建议直接实现 hook 体系,不管是框架层还是应用层都能用。

支持两种方式调用,功能少的可以在 app.js 中直接定义,多则写到 app/hook 中 比如 app.js :

module.exports = app => {
  // 框架层的 hook
  app.hook.add('appBeforeReady', async () => {
    console.log('before running!');
  });

   // 应用层的 hook
  app.hook.add('userAfterCreate', async () => {
    console.log('app is ready!');
  });
};

如果 hook 比较多怎么办?放到 app/hook 中

// app/hook/appBeforeReady.js
module.exports = async (data) => {
   // blabla
}

// app/hook/userAfterCreate.js
module.exports = async  (data) => {
   // blabla
}

运行的使用就直接调 await app.hook.run('appBeforeReady'); await app.hook.run('userAfterCreate');

这种方式我觉得比较舒服,也有扩展性~

killagu commented 6 years ago

@okoala boot 类是为了取代 app.js 现有的写法的, 框架层和应用层都能使用的。

提供的示例代码中 hook 方式更像是底层API, 实现的时候可能是使用类似的方法, 上层提供类的方式更友好一些。

面向应用开发者提供一种实现方式,达到统一的规范。

还有没看懂 userAfterCreate 这是什么方法?

运行时如何调用是实现细节了, 就不在此处讨论了。

okoala commented 6 years ago

@killagu userAfterCreate 表明是自定义的 hook,可以是业务层自己自定义,hook 是比较底层,但是扩展性就更广了。甚至你可以 hook 到某个插件中。

killagu commented 6 years ago

@okoala 你这样说就好像 messenger 了不是么, 一方去 messenger.on('foo'), 另一方去 messenger.send('foo'), 和我们讨论的生命周期不是一个概念了。

popomore commented 6 years ago

https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-probes/

运行检测和启动检测

atian25 commented 6 years ago

启动时间这里也需要加下打点, https://github.com/eggjs/egg/issues/1898#issuecomment-386823829

atian25 commented 6 years ago

文档是不是也要改改

atian25 commented 6 years ago

land via https://github.com/eggjs/egg/pull/2972 and publish at https://github.com/eggjs/egg/pull/2989

atian25 commented 6 years ago

https://eggjs.app/zh-cn/advanced/loader.html#%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F

ghost commented 6 years ago

@killagu:What about making 'configDidLoad' async?All the other methods are async.

@atian25:I'll help to do English trans if free.

killagu commented 6 years ago

@Maledong configDidLoad should be sync. It's same as

// app.js
module.exports = app => {
  // e.g. modify core middlewares order
};

It have be done before other files load, so the modifications can take effect.