标签: node.js

  • 【代友招聘】【成都】Web3 教学网站 后端工程师

    【代友招聘】【成都】Web3 教学网站 后端工程师

    Hackquest.io 是我长期关注并辅佐的一家专注于 Web3 教学的网站的。他们由一群很有热情的年轻人组成,努力勤奋又有天赋,从创业至今取得了相当可观的成长。现在他们需要招聘一名 Node.js 后端工程师,需求大约如下:

    Node.js开发工程师

    「职责描述」

    • 负责有效的设计、开发和使用性测试。
    • 制定应用部署和基础架构维护的最佳策略。
    • 确保开发能够被有效低成本的迁移和部署。
    • 设置测试环境并提高开发准确率。
    • 优化数据库结构。

    「后端技术栈」

    • Node.js(核心),Nest.js(核心)
    • PostgresSQL, MySQL 等任意一种关系型数据库以及 Redis
    • 熟悉 Google Cloud 或其他 Cloud 相关服务,部署,数据库管理
    • TypeScript(核心)
    • 熟悉 Prisma

    「任职要求」

    • 工作及项目经验 3 年及以上
    • 大型项目经验优先
    • 具备扎实的数据结构和计算机系统基础,编码功底扎实,代码习惯优秀

    有兴趣、有能力的同学,请与我取得联系,我会推荐给他们进行进一步的面试。

  • 【视频】Node.js 开发 RAR 解压缩命令行工具

    【视频】Node.js 开发 RAR 解压缩命令行工具

    拖来拖去,终于把 使用 node.js 开发命令行工具 workshop 的视频剪出来了,前几天上传到 B 站,访问量很一般,所以在自己的博客再捞一下。

    这次视频主要面向新手,主要呈现从 0 到 1 实现命令行工具的做法,希望观众无论基础如何,都能在看完视频之后,掌握封装仓库、实现命令行工具的做法。内容大约是:

    1. 不同系统下安装 node.js
    2. 创建命令行工具项目
    3. package.json 结构介绍
    4. 介绍 unrar-promise
    5. 介绍 yargs 实现命令行接口
    6. 开发功能
    7. 发布到 npm

    完成的项目放在 GitHub:meathill/unrar: a simple script to unarchive rar files (github.com) 非常简单,大家可以参考。

    有任何问题、建议均欢迎留言讨论。新的一年,我会努力多做视频、多做好视频,希望大家支持我。

  • node.js 里 ESM 与 CommonJS 的区别

    node.js 里 ESM 与 CommonJS 的区别

    可能大部分同学并不会直接用 node.js 开发 Web 后端程序,但是作为现代化前端,我们日常的各种开发都严重依赖开发脚手架,也即 node.js 环境下的各种工具链。目前已经有一些仓库逐步迁移到 ESM,所以了解一下 node.js 里 ESM 和 CommonJS 的区别也很有必要。

    我还是老习惯,后文列举出的差异并非文档记录,而是我在实操中遇到的大坑小坑,希望记下来能节省将来的时间和大家的时间。

    0. 设计原则

    我们可以把 CommonJS 理解成按需加载,需要什么就加载什么,加载进来就执行。所以可以动态加载、条件加载、循环加载。

    ESM 倾向于静态加载,方便解析依赖,优化运行效率。所以起初不能条件加载或者循环加载。不过后面考虑到实际需求,还是开放了 import() 做动态加载。

    这个设计原则可以导出后面的诸多不同。

    1. package.json

    package.json 里添加 type: 'module' 可以开启本项目的 ESM。不写或者 type: 'commonjs' 则继续使用 CommonJS。

    如果没有此配置,虽然我们代码中写的 好像是 ESM,但其实都会被 Babel 或其它什么工具转译成 CommonJS,最终运行的并不是 ESM。这点一定要注意。

    2. __dirname

    ESM 不再支持用 __dirname__filename 获取正在执行的 JS 文件在系统中的路径。作为替代方案,可以使用 import.meta.url 获取当前文件的 URL,不过返回结果是 file:// 协议,如果要继续使用 __dirname 可以这样:

    import {dirname} from 'path';
    import { fileURLToPath } from 'url';
    
    function getDirname(url) {
      const __filename = fileURLToPath(url);
      return dirname(__filename);
    }

    3. 解构

    使用 CommonJS 时,我们可以直接对导出的对象进行解构,比如:

    // lib.js
    module.exports = {
      foo: 'bar',
    };
    
    // index.js
    const {foo} = require('./lib');

    这样的用法在 ESM 中不可行。ESM 解构只能针对使用 export foo = 'bar' 这样主动暴露出的属性。对于一般对象的解构,我们只能写成:

    // lib.js
    export default {
      foo: 'bar',
    }
    
    // index.js
    import lib from './lib';
    
    const {foo} = lib;

    4. .mjs 文件与 .cjs 文件

    我们知道,node.js 加载模块时可以省去文件扩展名,比如 require('./foo'),不需要写最后的 .js.json,node.js 会自动去目录里查找对应的文件。

    node.js 会根据 type 的不同使用不同默认策略加载 js,我们也可以使用特定扩展名要求 node.js 在加载时使用特定模块类型。比如,我们使用 ESM 时,node.js 会把加载进来的 JS 都当 ESM 处理,如果这些 JS 还在使用 CommonJS 加载其它 JS,就会报错(前面说了,ESM 里不支持 require)。此时,我们可以把目标文件的扩展名写为 .cjs,node.js 就会当它是 CommonJS 来处理了。

    此功能在使用第三方库的时候很有用。比如 Postcss,它会加载项目里的配置文件,但它只支持 CommonJS,这时,如果执行时因没有 require 报错,就可以把配置文件的扩展名改成 .cjs

    5. 顶层 await

    开启 ESM 之后,可以使用顶层 await,省去一个异步函数。

    (这是促使我使用 ESM 的主要原因)

    6. importrequire

    ESM 中,我们可以使用 import 导入 CommonJS 模块和 ESM 模块;但是 CommonJS 的 require 只能用来导入 CommonJS 模块。如果要在 CommonJS 中导入 ESM 模块,需要使用 import() 然后异步处理。

    自然,ESM 里不能用 require

    7. 其它区别

    这些区别我在实际开发中没有遇到,大家自己阅读吧:Modules: ECMAScript modules | Node.js v17.8.0 Documentation (nodejs.org)

    (更多…)
  • 免费线上讲习班:使用 node.js 开发命令行工具

    免费线上讲习班:使用 node.js 开发命令行工具

    拜金山办公所赐,近期找工作找得颇有些烦躁。前几天跟老婆回了趟重庆,吃吃喝喝按按摩,很开心,感觉放松了不少。回来的路上,我想这就算提前过年了吧,接下来要多做点事情,正职未卜,先折腾副业吧。

    于是我想先搞一场面向初级开发者的线上讲习班(workshop),讲授如何使用 node.js 开发并发布一个 npm 包。

    什么是 workshop

    workshop,中文叫讲习班,工作坊。大概方式是:

    1. 由讲师讲解一些技术知识点
    2. 其他参与者针对讲过的技术知识点做现场演练
    3. 讲师及时答疑、讲解
    4. 考虑到时间因素,一般针对一些小专题,非系统性的培训

    与直播不同,直播是讲师一个人说+做,workshop 则强调给大家一个现场练习与答疑的机会。

    可能的收获

    这次我想做一个使用 node.js 开发 rar 解压工具的 workshop,大家可以学到:

    • 安装 node.js
    • 创建 node.js 项目
    • 使用 JavaScript 开发命令行工具
    • 使用现成的 npm 包
    • 在 node.js 中使用 ESM
    • 将自己的包发布到 npm

    学会上面几点,大家就可以使用 npm 上浩如烟海的开源仓库,封装成自己的工具。一方面可以加强自己的工作效率,另一方面也可以早日参与开源项目的建设。从各个方面来讲都是很大的提升。

    开始时间

    2022年1月29日,下午3点至5点。

    参与方式

    参加的同学,请添加我的微信,或在微信群和 QQ 群报名,我会把你们拉到 workshop 群。开始时我会启动腾讯会议,并发链接到群里。

    请大家提前准备电脑和腾讯会议。最好测试好麦克风,以便提问。


    这是我新年的新尝试,如果出现各种问题,还请大家见谅。

    欢迎报名。即使只有一位同学上线,我也会准时开始,讲解全部。

    我会同步开启直播,有兴趣但无法参加 workshop 的同学可以到我的直播间观看:https://live.bilibili.com/5126601

    如果效果尚可的话,我尽量搞成周期性;如果疫情没问题,再想办法搞线下。

    (更多…)
  • node.js 里使用 fifo

    node.js 里使用 fifo

    0. 需求

    前两天 Showman 遇到一个需求:

    1. 我们需要在服务器端录制视频
    2. 录制视频的过程主要由 node.js 控制,借助 puppeteer 操作浏览器
    3. 但是也会需要执行一些 shell 命令,此时为安全考虑,我们会启动一个封闭的临时环境给用户执行
    4. 这些封闭环境是用户进程间共用的,不会随时启动随时销毁
    5. 所以 node.js 就需要在其它环境里执行一些操作,返回内容,等待执行完毕后再继续下面的

    于是我的同事就让我用 fifo。

    1. 什么是 fifo

    我以前没有用过 fifo,所以搜索了一下。

    FIFO 特殊文件(同具名管道)与管道类似,只是可以用访问文件系统的方式来访问它。它可以被多个进程同时打开和读写。当进程通过 FIFO 交换数据时,内核将直接在内部交换数据,而不会写入到文件系统中。因此,FIFO 特殊文件在文件系统中没有内容;文件系统的入口(即文件)只是作为引用方式,让各进程能够使用文件名来访问管道。

    原文:https://man7.org/linux/man-pages/man7/fifo.7.html

    管道大家应该都知道,把 A 进程的输出直接输入到 B 进程里,加快处理速度。fifo 与管道的差别就是 fifo 可以通过文件路径直接访问,用起来更简单。

    2. 在命令行里使用 fifo

    创建 fifo,使用 mkfifo 命令:

    mkfifo xxx.fifo

    写入内容到 fifo:

    echo "something" > xxx.fifo

    读取 fifo:

    cat xxx.fifo

    因为 fifo 是管道,内容直接走内核,所以实际上硬盘上不会存储任何内容。如果我们在写入之后再 cat fifo,就不会得到任何内容。

    3. 在 node.js 里使用 fifo

    在 node.js 里使用 fifo 需要用 fs.opennet.Socket。因为我需要在执行完毕后继续下一步,所以进行了 Promise 封装:

    try {
      // 为避免执行时间过长导致进程超时,不断输出些内容
      const interval = setInterval(() => {
        log('termlang is processing...');
      }, 3E4);
      await new Promise((resolve, reject) => {
        // 打开名为 $basename-$lineno.sp.fifo 的管道
        open('./$basename-$lineno.sp.fifo', constants.O_RDONLY | constants.O_NONBLOCK, (err, fd) => {
          if (err) {
            clearInterval(interval);
            reject(err);
          }
          const pipe = new Socket({fd});
          pipe.on('data', data => {
            data = data.toString();
            // 以输出内容包括 finished 或 errored 为结束标记
            if (/(finished|errored)/.test(data)) {
              resolve(data);
            }
          });
        });
      });
      clearInterval(interval);
    } catch (err) {
      log(err);
    }

    4. 总结

    作为半路出家的前端,我对系统、对 Linux 一直缺乏了解。所以类似管道这种东西,我一直也不太熟悉,这次算学会了一个新技能,记录分享一下。

  • 聊聊 NPM 里的版本号和依赖

    聊聊 NPM 里的版本号和依赖

    好像一直没有写过版本号和依赖相关的内容,偶尔会有同学问,所以写一篇总结一下。

    0. Semver

    我们目前使用的版本规范通常基于 Semver,语义化版本。官方网站:语义化版本 2.0.0 | Semantic Versioning (semver.org)。按照其规则,版本号的结构应该是:

    主(大)版本号.次(小)版本号.修正(补丁版本)号

    其中,

    • 主版本号一般包含架构和 API 的变化。如果 API 出现重大变化,使得依赖它的软件要重构,那么就要体现在大版本号里。不过现在的代码仓库很少有破坏式重构,API 一般能够在至少 2、3 个大版本里保持稳定。所以主版本号变化一般出现在大型重构时,仓库内部的代码架构和组织形式出现重大变化,或者基于不同系统,就需要升级主版本了。
    • 次版本号一般表达功能变化。架构没有变化,原有 API 也基本维持不变,只是新增了功能。这个时候就会上调次版本号。
    • 修订号一般表示修复 bug。

    0.1 年代版本号

    另一种流行的版本号规范是年代版本号,比如 Ubuntu,每年会发布两个版本,目前是 21.04,10月份会发布 21.10。偶数年的 .04 版本会最终成为 LTS,长期维护版本;奇数年的版本和 .10 版本则只维护半年,里面包含各种最新的软件组件,方便喜欢尝新的用户。

    前端常见的软件和库,包括 node.js、Angular、Electron 也用这种方式确定版本号。

    1. 依赖中指定版本号

    我们在项目中可能会使用大量开源代码,这些开源代码通常都会使用包管理工具(比如 NPM,node package manager)安装和管理。

    在 package.json 里,我们可以使用几个运算符告诉 NPM 我们希望怎么使用这些依赖:

    • 不写,repo: '1.0.0'。要求使用 1.0.0 版本的 repo,必须完全一致。
    • ~repo: ~1.0.0。要求大版本为 1,小版本为 0,修订版本不限制,比 0 高就可以。
    • ^repo: ^1.0.0。要求大版本为 1,小版本高于 0 就可以。

    一般来说,使用 npm i repo 安装的依赖,默认规则是 ^;使用 npm i repo@version 安装的依赖,默认规则是写死。

    2. 升级依赖

    这个世界上不存在没有 Bug 的代码,也没有功能完善的代码。使用开源仓库,我们就要考虑升级依赖。一方面可以使用新功能,一方面可以解决 bug。

    通常来说,直接使用 npm update 就能升级项目依赖,NPM 会按照(1)里设定的规则更新依赖。

    3. 升级依赖的大版本

    只使用 npm update 无法升级大版本。原因如上文所述,大版本可能包含破坏性的 API 更新,很容易导致 dev/build 失败,作为工具无法妥善处理,必须交给开发者手动完成。

    很多同学因此不愿意升级大版本。但我建议大家还是要找机会做升级,尤其工具链,比如 webpack、babel。这些工具很多时候有时效性,长期不升级会导致各种问题。而且,升级工具链本身这也是我们偿还技术债务的好机会。

    其实升级工具链的大版本并不复杂,大多数半天就搞定了;如果没有用到偏门功能,甚至可能直接升级就能跑。——通常来说,开源软件的作者面对使用频率高的功能,会比较保守;没人用的功能改动就会大刀阔斧一些。

    升级依赖需要指定大版本,比如从 webpack 4 升级到 webpack 5,可以使用 npm i webpack@5,这样会安装大版本是 v5 的最新版本。这里有个小建议,公司的生产级别的项目,最好不要着急升级,等到 X.2 X.3 这样基本稳定的次版本发布后再升级,可以避免踩很多坑。

    一般来说,开源仓库的官方也会提供迁移指南,比如 webpack v4 to v5。只要你有 v4 的配置经验,照着指南操作,大多数时候都能顺利完成。

    4. 解决 npm audit 问题

    开源仓库的安全问题日趋严重,GitHub 和 NPM 都会帮我们检查依赖,并且根据已知的安全问题列表发出警告。所以在安装依赖时,我们经常能看到类似下面这种警告信息:

    found 5 moderate severity vulnerabilities
      run `npm audit fix` to fix them, or `npm audit` for details

    这个时候,我们应该执行 npm audit 查看所有的审计结果,可能得到如下的报告:

    ┌───────────────┬──────────────────────────────────────────────────────────────┐
    │ Moderate      │ Regular expression denial of service                         │
    ├───────────────┼──────────────────────────────────────────────────────────────┤
    │ Package       │ glob-parent                                                  │
    ├───────────────┼──────────────────────────────────────────────────────────────┤
    │ Patched in    │ >=5.1.2                                                      │
    ├───────────────┼──────────────────────────────────────────────────────────────┤
    │ Dependency of │ @vue/cli-service [dev]                                       │
    ├───────────────┼──────────────────────────────────────────────────────────────┤
    │ Path          │ @vue/cli-service > webpack-dev-server > chokidar >           │
    │               │ glob-parent                                                  │
    ├───────────────┼──────────────────────────────────────────────────────────────┤
    │ More info     │ https://npmjs.com/advisories/1751                            │
    └───────────────┴──────────────────────────────────────────────────────────────┘

    这个报告说明有问题的包是 glob-parent,它由 webpack-dev-server 引入,又因为 @vue/cli-service 而最终成为项目的依赖。很明显,它是 @vue/cli 的组成部分,是前端工具链的一环。那么通常来说,它的危害层级很低,多半不会影响到项目整体安全性。

    如果是 node.js 项目,就要小心了,服务器和后端都是安全重灾区,后果可能会很严重。这个时候,你可以往上翻,找到这些报告的最前面,如果可以修复,NPM 会告诉你应该运行什么命令更新问题依赖。如果不能修复,就要自己想办法了。

    比如,你可以移除出问题的依赖,不过多半不可行。或者,我比较常用的方案是,在 GitHub 上找到依赖仓库,自己 fork 一份,然后升级其中的依赖,然后发布一个我个人的版本,接下来就用我自己的版本。等到官方修复后,再用回官方版本。

    5. 总结

    最后按惯例总结一下。依赖是我们软件产品的重要组成部分,它们的版本事关重大,必须给予足够的关注。至少,每个月要检查一次依赖的安全审计问题,如有需要,就升级依赖。

    我们自己写代码的时候,也要遵守 Semver 的规定,适时上调版本号,让版本号能表示代码的发展情况。

  • 用 express.js 实现流式输出 HTTP 响应

    用 express.js 实现流式输出 HTTP 响应

    0. 前言

    我们先来总结一下客户端与服务器端的数据交互方式:

    1. ajax,即 XMLHttpRequest,最常见的数据交互方式,适用于较轻量级的一次性请求,比如用户登录、CRUD 等。
    2. SSE,服务器端事件,适用于有大量服务器端推送、且不需要从客户端发送数据的需求,比如盯股票行情
    3. WebSocket,适用于上下行数据都很多、都很频繁的情况,比如在线聊天、网络游戏等

    单从技术角度来看,大概是这几个。根据需求变化,我也可以组合出更多方案。比如,类似 Showman 生成视频这种需求,耗时很长,对服务器资源占用很高,就比较适合:

    1. 发起请求,产生一条任务记录,进入队列
    2. 服务器上运行独立的进程,从队列中取任务出来执行,并记录日志
    3. 客户端不断检查任务进度

    1. 需求

    今天的需求,介于轻量一次性,与需要服务器长时间执行之间,即 FastTest 中的发布功能。这里,我们有以下需求:

    1. 管理员点击 Publish 按钮,开始发布静态网站
    2. 服务器需要先保存数据,然后调用 webpack 发布所有语言版本
    3. 只有少数管理员会使用此功能,服务器压力不大
    4. 管理员希望能了解发布进度,及每一步的状态

    所以,提交任务后等待服务器慢慢跑就不合适;等待请求完成一次性看到所有结果也不合适;最合适的,就是基于长链接,不断获取返回信息,并在前端实时看到进度。也即是:流式输出 HTTP 响应。

    2. 实现

    2.1 后端

    后端实现我选择 node.js + express.js,在后端领域,这两个我比较熟悉。express.js 是在 node.js 网络模块上进行封装得来,使用起来很简单,也支持原生 node.js 方法。

    首先我们创建一个服务器:

    const express = require('express');
    const app = express();
    const port = 3100;
    
    app.listen(port, () => {
      console.log('FastTest Admin API at: ', port);
    });

    然后,我们在需要流式返回响应的接口里设置相应头 Content-type: application/octet-stream。接下来,只要我们不断向输出流写入内容就可以了。哦,对了,结束的时候,我们还要关闭输出流。

    app.post('/data', async(req, res, next) => {
      res.setHeader('Content-type', 'application/octet-stream');
      
      // 第一次返回
      res.write('Local data saved. Start to build dist。files.\n');
    
      // 数次返回
      for (const item of items) {
        await doSomething(item);
        res.write(`${item} done successfully.\n`);
      }
    
      // 最后,全部完成
      res.write('All done.');
      // 关闭输出流
      res.end();
    });

    2.2 axios

    axios 是很流行的 ajax 库,它进行了 Promise 封装,用起来很方便。这里我们要用 onDownloadProgress(即 axios 对 XMLHttpRequest.progress 的封装)获取下载进度,它会在每次服务器返回响应字符串的时候更新,我们只需要截取上次响应之后,这次响应新增的内容,即可。

    function publish(data, onDownloadProgress) {
      return axios.post('/data', data, {
        onDownloadProgress,
      });
    }
    
    async function doPublish() {
      if (isLoading.value) {
        return;
      }
    
      isPublishing.value = true;
      message.value = status.value = null;
    
      try {
        const { cases, lang } = store.state;
        let offset = 0;
        await publish({ cases, lang }, ({ target: xhr }) => {
          // responseText 包含了从一开始到此刻的全部响应内容,所以我们需要从上次结束的位置截取,获得新增的内容
          const { responseText } = xhr;
          const chunk = responseText.substring(offset);
          // 记录这一次的结束位置
          offset = responseText.length;
          currentStatus.value = chunk;
        });
        status.value = true;
        currentStatus.value = '';
        message.value = 'Published successfully.';
      } catch (e) {
        message.value = 'Failed to publish. ' + e.message;
      }
      isPublishing.value = false;
    }

    2.3 Vue3

    相对来说,Vue3 的部分最容易。这里我用了 animate.css 的 flash 效果,让信息更显眼。除此之外,就是简单的赋值。

    <template lang="pug">
    .alert.alert-info.mb-0.me-2.py-1.px-3.animated.flash.infinite.slower(
      v-if="currentStatus",
    ) {{currentStatus}}
    </template>

    3. 效果演示

    流式输出效果演示

    4. 部署

    一般来说,我们很少会直接用 node.js 当服务器,多半会启动 node.js 服务,然后用 nginx 反向代理给用户访问。这里需要注意,nginx 默认会将响应内容存入缓冲区,然后批量返回给客户端。这会导致流式输出无效,变成常规的执行完毕后一次性输出。

    所以我们必须修改 nginx 的配置:

    # 仅展示有关配置
    location /data {
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header Host $http_host;
            proxy_pass http://localhost:3100;
            # 关闭代理缓冲区,所有响应实时输出
            proxy_buffering off;
    }

    5. 总结 & 扩展阅读

    前些天我帮朋友做了个小项目,叫 FastTest。项目很简单,不过我主动扩展了技术选型,使用了非常多的技术,都是浅显入门的程度,非常适合用来做基础入门学习。本文也是从这个项目中提炼出来的。建议大家有空的时候看看,拉到本地,跑起来试试看效果。

    文中的几段代码,分别位于:

    项目的详细介绍请见:超全面全栈入门项目:fastest

    扩展阅读

  • 使用 Node.js 驱动 FFmpeg 在 Linux + vncserver 下完成视频录制

    使用 Node.js 驱动 FFmpeg 在 Linux + vncserver 下完成视频录制

    自动化录制屏幕有很多用途,比如生成教学视频、生成产品文档,等等。对比人工,自动化有很多好处:

    1. 避免创作者的设备和环境问题(比如邻居装修、麦克风不好等)
    2. 避免创作者的语言、发音问题(比如普通话不标准、不会说某种语言)
    3. 录制环境出现变化,可以方便的重录(比如换个背景图,界面有升级)
    4. 就像写博客一样,任何时候,拿出电脑或者手机都能编辑一段

    所以目前研究这方面应用的很多,我厂也是。我近期就投入大量时间在这项工作上面,现在终于有所成果,写篇博客分享一下。

    0. 准备环境

    首先推荐大家使用 Linux。Linux 开源,有很多开源免费的工具可以完成各种操作,不仅可以录屏,还可以很容易地模拟各种用户操作,给我们留下大量开发空间。

    建议选择有图形界面的 Linux 发行版,我尝试过 fedora 33 和 Debian 10 树莓派版,都很容易配置。如果使用纯命令行版本,然后自己完成安装图形界面,比如 gnome,再完成剩下来的配置,会很麻烦。

    然后记得把系统更新到最新版,以规避可能遇到的问题。

    1. 配置 vncserver

    如果只能在主屏录制,这个产品的实用性就会大打折扣。所以我们选择用 vncserver 创建虚拟屏幕,然后在虚拟屏幕上完成录制。如果需要的话,也可以随时用 vnc viewer 之类的软件连上 VNC 实时查看效果,非常方便。

    有些系统自带 vncserver,比如 Debian 10 树莓派,那就不用安装。我们选用 fedora 33,需要手动安装,这里推荐 TigerVNC,安装使用都很方便:

    sudo dnf install tigervnc

    安装完成后,使用:

    vncserver :5 -geometry 1280x720

    就可以创建虚拟显示器了。其中,:5 是显示器 id,可以顺延,比如 :6:7、甚至 :99,至于上限在哪里我暂时不知道。-geometry 1280x720 是设定显示器分辨率为 1280×720。

    另外,还可以使用下面的命令查看和关闭显示器:

     vncserver -list
     vncserver -kill :5 

    1.1 测试

    配置完成之后,可以用 Firefox 测试一下效果。

    # 安装 firefox
    sudo dnf install firefox
    
    # 在指定虚拟显示器打开 firefox
    DISPLAY=:5 firefox https://cn.bing.com
    
    # 截图,可能需要安装 xwd
    xwd -root -display :5 > screen.xwd
    
    # 转换成 png,可能需要安装 ImageMagick
    convert screen.xwd screen.png

    然后把图片下载到本地,或者启动一个 http 服务器就能看到了。

    1.2 关闭桌面

    默认的 VNC server 会启动桌面,此时可能会要求我们登录什么的。我们在这套系统当中并不需要桌面,只要有显示器即可,所以可以修改 ~/.vnc/xstartup 禁用:

    #!/bin/sh
    
    unset SESSION_MANAGER
    unset DBUS_SESSION_BUS_ADDRESS
    # 把下面这行注释掉 
    # exec /etc/X11/xinit/xinitr

    2. 使用 FFmpeg 捕获屏幕内容

    使用 FFmpeg 录屏比较简单,将输入源设置为指定显示器即可,formatx11grab,命令大体如下:

    ffmpeg -y -f x11grab -video_size 1280x720 -framerate 30 -i :5.0+0,0 -c:v h264 a.mp4

    其中,

    • -y 意思是自动覆盖前面生成的视频
    • :5.0+0,0 是使用刚才创建的 :5 显示器,用它的 0 号桌面,启动位置是 0,0 即左上角
    • -f x11grab 是使用 X server 抓取格式,Linux 下的图形界面一版是基于这个系统

    注意,上面这条命令里参数的顺序很重要,否则可能遇到 Protocol not found 等错误。

    3. 使用 node.js 驱动

    最后只要用 node.js 的 child_process.spawn() 功能调用上面的命令即可。这段代码属于公司,我就不贴了,主要分享几点经验教训:

    1. 要用 spawn,因为录制过程中我们需要用输出来判断录制状态,exec 这种只能在结束时提供输出的没法用
    2. FFmpeg 会把输出输出到 stderr,理由不明,不过记得要用 stderr 来检查
    3. 录像完成,如果在命令行,按 q 或者 ctrl+C 都可以停止录像,并开始封装视频文件。在 node.js 里,我们可以调用 cp.kill('SIGINT') 。注意,调用之后,FFmpeg 子进程并没有立刻结束,它要把前面的录像进行封装,这个过程也是需要时间的,所以如果你接下来还要对视频文件进行操作,应该等待子进程彻底结束
    4. 判断录像开始的依据,我目前用的是:输出里包含 Output #0, mp4, to 'a.mp4'
    5. 判断录像结束,视频已经生成的依据,我用的是 cp.on('exit', onExit),然后在 onExit 里处理。注意,其它情况导致 ffpmeg 子进程退出时也会触发这个函数,所以我们必须检查 code。此时,在我的机器上,code 是 255,表示它是用户手动中止的,可以当作判断依据。

    总结

    剩下来的内容基本就是怎么驱动图形界面程序运行了。一般来说用 puppeteer 比较好,可以很容易的跟 node.js 联动,我厂的 showman 也是基于这个方案来实现的,最后贴一段视频,大家看下效果:

    一段用上述技术生成的视频

    参考阅读

  • 使用 Node.js 驱动 FFMPEG  录屏

    使用 Node.js 驱动 FFMPEG 录屏

    FFMPEG 功能非常强大,不仅能转换视频格式、压缩视频、添加字幕等,还能录制屏幕内容。使用 FFMPEG 录屏的方法很简单:

    # Linux
    ffmpeg -video_size 1024x768 -framerate 25 -f x11grab -i :0.0+100,200 output.mp4 
    
    # macOS
    ffmpeg -f avfoundation -list_devices true -i "" 
    
    # Windows
    ffmpeg -f gdigrab -framerate 30 -offset_x 10 -offset_y 20 -video_size 640x480 -show_region 1 -i desktop output.mkv 

    更详细的介绍可以参考官方文档,这里不做太多摘抄。

    使用 Node.js child_process 可以调用外部程序执行操作,详细的用法可以参考官方文档。大概来说,分为:

    1. exec/execFile 调用程序,当执行完成,一次性获得结果
    2. spawn 调用程序,过程中流式获得输出

    我的录屏对时间点有非常高的要求,力求准确。所以我只能选择 spawn,然后通过检查日志输出得到准确的录制开始时间。

    所以,我的代码大概如此:

    class FfmpegRecorder {
      // 其它函数略去
      startRecording(args) {
        this.isFileReady = new Promise((resolve, reject) => {
          this._isFileReadyResolve = resolve;
          this._isFileReadyReject = reject;
        });
        this.recording = spawn(this.ffmpeg, args);
        
        this.recording.stdout.on('data', this.onStdData);
        this.recording.stderr.on('data', this.onStdErr);
      }
      stopRecording() {
        this.recording.kill('SIGINT');
        const path = resolve(process.cwd(), 'a.mp4');
        return path;
      }
    
      onStdData(data) {
        data = data.toString();
        this.stdout += data;
        if (this.stdout.indexOf('Output #0, mp4, to \'a.mp4\'') !== -1) {
          this._isFileReadyResolve();
          this._isFileReadyReject = null;
        }
      }
      onStdErr(data) {
        data = data.toString();
        this.stderr += data;
        if (this.stderr.indexOf('Output #0, mp4, to \'a.mp4\'') !== -1) {
          this._isFileReadyResolve();
          this._isFileReadyReject = null;
        }
      }
    }

    根据我在命令行里直接跑 ffmpeg 的结果,它会先初始化,然后开始录屏。录屏开始时,会输出 Output #0, mp4, to xxx.mp4 这样的日志,所以我就会反复检查 stdoutstderr,直到关键日志出现,然后告诉外面的程序开始录制了。

    这里比较奇怪的是,日志输出应该是正常的,走 stdout 通道,结果只能从 stderr 通道获取。我为防万一,两边都留下了同样的代码。可能我对 Linux 理解不够,将来再研究一下为什么会这样吧。

    上面的代码忽略了 onErroronExit 部分,有兴趣的同学请等我开源(公司代码)。

    在 Linux,stopRecording(),即 kill('SIGINT')(相当于按下 Ctrl+C)之后,FFMPEG 会终止录屏,并且生成可以播放的视频文件。但是在 Windows,只会留下一个无法播放的文件。

    通过观察命令行直接运行 ffmpeg 的结果和 node.js 保存的结果,我发现缺失了结束录制后处理视频文件的部分。实际上 FFMPEG 录屏时只会记录视频内容,录制结束后才会生成影片的meta 信息,而播放器必须读取后者才可以正常播放。所以我猜测在 Windows 下 kill('SIGINT') 会直接彻底杀死进程,而不仅仅是发送一个信号给 FFMPEG,并让它完成后续的工作。

    做出判断后,我尝试用按下 q 的方式通知 FFMPEG 停止工作,并等待 5s。果然成功,于是我构建了新类,继承前面的 FfmpegRecorder,大概代码如下:

    const endTag = /kb\/s:\d+.\d{2}/m;
    
    class WindowsRecorder extend FfmpegRecorder {
      stopRecording() {
        // 向 child process 输入 `q`
        this.recording.stdin.setEncoding('utf8');
        this.recording.stdin.write('q');
        const p = new Promise((resolve, reject) => {
          this._stopResolve = resolve;
          this._stopReject = reject;
        });
        // 设置一个超时保护 15s
        setTimeout(() => {
          this._stopReject();
        }, 15E3);
        return p;
      }
      onStdData(data) {
        super.onStdData(data);
        if (this._stopResolve && endTag.test(this.stdout)) {
          const path = resolve(process.cwd(), 'a.mp4');
          this._stopResolve(path);
        }
      }
      onStdErr(data) {
        super.onStdErr(data);
        if (this._stopResolve && endTag.test(this.stderr)) {
          const path = resolve(process.cwd(), 'a.mp4');
          this._stopResolve(path);
        }
      }
    }

    为了功能完善,我没有选择等待固定的时间,而是继续检查日志,直到发现 endTag 标记。另外,我也留下了 15s 超时保护,避免某些暂时没遇到的问题破坏功能。

    至此,功能基本稳定完成。

  • 解决 WSL Ubuntu 20.04 下使用 apt 源安装 node.js 的问题

    解决 WSL Ubuntu 20.04 下使用 apt 源安装 node.js 的问题

    随着 Ubuntu 20.04 发布,各大平台都适配发布了对应版本的系统,Windows WSL 也不例外。如果你是新系统,直接在 Microsoft Store 里搜索并安装 Ubuntu 即可;如果你是老系统,已经装过以前的版本,那么需要先卸载再安装,如果直接安装 Ubuntu 20.04 会有多个不同版本的 Ubuntu 共存。

    装完系统后,接着安装其它软件。我现在比较喜欢用包管理工具安装软件,因为容易更新,而我又是更新爱好者。所以按图索骥,找到 node.js 的二进制包安装指引,复制执行:curl -sL https://deb.nodesource.com/setup_14.x | sudo -E bash - ,结果报错:gpg: can't connect to the agent: IPC connect call failed

    经过搜索,得知这是 WSL 版 Ubuntu 20.04 的问题,与 WSL1 有一些不兼容,在 WSL2 上就没这个问题了。解决方案是装一些工具:

    sudo add-apt-repository ppa:rafaeldtinoco/lp1871129
    sudo apt update
    sudo apt install libc6=2.31-0ubuntu8+lp1871129~1 libc6-dev=2.31-0ubuntu8+lp1871129~1 libc-dev-bin=2.31-0ubuntu8+lp1871129~1 -y --allow-downgrades
    sudo apt-mark hold libc6

    然后问题就解决了。

    这个 issue 里还记录了一些别的方案,包括上面方案的修正版,不过我用起来没问题,也就没继续往下看。感兴趣的同学可以研究一下。