Closed95

ハンズオンNode.jsを進める【第7章 データストレージ】

ハガユウキハガユウキ
  • 前章ではTodo管理アプリケーションを作った。このアプリケーションではTodoをオブジェクトで表現していた。このオブジェクトは、アプリケーションを実行するNode.jsプロセスのメモリ上にしか存在しない。
  • 一度アプリケーションを停止すれば、アプリケーション実行中にTodo一覧に対して適用してきた変更は全て消えてしまう。
  • また、clusterモジュールを使ってアプリケーションをマルチプロセス化した時に、複数プロセス間でToDo一覧を共有するのも難しくなる。
  • データを永続化したり、複数プロセス間で簡単に共有したりするには、Node.jsプロセスとは独立したデータストレージが必要である。この章ではデータストレージの種類と、そのデータストレージをNode.jsでどのように扱うかを見ていく。
ハガユウキハガユウキ

データストレージに対する操作の抽象化

複数のデータストレージを扱う際に、毎回そのデータストレージ特有のコードを書くのはめんどくさい。なので、データストレージに対する操作をインターフェースとして抽象化して、データストレージごとにこのインターフェースの実装を用意する。
こうすることで、アプリケーションはデータストレージの具体的な操作や名前を知る必要がなく、抽象だけ知っていればOKの状態になる(データストレージごとの処理や条件分岐をアプリケーション側に書かなくて済む)。

複数の似たような対象から抽象を作るには、データストレージから共通の性質を抜き出す(もしくは共通の性質を無理やり作る)
もしくは目的ベースで抽象を作る

ハガユウキハガユウキ

これはOCP(open-closed principle)と呼ばれるソフトウェア設計の原則に沿ったものである。
「拡張に対して開いていて、修正に対して閉じている状態のこと」

このインターフェースで複数のデータストレージに対する操作を抽象化すれば、新しいデータストレージが増えても、アプリケーション側のコードを修正することなく(修正に対して閉じている)、データストレージの詳細を知らなくても簡単に新しいデータストレージに対応させることができる(拡張に対して開いている)。

これがもし、拡張に対して閉じていて修正に対して開いている場合(つまり、インターフェースを挟まないで1: Nの関係になっている)、何か新しいデータストレージを追加しようとした際に、アプリケーション側のコードを修正しないといけないし(修正に対して開いている)、データストレージの詳細を知らないとアプリケーション側で実装できない問題がある(拡張に対して閉じている)。

ハガユウキハガユウキ

それぞれのデータ操作に対応したメソッドを持つインターフェースを定義すれば良い。
また、データストレージに対する操作はNode.jsのプロセスの外とのやり取り、すなわちI/Oである。Node.jsの世界ではI/Oはノンブロッキングに操作するのが原則なため、これらのメソッドは全て非同期であるべきである。

ハガユウキハガユウキ

Todo管理アプリケーションではデータストレージに対する操作を次のようなインターフェースで定義する

- All()
  - 全てのToDoを取得する
  - ToDoの配列で解決されるPromiseインスタンスを返す
- findById(id: number)
  - idとマッチするタスクを取得する
  - タスクで解決されるPromsieインスタンスを返す
- whereByStatus(status: "progress" | "completed")
  - StatusでフィルタリングしてToDoを取得する
  - ToDoの配列で解決されるPromiseインスタンスを返す
- create(todo)
  - 引数に与えたToDoを作成する
  - 新しいtodoで解決されるPromiseインスタンスを返す
- update(id, todo)
  - 第一引数で指定したIDのTodoに、第二引数のオブジェクトをマージして更新する
  - 更新後のTodoで解決されるPromiseインスタンスを返す
  - 指定されたIDのToDoが存在しない場合、nullで解決されるPromiseインスタンスを返す
- destory(id)
  - 引数で指定したIDのToDoを削除する
  - 削除したToDoのIDで解決されるPromiseインスタンスを返す
  - 指定されたIDのToDoが存在しない場合、nullで解決されるPromiseインスタンスを返す
ハガユウキハガユウキ

todoってこんな感じだったか

const todos = [
  { id: 1, title: "ネーム", status: "completed" },
  { id: 2, title: "下書き", status: "progress" },
];

とりあえずStoreインターフェースは定義した

import { Todo, TodoStatus } from "~/models";

// goみたいに大文字で定義する必要なかったわ
export interface Store {
  all(): Promise<Todo[]>;
  findById(id: number): Promise<Todo>;
  whereByStatus(status: TodoStatus): Promise<Todo[]>;
  create(todo: Todo): Promise<Todo>;
  update(id: string, todo: Todo): Promise<Todo> | Promise<null>;
  destroy(id: string): Promise<string> | Promise<null>;
}
ハガユウキハガユウキ

プロセスについてはこの記事を見た方が早い
https://zenn.dev/yukihaga/scraps/bdf8ca20268e97

ハガユウキハガユウキ

いいかげんTSじゃないとしんどいから(実行時エラーが防げないのとどんな値が入っているかわからん)、express * TSの環境作るか。できたら、Next.jsを単体で入れてみたい。進んできたらprisma, mysqlコンテナ, sqlliteコンテナの環境を作るか

ハガユウキハガユウキ

expressってRails的なフレームワークだと勝手に思ってたけど、実際触ってみるとGoのGinやRubyのシナトラみたいに、意外と軽めのフレームワークだってことを知ってびっくりした(Railsみたいに色んな機能が最初からデフォルトで入っている感じじゃなくて、ミドルウェアで適宜追加していく感じ)

ハガユウキハガユウキ

データストレージ

データストレージってなんやねんって思って調べてみた

コンピュータ・データ・ストレージは、デジタル・データを保持するために使用されるコンピュータ・コンポーネントと記録媒体から構成される技術である[1]。コンピュータの中核機能であり、基本的な構成要素である[1]:15-16

https://en.wikipedia.org/wiki/Computer_data_storage

なるほど。要はデジタルデータを保持するための技術ってことか。

ハガユウキハガユウキ

これ一瞬必要なのかなって思ったけど、webpack.config.jsのtargetを設定すれば問題なかった。
デフォルトでは、webpack.config.jsのtargetはwebである
https://github.com/Richienb/node-polyfill-webpack-plugin#readme

ハガユウキハガユウキ

nodemon導入したらいけた。

おそらく、webpack --watchのコマンドのプロセスとnodemonのプロセスが同時に動いているから、変更を検知しつつ、本番に反映できたと思う。

↓ nodemon導入前

ps
  PID TTY           TIME CMD
16232 ttys000    0:02.02 -zsh -g --no_rcs
14460 ttys001    0:03.06 -zsh -g --no_rcs
98326 ttys001    0:00.92 npm run watch
98344 ttys001    0:06.45 webpack
98350 ttys003    0:00.80 -zsh -g --no_rcs

↓ nodemon導入後

ps
  PID TTY           TIME CMD
14460 ttys001    0:03.11 -zsh -g --no_rcs
99248 ttys001    0:00.59 npm start
99261 ttys001    0:00.01 sh -c -- webpack --watch & nodemon dist/main.js
99262 ttys001    0:05.43 webpack
99263 ttys001    0:00.30 node /Users/yuuki_haga/repos/node/node-practice/chapter_7/node_modules/.bin/nodemon dist/main.js
99265 ttys001    0:00.20 /Users/yuuki_haga/.nodenv/versions/18.9.1/bin/node dist/main.js
98350 ttys003    0:00.83 -zsh -g --no_rcs
ハガユウキハガユウキ

psコマンド

psは自分自身が現在実行中のプロセスを表示する
ps uでどのユーザーがいつ実行したかも表示してくれる。
全部のユーザーが実行したプロセスを見たいなら、ps auxを実行すれば良い。

https://uxmilk.jp/52328

ハガユウキハガユウキ

tsconfigでパスエイリアスを設定した

これで相対パスインポートを防げる

↓ tsconfig.json

    "baseUrl": "./",
    // pathsオプションは、baseUrlで指定したディレクトリからの相対パスを、特定のエイリアスにマッピングするために使用します。
    // 上記の例では、~/*パターンにマッチするすべてのモジュールパスを、./src/ディレクトリ内にある対応するファイルに解決します。
    "paths": {
      "~/*": ["./src/*"]
    }
ハガユウキハガユウキ

リンター

リンターは、プログラムを静的に解析し、バグや問題点を発見するツールのこと。
リンターを使って、問題点を解析することを「リントする(lint)」と呼ぶ。

TypeScriptコンパイラは型のチェックが充実しています。型の側面から問題点を発見するのが得意です。一方、ESLintはインデントや命名規則などのコーディングスタイルや、どのようなコードを書くべきか避けるべきかの意思決定、セキュリティやパフォーマンスに関する分野でのチェックが充実しています。どちらも相互補完的な関係です。

なるほど、TSコンパイラは型チェックに関心があるが、ESLintはインデントや命名規則などのコーディングスタイル(コーディング規約)や、どのようなコードを書くべきか避けるべきかに関心がある。

ハガユウキハガユウキ

ESLintを使ってみる

  1. eslintのインストール
  2. eslintがインストールされたかのバージョンチェック
  3. eslintの設定ファイル(.eslintrc.js)をプロジェクトルートにtouchで作る。
  • この.eslintrc.jsはcommonJSのモジュールシステムを採用している
  1. .eslintrc.jsという設定ファイルが設定する。
  2. 終了
ハガユウキハガユウキ

ESLintでチェックを実行するには、eslintコマンドを起動するだけ。
eslintコマンドは第一引数に、チェック対象のファイル名やディレクトリ名を指定する。
ディレクトリ全体を指定したかったら、ディレクトリ名を指定する。

ハガユウキハガユウキ

eslintでこのコードを実行すると以下のようなエラーが出る。
↓ リント対象のファイル

const hello_world = "HelloWorld"
console.log(hello_world)

↓ リント結果

npx eslint src

/Users/yuuki_haga/repos/node/node-practice/chapter_7/src/hello-world.js
  1:7   error    Identifier 'hello_world' is not in camel case  camelcase
  1:33  error    Missing semicolon                              semi
  2:1   warning  Unexpected console statement                   no-console
  2:25  error    Missing semicolon                              semi

✖ 4 problems (3 errors, 1 warning)
  2 errors and 0 warnings potentially fixable with the `--fix` option.
ハガユウキハガユウキ

行を見てみる。
最初の項目はエラーが出た行と列の番号を表している。
2つ目の項目は重大度を表している。
3つ目の項目は、問題点の説明を表している。
4つ目の項目は、そのエラーを出したeslintのルールのルール名を表示している。

ハガユウキハガユウキ

eslintのルールの中では、自動修正に対応しているものがある。(てことは自動修正に対応していないルールもある)

ESLintでコードを自動修正するには、eslintコマンドに--fixオプションをつける。

ハガユウキハガユウキ

semiというルールは自動修正に対応していたけど、camelcaseとうルールは自動修正に対応していなかった。

ハガユウキハガユウキ

Shareable configを導入する

eslintのルールはあまりにも多いので、sharable configを利用する。
sharable configは誰かが設定したルールのプリセットである。これを導入すると、自分でルールで設定する手間が省ける。

有名なshareable configのひとつに、ESLint公式が公開しているeslint:recommendedがあります。これを導入すると、Rulesの一覧でチェックマークがついているルールが一括して有効化されます。これは公式が提供してるため有名ですが、有効になっているルールが少ない

ハガユウキハガユウキ

airbのeslint-config-airbnb-baseというsharable configが人気だそう。

ついでに以下のパッケージもインストールしておく

  • eslint-plugin-import
    • importの順番をルール化して、自動で整列してくれる
  • eslint-plugin-unused-imports
    • 未使用のimportを自動で削除してくれる

もし、rulesを使わないなら、消す。

ハガユウキハガユウキ
  extends: ["airbnb-base"],

これでsharable configは導入できた。ちゃんと予想してたエラーは出たのだが、シングルよりかはダブル使いたいんよなあ

npx eslint src

/Users/yuuki_haga/repos/node/node-practice/chapter_7/src/hello-world.js
  1:7   error    Identifier 'hello_world' is not in camel case  camelcase
  1:21  error    Strings must use singlequote                   quotes
  1:33  error    Missing semicolon                              semi
  2:1   warning  Unexpected console statement                   no-console
  2:25  error    Missing semicolon                              semi

✖ 5 problems (4 errors, 1 warning)
  3 errors and 0 warnings potentially fixable with the `--fix` option.
ハガユウキハガユウキ

ルールを上書きしたいなら、ログを見て上書きしたいルールのルール名を見て、それをrulesプロパティで設定すれば良い。

ルールを部分的に無効化する

.eslintrc.jsで設定した規約はプロジェクト全体に及びます。コードを書いていると、どうしても規約を破らざるをえない部分が出てくることがあります。その場合は、コードのいち部分について、ルールを無効化することもできます。

部分的にルールを無効にするには、その行の前にコメントeslint-disable-next-lineを追加します。たとえば、次の例ように書いておくと、変数名hello_worldがキャメルケースでなくても、ESLintは警告を出さなくできます。

.eslintrc.jsで設定した規約はプロジェクト全体に及ぶ。
elint-disable-next-line ルール名でルールを無効化できそう。

ハガユウキハガユウキ

ESLintではTypeScriptをチェックできない

ESLintでは、TypeScriptはチェックできない。これを補うのが「TypeScript ESLint」である。
これを導入するとESLintでTypeScriptがチェックできるようになる。

ハガユウキハガユウキ

TypeScript ESLintは2つのパッケージで構成されている。

@typescript-eslint/parserは、ESLintにTypeScriptの構文を理解させるためのパッケージである。
@typescript-eslint/eslint-pluginは、TypeScript向けのルールを追加するパッケージである。
(ESLintの200以上のルールに加えて、TypeScript ESLintを導入すると、100以上のルールが追加される)

どんなルールが追加されるかは、このドキュメントを見ればOK
https://typescript-eslint.io/rules/

ハガユウキハガユウキ

TypeScript ESLintの設定ファイルを作る

TypeScript ESLintを動かすためには、次の2つの設定ファイルを、プロジェクトのルートに作る必要がある。

  • tsconfig.eslint.json(TSでESLintを動かしたい場合、このファイルが必要。)
  • .eslintrc.js(これは昔から必要だった。typescript eslint用の設定を追加する必要がある)
ハガユウキハガユウキ

tsconfig.eslint.json

TypeScript ESLintは、チェック時に型情報を利用するために、TypeScriptコンパイラを使います。その際のコンパイラ設定をtsconfig.eslint.jsonに書きます。コンパイラ設定は、tsconfig.jsonの内容をextendsで継承しつつ、上書きが必要なところだけ記述していきます。

TypeScript ESLintはTSコンパイラを使うから、TSコンパイラの設定を
tsconfig.eslint.json内で継承させる必要がある。

{
  "extends": "./tsconfig.json"
}

tsファイルに加えて、ESLintの設定ファイル.eslintrc.js自体もESLintのチェック対象に含めたいので、allowJsの追加とincludeの上書きする

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "allowJs": true
  },
 // TypeScript ESLintでチェックする対象は、includeに追加していく必要がある
  "include": ["src", ".*.js", "*.js"]
}
ハガユウキハガユウキ

tsconifg.eslint.jsonが正しく設定されているかは、以下のコマンドを実行して出力を確認する

npx tsc --showConfig --project tsconfig.eslint.json
ハガユウキハガユウキ

tsconfig.eslint.jsonって要は、eslintに特化させたtsコンパイラの設定ファイルってことか

ハガユウキハガユウキ

eslintのエラーチェックは実際にnpx eslint .をしないとできなかった。どんなエラーが出ているかもわからんかったし。VSCodeのeslintのプラグインを導入することで、コードを書いた瞬間にeslintの規約に則ってないと、エラーが即座にコード上に表示される

ハガユウキハガユウキ

プリティアも導入しとくか

ハガユウキハガユウキ

Prettierとは

Prettierはコードのフォーマットを自動整形するツールである。
phpやjsxなどtsやjs以外にも色々サポートしている。

ハガユウキハガユウキ

eslint導入しただけでも、結構エラーを教えてくれる。
ホバーすると、eslintのどのルールによって指摘されているかを見れる。

ハガユウキハガユウキ

prettierコマンドを実行してみる。
コマンドはprettier [オプション] [ファイル/ディレクトリ]の形式で実行できる。
もし、ディレクトリを指定した場合、そのディレクトリ配下の全てのファイルがプリティアの対象になる。

ハガユウキハガユウキ

上のコードで試しにeslintの--fixをやってみたらこんな感じのコードになった。

↓ before

const hello = ( name: string) =>   {
console.log("Hello World "
 + name);
}

↓ after

const hello = (name: string) => {
  console.log(`Hello World ${
    name}`);
};
ハガユウキハガユウキ

確かに、もともと警告されていたエラー(余分なスペース等)は消えたが、バッククォートじゃねえんだよな感が強いな。

ハガユウキハガユウキ

プリティアだとここまで綺麗になる。

↓ before

const hello = ( name: string) =>   {
console.log("Hello World "
 + name);
}

↓ after(npx prettier src)

const hello = (name: string) => {
  console.log("Hello World " + name);
};
ハガユウキハガユウキ

npx prettier srcはファイルを整形した場合の結果を表示してくれるだけで、実際に上書きしたいなら、--writeオプションを追加する。

npx prettier --write src
ハガユウキハガユウキ
const hello = (name: string) => {
  // eslintのprefer-templateルールに引っかかる
  // prefer-templateルールは、文字列連結の代わりにテンプレート・リテラルを必要とするルール
  console.log("Hello World " + name);
};

prettierだけやったら、prefer-tempalteルールに引っかかりやがった。

ハガユウキハガユウキ

Prettierはデフォルトの整形ルールが定義されている。
プリティアの設定を変更したいなら、プロジェクトのルートに(.prettierrc)というプリティアの設定ファイルを作成する

↓ サンプルの.prettierrc

{
  "tabWidth": 2,
  "semi": true,
  "singleQuote": true
}
ハガユウキハガユウキ

Prettierはプロジェクトルートに.prettierrcが存在する場合は自動で設定ファイルを読み込んで整形ルールを設定してくれる。eslintがコマンド実行時のディレクトリの.eslitrc.jsを参照するのと似ているな

ハガユウキハガユウキ

上記の例ではJSONフォーマットで設定ファイルを作成しましたが、PrettierはJSON以外にもJS,YAML,TOMLのフォーマットをサポートしています。

そうなのか。

ハガユウキハガユウキ

Prettierをプロジェクトに導入する時に整形ルールについて悩む場合があるかもしれません。

整形ルールについては好みの部分も大きいので、プロジェクトの開発者で話し合って決めるようにしましょう。整形ルールを変更したい場合はprettierコマンドを実行するだけなので、後から簡単に変更できる前提で決めてしまっても問題ありません。

特にこだわりが無い場合は、Prettierのデフォルトの整形ルールをそのまま利用するのがオススメです。

ハガユウキハガユウキ

プリティアはダブルコートオンリーにしたいな。
あとは、自動改行の長さを制御したい。改行だらけだと縦にコード長くなってみづらいから。

ハガユウキハガユウキ

ESLintは、インデントや命名規則などのコーディングスタイル(コーディング規約)や、どのようなコードを書くべきか避けるべきかに関心がある。
Prettierは、コードをどのように整形するかに関心がある。

行末にセミコロンつけるや不要なスペースを消す等、ESLintにはコード品質のルールだけではなくスタイルルールも含まれている。ゆえに、ESLintとPrettierは解決する課題がかぶっている時があるので、違いが分かりづらい。PrettierとESLintは衝突する可能性もあるそう。

ESLintだけだと、改行を減らしてコードを柔軟かつ綺麗にフォーマットするのは難しい。
Pretteirだけだと、キャメルケースなど命名規則のエラーに対応できない
Prettierのドキュメントを見ると、コード品質に関する問題にはESLint、コードの書式に関する問題にはプリティアを使うと良いそう

ハガユウキハガユウキ

eslintとプリティアの衝突を避ける

Lintersには通常、コード品質ルールだけでなく、スタイルルールも含まれています。ほとんどの文体規則はPrettierを使うときには不要ですが、さらに悪いことに、Prettierと衝突する可能性があります!PrettierとLintersで説明したように、コードの書式に関する問題にはPrettierを使い、コード品質に関する問題にはLintersを使いましょう。

ハガユウキハガユウキ

幸いなことに、Prettierと衝突するルールや不要なルールは、あらかじめ用意されている設定を使って簡単にオフにすることができる:

ESLint側でオフにするってことか。

ハガユウキハガユウキ

eslintのvscodeプラグインはESLintのルールに違反してたら警告を出してくれるが、プリティアは何も警告とか出さんのね。

てか前にeslintで不要なスペースで警告出してくれたけど、プリティアのSharable Configを入れてからESLintが何もエラーを出さなくなったな。

ハガユウキハガユウキ

settings.jsonに以下のコードを入れる。eslintとprettierのプラグインを入れていたら、これでセーブ時にeslintとprettierが走るはず。

{
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  }
}
ハガユウキハガユウキ

よし、あとはDockefileとdocker-compose.ymlを導入したら一旦Node.js * TSの環境はOK。あとはNext.js単体と、mysqlコンテナ、sqlliteコンテナ、prismaを導入するか

ハガユウキハガユウキ

pakcage.jsonのtypeプロパティについて

package.json "type" フィールド#。
.jsで終わるファイルは、最も近い親package.jsonファイルが "module "という値を持つトップレベルフィールド "type "を含んでいる場合、ESモジュールとしてロードされます。

最も近い親 package.json は、現在のフォルダ、そのフォルダの親、そしてボリュームのルートに達するまで検索したときに最初に見つかった package.json として定義されます。

最も近い親のpackage.jsonに "type "フィールドがない場合、または "type "が含まれている場合、.jsファイルはCommonJSとして扱われます:"commonjs "を含む場合、.js ファイルは CommonJS として扱われます。ボリュームルートに到達してもpackage.jsonが見つからない場合、Node.jsはデフォルトの、"type "フィールドのないpackage.jsonを優先します。

.jsファイルのimport文は、最も近い親のpackage.jsonが "type "を含んでいる場合、ESモジュールとして扱われます:"module "が含まれていれば、ESモジュールとして扱われます。

package.jsonのtypeプロパティがなかったら、CommonJSのモジュールだと認識させられる。
typeプロパティがあって、moduleが指定されていたら、ES Modulesのモジュールとして認識される。
なるほどなあ。

https://nodejs.org/docs/latest-v13.x/api/esm.html#esm_enabling

ハガユウキハガユウキ

tsconfig.jsonのmoduleResolutionについて

モジュール解決とは、コンパイラがimportが何を指しているのかを突き止めるために行う処理のことである。import{a}from "moduleA "のようなimport文を考えてみよう。aの使用をチェックするために、コンパイラーはaが何を表しているかを正確に知る必要があり、その定義moduleAをチェックする必要がある。

この時点で、コンパイラーは "moduleAの形は?"と尋ねるだろう。これは簡単なことのように聞こえますが、moduleAはあなた自身の.ts/.tsxファイルや、あなたのコードが依存している.d.tsで定義されている可能性があります。

まず、コンパイラーはインポートされたモジュールを表すファイルを見つけようとします。そのために、コンパイラーは2つの異なる戦略のうちの1つに従います:ClassicまたはNodeです。これらのストラテジーはコンパイラに moduleA を探す場所を指示します。

それがうまくいかず、モジュール名が非相対的な場合("moduleA "の場合はそうです)、コンパイラーは周囲のモジュール宣言を見つけようとします。非相対的インポートについては次に説明する。

最後に、コンパイラーがモジュールを解決できなかった場合、エラーが記録されます。この場合、エラーはerror TS2307: Cannot find module 'moduleA'のようになります。

ハガユウキハガユウキ

コンパイラはモジュールのインポート文を見た際に、何のモジュールを指しているのかを突き詰めようとする。この際にコンパイラがモジュールをどのように解決するかをtsconfig.jsonのmoduleResolutionで指定する。もしコンパイラがモジュールを解決できなかったら、Cannot find moduleエラーが出るだけ。

ハガユウキハガユウキ

関数の引数で、void returnが期待されていたのにpromiseが返された。

router.getの引数にPromiseを返す関数を指定しちゃダメってことか。やっと理解した。

ハガユウキハガユウキ

ここまでで参考にした記事
↓ put使うならbodyは必要
https://stackoverflow.com/questions/1233372/is-an-http-put-request-required-to-include-a-body

↓ TSのインターフェースと型エイリアスは似ているけど、インターフェースの場合、型として使うだけではなくクラスに実装することができる。脳死でこれを選ぶってよりかは、用途に応じて適切なものを選べるのが良い。
https://typescriptbook.jp/reference/object-oriented/interface
https://typescriptbook.jp/reference/object-oriented/interface/interface-vs-type-alias

↓ Promise<T>のTにはfullfilledされた時のPromiseが持つデータの型を書く
https://typescriptbook.jp/reference/asynchronous/promise

↓ nodeでuuid
https://qiita.com/aosho235/items/79a7bb234af047ca5460

↓ express * TypeScrriptのボディははジェネリクスで指定できる
https://blog-mk2.d-yama7.com/2020/03/20200314_express-reqres-generics/

↓ TSでクラスの継承するなら、extendsキーワードが必要。この継承したサブクラスでコンストラクタを実行する場合、コンストラクタ内でsuperを呼び出して親コンストラクタを呼び出す必要がある。
https://typescriptbook.jp/reference/object-oriented/class/class-inheritance
https://www.typescriptlang.org/docs/handbook/classes.html

ハガユウキハガユウキ

ファイルシステム

  • ファイルにデータを直接書き込んで保存するのは、データの永続化の手段としては、最もシンプルである。
  • Node.jsではファイルシステムに対する操作はfsモジュールを使って行う。
    • fsモジュールはコールバックベースのAPIとPromiseベースのAPIがある
ハガユウキハガユウキ

fsモジュールのwriteFileは、デフォルトではファイルが存在しなければ新規に作成して、存在すればそのファイルの内容を上書きする。

ハガユウキハガユウキ

あるディレクトリに存在するファイルの一覧を取得するには、readdir()を使う。
あるファイルの内容を読み込むには、readFile()を使う。

> await fs.promises.readdir("todos")
[ '1.json' ]
>
> await fs.promises.readFile("todos/1.json")
<Buffer 7b 22 69 64 22 3a 32 2c 22 74 69 74 6c 65 22 3a 22 e3 83 8d e3 83 bc e3 83 a0 22 2c 22 73 74 61 74 75 73 22 3a 22 70 72 6f 67 72 65 73 73 22 7d>
ハガユウキハガユウキ

readFileはデフォルトではファイルの内容をBufferオブジェクトとして返す。
このオブジェクトはバイナリデータを扱うには便利だが、テキストデータを扱うなら、第二引数にエンコードを指定して文字列に変換する必要がある。

> await fs.promises.readFile("todos/1.json", "utf8")
'{"id":2,"title":"ネーム","status":"progress"}'
ハガユウキハガユウキ

fsモジュールでは、createReadStream()、createWriteStream()など、ファイルの読み書きをストリームのインターフェースで行えるメソッドも提供されている。ストリームを使うと、pipe()により柔軟に処理を連結できたり、ファイルの読み書きを複数回に分けるのが容易になったり、大量のデータがあるときにメモリを節約できる。

ハガユウキハガユウキ

ファイルパスを扱うなら、passモジュールが便利
パスの連結にはjoinを使う

ハガユウキハガユウキ

joinの良いところは、windowsやmac, linuxなどのパスの連結じの文字列の違いを吸収してくれるところ

ハガユウキハガユウキ

joinと似たようなメソッドでresolveがある。resolveはjoinとは違って絶対パスを返す。
そして、第二引数以降に「/」で始まるパスが与えられた時にjoin()は「/」を無視して連結するのに対して、resolve()はそれ以降の引数だけを使って絶対パスを構築する(つまり/を基準にして絶対パスを作っちゃうってこと)

ハガユウキハガユウキ
> const filePath = "path/to/file.txt"
undefined
> path.dirname(filePath)
'path/to'
> path.basename(filePath)
'file.txt'
>
> path.extname(filePath)
'.txt'
> path.parse(filePath)
{
  root: '',
  dir: 'path/to',
  base: 'file.txt',
  ext: '.txt',
  name: 'file'
}
> path.join("path1", "path2")
'path1/path2'
> path.join("foo/bar", "..", "/baz", "file.txt")
'foo/baz/file.txt'
> path.resolve("path1", "path2")
'/Users/yuuki_haga/repos/node/node-practice/chapter_7/path1/path2'
> path.resolve("foo/bar", "..", "/baz", "file.txt")
'/baz/file.txt'
>
ハガユウキハガユウキ

webpack.config.jsのoutputプロパティのmoduleプロパティをtrueにすると、バンドル結果がESModule形式になる。

  output: {
    filename: "main.js",
    path: path.resolve(__dirname, "dist"),
    // これでバンドル結果がESモジュール形式のコードになる
    module: true,
    chunkFormat: "module",
  },

https://webpack.js.org/configuration/output/

ハガユウキハガユウキ

pakcaage.jsonのmainプロパティには、モジュールの中で最初に呼ばれるスクリプトファイルを指定する。

https://qiita.com/dondoko-susumu/items/cf252bd6494412ed7847#main

シェルスクリプトは&で接続することで並列処理、&&では直列処理を行うことができます。しかし、コマンドを接続したことで冗長になってしまいました。使用する上で少々不便なので、このコマンドもnpm-scriptsへ追加してしまいます。

https://ics.media/entry/12226/

webpackでtsコンパイラを使っていると、~/modelsなどのパスを解決してくれないので、このプラグインをwebpackに導入する

https://www.npmjs.com/package/tsconfig-paths-webpack-plugin

ハガユウキハガユウキ

eslintルールのno-restricted-syntaxはfor ofとかではなくて、forEachとかを使えというルール
https://qiita.com/putan/items/0c0037ce00d21854a8d0

ts-nodeを使えばtsファイルをトランスパイルしなくても実行できる。ts-nodeはreplにも対応しているのか。
https://www.wakuwakubank.com/posts/726-typescript-ts-node/

ts-node の Unknown file extension ".ts" エラーを解決する
https://qiita.com/nyanchu/items/82903e0463fa9d558639

curlでjsonデータをapiにポストする

curl -X POST -H "Content-Type: application/json" -d "{"name" : "佐藤" , "mail" : "sato@example.com"}" localhost:5000/api/v1/users

https://qiita.com/Jazuma/items/5aa0a205f67c6dba9425

ハガユウキハガユウキ

リレーショナルデータベース

ファイルシステムと比べて、導入は簡単ではないが、データ構造を厳密に定義することができたり、どんなデータ構造で保存されているのかをスキーマファイルで確認できる。各データ項目に対してデータベースレベルで制約をつけることもできる。

データの整合性を保つ上で不可欠な処理の単位(トランザクション)を決めてデータを操作できるし、
同じデータに対する操作が複数同時に発生した場合に対処するためのロックの仕組みもリレーショナルデータベースには備わっている。

ハガユウキハガユウキ

Function.prototype.bind()とthis

db.run()をそのままdb.run()として実行すれば、このメソッドの中ではdbがthisとして使われる。
一方 util.promifisy(db.run)のような書き方をした場合、db.runというメソッド自体が一度変数(util.promisify()の引数)に割り当てられます。変数に割り当てられたdb.runを関数として実行すると、その中ではthisがundefinedになってしまう(元々runメソッド内のthisはdbを指し示していたが、runメソッド自体を変数に関数として代入したため、thisが指し示すものがなくなって、undefinedになっちゃう)。runメソッドの内部実装でthisを使っている可能性は大いにあるので、そのような場合、bindでthisを固定した方が良い。

node
Welcome to Node.js v18.9.1.
Type ".help" for more information.
> .editor
// Entering editor mode (Ctrl+D to finish, Ctrl+C to cancel)
// Entering editor mode (Ctrl+D to finish, Ctrl+C to cancel)
class MyClass {
  method1() {
    console.log("method1")
  }
  method2() {
    this.method1()
  }
}
undefined
> const ins = new MyClass()
undefined
> ins.method1()
method1
undefined
> ins.method2()
method1
undefined
> const insMethod = ins.method1
undefined
> insMethod()
method1
undefined
> const insMethod2 = ins.method2
undefined
> insMethod2
[Function: method2]
> insMethod2()
Uncaught TypeError: Cannot read properties of undefined (reading 'method1')
    at method2 (REPL3:7:10)
> .editor
// Entering editor mode (Ctrl+D to finish, Ctrl+C to cancel)
function add(a, b) {
  return (Number(this) || 0) + a + b
}

undefined
> add(1, 2)
3
> add.bind()(1, 2)
3
> add.bind(1)(1, 2)
4
> add.bind(1, 2, 3)()
6
> add.length
2
> add.bind(1, 2).length
1
> insMethod2.bind(ins)()
method1
undefined
>
ハガユウキハガユウキ

なるほど、bindを使う理由はthisに依存するコードで実行時エラーを出さないようにするためか。

ハガユウキハガユウキ
  • テーブルの作成
  • データの作成

mysqlだけやったらもうNode.jsは終わりにしよう。この知識普通に生きない可能性がある
Node.jsをサーバーサイドで採用した時のTSの設定が結構めんどい。やっぱNodeはフロントやな
普通にサーバーサイドで使えるようにするために、結構設定しないといけない

ハガユウキハガユウキ

dockerfileでなんで先にpackage.jsonだけコピーするのかなと思ったけど、調べてみるとdockerfileのキャッシュが関係しているそう。差分がなければキャッシュが使われるから、copy . . をnpm ciの前に書いちゃうと、キャッシュが効かなくて(差分があるならキャッシュは使わない)、ビルドするたびにnpm ciを実行する必要が出てきてしまう。

ハガユウキハガユウキ

appコンテナからdbコンテナとTCPコネクションを確立する場合は、dbコンテナのポートが必要。おそらくhostにdbと指定すると、このdbはドメインみたいな役割をしていて、dbコンテナのIPアドレスに変換してくれるのだと思う。

>   const connection = await mysql.createConnection({
       host: "db",
       user: "root",
       password: "password",
       database: "todo_development",
       port: 3306,
     });
ハガユウキハガユウキ

localhostからdbコンテナとTCPコネクションを確立する場合、dbコンテナのポートとマッピングしたポートば必要。

// Entering editor mode (Ctrl+D to finish, Ctrl+C to cancel)
const connection = await mysql.createConnection({
  host: "localhost",
  user: "root",
  password: "password",
  database: "todo_development",
  port: "3366",
})
ハガユウキハガユウキ

↓ docker-compose.yml

version: "3"
services:
  app:
    build: .
    tty: true
    # ポートマッピング
    ports:
      - "3000:3000"
    volumes:
      # バインドマウント
      - ./:/app
      # ボリュームマウント
      - node-volumes:/app/node_modules
  db:
    image: mysql:latest
    volumes:
      - todo_db_volume:/var/lib/mysql
      - ./db/init/sqls:/docker-entrypoint-initdb.d
      - ./db/conf:/etc/mysql/conf.d
    environment:
      MYSQL_ROOT_PASSWORD: "password"
    ports:
      - 3366:3306

volumes:
  node-volumes:
  todo_db_volume:
ハガユウキハガユウキ

データベースクラスに対してインターフェースが実装できたので、一旦クローズ
デプロイとテストに関してはいつかやってみたい。

ハガユウキハガユウキ

↓ データストレージ操作に対するインターフェース(このインターフェースがあれば、ファイル、DB、KVS全部いける)

import { Todo, TodoStatus } from "~/models";

// goみたいに大文字で定義する必要なかったわ
export interface Store {
  // TypeScriptでPromiseの型を指定する場合はジェネリクスを伴いPromise<T>と書きます。
  // TにはPromiseが履行された(fulfilled)ときに返す値の型を指定します。
  all(): Promise<Todo[]>;
  findById(id: string): Promise<Todo | null>;
  whereByStatus(status: TodoStatus): Promise<Todo[]>;
  create(todo: Todo): Promise<Todo | null>;
  updateStatus(todo: Todo): Promise<Todo | null>;
  destroy(id: string): Promise<string | null>;
}

↓ ファイルシステムのインターフェース実装

import { readdir, readFile, unlink, writeFile } from "fs/promises";
import { extname } from "path";
import { rootDir } from "~/constants";
import { Todo, TodoStatus } from "~/models";
import { Store } from "./store";

// インターフェースをクラスに実装するには、implementsキーワードを使う
// てかこのfileSysteってだいぶ実装がtodoに依存しているな。todo以外にも使えるようにしたい
export class FileSystem implements Store {
  async all() {
    const files = (await readdir(`${rootDir}/store/fileStore/todos`)).filter(
      (file) => extname(file) === ".json",
    );

    // Promise.all()の返すPromiseインスタンスは、引数の配列に含まれているPromiseインスタンスが全てfulfilledになった時にfulfilledになる。
    // 1つでもrejectedになると、その他のPromiseインスタンスの結果を待たずにrejectedになる。
    // fulfilledなPromiseインスタンスを返す時、その値は引数のPromiseインスタンスが解決された値を、引数で与えられた順番通り保持する配列になる。
    return Promise.all(
      files.map((file) =>
        readFile(`${rootDir}/store/fileStore/todos/${file}`, "utf8").then(
          (todo) => JSON.parse(todo) as Todo,
        ),
      ),
    );
  }

  async findById(id: string) {
    const todos = await this.all();
    return todos.find((todo) => todo.id === id);
  }

  async whereByStatus(status: TodoStatus) {
    const todos = await this.all();
    return todos.filter((todo) => todo.status === status);
  }

  async create(todo: Todo) {
    const newTodo = new Todo({
      id: crypto.randomUUID(),
      title: todo.title,
      status: TodoStatus.Progress,
    });

    await writeFile(`${rootDir}/store/fileStore/todos/${newTodo.id}.json`, JSON.stringify(newTodo));
    return newTodo;
  }

  async updateStatus(todo: Todo) {
    const fileName = `${rootDir}/store/fileStore/todos/${todo.id}.json`;
    return readFile(fileName, "utf8").then(
      (content) => {
        const newTodo = new Todo({
          ...JSON.parse(content),
          ...todo,
        });
        return writeFile(fileName, JSON.stringify(newTodo)).then(() => newTodo);
      },
      // ファイルやディレクトリが存在しない場合、エラーオブジェクトのcodeプロパティに"ENOENT"という文字列がセットされる
      (err) => (err.code === "ENOENT" ? null : Promise.reject(err)),
    );
  }

  async destroy(id: string) {
    return unlink(`${rootDir}/store/fileStore/todos/${id}.json`).then(
      () => id,
      (err) => (err.code === "ENOENT" ? null : Promise.reject(err)),
    );
  }
}

↓ データベースへのインターフェース実装(mysql2を利用した)

import { generateUUID } from "~/helpers";
import { Todo, TodoStatus } from "~/models";
import { initDBConnection } from "./db";
import { Store } from "./store";

export class TodoStore implements Store {
  async all() {
    const connection = await this.buildDBConnection();
    const [rows] = await connection.execute("SELECT * FROM `todos`");

    if (Array.isArray(rows)) {
      const newRows = rows as Todo[];
      // 空の配列に対してmapをやったら空の配列
      return newRows.map((row) => new Todo(row));
    } else {
      return [];
    }
  }

  async findById(id: string) {
    const connection = await this.buildDBConnection();
    const [rows] = await connection.execute("SELECT * FROM `todos` WHERE `id` = ?", [id]);

    if (Array.isArray(rows)) {
      const newRows = rows as Todo[];
      const todos = newRows.map((row) => new Todo(row));
      return todos.find((todo) => todo.id === id);
    } else {
      return null;
    }
  }

  async whereByStatus(status: TodoStatus) {
    const connection = await this.buildDBConnection();
    const [rows] = await connection.execute("SELECT * FROM `todos` WHERE status = ?", [status]);

    if (Array.isArray(rows)) {
      const newRows = rows as Todo[];
      // 空の配列に対してmapをやったら空の配列
      return newRows.map((row) => new Todo(row));
    } else {
      return [];
    }
  }

  async create(todo: Todo) {
    try {
      const connection = await this.buildDBConnection();
      const todoId = generateUUID();
      const newTodo = new Todo({ ...todo, id: todoId });
      const [rows] = await connection.execute(
        "INSERT INTO todos (id, title, status) VALUES (?, ?, ?)",
        [newTodo.id, newTodo.title, newTodo.status],
      );

      return newTodo;
    } catch (err) {
      return null;
    }
  }

  async updateStatus(todo: Todo) {
    try {
      const connection = await this.buildDBConnection();
      const [rows] = await connection.execute("UPDATE todos SET status = ? WHERE id = ?", [
        todo.status,
        todo.id,
      ]);
      return todo;
    } catch (err) {
      return null;
    }
  }

  async destroy(id: string) {
    try {
      const connection = await this.buildDBConnection();
      const [rows] = await connection.execute("DELETE FROM todos WHERE id = ?", [id]);
      return id;
    } catch (err) {
      console.error("エラーが出ました", err);
      return null;
    }
  }

  async buildDBConnection() {
    const connection = await initDBConnection();
    return connection;
  }
}

↓ エンドポイントの実装(storeは抽象(インターフェース)に依存させるようにしたので、どんなデータストレージが来ても、このファイルは変更せずに済む。ただし、依存性の注入(Di)している部分はどうしてもデータストレージが変更するたびにどうしても変更しないといけないので、DIコンテナとかで、依存性を一元管理できた方が良いかも

import express, { Request } from "express";
import { AppError, StatusErrorCode, StatusSuccessCode, Todo, TodoStatus } from "~/models";
import { TodoStore } from "~/store";

const store = new TodoStore();

export const router = express.Router();

router.route("/hello").get((req, res, next) => {
  res.status(StatusSuccessCode.Ok).send("World");
});

// 「/api/todos/」にマッチする場合
// クエリパラメータで絞り込めるようにした
router
  .route("/")
  // Todo一覧の取得
  .get((req, res, next) => {
    const { status } = req.query;

    if (status === TodoStatus.Completed || status === TodoStatus.Progress) {
      store
        .whereByStatus(status)
        .then((todos) => res.json(todos))
        .catch(next);
    } else {
      store
        .all()
        .then((todos) => res.json(todos))
        .catch(next);
    }
  })
  // Todoの新規登録
  .post((req: Request<unknown, unknown, { todo: { title: string } }>, res, next) => {
    const { title } = req.body.todo;

    if (!title || typeof title !== "string") {
      // Expressではミドルウェアでnextがエラー引数で呼び出されるか、同期処理がエラーを投げたとき(throw)に、そのエラーを捕虜してエラーハンドリングミドルウェアで処理する
      // 独自のエラーハンドリングミドルウェアを用意していないなら、デフォルトのエラーハンドリングミドルウェアでエラーハンドリングが行われる
      const err = new AppError({
        statusCode: StatusErrorCode.BadRequest,
        message: "title is required",
      });
      next(err);
    }

    const newTodo = new Todo({ title });
    store
      .create(newTodo)
      .then((todo) => res.status(StatusSuccessCode.Created).json(todo))
      .catch(next);
  });

router
  .route("/:id")
  // 指定したidでTodoを取得
  .get((req: Request<{ id: string }>, res, next) => {
    const { id } = req.params;
    store
      .findById(id)
      .then((todo) => res.status(StatusSuccessCode.Ok).json(todo))
      .catch(next);
  })
  // 指定したTodoを削除
  .delete((req: Request<{ id: string }>, res, next) => {
    const { id } = req.params;
    store
      .destroy(id)
      .then((id) => {
        if (id) {
          // res.endはボディを何も返さない時に使う
          res.status(StatusSuccessCode.NoContent).end();
        } else {
          next();
        }
      })
      .catch(next);
  });

router
  .route("/:id/status")
  // putはリソース自体を上書きする
  // putにボディは必要
  .put((req: Request<{ id: string }, unknown, { todo: { status: TodoStatus } }>, res, next) => {
    const { status } = req.body.todo;
    const { id } = req.params;
    store
      .updateStatus(new Todo({ id, status }))
      .then((todo) => res.status(StatusSuccessCode.Ok).json(todo))
      .catch(next);
  });

このスクラップは2023/09/26にクローズされました