Cabin Fever 编码:Node.js 后端教程

已发表: 2022-03-11

COVID-19 封锁让我们中的许多人被困在家里,也许希望仅仅是机舱热是我们将经历的最严重的发烧。 我们中的许多人正在消费比以往更多的视频内容。 虽然现在锻炼尤为重要,但有时,当笔记本电脑无法触手可及时,人们会怀念那种奢华的老式遥控器。

这就是这个项目的用武之地:有机会将任何智能手机——即使是因为缺乏更新而无用的旧智能手机——变成下一个 Netflix/YouTube/Amazon Prime Video/等的方便遥控器。 狂欢手表。 它也是一个 Node.js 后端教程:一个使用 Express 框架和 Pug(以前的 Jade)模板引擎学习后端 JavaScript 基础知识的机会。

如果这听起来令人生畏,那么完整的 Node.js 项目将在最后呈现; 读者只需要学习他们对学习感兴趣的内容,并且在此过程中会有相当多的对一些基础知识的温和解释,更有经验的读者可以跳过。

为什么不只是……?

读者可能会想,“为什么要编写 Node.js 后端代码?” (当然,除了学习机会。)“不是已经有一个应用程序了吗?”

当然——很多。 但有两个主要原因这可能是不可取的:

  1. 对于那些试图重新利用旧手机的人来说,这可能不再是一个选择,就像我想使用的 Windows Phone 8.1 设备一样。 (应用商店于 2019 年底正式关闭。)
  2. 信任(或缺乏信任)。 就像在任何移动平台上可以找到的许多应用程序一样,它们通常要求用户授予比应用程序声称要做的更多的权限。 但即使这方面受到适当限制,远程控制应用程序的性质意味着用户仍然必须相信应用程序开发人员不会通过包含间谍软件或其他恶意软件来滥用他们在解决方案的桌面端的权限。

这些问题已经存在了很长时间,甚至是 2014 年在 GitHub 上发现的类似项目的动机。 nvm使得安装旧版本的 Node.js 变得很容易,即使需要升级一些依赖项,Node.js 在向后兼容方面也享有盛誉。

不幸的是,bitrot 赢了。 顽固的方法和 Node.js 后端兼容性无法与 Grunt、Bower 和许多其他组件的旧版本之间无休止的弃用和不可能的依赖循环相匹敌。 几个小时后,很明显从头开始会容易得多——尽管作者自己建议不要重新发明轮子。

旧的新 Gizmo:使用 Node.js 后端将手机重新用作遥控器

首先,请注意这个 Node.js 项目目前特定于 Linux——特别是在 Linux Mint 19 和 Linux Mint 19.3 上开发和测试——但当然可以添加对其他平台的支持。 它可能已经在 Mac 上运行。

假设安装了现代版本的 Node.js,并且在将作为项目根目录的新目录中打开了一个命令提示符,我们就可以开始使用 Express:

 npx express-generator --view=pug

注意:这里, npxnpm附带的一个方便的工具,它是 Node.js 附带的 Node.js 包管理器。 我们使用它来运行 Express 的应用程序骨架生成器。 在撰写本文时,生成器创建了一个 Express/Node.js 项目,默认情况下仍会引入一个名为 Jade 的模板引擎,尽管 Jade 项目从 2.0 版开始将其自身重命名为“Pug”。 因此,要立即使用 Pug(另外,避免弃用警告),我们添加了npx --view=pug ,这是由 npx 运行的express-generator脚本的命令行选项。

完成后,我们需要从 Node.js 项目的package.json中新填充的依赖项列表中安装一些包。 执行此操作的传统方法是运行npm ii表示“安装”)。 但有些人仍然喜欢 Yarn 的速度,所以如果你安装了 Yarn,只需运行不带参数的yarn

在这种情况下,可以安全地忽略来自 Pug 的子依赖项之一的(希望即将修复的)弃用警告,只要在本地网络上保持按需访问即可。

快速的yarn startnpm start ,然后在浏览器中导航到localhost:3000 ,表明我们基于 Express 的基本 Node.js 后端工作正常。 我们可以用Ctrl+C杀死它。

Node.js 后端教程,第 2 步:如何在主机上发送击键

远程部分已经完成了一半,让我们将注意力转向控制部分。 我们需要能够以编程方式控制我们将在其上运行 Node.js 后端的机器,假装它正在按下键盘上的键。

为此,我们将使用其官方说明安装xdotool 。 在终端中快速测试他们的示例命令:

 xdotool search "Mozilla Firefox" windowactivate --sync key --clearmodifiers ctrl+l

…应该完全按照它所说的去做,假设当时 Mozilla Firefox 是打开的。 那挺好的! 我们很快就会看到,让我们的 Node.js 项目调用像xdotool这样的命令行工具很容易。

Node.js 后端教程,第 3 步:功能设计

这可能不适用于每个人,但就我个人而言,我发现许多现代物理遥控器的按钮数量大约是我将使用的按钮数量的五倍。 因此,对于这个项目,我们正在研究一个全屏布局,其中包含一个 3×3 网格的漂亮、大、易于定位的按钮。 这九个按钮可能是什么取决于个人喜好。

事实证明,即使是最简单功能的键盘快捷键在 Netflix、YouTube 和 Amazon Prime Video 中也不尽相同。 这些服务也不像原生音乐播放器应用程序那样使用通用媒体键。 此外,某些功能可能不适用于所有服务。

所以我们需要做的是为每个服务定义一个不同的远程控制布局,并提供一种在它们之间切换的方法。

定义远程控制布局并将它们映射到键盘快捷键

让我们使用一些预设来制作一个快速原型。 我们将它们放在common/preset_commands.js中——“common”,因为我们将包含来自多个文件的这些数据:

 module.exports = { // We could use ️ but some older phones (eg, Android 5.1.1) won't show it, hence ️ instead 'Netflix': { commands: { '-': 'Escape', '+': 'f', '': 'Up', '⇤': 'XF86Back', '️': 'Return', '': 'Down', '': 'Left', '': 'Right', '': 'm', }, }, 'YouTube': { commands: { '⇤': 'shift+p', '⇥': 'shift+n', '': 'Up', 'CC': 'c', '️': 'k', '': 'Down', '': 'j', '': 'l', '': 'm', }, }, 'Amazon Prime Video': { window_name_override: 'Prime Video', commands: { '⇤': 'Escape', '+': 'f', '': 'Up', 'CC': 'c', '️': 'space', '': 'Down', '': 'Left', '': 'Right', '': 'm', }, }, 'Generic / Music Player': { window_name_override: '', commands: { '⇤': 'XF86AudioPrev', '⇥': 'XF86AudioNext', '': 'XF86AudioRaiseVolume', '': 'r', '️': 'XF86AudioPlay', '': 'XF86AudioLowerVolume', '': 'Left', '': 'Right', '': 'XF86AudioMute', }, }, };

可以使用xev找到键码值。 (对我来说,使用这种方法无法发现“音频静音”和“音频播放”,因此我还查阅了媒体键列表。)

读者可能会注意到spaceReturn之间大小写的区别——不管是什么原因,必须尊重这个细节, xdotool才能正常工作。 与此相关的是,我们有几个明确的定义——例如, shift+p ,即使P也可以工作——只是为了保持我们的意图清晰。

Node.js 后端教程,第 4 步:我们 API 的“关键”端点(请原谅双关语)

我们需要一个POST到的端点,这反过来将使用xdotool模拟击键。 由于我们可以发送不同的密钥组(每个服务一个),我们将调用特定group的端点。 我们将通过将routes/users.js重命名为routes/group.js并在app.js中进行相应的更改来重新利用生成的users端点:

 // ... var indexRouter = require('./routes/index'); var groupRouter = require('./routes/group'); // ... app.use('/', indexRouter); app.use('/group', groupRouter); // ...

关键功能是通过routes/group.js xdotool 我们暂时将YouTube硬编码为首选菜单,仅用于测试目的。

 const express = require('express'); const router = express.Router(); const debug = require('debug')('app'); const cp = require('child_process'); const preset_commands = require('../common/preset_commands'); /* POST keystroke to simulate */ router.post('/', function(req, res, next) { const keystroke_name = req.body.keystroke_name; const keystroke_code = preset_commands['YouTube'].commands[keystroke_name]; const final_command = `xdotool \ search "YouTube" \ windowactivate --sync \ key --clearmodifiers ${keystroke_code}`; debug(`Executing ${final_command}`); cp.exec(final_command, (err, stdout, stderr) => { debug(`Executed ${keystroke_name}`); return res.redirect(req.originalUrl); }); }); module.exports = router;

在这里,我们从名为keystroke_name的参数下的POST请求正文 ( req.body ) 中获取请求的键“名称”。 那会像 。 然后我们使用它从preset_commands['YouTube']commands对象中查找相应的代码。

最后的命令不止一行,所以每行末尾的\ s 将所有部分连接成一个命令:

  • search "YouTube"获取标题中包含“YouTube”的第一个窗口。
  • windowactivate --sync激活获取的窗口并等待它准备好接收击键。
  • key --clearmodifiers ${keystroke_code}发送击键,确保临时清除可能会干扰我们发送的内容的修饰键,例如 Caps Lock。

此时,代码假定我们正在为其提供有效输入——我们稍后会更加小心。

为简单起见,该代码还将假定只有一个标题中带有“YouTube”的应用程序窗口打开——如果有多个匹配项,则无法保证我们会将击键发送到预期的窗口。 如果这是一个问题,那么可以通过在除要远程控制的窗口之外的所有窗口上切换浏览器选项卡来更改窗口标题可能会有所帮助。

准备好之后,我们可以再次启动我们的服务器,但这一次启用了调试,因此我们可以看到debug调用的输出。 为此,只需运行DEBUG=old-fashioned-remote:* yarn startDEBUG=old-fashioned-remote:* npm start 。 运行后,在 YouTube 上播放视频,打开另一个终端窗口,然后尝试 cURL 调用:

 curl --data "keystroke_name=️" http://localhost:3000/group

这会在其主体中向我们的本地机器发送一个POST请求,其中包含请求的击键名称的端口3000 ,即我们的后端正在侦听的端口。 运行该命令应该会在npm窗口中输出有关ExecutingExecuted的注释,更重要的是,打开浏览器并暂停其视频。 再次执行该命令应该给出相同的输出并取消暂停。

Node.js 后端教程,第 5 步:多个远程控制布局

我们的后端还没有完成。 我们还需要它能够:

  1. preset_commands生成远程控制布局列表。
  2. 一旦我们选择了特定的遥控器布局,就会生成一个击键“名称”列表。 (我们也可以选择直接在前端使用common/preset_commands.js ,因为它已经是 JavaScript,并在那里过滤。这是 Node.js 后端的潜在优势之一,我们只是不在这里使用它.)

这两个特性都是我们的 Node.js 后端教程与我们将要构建的基于 Pug 的前端相交的地方。

使用 Pug 模板呈现遥控器列表

等式的后端部分意味着将routes/index.js修改为如下所示:

 const express = require('express'); const router = express.Router(); const preset_commands = require('../common/preset_commands'); /* GET home page. */ router.get('/', function(req, res, next) { const group_names = Object.keys(preset_commands); res.render('index', { title: 'Which Remote?', group_names, portrait_css: `.group_bar { height: calc(100%/${Math.min(4, group_names.length)}); line-height: calc(100vh/${Math.min(4, group_names.length)}); }`, landscape_css: `.group_bar { height: calc(100%/${Math.min(2, group_names.length)}); line-height: calc(100vh/${Math.min(2, group_names.length)}); }`, }); }); module.exports = router;

在这里,我们通过在preset_commands文件中调用Object.keys来获取我们的远程控制布局名称 ( group_names )。 然后我们将它们和我们需要的一些其他数据发送到通过res.render()自动调用的 Pug 模板引擎。

注意不要将此处的keys的含义与我们发送的击键混淆: Object.keys为我们提供了一个数组(有序列表),其中包含构成 JavaScript 对象的键值对的所有

 const my_object = { 'a key' : 'its corresponding value' , 'another key' : 'its separate corresponding value' , };

如果我们查看common/preset_commands.js ,我们会看到上面的模式,我们的(在对象意义上)是我们组的名称: 'Netflix''YouTube'等。它们对应的值不是my_object上面的简单字符串——它们本身就是完整的对象,有自己的键,即commands ,可能还有window_name_override

诚然,这里传递的自定义 CSS 有点 hack。 我们完全需要它而不是使用现代的、基于 flexbox 的解决方案的原因是为了更好地兼容移动浏览器的美妙世界,在它们更美妙的旧版本中。 在这种情况下,需要注意的主要是在横向模式下,我们通过每屏显示不超过两个选项来保持按钮较大; 在纵向模式下,四个。

但是,这实际上是在哪里变成了要发送到浏览器的 HTML 呢? 这就是views/index.pug的用武之地,我们希望它看起来像这样:

 extends layout block header_injection style(media='(orientation: portrait)') #{portrait_css} style(media='(orientation: landscape)') #{landscape_css} block content each group_name in group_names span(class="group_bar") a(href='/group/?group_name=' + group_name) #{group_name}

第一行很重要: extends layout意味着 Pug 将在views/layout.pug的上下文中获取这个文件,这是我们将在这里和另一个视图中重用的父模板。 我们需要在link行之后添加几行,以便最终文件如下所示:

 doctype html html head title= title link(rel='stylesheet', href='/stylesheets/style.css') block header_injection meta(name='viewport', content='user-scalable=no') body block content

我们不会在这里介绍 HTML 的基础知识,但对于不熟悉它们的读者来说,这个 Pug 代码反映了几乎随处可见的标准 HTML 代码。 它的模板方面从title= title开始,它将 HTML 标题设置为与我们通过res.render传递 Pug 的对象的title键对应的任何值。

我们可以看到稍后使用我们命名为header_injectionblock模板化两行的不同方面。 像这样的块是占位符,可以用扩展当前模板的模板替换。 (不相关, meta行只是移动浏览器的一种快速解决方法,因此当用户连续多次点击音量控制时,手机不会放大或缩小。)

回到我们的block s:这就是为什么views/index.pug定义了自己的block s,其名称与views/layout.pug中的名称相同。 在这个header_injection的例子中,这让我们可以使用特定于手机将处于的纵向或横向方向的 CSS。

content是我们放置网页主要可见部分的地方,在这种情况下:

  1. 循环遍历我们传递给它的group_names数组,
  2. 为每个元素创建一个<span>元素,并应用 CSS 类group_bar ,并且
  3. 根据group_name在每个<span>中创建一个链接。

我们可以在通过views/layout.pug拉入的文件中定义 CSS 类group_bar ,即public/stylesheets/style.css

 html, body, form { padding: 0; margin: 0; height: 100%; font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; } .group_bar, .group_bar a, .remote_button { box-sizing: border-box; border: 1px solid white; color: greenyellow; background-color: black; } .group_bar { width: 100%; font-size: 6vh; text-align: center; display: inline-block; } .group_bar a { text-decoration: none; display: block; }

此时,如果npm start仍在运行,在桌面浏览器中访问http://localhost:3000/应该会显示两个非常大的 Netflix 和 YouTube 按钮,其余的可以通过向下滚动来使用。

使用桌面浏览器对遥控器布局选择器进行的测试,显示了 Netflix 和 YouTube 的两个非常大的按钮。

但是如果我们此时单击它们,它们将不起作用,因为我们还没有定义它们链接到的路由( /groupGET 。)

显示选择的遥控器布局

为此,我们将在最后的module.exports行之前将其添加到routes/group.js中:

 router.get('/', function(req, res, next) { const group_name = req.query.group_name || ''; const group = preset_commands[group_name]; return res.render('group', { keystroke_names: Object.keys(group.commands), group_name, title: `${group_name.match(/([AZ])/g).join('')}-Remote` }); });

这将获取发送到端点的组名(例如,通过将?group_name=Netflix放在/group/的末尾),并使用它从相应的组中获取commands的值。 该值( group.commands )是一个对象,该对象的键是我们将在遥控器布局上显示的名称( keystroke_names )。

注意:没有经验的开发人员不需要深入了解它的工作原理,但title的值使用了一些正则表达式来将我们的组/布局名称转换为首字母缩略词——例如,我们的 YouTube 遥控器将具有浏览器标题YT-Remote 。 这样,如果我们在手机上尝试之前在主机上进行调试,我们将不会让xdotool抓取远程控制浏览器窗口本身,而不是我们试图控制的那个。 同时,在我们的手机上,如果我们想为遥控器添加书签,标题会很漂亮和简短。

与我们之前遇到的res.render ,这一次发送它的数据以与模板views/group.pug 。 我们将创建该文件并用以下内容填充它:

 extends layout block header_injection script(type='text/javascript', src='/javascript/group-client.js') block content form(action="/group?group_name=" + group_name, method="post") each keystroke_name in keystroke_names input(type="submit", name="keystroke_name", value=keystroke_name, class="remote_button")

views/index.pug ,我们将覆盖views/layout.pug中的两个博客。 这一次,我们放入标头的不是 CSS,而是一些客户端 JavaScript,我们很快就会谈到。 (是的,在一个小气的时刻,我重命名了不正确的复数javascripts ......)

这里的主要content是由一堆不同的提交按钮组成的 HTML 表单,每个按钮对应一个keystroke_name 。 每个按钮使用它显示的按键名称作为它与表单一起发送的值来提交表单(发出POST请求)。

我们还需要在主样式表文件中添加更多 CSS:

 .remote_button { float: left; width: calc(100%/3); height: calc(100%/3); font-size: 12vh; }

早些时候,当我们设置端点时,我们完成了对请求的处理:

 return res.redirect(req.originalUrl);

这实际上意味着当浏览器提交表单时,Node.js 后端通过告诉浏览器返回到提交表单的页面(即主远程控制布局)来响应。 不切换页面会更优雅; 然而,我们希望最大限度地兼容陈旧的移动浏览器的怪异而美妙的世界。 这样,即使没有任何前端 JavaScript 工作,我们的 Node.js 后端项目仍然可以正常工作。

一小段前端 JavaScript

使用表单提交击键的缺点是浏览器必须等待,然后执行额外的往返:页面及其依赖项必须从我们的 Node.js 后端请求并交付。 然后,它们需要由浏览器再次呈现。

读者可能想知道这可能会产生多大的影响。 毕竟,页面很小,它的依赖非常少,我们最终的 Node.js 项目将通过本地 wifi 连接运行。 应该是低延迟设置,对吧?

事实证明——至少在运行 Windows Phone 8.1 和 Android 4.4.2 的旧智能手机上进行测试时——不幸的是,在快速点击以将播放音量提高或降低几个档次的常见情况下,效果非常明显。 这就是 JavaScript 可以提供帮助的地方,而无需取消我们通过 HTML 表单手动POST的优雅回退。

此时,我们的最终客户端 JavaScript(将放在public/javascript/group-client.js中)需要与旧的、不再支持的移动浏览器兼容。 但我们不需要太多:

 (function () { function form_submit(event) { var request = new XMLHttpRequest(); request.open('POST', window.location.pathname + window.location.search, true); request.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); request.send('keystroke_name=' + encodeURIComponent(event.target.value)); event.preventDefault(); } window.addEventListener("DOMContentLoaded", function() { var inputs = document.querySelectorAll("input"); for (var i = 0; i < inputs.length; i++) { inputs[i].addEventListener("click", form_submit); } }); })();

在这里, form_submit函数只是通过异步调用发送数据,最后一行阻止了浏览器的正常发送行为,从而根据服务器响应加载新页面。 该片段的后半部分只是等待页面加载,然后连接每个提交按钮以使用form_submit 。 整个事情都包裹在一个IIFE中。

最后的润色

在我们的 Node.js 后端教程代码的最终版本中,对上述代码段进行了许多更改,主要是为了更好地处理错误:

  • Node.js 后端现在检查发送给它的组的名称和击键以确保它们存在。 此代码位于一个函数中,可用于routes/group.jsGETPOST函数。
  • 如果他们不这样做,我们会使用 Pug error模板。
  • 前端 JavaScript 和 CSS 现在使按钮在等待服务器响应时暂时呈灰色轮廓,当信号一路通过xdotool并顺利返回时变为绿色,如果没有按预期工作则为红色.
  • 如果 Node.js 后端死了,它将打印一个堆栈跟踪,考虑到上述情况,这种情况不太可能发生。

欢迎读者在 GitHub 上仔细阅读(和/或克隆)完整的 Node.js 项目。

Node.js 后端教程,第 5 步:实际测试

是时候在与运行npm start和电影或音乐播放器的主机连接到同一 wifi 网络的实际手机上试用它了。 只需将智能手机的网络浏览器指向主机的本地 IP 地址(后缀为:3000 ),这可能通过运行hostname -I | awk '{print $1}'最容易找到。 在主机上的终端中hostname -I | awk '{print $1}'

Windows Phone 8.1 用户可能会注意到的一个问题是,尝试导航到类似192.168.2.5:3000的内容会弹出错误消息:

标题为“不支持的地址”的 Windows Phone 错误消息的屏幕截图,其中显示“Internet Explorer Mobile 不支持此类地址,无法显示此页面。

值得庆幸的是,没有必要气馁:只需添加http://前缀或添加尾随/即可获取地址,无需进一步投诉。

遥控器布局选择画面。

在那里选择一个选项应该会给我们带来一个工作遥控器。

“通用/音乐播放器”远程控制屏幕。

为了更加方便,用户可能希望调整其路由器的 DHCP 设置以始终为主机分配相同的 IP 地址,并为布局选择屏幕和/或任何喜欢的布局添加书签。

欢迎拉取请求

可能不是每个人都会完全喜欢这个项目。 以下是一些改进的想法,对于那些想要进一步挖掘代码的人:

  • 调整布局或为其他服务(如迪士尼 Plus)添加新布局应该很简单。
  • 也许有些人更喜欢“轻模式”布局和切换选项。
  • 退出 Netflix,因为它是不可逆的,真的可以使用“你确定吗?” 某种形式的确认。
  • 该项目肯定会受益于 Windows 支持。
  • xdotool的文档确实提到了 OSX——这个(或者这个)项目可以在现代 Mac 上运行吗?
  • 对于高级休闲,一种搜索和浏览电影的方式,而不必选择单个 Netflix/Amazon Prime Video 电影或在计算机上创建 YouTube 播放列表。
  • 一个自动化测试套件,以防任何建议的更改破坏了原始功能。

我希望您喜欢这个 Node.js 后端教程,并因此获得了改进的媒体体验。 快乐的流媒体和编码!

相关:构建 Node.js/TypeScript REST API,第 1 部分:Express.js