yliu

时来天地皆同力,运去英雄不自由

漫谈程序初始化


前言

在软件工程的开发中有生命周期这个概念,它的作用就是定义各个阶段需要处理的事情跟 tcp/ip 协议分层一个意思,今天重点聊一聊初始化这个阶段。

  • 在日常使用的 webpackvite 等工具会有一个配置收集的过程,这个过程就是初始化;
  • 在使用 reactvue 等框架时也会有 created 等生命周期函数暴露,在此阶段执行一些请求 api 等操作,这也是初始化;
  • 在使用 koanode 框架启动服务之前进行 use 装载也是初始化;
  • 甚至,一段代码在执行之前通常会经历以下三个阶段,也可以概括为初始化
    • 分词/词法分析
    • 解析/语法分析
    • 生成代码

下面,我把任务分为两部分:

  • 可以前置化处理
  • 不能前置化处理

前置初始化

上面举例的一大堆,你会发现很多任务我们可以很自然完成,例如:

  • webpackvite 读取配置,如果让你写大概就是根据 npm script 填写 config 的路径进行解析,然后通过 node 的 fs 模块进行读取
  • koa 在装载插件之前可能还需要自动导入所有符合要求的文件,这里可以通过 glob 模块来查找所有符合规则的文件进行批量导入

上面的任务都可以通过 node 提供的同步 api 进行完成,且他们只会运行一次并不会影响到主体的功能。

但是有一些操作,例如请求网络,异步加载模块后续的操作都需要等待加载完成之后才能执行,这种情况下没有同步的语法供使用,没办法完成前置依赖的处理。

下面就抛砖引玉聊聊这种情况如何处理

非前置化处理

下面的例子都以 db 模块为例,它负责连接数据库之后进行读取数据,可能有一个 connect 的方法和 queue 的查询方法。

class Db {
  constructor() {
    this.signIn = false;
  }
  connect() {
    if (this.signIn) {
      return Promise.resolve(true);
    }
    return new Promise((resolve) => {
      setTimeout(() => {
        this.signIn = true;
        resolve(true);
      }, 3000);
    });
  }
  async queue(str) {
    if (!this.signIn) {
      throw new Error(`必须连接数据库才能使用queue!`);
    }
    console.log(str);
  }
}
const db = new Db();

const app = async () => {
  await db.connect();
  await db.queue(`xx`);
};
app();

为了演示,后面代码全部为简化版本,不执行具体操作

我们在程序中调用这个 db 模块,你会发现发现 await db.connect() 这段代码省略不了,我们的程序依赖 connect 这一步。

且因为只是演示没有传递具体的密码和账号,但想象一下每次调用 db 都需要手动传递一次账号和密码也太糟心了。

有什么办法可以简化这个过程呢?可以对 connect 进行一次封装,最后暴露 db 模块出去。

封装 connect

class Db {
  constructor() {
    this.signIn = false;
  }
  connect() {
    if (this.signIn) {
      return Promise.resolve(true);
    }
    return new Promise((resolve) => {
      setTimeout(() => {
        this.signIn = true;
        resolve(true);
      }, 3000);
    });
  }
  async queue(str) {
    if (!this.signIn) {
      throw new Error(`必须连接数据库才能使用queue!`);
    }
    console.log(str);
  }
}
const proxyDb = (() => {
  const db = new Db();
  let sign = false;
  return async () => {
    if (!sign) {
      sign = await db.connect();
    }
    return db;
  };
})();
const app = async () => {
  const db = await proxyDb();
  await db.queue('xxx');
};
app();

使用一个代理将 connect 进行缓存起来,确保执行一次,后续使用直接 await 调用。

不过这种方法虽然实现简单,但是体验只能说一般,有没有更加优雅的方法呢?

预先队列

观察上面 db 的操作,可以看到两部分

  • 登录操作
  • 依赖登录操作的后续

我们可以把需要依赖登录的操作进行一个封装,如果没有登录就 push 到队列中,等到登录的时候进行一个整体的执行。

class Db {
  constructor() {
    this.signIn = false;
    this.list = [];
  }
  connect() {
    if (this.signIn) {
      return Promise.resolve(true);
    }
    return new Promise((resolve) => {
      setTimeout(() => {
        this.signIn = true;
        this.list.forEach((fn) => fn());
        this.list = [];
        resolve(true);
      }, 3000);
    });
  }
  queue(str) {
    return new Promise((resolve) => {
      const fn = () => {
        console.log(str);
        resolve(undefined);
      };
      if (this.signIn) {
        return fn();
      }
      this.list.push(fn);
    });
  }
}
const db = new Db();
db.connect();
const app = async () => {
  await db.queue('xxx');
};
app();
  • 在直接执行 queue 的时候状态还没有登录,添加到 list 中;
  • connect 执行成功,释放队列的值

预先队列状态分离

上述的要求我们实现了,但是后面如果还有其他的方法,例如 toArrayfindOne 等方法,一个个写重复的步骤太繁琐了。

按照设计模式单一原则,我们尝试进行分离一下

  • 执行 db 相关的操作只执行这部分
  • 对未登录状态的操作进行一个统一拦截

这部分拦截可以基于 ES6 的 proxy,也可以是 class 的 extends,这里采用 extends 的方式来进行。

class Db {
  constructor() {
    this.signIn = false;
  }
  connect() {
    if (this.signIn) {
      return Promise.resolve(true);
    }
    return new Promise((resolve) => {
      setTimeout(() => {
        this.signIn = true;
        resolve(true);
      }, 3000);
    });
  }
  async queue(str) {
    console.log(str);
  }
}
class ProxyDb extends Db {
  constructor() {
    super();
    this.list = [];
    ['queue'].forEach((item) => {
      this[item] = (...rest) => {
        return new Promise((resolve) => {
          const fn = () => {
            const result = super[item].apply(this, rest);
            return resolve(result);
          };
          if (!super.signIn) {
            this.list.push(fn);
            return;
          }
          fn();
        });
      };
    });
  }
  async connect() {
    await super.connect();
    this.list.forEach((fn) => fn());
  }
}
const db = new ProxyDb();
db.connect();
db.queue('xxx');

通过继承重写 connect 操作,在 constructor 阶段把需要代理的方法手写到子类中,最后利用 Promise 的特性,等待 connect 完成之后 resolve

顶层 await

除了上述的两种方法,ES6 的最新 顶层 await 也可以帮助实现效果,顶层 await 是为了解决模块异步加载问题,对于本文刚好可以用到。

我们之所以要对 db 模块 进行缓存和队列等一系列操作,就是因为初始化这部分我们没办法完成前置,不能像读取配置文件一样通过同步 api 语法完成。

但是顶层 await 的出现,让其有一种同步的语法完成这部分工作。

class Db {
  constructor() {
    this.signIn = false;
  }
  connect() {
    if (this.signIn) {
      return Promise.resolve(true);
    }
    return new Promise((resolve) => {
      setTimeout(() => {
        this.signIn = true;
        resolve(true);
      }, 3000);
    });
  }
  async queue(str) {
    if (!this.signIn) {
      throw new Error(`必须连接数据库才能使用queue!`);
    }
    console.log(str);
  }
}

这是最初我们的 db 模块,使用顶层 await 只需要,直接 import

const db = new Db();
await db.connect();
await db.queue('xxx');

最后

抛砖引玉聊了初始化加载可能遇到的情况,受限于聊天的方向,很多异常情况没有给予考虑,例如数据库如果连接操作,需要手动对队列的操作进行 reject 的错误抛出。

最后如果文章有什么错误或者错别字欢迎指出。

参考: