1. ホーム
  2. node.js

Node/Expressでエンタープライズアプリを構築する

2023-10-01 16:27:37

質問

Node/Express/Mongo(実際にはMEANスタックを使用)を使用してエンタープライズアプリケーションを構成する方法を理解しようとしています。

2 冊の本を読み、(StackOverflow の同様の質問を含め)いくつかググった後、Express を使用して大規模なアプリケーションを構造化する良い例を見つけることができませんでした。私が読んだすべてのソースは、次のエンティティによってアプリケーションを分割することを示唆しています。

  • ルート
  • コントローラ
  • モデル

しかし、この構造の主な問題点は、コントローラが神のオブジェクトのようなものであり、コントローラ自身が req , res オブジェクトがあり、バリデーションを担当し ビジネスロジックを持つ に含まれています。

一方、ルートはエンドポイント(パス)をコントローラのメソッドにマッピングするだけなので、私には過剰なエンジニアリングのように思えます。

私はScala/Javaのバックグラウンドを持っているので、すべてのロジックをコントローラ/サービス/Daoの3層に分離する習慣があります。

私にとっては、以下の記述が理想的です。

  • コントローラは、WEB部分とのやりとり、すなわちマーシャル/アンマーシャル、いくつかの簡単な検証(required, min, max, email regexなど)だけを担当します。

  • サービス層は(実はNodeJS/Expressアプリでは見逃していました)、ビジネスロジックといくつかのビジネス検証のみを担当します。サービス層はWEB部分について何も知りません(つまり、WEBコンテキストからだけでなく、アプリケーションの他の場所からも呼び出すことができます)。

  • DAOレイヤーについては、私にとってはすべてクリアです。マングースのモデルは実際にDAOなので、ここが一番わかりやすいですね。

私が見た例はとてもシンプルで、Node/Expressの概念しか示していないように思いますが、ビジネスロジックや検証の多くを含む、実際の例を見てみたいです。

EDITです。

もうひとつはっきりしないのは、DTOオブジェクトが存在しないことです。この例を考えてみましょう。

const mongoose = require('mongoose');
const Article = mongoose.model('Article');
exports.create = function(req, res) {
    // Create a new article object
    const article = new Article(req.body);
    // saving article and other code
}

このJSONオブジェクトは req.body の JON オブジェクトが Mongo ドキュメントを作成するためのパラメータとして渡されています。これは私にとっては嫌な感じがします。私は生の JSON ではなく、具象クラスで作業したいのです。

ありがとうございます。

どのように解決するのですか?

コントローラは、あなたがそうであって欲しくないと思うまで、神オブジェクトです...

   - ズルズルって言わないんだね(╯°□°)╯︵┻━━━━━━━━━━━!!!!

解答に興味があるだけ? 飛び乗る 最新のセクション 結果発表です。 .

┬──┬─◡ノ(° -°ノ)ノ

回答に入る前に、この回答が通常のSOの長さよりずっと長くなってしまったことをお詫びします。コントローラだけでは何もできず、MVCパターン全体が重要なのです。そのため、最小限の責任で適切な分離されたコントローラを実現する方法を示すために、ルータ <-> コントローラ <-> サービス <-> モデルに関するすべての重要な詳細を調べることが適切であると感じました。

想定されるケース

まずは小さな仮想的なケースから。

  • AJAXでユーザー検索を提供するAPIを持ちたい。
  • Socket.ioを通じて同じユーザー検索を提供するAPIを持ちたい。

まずはExpressから。簡単でしょう?

routes.js

import * as userControllers from 'controllers/users';
router.get('/users/:username', userControllers.getUser);

コントローラ/ユーザ.js

import User from '../models/User';
function getUser(req, res, next) {
  const username = req.params.username;
  if (username === '') {
    return res.status(500).json({ error: 'Username can\'t be blank' });
  }
  try {
    const user = await User.find({ username }).exec();
    return res.status(200).json(user);
  } catch (error) {
    return res.status(500).json(error);
  }
}

では、Socket.ioの部分をやってみましょう。

これは socket.io の質問ではないので、定型文は省きます。

import User from '../models/User';
socket.on('RequestUser', (data, ack) => {
  const username = data.username;
  if (username === '') {
    ack ({ error: 'Username can\'t be blank' });
  }
  try {
    const user = User.find({ username }).exec();
    return ack(user);
  } catch (error) {
    return ack(error);
  }
});

うーん、何か臭うなぁ...。

  • if (username === '') . コントローラバリデータを2回書かなければなりませんでした。もし n コントローラバリデータがあったらどうでしょう?それぞれを2つ(またはそれ以上)コピーして最新の状態に保つ必要があるでしょうか?
  • User.find({ username }) が2回繰り返されています。それはもしかしたら、サービスかもしれません。

今、ExpressとSocket.ioの正確な定義にそれぞれ接続された2つのコントローラを書きました。Express と Socket.io の両方が後方互換性を持つ傾向があるため、これらはその寿命が尽きるまで壊れない可能性が高いです。 しかし の場合、再利用はできません。エクスプレスを Hapi ? コントローラを全て作り直す必要があります。

もう一つ、あまり目立たないかもしれませんが、悪い匂いがします...。

コントローラのレスポンスは手作りです。 .json({ error: whatever })

RLのAPIは常に変化しています。将来的には、あなたのレスポンスが { err: whatever } のような、あるいはもっと複雑な(そして有用な)ものを望むかもしれません。 { error: whatever, status: 500 }

始めよう (可能な解決策)

呼び出すことができない なぜなら、世の中には無限のソリューションが存在するからです。それは、あなたの創造性とニーズ次第です。私は比較的大きなプロジェクトでこの方法を使用していますが、うまく機能しているように見えますし、私が前に指摘したことすべてを修正しています。

最後まで面白くするために、Model -> Service -> Controller -> Routerとします。

モデル

質問の主題ではないので、モデルについての詳細には触れません。

以下のようなMongooseのモデル構造を持っているはずです。

models/User/validate.js

export function validateUsername(username) {
  return true;
}

mongoose 4.x のバリデータに適した構造については、以下の記事を参照してください。 はこちら .

models/User/index.js

import { validateUsername } from './validate';

const userSchema = new Schema({
  username: { 
    type: String, 
    unique: true,
    validate: [{ validator: validateUsername, msg: 'Invalid username' }],
  },
}, { timestamps: true });

const User = mongoose.model('User', userSchema);

export default User;

username フィールドを持つ基本的な User Schema と created updated mongoose-controlledフィールドがあります。

を入れた理由は validate フィールドを含む理由は、ほとんどのモデルのバリデーションをコントローラではなく、ここで行うべきであることに気づいてもらうためです。

Mongoose Schema はデータベースに到達する前の最後のステップです。誰かが MongoDB に直接問い合わせない限り、モデルの検証は必ず行われるので、コントローラに置くよりも安全です。先ほどの例のようにバリデータをユニットテストするのが簡単だと言っているわけではありません。

これについてもっと読む はこちら はこちら .

サービス

このサービスはプロセッサとして動作します。与えられたパラメータを処理し、値を返します。

ほとんどの場合(これを含む)、これは マングースモデル を返し プロミス (またはコールバック; ただし 私なら間違いなく はES6とプロミスを使います。)

services/user.js

function getUser(username) {
  return User.find({ username}).exec(); // Just as a mongoose reminder, .exec() on find 
                               // returns a Promise instead of the standard callback.
}

この時点で、あなたは不思議に思うかもしれません、いいえ catch ブロックはないのでしょうか?そうではありません。 クールなトリック を行うので、この場合はカスタムブロックは必要ありません。

他の場合、些細な同期サービスで十分です。同期サービスが決してI/Oを含まないことを確認してください。 Node.jsのスレッド全体が .

services/user.js

function isChucknorris(username) {
  return ['Chuck Norris', 'Jon Skeet'].indexOf(username) !== -1;
}

コントローラ

コントローラの重複を避けたいので、コントローラには a コントローラが必要です。

controllers/user.js

export function getUser(username) {
}

この署名は今どのように見えますか?きれいでしょう?usernameパラメータにしか興味がないので、以下のような無駄なものを取る必要はありません。 req, res, next .

不足しているバリデータとサービスを追加してみましょう。

コントローラ/user.js

import { getUser as getUserService } from '../services/user.js'

function getUser(username) {
  if (username === '') {
    throw new Error('Username can\'t be blank');
  }
  return getUserService(username);
}

まだきちんと見えますが、...どうでしょう? throw new Error はどうでしょう、アプリケーションがクラッシュしませんか?- しーっ、待てよ。まだ終わってませんよ。

この時点で、私たちのコントローラのドキュメントは以下のようなものになります。

/**
 * Get a user by username.
 * @param username a string value that represents user's username.
 * @returns A Promise, an exception or a value.
 */

に記載されている "値" とは何でしょうか? @returns ? 先ほど、サービスは同期でも非同期でもいいと言ったことを思い出してください ( Promise )? getUserService はこの場合、非同期ですが isChucknorris サービスはそうではないので、Promiseの代わりに単に値を返すことになります。

みんながドキュメントを読んでくれることを願っています。なぜなら、あるコントローラは他のコントローラとは異なる扱いが必要であり、その中には try-catch ブロックが必要だからです。

開発者 (私を含む) が最初に試す前にドキュメントを読むことを信用できないので、この時点で決断を下さなければなりません。

  • コントローラを強制的に Promise 戻る
  • 常にPromiseを返すサービス

⬑これでコントローラーの戻り値が一貫しないことが解決します(try-catchブロックを省略できることではありません)。

私は、最初の選択肢を好みます。なぜなら、コントローラはほとんどの場合、最も多くのPromiseを連鎖させるものだからです。

return findUserByUsername
         .then((user) => getChat(user))
         .then((chat) => doSomethingElse(chat))

ES6 Promise を使っている場合は、代わりに Promise の素晴らしいプロパティを利用することができます。 Promise はその寿命の間、非プロミスを処理することができ、 それでもなお Promise :

return promise
         .then(() => nonPromise)
         .then(() => // I can keep on with a Promise.

もし私たちが呼び出す唯一のサービスが Promise を使わないのであれば、自分で作ればいいのです。

return Promise.resolve() // Initialize Promise for the first time.
  .then(() => isChucknorris('someone'));

例に戻ると、次のようになります。

...
return Promise.resolve()
  .then(() => getUserService(username));

実際に必要なのは Promise.resolve() は必要ありません。 getUserService はすでにPromiseを返していますが、一貫性を持たせたいのです。

について疑問に思っているのであれば catch ブロックについて疑問に思っている方: カスタムな処理を行いたい場合を除き、コントローラ内でこれを使用することはありません。こうすることで、すでに組み込まれている 2 つの通信チャネル (エラーの場合は例外、成功の場合は return メッセージ) を利用して、個別のチャネルでメッセージを配信することができるようになります。

ES6 Promiseの代わりに .then の代わりに、より新しい ES2017 の async / await ( 現在では正式な ) をコントローラに追加します。

async function myController() {
    const user = await findUserByUsername();
    const chat = await getChat(user);
    const somethingElse = doSomethingElse(chat);
    return somethingElse;
}

お知らせ async の前にある function .

ルーター

ついにルーターが登場!やったー

つまり、まだユーザーに対して何も応答しておらず、あるのは常に Promise (を返すことがわかっているコントローラだけです。) そして、以下のような場合に例外が発生する可能性があります。 throw new Error is called または何らかのサービス Promise が壊れる。

ルーターは、統一された方法で、ペンションをコントロールし、クライアントにデータを返すもので、それは何らかの既存のデータであるでしょう。 null または undefined data またはエラー。

Router は複数の定義を持つ唯一のものになります。その数はインターセプターに依存します。この例では、API(Expressを使用)とSocket(Socket.ioを使用)です。

何をしなければならないかを確認しましょう。

ルータは (req, res, next)(username) . 素朴なバージョンでは、次のような感じです。

router.get('users/:username', (req, res, next) => {
  try {
    const result = await getUser(req.params.username); // Remember: getUser is the controller.
    return res.status(200).json(result);
  } catch (error) {
    return res.status(500).json(error);
  }
});

これはうまく機能しますが、このスニペットをすべてのルートにコピーペーストすると、膨大な量のコードが重複することになります。そこで、より良い抽象化を行う必要があります。

この場合、ある種の偽ルータークライアントを作成し、プロミスと n パラメータを受け取り、ルーティングを行い return タスクを実行します。

/**
 * Handles controller execution and responds to user (API Express version).
 * Web socket has a similar handler implementation.
 * @param promise Controller Promise. I.e. getUser.
 * @param params A function (req, res, next), all of which are optional
 * that maps our desired controller parameters. I.e. (req) => [req.params.username, ...].
 */
const controllerHandler = (promise, params) => async (req, res, next) => {
  const boundParams = params ? params(req, res, next) : [];
  try {
    const result = await promise(...boundParams);
    return res.json(result || { message: 'OK' });
  } catch (error) {
    return res.status(500).json(error);
  }
};
const c = controllerHandler; // Just a name shortener.

もっと詳しく知りたい方は、この トリック の他の返信で、この完全版について読むことができます。 React-Reduxとsocket.ioを使ったWebsocketについて ("SocketClient.js"セクション)を参照してください。

を使うと、ルートはどのようになりますか? controllerHandler ?

router.get('users/:username', c(getUser, (req, res, next) => [req.params.username]));

冒頭と同じように、きれいな1行で。

さらなるオプションの手順

コントローラの約束

ES6 Promisesを使用している方のみ適用されます。ES2017 async / await のバージョンは、すでに私には良いように見えます。

なぜか、私はこのバージョンで Promise.resolve() という名前を付けて初期化Promiseを構築するのが嫌いです。そこで何が起こっているのかが明確でないからです。

むしろもっとわかりやすいものに置き換えてほしい。

const chain = Promise.resolve(); // Write this as an external imported variable or a global.

chain
  .then(() => ...)
  .then(() => ...)

これで、あなたは chain がPromiseの連鎖の始まりであることがわかりました。あなたのコードを読む人は皆、そうでなくても、少なくともそれがサービス関数の連鎖であると仮定しています。

Expressエラーハンドラ

Express にはデフォルトのエラーハンドラがあり、少なくとも予期しないエラーを捕捉するために使用する必要があります。

router.use((err, req, res, next) => {
  // Expected errors always throw Error.
  // Unexpected errors will either throw unexpected stuff or crash the application.
  if (Object.prototype.isPrototypeOf.call(Error.prototype, err)) {
    return res.status(err.status || 500).json({ error: err.message });
  }

  console.error('~~~ Unexpected error exception start ~~~');
  console.error(req);
  console.error(err);
  console.error('~~~ Unexpected error exception end ~~~');


  return res.status(500).json({ error: '⁽ƈ ͡ (ुŏ̥̥̥̥םŏ̥̥̥̥) ु' });
});

さらに言えば、おそらく次のようなものを使うべきでしょう。 デバッグ または ウィンストン の代わりに console.error のように、より専門的な方法でログを処理することができます。

そしてこれが、これを controllerHandler :

  ...
  } catch (error) {
    return res.status(500) && next(error);
  }

捕捉されたエラーをExpressのエラーハンドラにリダイレクトしているだけです。

ApiErrorとしてのエラー

Error は、Javascriptで例外を投げるときに、エラーをカプセル化するためのデフォルトのクラスと考えられています。もし本当に自分自身の制御されたエラーだけを追跡したいのであれば、私ならおそらく throw Error から Express エラーハンドラ Error から ApiError に変更し、さらにそれをステータス・フィールドに追加することで、よりニーズに合ったものにすることができます。

export class ApiError {
  constructor(message, status = 500) {
    this.message = message;
    this.status = status;
  }
}

追加情報

カスタム例外

任意の時点で任意のカスタム例外を投げることができるのは throw new Error('whatever') を使うか、あるいは new Promise((resolve, reject) => reject('whatever')) . を使って遊ぶだけです。 Promise .

ES6 ES2017

それは非常に意見が分かれるところですね。IMO ES6 (あるいは ES2017 は、Nodeをベースにした大きなプロジェクトに取り組むのに適切な方法です。

まだ使用していない場合は ES6 の機能や ES2017 バベル トランスパイラです。

結果

これは、コメントや注釈のない完全なコードです(すでに前に示されています)。このコードに関するすべてのことは、該当するセクションまでスクロールして確認することができます。

ルーター.js

const controllerHandler = (promise, params) => async (req, res, next) => {
  const boundParams = params ? params(req, res, next) : [];
  try {
    const result = await promise(...boundParams);
    return res.json(result || { message: 'OK' });
  } catch (error) {
    return res.status(500) && next(error);
  }
};
const c = controllerHandler;

router.get('/users/:username', c(getUser, (req, res, next) => [req.params.username]));

コントローラ/ユーザ.js

import { serviceFunction } from service/user.js
export async function getUser(username) {
  const user = await findUserByUsername();
  const chat = await getChat(user);
  const somethingElse = doSomethingElse(chat);
  return somethingElse;
}

services/user.js

import User from '../models/User';
export function getUser(username) {
  return User.find({}).exec();
}

models/User/index.js

import { validateUsername } from './validate';

const userSchema = new Schema({
  username: { 
    type: String, 
    unique: true,
    validate: [{ validator: validateUsername, msg: 'Invalid username' }],
  },
}, { timestamps: true });

const User = mongoose.model('User', userSchema);

export default User;

models/User/validate.js

export function validateUsername(username) {
  return true;
}