[关闭]
@wy 2019-12-12T07:23:05.000000Z 字数 9893 阅读 601

通过浏览器工作台启动本地项目

nodejs


一直对通过浏览器工作台启动本地项目感兴趣,类似 vue-cli3 中提供的 vue ui,在浏览器中打开工作台,就能够创建、启动、停止、打包、部署你的项目,很好奇这一系列背后的实现原理。

最近在用 umijs 写项目,就顺便看了它提供的 cli 工具,并解开了自己的疑问。正好自己项目中也要实现类似的功能,明白了原理,只需要再完善打磨就好了。

体验工作台的功能,自己会猜测对应功能的实现方式,换做是我的话,我大致会如何去写。带着自己的疑问去看别人写的源码,在这个过程中验证自己的猜测,去学习别人处理的技巧。

本文会删繁就简的实现启动项目这个功能来说明工作台的工作原理,对于边界和异常情况没有做过多处理,要投入使用中,可以做进一步的改进。

关键点

  1. 启动服务,访问可视化工作台 UI 界面
  2. 通过工作台,执行本地项目指定的命令
  3. 将执行命令的数据主动推送到客户端显示

细化:

第一点,在本地启动一个服务,能够访问到页面,选择使用 node 的框架 express 完成,统一返回 index.html 页面。界面可以使用任意框架来做,选择 Vuereact 甚至 jQuery 都可以。

第二点,在界面中完成一个交互,像点击 启动 按钮,后端要去指定的目录下,执行启动项目的命令,例如 Vue-cli3 构建的项目,需要用 npm run serve 来启动本地服务,就需要能够执行 shell 命令,使用 node 提供的 child_process 模块完成。

第三点,执行命令时打印的信息,原本如果用系统的终端,就可以在终端打印出来,用到浏览器端 UI 界面来执行命令,要跟在终端中一样,需要把信息打印在页面中。
执行的任务有些时间比较长,并且在执行过程中会有异步情况。如果用 http 接口请求的方式,把服务端的信息发送给客户端,客户端需要去轮询接口,直到确定命令执行完成停止,这样会带来不少开销。
选择 webSocket 在浏览器和服务器之间建立全双工的通信通道,服务端接收浏览器的命令,服务端主动推送数据到客户端。这里选择使用 sockjs 模块完成。

启动服务

  1. const express = require('express');
  2. const app = express();
  3. const fs = require('fs');
  4. const path = require('path');
  5. app.use('/*',(req, res) => {
  6. let indexHtml = fs.readFileSync(path.join(__dirname, './index.html'));
  7. res.set('Content-Type', 'text/html');
  8. res.send(indexHtml);
  9. })
  10. const server = app.listen(3002,'0.0.0.0',()=> {
  11. console.log('服务启动');
  12. })

这段代码比较简单,起一个服务,返回 html 文档,打开浏览器,访问 http://localhost:3002/ 即可。

创建通信通道

安装模块 npm i sockjs,并使用:

  1. const sockjs = require('sockjs');
  2. const ss = sockjs.createServer(); // 创建 sock 服务
  3. // ... 省略上面的 express 代码
  4. let conns = {}; // 存入连接实例
  5. // 监听有客户端连接
  6. ss.on('connection', (conn) => {
  7. console.log('conn: ', conn.id);
  8. console.log('有访问来了');
  9. conns[conn.id] = conn; // 缓存本次访问者,可以在别处也能发送信息
  10. // 向客户端发送数据
  11. conn.write(JSON.stringify({message: '来了老弟'}));
  12. // 监控客户端发送的数据
  13. conn.on('data', (messsage) => {
  14. console.log('拿到数据', messsage);
  15. })
  16. // 客户端断开连接
  17. conn.on('close', () => {
  18. console.log('离开了 ');
  19. delete conns[conn.id];
  20. })
  21. })
  22. // 将sockjs服务挂载到http服务上
  23. ss.installHandlers(server, {
  24. prefix: '/test',
  25. log: () => {},
  26. });

以上启动一个 sock 服务,和 express 启动的服务做了连接, 并设置了访问的前缀为 /test, 这就可以在页面中通过 sockjs-client 访问到这个服务,访问的地址为 http://localhost:3002/test

  • 事件 connection 监听有客户端成功连接,每次连接都会触发回调函数,回调中接收一个连接实例(Connection instance)。 例子中用变量 conn 来接收。
  • 连接实例下的事件 data,监听客户端发送的数据,消息是unicode字符串。所以如果用对象,需要 JSON.parse 解析。
  • 连接实例下的事件 close,当客户端断开连接时触发。
  • 连接实例下的方法 write(),向客户端发送数据。如果要发送对象,则先用 JSON.stringify 转成字符串。

具体可以参考https://github.com/sockjs/sockjs-node

以上用 conns 通过 id 缓存访问的实例实例,目的是可以封装一个通用的发送数据的方法,可以再任意需要的地方调用,让客户端都可以接收到,这个到后面会有用处。

index.html 中的代码实现:

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>测试在浏览器中启动本地服务</title>
  6. <link rel="stylesheet" href="https://gw.alipayobjects.com/os/lib/xterm/3.14.5/dist/xterm.css">
  7. <script src="https://gw.alipayobjects.com/os/lib/xterm/3.14.5/dist/xterm.js"></script>
  8. <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1.4.0/dist/sockjs.min.js"></script>
  9. </head>
  10. <body>
  11. <script>
  12. // 向浏览器写入打印信息
  13. let term = new Terminal();
  14. term.write('打印信息:\r\n');
  15. term.open(document.getElementById('terminal'));
  16. // 连接 webSocket 服务
  17. var sock = new SockJS('http://localhost:3002/test');
  18. // 初次连接成功后触发的事件
  19. sock.onopen = function() {
  20. sock.send(JSON.stringify({type: 'init', message: '成功连接'}));
  21. };
  22. // 接收服务器发送的消息
  23. sock.onmessage = function(message){
  24. console.log('message: ', message);
  25. }
  26. </script>
  27. </body>
  28. </html>

引入了 xterm.js 工具,把接收的消息打印在页面中。

引入 sockjs-client 文件,使用其中提供的方法,方便和服务端交互。

  • new SockJS 传入 sock 连接的 url 地址,与服务器建立通信通道。
  • onopen 和服务端连接成功后触发的事件
  • onmessage 当接收来自服务端的消息时触发
  • send() 方法,向服务端发送数据。

服务端和客户端建立 webSocket 通道后,就可以通信了。

这个例子是在初次加载页面就连接 webSocket 服务端,也可以在需要的时候再进行连接。

在这个过程中区分要做的不同的事情,可以在数据中自定义一些 type 类型,例如:

{type: 'task/init', message: '初始话服务'} 初始服务
{type: 'task/run', message: '启动服务'} 启动一个项目服务
{type: 'task/close', message: '停止服务'} 停止一个项目服务

服务端和客户端可以根据不同的 type 类型,做不同的事情。

服务端:

  1. conn.on('data', (messsage) => {
  2. // 解析为对象
  3. const data = JSON.parse(messsage);
  4. switch(data.type){
  5. case 'task/init':
  6. // 初始服务
  7. break;
  8. case 'task/run':
  9. // 启动一个项目服务
  10. break;
  11. case 'task/cancel':
  12. // 停止一个项目服务
  13. break;
  14. }
  15. })

客户端:

  1. sock.send(JSON.stringify({type: 'task/init', message: '初始服务'}));
  2. sock.send(JSON.stringify({type: 'task/run', message: '启动一个项目服务'}));
  3. sock.send(JSON.stringify({type: 'task/cancel', message: '停止一个项目服务'}));

执行 shell 命令

使用 Node 内置模块 child_process 提供的 spawn 方法执行指定的命令,这个方法可以衍生一个新的子进程,不阻塞主进程的执行。

新建 runCommand.js

  1. let { spawn } = require('child_process');
  2. // 封装可执行命令的方法。
  3. function runCommand(script, options={}){
  4. options.env = {
  5. ...process.env,
  6. ...options.env
  7. }
  8. options.cwd = options.cwd || process.cwd();
  9. // 设置衍生的子进程与主进程通信方式
  10. options.stdio = ['pipe', 'pipe', 'pipe', 'ipc'];
  11. let sh = 'sh',shFlag = '-c';
  12. return spawn(sh, [shFlag, script], options)
  13. }
  14. // 使用
  15. runCommand('node test.js', {
  16. cwd: "/users/node_files/",
  17. env: {
  18. ENV_TEST: '测试数据'
  19. }
  20. });

在目录 /users/test/node_files/(此目录由自己设定), 新建 test.js

  1. //用 console.log 向终端输出内容
  2. console.log(111);
  3. console.log('获取自定义环境变量数据', process.env.ENV_TEST);
  4. // 子进程向父进程发送数据
  5. process.send('我是子进程发出的数据')

上面封装了一个通用执行命令的函数,接受两个参数:

打开终端,此时运行 node runCommand.js,会发现运行后终端没有任何输出。明明有 console.log,为什么没有输出呢?

这就需要简单了解下 stdio(标准输入输出)。

stdio(标准输入输出)

spawn 方法执行命令会新打开一个子进程,test.js 就在这个子进程中运行。如果关心子进程内部的输出,需要设置子进程与主进程通信的管道。通过设置参数的 stdio ,可以将子进程的 stdio 绑定到不同的地方。

可以给 stdio 设置数组

stdio : [输入设置 stdin, 输出设置 stdout, 错误输出 stdrr, [其他通信方式]]

举例:

  1. const fd = require("fs").openSync("./node.log", "w+");
  2. child_process.spawn("node", ["-c", "test.js"], {
  3. // 把子进程的输出和错误存在 node.log 这个文件中
  4. stdio: [process.stdin, fd, fd]
  5. });

以上这个例子,是把运行 test.js,输出的结果和错误信息打印在 node.log中。

如果设置 stdio['pipe', 'pipe', 'pipe'],子进程的 stdio 与父进程的 stdio 通过管道连接起来。此时通过监听子进程输出(stdout)事件,来获取子进程的输出流数据。

  1. // 创建子进程,执行命令
  2. const ipc = runCommand('node test.js', {
  3. env: {
  4. ENV_TEST: '测试数据'
  5. }
  6. });
  7. // 接收子进程的输出数据,例如 console.log 的输出
  8. ipc.stdout.on('data', log => {
  9. console.log(log.toString());
  10. });
  11. // 当子进程执行结束时触发
  12. ipc.stdout.on('close', log => {
  13. console.log('结束了');
  14. });
  15. // 当主程序结束时,无论子程序是否执行完毕,都kill掉
  16. process.on('exit', () => {
  17. console.log('主线程退出');
  18. ipc.kill('SIGTERM'); // 终止子进程
  19. });

以上运行 node runCommand.js,就可以在终端打印出 test.js 输出的内容。监听了 ipc.stdoutdata 事件,有数据输出就触发了。

但是,运行后,在终端并没有看到 test.js我是子进程发出的数据,这句话。用的是 process.send 发送的,这是要和主进程进行通信。那就需要额外的 ipc(进程间通信),设置 stdio['pipe', 'pipe', 'pipe', 'ipc'],此时要监听子进程 process.send 发送的数据,需要监听 message 事件。

  1. ipc.on('message',function(message){
  2. console.log('message: ', message);
  3. })

此时再运行 node runCommand.js,就可以在终端打印出所有数据了。

以上设置的 stdio,无论是 console.log 这种在进程中输出流的形式,或者是 process.send 这种与主进程通信的形式,都可以拿到数据。

以上只是简略的说了下 stdio的一种设置方式,详细可以参考https://cnodejs.org/topic/5aa0e25a19b2e3db18959bee

代码整合

回顾之前抛出的关键点:

通过上面对每一个点的单独实现,基本解决了上述问题,这时就需要将零散的代码整合起来,来实现一个相对完善的功能。

首先新建一个 taskManger.js,用来创建子进程执行命令,并将子进程输出的数据通知给调用方。

  1. let { spawn } = require('child_process');
  2. let {EventEmitter} = require('events');
  3. // 继承有 on emit 的方法
  4. class TaskManger extends EventEmitter {
  5. constructor(){
  6. super();
  7. }
  8. // 初始调用,接收发送数据的方法
  9. init(send){
  10. // 监听 自定义事件
  11. this.on('std-out-message', (message) => {
  12. send({
  13. type: 'task.log',
  14. payload: {
  15. log: message
  16. }
  17. });
  18. })
  19. }
  20. // 通用的执行命令函数
  21. runCommand(script, options={}){
  22. options.env = {
  23. ...process.env,
  24. ...options.env
  25. }
  26. options.cwd = options.cwd || process.cwd();
  27. options.stdio = ['pipe', 'pipe', 'pipe', 'ipc'];
  28. let sh = 'sh',shFlag = '-c';
  29. return spawn(sh, [shFlag, script], options)
  30. }
  31. // 开始任务
  32. async run(script, options){
  33. this.ipc = await this.runCommand(script, options);
  34. this.processHandler(this.ipc);
  35. }
  36. // 取消任务
  37. cancel(){
  38. this.ipc.kill('SIGTERM');
  39. }
  40. // 接收创建的子进程
  41. processHandler(ipc){
  42. // 子进程 **process.send** 发送的数据
  43. ipc.on('message', (message) => {
  44. this.emit('std-out-message', message);
  45. });
  46. // 接收子进程的输出数据,例如 console.log 的输出
  47. ipc.stdout.setEncoding('utf8');
  48. ipc.stdout.on('data', log => {
  49. this.emit('std-out-message', log);
  50. });
  51. // 当子进程执行结束时触发
  52. ipc.stdout.on('close', log => {
  53. console.log('结束了', log);
  54. this.emit('std-out-message', '服务停止');
  55. });
  56. // 当主程序结束时,无论子程序是否执行完毕,都kill掉
  57. process.on('exit', () => {
  58. console.log('主线程退出');
  59. ipc.kill('SIGTERM'); // 终止进程
  60. });
  61. }
  62. }
  63. module.exports = TaskManger;

以上在使用时调用 init()初始化接收一个将来发送给 socket 的方法(下面会有使用示例)。run() 方法接收命令并创建子进程执行,执行过程数据通过在定义事件 std-out-message,通知给监听方。进而调用方法通知 socket

新建一个 socket.js 文件来使用:

  1. let express = require('express');
  2. let app = express();
  3. let fs = require('fs');
  4. let path = require('path');
  5. let TaskManger = require('./taskManger');
  6. let sockjs = require('sockjs');
  7. const ss = sockjs.createServer();
  8. app.use('/*',(req, res) => {
  9. let indexHtml = fs.readFileSync(path.join(__dirname, './index.html'));
  10. res.set('Content-Type', 'text/html');
  11. res.send(indexHtml.toString());
  12. })
  13. const server = app.listen(3002,()=> {
  14. console.log('服务启动');
  15. })
  16. const task = new TaskManger();
  17. // 发送给 访问者。
  18. const send = (payload) => {
  19. const message = JSON.stringify(payload);
  20. Object.keys(conns).forEach(id => {
  21. conns[id].write(message);
  22. });
  23. }
  24. let conns = {};
  25. ss.on('connection', (conn) => {
  26. conns[conn.id] = conn;
  27. conn.on('data', async (data) => {
  28. const datas = JSON.parse(data);
  29. switch(datas.type){
  30. case 'task/init': // 初始服务
  31. task.init(send);
  32. break;
  33. case 'task/run': // 启动一个项目服务
  34. task.run('npm run serve', {
  35. cwd: `/Users/test/vue-cli3-project` // cwd可以设置为你本地的vue-cli3创建的项目目录地址
  36. });
  37. break;
  38. case 'task/cancel': // 停止一个项目服务
  39. task.cancel()
  40. break;
  41. }
  42. })
  43. conn.on('close', () => {
  44. delete conns[conn.id];
  45. })
  46. })
  47. ss.installHandlers(server, {
  48. prefix: '/test',
  49. log: () => {},
  50. });

以上通过自定义的 type 来区分不同的命令。cwd 目录路径,设置为你本地的 vue-cli3 创建的项目目录地址,或者其他项目,并传入正确的启动命令即可。

index.html 完整代码:

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <meta http-equiv="X-UA-Compatible" content="ie=edge">
  7. <title>测试在浏览器中启动本地服务</title>
  8. <link rel="stylesheet" href="https://gw.alipayobjects.com/os/lib/xterm/3.14.5/dist/xterm.css">
  9. <script src="https://gw.alipayobjects.com/os/lib/xterm/3.14.5/dist/xterm.js"></script>
  10. <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1.4.0/dist/sockjs.min.js"></script>
  11. </head>
  12. <body>
  13. <button id="button">启动应用</button>
  14. <button id="cancel">停止应用</button>
  15. <div id="terminal"></div>
  16. <script>
  17. let term = new Terminal();
  18. term.write('打印信息:\r\n');
  19. term.open(document.getElementById('terminal'));
  20. var sock = new SockJS('http://localhost:3002/test');
  21. // 连接成功触发
  22. sock.onopen = function() {
  23. console.log('open');
  24. // 初始化任务
  25. let data = {
  26. type: 'task/init'
  27. }
  28. sock.send(JSON.stringify(data));
  29. };
  30. // 后端推送过来的数据触发
  31. sock.onmessage = function(message){
  32. console.log('message: ', message);
  33. const data = JSON.parse(message.data);
  34. let str = data.payload.log.replace(/\n/g, '\r\n');
  35. // 将打印信息写在页面上
  36. term.write(str);
  37. }
  38. // 启动项目服务
  39. button.onclick = function(){
  40. const task = {
  41. type: 'task/run'
  42. }
  43. sock.send(JSON.stringify(task));
  44. }
  45. // 取消项目服务
  46. cancel.onclick = function(){
  47. const task = {
  48. type: 'task/cancel'
  49. }
  50. sock.send(JSON.stringify(task));
  51. }
  52. </script>
  53. </body>
  54. </html>

总结

通过上述例子,关键点其实是两点,执行命令和建立 Socket 服务,如果对 nodejsAPI 熟悉的话,很快就能完成这一功能。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注