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