『node.js での、「http.server@python3」相当』微メモ(3) – using ejs, express (2)

これこれの続きなのでタイトルを引き継ぐが、中身は既に「http.server@python3 相当」の範疇を超えてる。ejs を使うなら例えば jinja2 相当、express を使うなら例えば django 相当、なのはひとつ前と同じ。これのさらに発展形。

(2)から一気に実用になるコードの一歩手前まで進ませる。

「node.js + node.js で使えるサードパーティモジュール + node.js とは無縁の何か」の実際が「node.js + ejs + express + python」という点は (2)のままで、これに現実味を持たせるためには「この外部依存 python スクリプトが超ヘビー級である」ことに耐えるような作りにすること。まぁ要するに、ひとまずの最初のゴールはまさに make_yt_mylocalplaylist.py との連携とかね、たとえば。

ちょっと苦労したぞ。WEB サービス型のアプリケーションに不慣れな人は追っかけるのが大変かもしれんが頑張って。慣れてる人ならそんなに難しくはないと思う:

serv.js
 1 'use strict';
 2 
 3 var listenport = parseInt(process.argv[2], 10) || 8080;
 4 var fs = require('fs');
 5 var ejs = require('ejs');
 6 var express = require('express');
 7 var path = require('path');
 8 var app = module.exports = express();
 9 //const querystring = require('querystring');
10 /* 新しい node.js の場合は require('child_process') */
11 const subprocess = require('child_process');
12 
13 app.engine('.html', ejs.__express);
14 app.set('views', path.join(__dirname, 'views'));
15 app.set('view engine', 'html');
16 
17 //
18 // ブラウザから「/」をリクエストするとテンプレート「views/result.html」
19 // でレンダーするが、「result.html」はクライアントサイド jquery.ajax で
20 // aaaa を成功するまでリクエストし続ける、というやりとり、という例ね。
21 // (実行前に既に aaaa が出来てるなら消しといてね。)
22 //
23 let resfile = "aaaa";
24 app.get("/", (req, res) => {
25     res.render("result.html", {resfile: resfile});
26     // この get_users.py は時間がかかる、とする。
27     // (ただのサンプルなので get_users.py 内は単に sleep するだけ。)
28     // ゆえに、spawnSync ではなく非同期で。
29     const pyres = subprocess.spawn("py", ["get_users.py", resfile]);
30     // ↓pythonスクリプトの標準エラーをキャプチャしたけりゃ
31     //pyres.stderr.on("data", (data) => { console.log(data.toString()); });
32     pyres.on("close", (code) => {
33         console.log(`get_users.py exited(${code}).`);
34     });
35 });
36 app.get("/" + resfile, (req, res) => {
37     console.log(`/${resfile}`);
38     fs.readFile(resfile, function(err, data) {
39         if (!err) {
40             res.writeHead(200, {'Content-Type': 'application/json'});
41             res.write(data.toString());
42             fs.rm(resfile, (err) => {});
43             return res.end();
44         } else {
45             res.writeHead(404, {'Content-Type': 'text/html'});
46             res.write("Not Found.");
47             return res.end();
48         }
49     });
50 });
51 
52 /* istanbul ignore next */
53 if (!module.parent) {
54     app.listen(listenport);
55     console.log('Express started on port ' + listenport);
56 }

「py」が Windows 依存なので云々の件は(1)(2)のままね。

get_users.py
 1 # -*- coding: utf-8 -*-
 2 # CGI のマナーだのそう言ったことなど知ったことではない、ほんとにただのただの
 3 # python スクリプトだよ、sys.argv[1]で与えられたファイル名の json を作るだけの。
 4 # ただし「とても時間がかかるスクリプト」の模擬として、長時間の sleep をする。
 5 import time
 6 import json
 7 import io
 8 import sys
 9 
10 time.sleep(5);
11 with io.open(sys.argv[1], "w", encoding="utf-8", newline="\n") as fo:
12     json.dump([
13         {"user": "aka", "age": 1},
14         {"user": "shiro", "age": 5},
15         {"user": "kuma", "age": 30},
16         {"user": "hito", "age": 42},
17         {"user": "ro-jin", "age": 77},
18     ], fo)
19 sys.stderr.write("done.\n")
views/result.html
 1 <!DOCTYPE html>
 2 <html>
 3   <head>
 4     <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
 5     <link
 6       href="https://cdnjs.cloudflare.com/ajax/libs/tabulator/5.2.2/css/tabulator_site.min.css"
 7       rel="stylesheet">
 8     <script
 9       type="text/javascript"
10       src="https://cdnjs.cloudflare.com/ajax/libs/tabulator/5.2.2/js/tabulator.min.js">
11     </script>
12     <script>
13       var timerId = null;
14       var table = null;
15       function waitData() {
16           let datapath = "<%= resfile %>";
17           $.ajax(
18               {
19                   url: datapath,
20                   success: function(result) {
21                       console.log(result);
22                       table.setData(result);
23                       clearInterval(timerId);
24                   },
25                   error: function(xhr) {
26                       console.log(xhr);
27                       if (!timerId) {
28                           timerId = setInterval(waitData, 1000);
29                       }
30                   }
31               });
32       }
33       function setupPlaylist() {
34           table = new Tabulator("#playlist", {
35               "columns": [
36                   {
37                       "field": "user",
38                       "title": "user",
39                   },
40                   {
41                       "field": "age",
42                       "title": "age",
43                   }
44               ]
45           });
46           table.on("tableBuilt", function () {
47               waitData();
48           });
49       }
50       $(document).ready(setupPlaylist);
51     </script>
52   </head>
53   <body>
54     <div id="playlist"></div>
55   </body>
56 </html>

nodejsejsexpresspy_ss
ビューが「node.js ではなくて、ブラウザ側の javascript (ajax, jquery)」に依拠してる、てわけね、現実の問題ではほぼ必ずこれに近いことをすることになるだろう、てこと。なので今回のこの例が、かなり実用のための雛形のベースに近い実例、てこと。ただし、今回の「aaaa」という固定ファイル名でなくリクエストなどに応じた動的な値にする際のルーティングの問題だけは残ってるので注意。おそらくファイル名のプレフィクスやサフィックスの工夫だけでイケると思う。

ともあれこれが「適材適所で使いたい言語を使いつつの WEB サービスの一例」という道筋がだいたい付いたであろう、てことね。最後に Docker でまとめあげれば、「配布しやすいもの」となりハッピーだ、となるが、それは後日。


なお、今回のこれで個人的に苦労した点なんだけど、結論としては単なるワタシの誤解というか「頭の混乱」。こういうことをしやがってたのよあたしゃ:

1 app.get("/", (req, res) => {
2     res.render("result.html?file=aaaa");
3 }

これは「Error: Cannot find module ‘html?file=aaaa’」なんてことになるんだけれど、これね、「このエラーを解決しようとしなくていい」ことにしばらく気が付かなくてなぁ…。この render は「テンプレートファイル(views/result.html)」を持ってきてレンダーする、ということをしてるんであって、クライアントからのリクエスト(req)、クライアントへのレスポンス(res)とは全然関係ないんだから。テンプレートに html という拡張子をつけてるがゆえに、頭の中でついごっちゃにしちゃうんだよね。そしてこの混同は「デジャブ」なのよ。ほかの WEB フレームワークを使ってる時も良くやる。

「?file=aaaa」みたいなクエリパラメータのアプローチに拘るのもまぁいいんだけれど、最終的にはお見せしたように「テンプレートに渡すデータ」をそのまま使うアプローチにした(ハイライトしてる行)。

この問題を除けば、(1)→(2)→(3)というふうに順を追って来た甲斐あって、別に苦労はなかった。ただ Express のドキュメントがちょっと追っかけにくいんだよね。これは issues から質問を検索することで補うほうがいいかも、と思った。


2022-05-23 07:00追記:
「aaaa」みたいに独自命名規則を採用することにもメリットがあるんで、上で書いたのが悪いわけではないんだけれど、「aaaa.json」という名前をそのまま使えるんであれば、いわゆる「デフォルトルーティング」でやっちゃえばいいよなと:

serv.py (改)
 1 'use strict';
 2 
 3 var listenport = parseInt(process.argv[2], 10) || 8080;
 4 var fs = require('fs');
 5 var ejs = require('ejs');
 6 var express = require('express');
 7 var path = require('path');
 8 var url = require('url');
 9 var app = module.exports = express();
10 //const querystring = require('querystring');
11 /* 新しい node.js の場合は require('child_process') */
12 const subprocess = require('child_process');
13 var mime = require('mime-types');
14 
15 app.engine('.html', ejs.__express);
16 app.set('views', path.join(__dirname, 'views'));
17 app.set('view engine', 'html');
18 
19 //
20 // ブラウザから「/」をリクエストするとテンプレート「views/result.html」
21 // でレンダーするが、「result.html」はクライアントサイド jquery.ajax で
22 // aaaa.json を成功するまでリクエストし続ける、というやりとり、という例ね。
23 // (実行前に既に aaaa.json が出来てるなら消しといてね。)
24 //
25 let resfile = "aaaa.json";
26 app.get("/", (req, res) => {
27     res.render("result.html", {resfile: resfile});
28     // この get_users.py は時間がかかる、とする。
29     // (ただのサンプルなので get_users.py 内は単に sleep するだけ。)
30     // ゆえに、spawnSync ではなく非同期で。
31     const pyres = subprocess.spawn("py", ["get_users.py", resfile]);
32     // ↓pythonスクリプトの標準エラーをキャプチャしたけりゃ
33     //pyres.stderr.on("data", (data) => { console.log(data.toString()); });
34     pyres.on("close", (code) => {
35         console.log(`get_users.py exited(${code}).`);
36     });
37 });
38 app.get("*", (req, res) => {
39     console.log(`${req.url}`);
40     fs.readFile("." + url.parse(req.url, true).pathname, function(err, data) {
41         if (!err) {
42             res.writeHead(200, {'Content-Type': mime.lookup(req.url)});
43             res.write(data.toString());
44             fs.rm(resfile, (err) => {});
45         } else {
46             res.writeHead(404, {'Content-Type': 'text/html'});
47             res.write("Not Found.");
48         }
49         return res.end();
50     });
51 });
52 
53 /* istanbul ignore next */
54 if (!module.parent) {
55     app.listen(listenport);
56     console.log('Express started on port ' + listenport);
57 }

にしてもそもそも「*」を書く必要があること、もしくは「要求されたファイルの中身を返すだけ」を書く必要があること、は若干腑に落ちぬ。これのデフォルトくらい用意されてないんだろか?