オレオレはてなブックマーク「Neo's Hatebu 2」を作った
Heroku 無料枠終了のお知らせ。
- 過去記事 : 2018-11-17 はてなブックマークにノイズが多いのでオレオレはてなブックマーク「Neo's Hatebu」を作った
- 過去記事 : 2020-08-04 Neo's Hatebu に利き手モード、アクセスキー、件数表示機能を付けた
- GitHub リポジトリ : Neos21/neos-hatebu: オレオレはてなブックマーク
2018年11月に開発し、個人的には毎日使ってきた、俺専用のオレオレはてなブックマーク Web アプリ「Neo's Hatebu」。はてなブックマークのホッテントリのうち、自分が指定した「NG ワード」を含んでいるエントリや、特定ドメインのサイトのエントリなどを除外して表示するモノだ。自分は一瞬でも視界に入れたくない分野がいくつかあるので、そういうノイズを完全に非表示にするために作ったウェブアプリだった。
コチラは公開当初から Heroku で公開しており、Angular 製のフロントエンドに Express.js 製のバックエンド構成で、Sequelize という O/R マッパーを通して Heroku が提供する PostgreSQL でデータ永続化をしていた。Heroku は任意のウェブアプリをデプロイできる PaaS で、Heroku Postgres がありながら無料で利用できるというところで愛用してきたのだが、2022-08-25 付けで、その無料枠が廃止される旨がアナウンスされた。
Heroku の無料枠で稼動しているアプリは 2022-11-28 をもって停止されるそうで、以降は有料プランに切り替えないと使えなくなるそうだ。
自分はこのクラウド時代になってもまだ「何もかも全部無料で済ませたい」と文句を垂れている時代錯誤のオッサンなので、Heroku の機能には不満はなかったが、有料になるならということで、Heroku の退会、そして Neo's Hatebu の引越しを決意した。
そういう経緯で作ったのが Neo's Hatebu 2。見た目や機能はほとんど同じだが、引越しのために今回イチから作り直した。
- GitHub リポジトリ : Neos21/neos-hatebu-2: Neo's Hatebu 2
今回も俺専用なので、皆さんが参照できるようなサイトは公開していない。後述するが各自の環境でセットアップしてもらえば一応動かせる。
構成をよりシンプルにしたい…
世のクラウドサービスが次々と無料枠を廃止して有償化していく中で、未だ無料で利用できているのが OCI (Oracle Cloud) の Always Free 枠だ。自分はココで AMD プロセッサの VM.Standard.E2.1.Micro インスタンスを2台立てていて、ココでプライベートな Node.js 製ウェブサーバも動かしたりしていた。というワケで実行環境は OCI の IaaS VM で良いだろう。
既に OCI VM 上に Node.js がインストールされているので、とりあえず Neo's Hatebu を動かすだけならすぐに環境構築できたと思う。OCI に PostgreSQL をインストールするか、もしくは Sequelize の設定を変えて SQLite を使うようにしたりすれば、引越しは完了できたと思う。
しかし、Neo's Hatebu には他にも困っていたところがあって、そいつを今回解消したい思いもあった。というのは、node-sass
のバージョンが古くなると npm install
が失敗するようになり、定期的なバージョン追従が必要なところだ。node-sass
はインストール時にネイティブバイナリを GitHub Releases からダウンロードしてきているっぽいのだが、バージョンが古くなるとこのバイナリがダウンロードできず、npm install
が失敗するというのをちょくちょく経験してきた。Neo's Hatebu を開発していた2018年頃はまだ SCSS の学習なんかも楽しくて使っていたけど、最近は面倒臭さが上回り、一度作ったらできるだけ長く放置できるアプリを作りたいという思いが強いので、このタイミングで node-sass
を引っ剥がしてピュア CSS に直すことにした。デザインには Bootstrap 4 も使っていたが、それもボタンのデザインぐらいにしか活用できていなかったので、Bootstrap も引っ剥がすことにした。
Neo's Hatebu は当時、「Node.js を使ったフルスタックなウェブアプリを一人で作る」という勉強も兼ねて作り始めたモノだったので、この程度の規模のアプリにしてはいささか大仰な構成が目立つ。フロントエンドの Angular では NgModule での分割粒度が細かかったり、バックエンドの Express.js 側では自分一人しか使わない前提なのに「ログインユーザ」を DB 管理したりしていて、まぁ面倒臭い。若かりし僕はモダンな技術を駆使して俺色んなこと勉強してる~っていうのが楽しかったんだろうけど、オジサンはもうそういうところで頑張るのが面倒臭くなっちゃったんだ。4年間で変わったよね。w
というワケで、見た目や使用感はほぼそのままに、もう少しメンテナンスコストを減らせるように、依存モジュールを減らしてシンプルな構成にしたいと考えた。
バックエンドは NestJS・TypeORM・SQLite を採用
ところで、以前、FilmDeX というアプリを作ったのだが、このアプリの製作過程で NestJS というフレームワークを採用していた。
- 過去記事 : 2021-09-17 見た映画の感想を管理するアプリ「FilmDeX」を作った 前編
- 過去記事 : 2021-09-18 見た映画の感想を管理するアプリ「FilmDeX」を作った 後編
- GitHub リポジトリ : Neos21/filmdex: FilmDeX : 観た映画の情報・感想を一覧表示・管理するウェブアプリ
- 公開しているサイトはコチラ : FilmDeX
FilmDeX は最終的に、「非公開の Google スプレッドシート」をデータ永続化層に利用し、Angular フロントエンド部分のみを GitHub Pages で公開する、という形で「サーバサイド」をなくした。
しかし当初は Angular + NestJS のフルスタック構成でアプリを作っており、デプロイ先さえ見つかればそのまま公開できるところまで作り終わっていた。今回 Neo's Hatebu をリニューアルするにあたって、この Angular + NestJS という構成を採用することにした。
そして同時に、O/R マッパーは TypeORM、データベースは SQLite とした。これらも FilmDeX の製作時に検証済みで、かねてより DB は PostgreSQL まで大仰じゃなくても良いよなーなんて思っていたところだったので、ちょうど良い構成だと思った。
そういうことで、Neo's Hatebu 1 と Neo's Hatebu 2 の技術スタックは次のように変更することにした。
- フロントエンド : Angular → Angular (変更なし・バージョンアップは行う)
- スタイルシート : SCSS → CSS (Bootstrap などの CSS フレームワークも導入しない)
- バックエンド : Express.js → NestJS (元々の Express は JS で書いていたので、NestJS により TypeScript 化も果たした)
- O/R マッパー : Sequelize → TypeORM
- データベース : PostgreSQL → SQLite (シングルファイルで完結してシンプル!)
バックエンドの実装
実装は先にバックエンドから始めた。NestJS と TypeORM のお作法は大体分かっていたし、元の Express.js のコードを一つずつ書き直していくだけであった。
問題は自分の集中力で、「こういうモノを作れば良い」と分かりきっている状況で、それを「淡々と作るだけ」という体力・集中力が持たなくなっていた。マジでオッサンw。15分くらい頑張って書いて2・3時間ダラダラして、また5分くらい書いてその日はおしまい、みたいな感じでダラダラと作っていた。「結果が分かっている物事をわざわざやるのがダルい」という気持ちなのだが、かといって「答えがハッキリしない問題に取り組みたいのか」というとそうでもない。ただただ何もかもが面倒臭くて熱が入らなかった。w
元のコードと同じ RESTful API で、ユーザ名・パスワードで認証し、発行された JWT を使って各種 API コールを認可する、という普通の構成。ただし、パスワードに関しては以前やっていたようなハッシュ化などはせず、平文で環境変数から注入するだけ、というサボリ仕様。ユーザ名も環境変数で注入するので、「ユーザアカウント」みたいな概念はない。完全にお一人様なアプリ。w
バックエンドでは API 以外に、以下の2つの機能が重要だった。コレがあるために、常時起動できるサーバ・データ永続化層が必要なのであった。
- 初回起動時にマスタデータを DB 投入する
- 毎日定期的に「はてなブックマーク」をスクレイピングする
サーバ起動時に何かさせる、という処理は、NestJS の Service クラスに onModuleInit()
というメソッドを作ることで実現した。Angular でいう ngOnInit()
みたいなモノだ。
まず、TypeORM を NestJS の Module として導入しているので、サーバ起動時に SQLite の DB ファイルは自動的に生成される。その上で Service クラスの onModuleInit()
メソッドが自動発火するので、DB 内のマスタデータの有無を見て必要なマスタデータを投入するようにした。
定期的なスクレイピングについても、NestJS の @nestjs/schedule
パッケージを使えば、Service クラスに Cron Job スケジュールのメソッドを作れるようになるので、コイツでスクレイピング処理を呼び出すことにした。
ちなみに、Heroku 時代は30分で自動スリープしてしまう Dyno を叩き起こすために、Cron-Job.org という別サービスを使って定期的に HTTP リクエストを飛ばしていたのだが、OCI なら24時間稼動できるので、インスタンスを叩き起こす必要もなくなり、今回で Cron-Job.org もお役御免となった。w
実装機能としてはこんなところ。他に NestJS に関していえば、ユニットテスト機能はまるっと削除したのと、ESLint だけ残して Prettier は削除したのぐらいか。どうせ自分で手元で動かして動作確認しちゃうので、ユニットテストコードとか書かなくていいか、っていう。最初は Prettier にオートフォーマットさせようと思ったのだが、どうしても「読みやすくない改行」にされてしまう感じがあり、ESLint だけにした。コレで依存パッケージを減らせたし、設定ファイルもかなり少なくできたので、Git リポジトリを開いた時に見通しやすくなったと思う。
フロントエンドの実装
フロントエンドは同じ Angular で、v9 系と v14 系とでほとんど API に違いはなかったので、下手したらファイルコピーで持ってくればそのまま動きそうな勢いだったが、ほぼコピペはナシでイチから書き直した。その主な理由は、NgModule の分割を減らすことと、状態管理・API キャッシュ管理の手間を省くため、という点だった。
画面数はログイン画面を入れて5つ、エンティティ数も5つであり、それらはほぼ同時に参照・操作するので、Angular 内ではどうしても SharedModule が膨れ上がる構成になり、1画面ごとに NgModule を分割する効果がまずなかったのだった。
コンポーネントの状態管理は、今までコンポーネントのプロパティを直接操作する形でやってきたが、今回は BehaviorSubject
という RxJS の Subject を使うことにした。初めはその概念になかなか慣れなかったが、次のような所作で落ち着いた。
new BehaviorSubject()
を作ったらsubscribe()
はしないunsubscribe()
のし忘れによるメモリリークを防ぐため、コンポーネント HTML のasync
パイプを活用する
- 値を取り出したかったら
getValue()
で静的な状態を取り出して触る - 値を保存する時に
next()
を呼ぶ
1画面の状態 (ローディング中、とか、エラーメッセージ表示とか、一覧データの表示とか) を管理するのは一つの BehaviorSubject
で行い、それを .pipe(map())
で個別のプロパティに分割する、という形にした。
- 今までは、よくこんな実装にしてた
@Component({ ... })
export class MyComponent implements OnInit {
/** ローディング中かどうか */
public isLoading: boolean = true;
/** エラーメッセージ表示用 */
public errorMessage: string = '';
/** 表示するデータ */
public items: Array<Item> = [];
// ↑ こんな感じでプロパティを置いてた
/** 初期表示処理 */
public ngOnInit(): void {
this.isLoading = true; // ローディング中の表示にする
this.errorMessage = '';
this.items = [];
// 当時はまだ `async`・`await` も使っていませんでした
this.showList() // 初期表示処理
.then((items) => {
this.isLoading = false; // 「ローディング中」の表示をやめて正常終了する
this.items = items;
})
.catch((error) => {
this.isLoading = false;
this.errorMessage = error.toString(); // エラーメッセージを表示する
});
}
BehaviorSubject
を使うとこんな実装で良くなる
import { Component, OnDestroy, OnInit } from '@angular/core';
import { BehaviorSubject, map } from 'rxjs';
@Component({ ... })
export class MyComponent implements OnInit, OnDestroy {
/** ページデータの状態管理オブジェクト (コイツは HTML からは直接参照しないので `private` で定義する) */
private readonly dataState$ = new BehaviorSubject<{ isLoading?: boolean; error?: Error | string | any }>({ isLoading: true });
/** ローディング中か否か */
public readonly isLoading$ = this.dataState$.pipe(map(dataState => dataState.isLoading));
/** エラー情報 */
public readonly error$ = this.dataState$.pipe(map(dataState => dataState.error));
/** 初期表示処理 */
public async ngOnInit(): Promise<void> {
this.dataState$.next({ isLoading: true }); // ローディング中の表示にする
try {
await this.showList(); // 初期表示のための処理を呼ぶ
this.dataState$.next({ }); // 「ローディング中」の表示をやめて正常終了する・`isLoading: false` を書いたのと同じ
}
catch(error) {
// エラーメッセージを表示する・`isLoading: false` は書かなければ `undefined` で Falsy なのでコレで足りる
this.dataState$.next({ error });
}
}
/** コンポーネント破棄時 */
public ngOnDestroy(): void {
this.dataState$.unsubscribe(); // 一応 `unsubscribe()` を呼んでおく
}
}
この良さ、伝わるかしら。「ローディング中の表示にすると同時に、エラーメッセージの表示は消して…」「エラー表示をする時はローディング中の表示を消して…」みたいな、複数のフラグを一度に操作できるようになっている。以前のやり方だと「処理開始時点でプロパティの初期化はしたけど、処理完了時に操作し忘れた」とか、「エラーハンドリングの時だけ操作し忘れた」みたいなやらかしを起こしやすい。
一方、dataState$
で一元管理するやり方では、dataState$
がオブジェクトなので、値の設定が必須なプロパティは型エラーが出ることで「操作し忘れ」が発生しないし、「値があれば利用し、値がなければ undefined
で良い」というモノであれば ?
(オプションプロパティ) で宣言しておけば良い。上の例でも isLoading: false
はイチイチ書かずとも、「書かなかったら自動的に isLoading: false
と同義」という扱いになって、コード上の見通しも良くなる。
HTML 側も、エラー表示とかローディング表示とかは共通コンポーネントを作ることで実装はとても楽になった。複数画面で同じ実装で放っておける部品が多くなり、コードの行数としても減らせたので見通しはよくなったと思う。
API をコールして取得したデータは、コレまでも Service クラスのプロパティに蓄えてキャッシュしてはいたのだけど、コチラに関しても BehaviorSubject
を活用すると、値の取り出しとか、キャッシュの一部更新とかがやりやすくなった。RxJS は慣れるとメチャクチャ気持ち良い。w
あ、そうそう。HttpClient は今回も Promise 化して利用しているのだけど、.toPromise()
は非推奨になってしまったので、firstValueFrom()
という関数を利用するように変えた。
// 今まではコレで Promise 化できていたが現在は非推奨・今後 `.toPromise()` は廃止されてしまうようだ
const response = await this.httpClient.get<MyItems>(`/api/my-items/1`).toPromise();
// 次のように直すと同じ結果になる
import { firstValueFrom } from 'rxjs'; // ← コレをインポートする
const response = await firstValueFrom(this.httpClient.get<MyItems>(`/api/my-items/1`));
複数のエンティティ・画面でほぼ同じように実装しているので、ホントに型定義のためだけのボイラープレート的なコードが多いというか。クラス名が違うだけで Diff 取ったら9割同じ、みたいなコードが沢山出来ている。w
SCSS から CSS への移植は別に難しいことはないので割愛。Bootstrap を利用していた部分も少なかったので、必要なところだけちょちょっと移植しておしまい。
あと Angular CLI 周りでは、NestJS 側と同様にユニットテスト・E2E テストのコード・依存パッケージを削除し、.browserslist
とか細かい設定ファイルも極力減らした。結果、package.json
・angular.json
・tsconfig.json
・.eslintrc.js
の4つに減らせた。Lint 機能については Angular v9 頃までとは構造が変わっていて、$ ng lint
の初回実行時に @angular-eslint
関連のパッケージをインストールするようになっていた。
やろうと思えば ReactiveFormsModule
も使わずに実装できるくらいにはフォーム部品が少ないのだが、とはいえ [(ngModel)]
で処理するのも何かなーと思い、リアクティブフォームだけは使い続けている。
そんなワケで、フロントエンド側に関しては Angular CLI による必須のパッケージ以外は全く導入せず実装完了した。
Nx は使わなかった
ところで、Angular + NestJS という構成については Nx というモノレポ管理ツールがサポートしていて、フロントエンドとバックエンドの開発サーバを nx
コマンドで同時に処理したりできるようだ。
自分もとりあえず試しに Nx で雛形プロジェクトを立ち上げてみたのだが、Nx が間に挟まることで設定ファイルが増えたり、生の Angular・NestJS とはちょっと違うお作法が必要になる部分があったりして、何か面倒臭くなって採用しなかった。
Neo's Hatebu は別に RESTful API を疎結合に作る必要がないくらいの小規模なアプリではあるのだが、とはいえ Angular と NestJS は記法が似ているだけで別々の領域を担うフレームワークなので、それを無理に統合するくらいなら、最初から Rails みたいな全部入りの一つのフレームワークで作った方が良いんだと思う。tsconfig.json
の継承とかも発生して面倒臭くなったりするので、今回は client/
と server/
のそれぞれのディレクトリで個別に package.json
を管理することにした。あんまりモノレポの旨味は感じなかったなぁ…。
OCI 上で SQLite のインストールがコケた
実装は Mac と Windows WSL で進めてきて、特に問題なかったのでいよいよ OCI 上に載せてみよう、と思ったのだが、NestJS サーバを起動したところ、TypeORM が以下のエラーを出力してきた。
DriverPackageNotInstalledError: SQLite package has not been found installed. Try to install it: npm install sqlite3 --save
勿論、package.json
に sqlite3
は書いてある。色々試した結果、最初の $ npm install
を $ npm install --build-from-source
とすることで、SQLite のバイナリをダウンロードしてくるのではなくそのマシン上でビルドさせることで解決した。恐らく Always Free の CPU が AMD プロセッサなのが原因かな?/usr/bin/sqlite3
に最初から入っている SQLite は普通に使えてたから、npm パッケージとしての sqlite3
でコケるとは思わなくて少し焦った…。
でもつまづいたのはそれくらい。マシンスペックが低いので $ npm install
やビルドにイチイチ時間がかかるものの、起動後のサーバにアクセスしてウェブアプリを利用する分には全く性能問題は感じない。なんなら Heroku の時よりキビキビ動いてるかもw
実装は満足・自分の衰えを痛感
こうして作成した Neo's Hatebu 2 は問題なく動作しているので、Heroku の方は完全にサーバ停止し、退会処理をしてきた。4年間ありがとう、Heroku。
この記事もまとまりのない乱文をダラダラと書いてきたけど、とりあえず実装できたモノに関しては満足している。なるべくシンプルで依存パッケージの少ない構成にでき、メソッドやクラス単位のコード量も見通しよく削減できたと思う。
コーディング自体は楽しい。短いコードとか、簡潔でエレガントな方法で必要な処理を実装できると気分が良い。後日実装したファイル達を再び眺めて、コメントを読まずとも構造が理解しやすいなと感じると安心する。精神衛生的に良いコードを書けている、というのはストレスがないどころか、耳掃除的な快感すらある。w
だが、設計を考えたり、特定のライブラリの実装方法を調べたり、見慣れないエラーを解消したり、という開発の作業そのものを、1日に何時間もやる元気がなかなか出なくなった。常に疲れている。睡眠時間は毎日しっかり寝ているのに、毎日ずっと「徹夜明けの午後」みたいなボンヤリ具合。「あー何もしたくない、とにかく寝転がりたい」と、すぐ集中力が尽きる。自分の体力的な衰えが酷く、それがとにかく辛かった。実装自体は「楽しい」のに、続けたくなくなる、っていう不思議さ。うまく人に説明できないけど、この状態は物凄くストレス。やりたいと思っているのに、同時に「やりたくない」とも感じている、っていう。
コレが「プログラマ35歳限界説」の症状なのか?よく分からんが、とにかくしんどい。困ったねぇ~。
なんか暗くなっちゃったんで、最後に cloc
でコードの規模を比較してみた。dist/
や node_modules/
などは省いて、純粋にソースコードのみ。
- Neo's Hatebu 1 : フロントエンド
93 text files.
92 unique files.
11 files ignored.
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
TypeScript 41 230 694 1222
JavaScript 27 148 329 948
Sass 4 123 51 528
JSON 6 0 0 407
HTML 7 25 1 191
Markdown 1 12 0 25
YAML 1 0 0 3
-------------------------------------------------------------------------------
SUM: 87 538 1075 3324
-------------------------------------------------------------------------------
- Neo's Hatebu 2 : フロントエンド
57 text files.
57 unique files.
3 files ignored.
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
TypeScript 34 171 392 1027
CSS 8 77 75 489
HTML 10 30 8 202
JSON 3 5 0 183
JavaScript 1 0 1 30
Markdown 1 1 0 11
-------------------------------------------------------------------------------
SUM: 57 284 476 1942
-------------------------------------------------------------------------------
フロントエンドは 87 → 57 ファイル。コメントを除いたコード行も 3324 → 1942 行と、だいぶ削減できたようだ。コレで実現している画面や機能はほぼほぼ同じなのが良いところ。
- Neo's Hatebu 1 : バックエンド (Express.js)
30 text files.
30 unique files.
0 files ignored.
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
JavaScript 27 148 329 948
JSON 3 0 0 274
-------------------------------------------------------------------------------
SUM: 30 148 329 1222
-------------------------------------------------------------------------------
- Neo's Hatebu 2 : バックエンド (NestJS)
37 text files.
37 unique files.
3 files ignored.
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
TypeScript 30 159 375 779
JSON 3 5 0 98
JavaScript 1 0 3 31
Markdown 1 1 0 21
-------------------------------------------------------------------------------
SUM: 35 165 378 929
-------------------------------------------------------------------------------
Before のバックエンドは JavaScript (Node.js) で実装してトランスパイルなしに動かしていた。After の NestJS は TypeScript で書いており、型定義のためだけのファイルなども作ったためかファイル数が微増しているが、結果的にはソースコード行数が 1222 → 929 行と減っている感じ。
別に行数が全てではないので、行数削減のために三項演算子を乱用するようなことはしていないのだが、同じ機能を提供していながらコード行数が少ないということは、それだけ無駄なく簡潔に書けているという一つの指標にはなるのかなと思うので、良かったのかなーと思う。コードリーディングする立場としても、長文読まされるよりは短文を提示される方が理解度が高くなると思う。というのは、短文なら長文と比べて、同じ時間内に繰り返し読み直せるから、頭に定着しやすいであろうからだ。数ヶ月後、数年後の自分が今日のことをすっかり忘れていても、このコードを読んですぐに思い出せるような簡素な書き方が出来ていれば、一番「メンテナンスコストが低いコード」になったと思うのである。
以上。