如何将你的 ThinkJS 项目部署到 ZEIT 上

什么是 ZEIT

ZEIT 是免费的云平台,支持部署静态网站以及 Serverless 函数。Serverless 是近几年比较火的概念,简单去理解就是你只需要去实现具体的业务逻辑,而与最终服务相关的服务器、HTTP 服务等则由第三方管理。Serverless 又被称为 FaaS(函数即服务),由于业务粒度非常细,所以非常方便做动态扩容等自动化运维任务。

//一个最简单的基于 Node.js 的 Serverless 函数
module.exports = function(req, res) {
  const { name = 'World' } = req.query
  res.send(`Hello ${name}!`)
}

通过 ZEIT 提供的 CLI 工具 now,我们可以一条命令将 Node.js, Golang, Python, Ruby, PHP, Rust 等语言的应用部署到 ZEIT 上。如果你想了解更多关于 ZEIT 这个公司的知识也可以看这篇知乎回答了解更多。

如何使用 ZEIT

注册非常方便,打开 https://zeit.co 点击右上角的 "Join Free",使用 Github 或者 Gitlab 账号登录后会自动注册。当然你也可以使用邮箱注册,会发送一封确认邮件到你的邮箱。登录后会让你填写昵称、头像和唯一 ID等配置。

选择 Continue 之后如果是通过邮箱登录进来的会问你是否需要绑定 Github 账号,可以让 Github 与 ZEIT 之间的持续集成更加方便,当然你也可以选择 SKIP 跳过。最后一步则会指导你如何创建项目,它提供了很多快速创建的模板,例如 Next.jsReactVuepressGatsbyDoczNuxt.jsSvelteAngular

按照示例使用 npm install -g now 安装 CLI 工具,初始化项目后直接使用 now 命令即可发布到 ZEIT 上,整体流程非常简单。

部署 Koa.js 服务

通过刚才的示例我们可以了解到其实它的本质就是将 HTTP 请求的 requestresponse传入方法中,处理后再返回给 HTTP,所以它除了 Serverless 函数之外也是完全支持 Koa.js 以及基于 Koa.js 的 ThinkJS 服务部署的。我们先来看看如果要部署一个 Koa.js 服务应该怎么做。

Fork 快速部署

由于 ZEIT 官方主推 Serverless 服务,所以把 Node.js 的脚手架模板去除掉了,所以我们只能自己创建项目了,为了方便我提供了一个 DEMO 仓库 https://github.com/lizheming/now-koa-demo。如果在刚才的注册流程中你绑定了 Github 的账号的话你可以选择直接 Fork 该仓库,等一小会儿之后就会收到 ZEIT 的 Github 通知告诉你网站已经部署成功,并在 commit 中提供部署后的地址。

命令行部署

如果没有绑定 Github 账户也没关系,我们可以通过命令行部署服务。将 DEMO 仓库克隆下来后直接使用 now 命令就可以了。部署成功后 ZEIT 会给我们返回一个当前提交版本的唯一地址,比如说 https://now-koa-demo-pac7dbxrf.now.sh/ 打开之后就会见到 Hello from koa.js! 的返回信息。

注意事项

index.js 文件内容与正常的 Koa.js 项目代码无异,唯一的区别是最终项目没有直接调 app.listen() 方法进行监听,而是使用 module.exports = app.callback() 将最终的 callback 方法进行了返回。我们知道 app.callback() 方法返回的是接受 request 和 response 对象作为参数的函数,这就回到了文章最开始的示例了。

我们再来看看 now.json 的内容。该 JSON 文件用于告诉 now 服务 index.js 文件需要使用 @now/node 运行时执行,而所有的请求需要转发到 index.js 文件上。听起来是不是非常像 Nginx 上的内容?

{
  "version": 2,
  "builds": [
    { "src": "index.js", "use": "@now/node" }
  ],
  "routes": [
    { "src": "/(.*)", "dest": "/index.js" }
  ]
}

部署 ThinkJS 服务

成功部署 Koa.js 服务之后,下面我们就来看看怎么给你的 ThinkJS 服务找一个免费空间部署上去吧!为了方便我也提供了一个 DEMO 仓库 https://github.com/lizheming/now-thinkjs-demo,Fork 该仓库可快速体验 Now 部署 ThinkJS 服务。Fork 成功后过一会就会收到部署成功后的提示,同时告知你部署后的唯一地址,例如 https://now-thinkjs-demo-hrmqxxv2p.now.sh/

然而这只是我折腾成功后的结果,基于 ThinkJS 的服务直接部署并没有部署 Koa.js 服务那么简单,这主要是由 ThinkJS 框架本身的特性决定的。下面我将其中需要注意的点一一道来,方便其它已有服务的迁移。我们先来看看针对 ZEIT 平台的 ThinkJS 启动文件有那些内容。然后我们基于该文件主要讲述下碰到的问题以及为什么需要这么做。

const path = require('path');
const Application = require('thinkjs');

const Loader = require('thinkjs/lib/loader');
class NowLoader extends Loader {
  writeConfig() {}
}

const app = new Application({
  ROOT_PATH: __dirname,
  APP_PATH: path.join(__dirname, 'src'),
  VIEW_PATH: path.join(__dirname, 'view'),
  proxy: true, // use proxy
  env: 'now',
  external: {
    log4js: {
      stdout: path.join(__dirname, 'node_modules/log4js/lib/appenders/stdout.js'),
      console: path.join(__dirname, 'node_modules/log4js/lib/appenders/console.js')
    },
    static: {
      www: path.join(__dirname, 'www')
    }
  }
});

const loader = new NowLoader(app.options);
loader.loadAll('worker');
module.exports = function (req, res) {
  return think.beforeStartServer().catch(err => {
    think.logger.error(err);
  }).then(() => {
    const callback = think.app.callback();
    return callback(req, res);
  }).then(() => {
    think.app.emit('appReady');
  });
};

服务启动问题

刚才部署 Koa.js 的时候我们知道了,ZEIT 运行时接受的文件需要返回一个函数。在 Koa.js 中是 app.callback(),而在 ThinkJS 中则是 think.app.callback() 。不过我们却不能直接这么返回,因为从源码中我们可以了解到 ThinkJS 服务启动做了以下几件事情:

  • 初始化 Loader 实例,在对应的进程上加载需要的文件,包括 config, middleware, controller, logic, model, service 等。
  • 执行 beforeStartServer() 启动前钩子
  • 启动服务
  • 启动后向全局发送 appReady 事件

目前 ThinkJS 服务中并没有纯粹的非启动方法包含这些内容,所以我选择了在启动脚本中模拟正常的启动流程自定义启动过程的方式。由于多进程逻辑稍微复杂点,所以我直接按照单进程模式模拟。

  • 实例化 Loader,使用 loader.loadAll('worker') 加载所有的依赖文件
  • 在回调中执行 beforeStartServer() 启动前钩子
  • 执行 callback() 启动服务
  • 启动后向全局发送 appReady 事件

文件引用问题

项目文件相对引用

我们知道 ThinkJS 的本质是文件夹即路由的模式,Controller, Model, View 等文件按照一定的文件夹规则放置,通过动态读取文件的形式找到对应的文件并加载执行。这在正常的项目中本来不存在什么问题,但是 ZEIT Now 平台为了节省空间,会对在入口文件中没有显示依赖的文件进行忽略。

我们正常的启动文件中只会定义 APP_PATH ,而 VIEW_PATH 甚至是静态资源目录是在 src/config/adapter.js 以及 src/config/middleware.js 中定义的。而这两个文件又是动态读取文件引入的,导致在上传的时候由于没有显式依赖该文件而不上传该文件。所以为了解决这个问题,我选择了在启动文件中再次显示声明一下需要加载的文件。当然这些配置对 ThinkJS 来说是没有用的。

依赖文件相对引用

可以看到,除了正常的项目文件的引用之外,我还写了两个 log4js 文件的引用,这又是为什么呢?

主要还是因为 ZEIT 为了节省体积,除了会限制只上传需要的文件之外,还会针对入口文件使用 webpack 进行打包。使用 webpack 打包后所有的依赖都在入口文件中了,这样就不用上传硕大的 node_modules 文件夹,可以极大的减小体积。ZEIT 将该针对 Node.js 项目打包成单文件的打包工具开源出来了 https://github.com/zeit/ncc 如果项目中有需要打包成单文件减小体积的需求也可以使用。

log4js 非常早期的版本中是通过 require(./${type}) 的形式将对应的日志输出器加载进来的。由于打包后目录结构发生变化,打包后当前文件夹并没有对应的文件,所以会导致执行的时候报文件找不到的错误。所以为了解决这个问题则同样需要在入口文件中显式的声明这些文件的依赖。

去年2月份就有用户针对这个问题提了 Commit 将所有的加载器显式依赖后再进行选择解决了这个问题。所以在新版 log4js 的中已经不存在这个问题了,不过我还是在这里说明一下,是因为可能项目中引用的其它依赖会有这个问题,还是需要注意一下的。

写入权限问题

除了上面的问题之外,部署的时候我还碰到了文件写入无权限的问题。由于 ZEIT Now 提供无状态服务,所以写入文件等副作用操作在 ZEIT 中被禁止了。如果你有文件写入操作的话会在控制台中提示写入失败并抛错。

而在 ThinkJS 中由于各种配置文件比较多,为了方便问题排查,会在配置文件加载完成后调用 writeConfig() 方法写一份最终合并后的配置在 runtime 目录中,例如 runtime/config/production.json 文件。这样的话在 ZEIT 平台就会报错导致服务无法正常启动了。

不过目前 ThinkJS 并没有提供一个配置能够取消这个配置文件写入的操作。所以我提供的解决方法则是通过继承将 writeConfig() 方法复写掉来组织文件写入的操作。

当然这是 ThinkJS 本身的文件写入操作,如果说你的项目中还有其它文件写入操作的话,也需要做对应的操作。例如 logger 日志的配置可以输出到控制台,文件上传等必须写入文件的则可以写到系统临时目录 /tmp 中。不同的系统临时目录可能不太一样,Node.js 中建议通过 require('os').tmpdir() 来获取。

后记

通过 ZEIT 平台,极大的降低了部署 Node.js 服务的成本,不仅是机器成本,维护成本也极大的降低了。其实正常的 Node.js 项目部署起来还是非常方便的,主要还是 ThinkJS 的依赖引用并非显式的,导致了在打包上的一些困难,其它的都还是很方便的。如果有什么其它的问题,也欢迎大家多多交流。

参考资料:

发表评论

电子邮件地址不会被公开。 必填项已用 * 标注