![]()
こちらの記事は、Sam Quinn氏により2019年 4月に公開された『 Bulletproof node.js project architecture 』の和訳です。
本記事は原著者から許可を得た上で記事を公開しています。
GitHub repositoryでの実装例: 2019/04/21 アップデート
始めに
Express.jsは、node.js のREST APIを作成するための優れたフレームワークですが、node.jsプロジェクトの設計方法についての手がかりを与えてくれるものではありません。
ばからしく聞こえるかもしれませんが、この問題は確かに存在するのです。
node.jsプロジェクト構造の正しい設計により、コードの重複を回避でき、安定性を向上させます。また、正しく設計されていれば、サービスをスケールさせるときに役に立つかもしれません。
この記事は、貧弱な構造のnode.jsプロジェクト、望ましくないパターン、そしてコードリファクタリングと構造の改善に無数の時間を費やし対応してきた、長年の経験に基づく広範囲な探求です。
本記事に合わせnode.jsプロジェクトのアーキテクチャを見直すために助けが必要な場合は、santiago@softwareontheroad.comにご連絡ください。
目次
- フォルダ構造
- 3層アーキテクチャ
- サービスレイヤー
- Pub/Subレイヤー
- Dependency Injection (DI) --※日本語で「依存の注入」
- ユニットテスト
- Cron ジョブと定期的なタスク
- 構成情報及びシークレット
- ローダー
例(GitHub repojitory)
フォルダ構造
以下はこれから話を進めていくnode.jsプロジェクトの構造です。
構築するすべてのnode.js REST APIサービスで、これをを使用します。では、それぞれのコンポーネントが何をするのか詳しく見ていきましょう。
│ app.js # App entry point
└───api # Express route controllers for all the endpoints of the app
└───config # Environment variables and configuration related stuff
└───jobs # Jobs definitions for agenda.js
└───loaders # Split the startup process into modules
└───models # Database models
└───services # All the business logic is here
└───subscribers # Event handlers for async task
└───types # Type declaration files (d.ts) for Typescript
単なるJavascript ファイルの並び替えをする方法ではありません..
3層アーキテクチャ
下記のアイデアは、「関心の分離」の原則に基づき、ビジネスロジックをnode.js APIルーティングから分離させるものです。
![]()
これはあなたがいつか、CLIツールでビジネスロジックを使用したい、定期的なタスク処理では十分でない、と思うようになったときのためです。
そしてnode.jsサーバーからそれ自体へのAPI呼び出しは、良いアイディアではありません...
![]()
コントローラーにビジネスロジックを入れてはダメです!!
express.jsコントローラーを使用してアプリケーションのビジネスロジックを保存したくなるかもしれませんが、これはすぐにスパゲッティコードになります。ユニットテストを書く必要があるときには、リクエストまたはレスポンスexpress.jsオブジェクトの複雑なモックを扱うことになります。
いつ応答を送信するべきかを区別するのは複雑です。 バックグランドで処理が続行され、その後 応答がクライアントに送信されたとしましょう。
以下は望ましくない例です。
route.post('/',async(req,res,next)=>{// This should be a middleware or should be handled by a library like Joi.constuserDTO=req.body;constisUserValid=validators.user(userDTO)if(!isUserValid){returnres.status(400).end();}// Lot of business logic here...constuserRecord=awaitUserModel.create(userDTO);deleteuserRecord.password;deleteuserRecord.salt;constcompanyRecord=awaitCompanyModel.create(userRecord);constcompanyDashboard=awaitCompanyDashboard.create(userRecord,companyRecord);...whatever...// And here is the 'optimization' that mess up everything.// The response is sent to client...res.json({user:userRecord,company:companyRecord});// But code execution continues :(constsalaryRecord=awaitSalaryModel.create(userRecord,companyRecord);eventTracker.track('user_signup',userRecord,companyRecord,salaryRecord);intercom.createUser(userRecord);gaAnalytics.event('user_signup',userRecord);awaitEmailService.startSignupSequence(userRecord)});
ビジネスロジックをサービスレイヤーで扱っている
このレイヤーは、ビジネスロジックが存在すべき場所です。
それは、node.jsに適用されるSOLID原則に従って、明確な目的(情報)を持つクラスのコレクションです。
このレイヤーには「SQLクエリ」のいかなるフォームも存在するべきではありません。データアクセス層を使用してください。
- express.jsルーターからソースコードを遠ざける
- リクエストまたはレスポンスオブジェクトをサービスレイヤーに渡さない
- ステータスコードやヘッダーなど、HTTPトランスポートレイヤーに関連するものをサービスレイヤーから返さない
例
route.post('/',validators.userSignup,// this middleware take care of validationasync(req,res,next)=>{// The actual responsability of the route layer.constuserDTO=req.body;// Call to service layer.// Abstraction on how to access the data layer and the business logic.const{user,company}=awaitUserService.Signup(userDTO);// Return a response to client.returnres.json({user,company});});
サービスが裏でどのように機能するかを以下に示します。
importUserModelfrom'../models/user';importCompanyModelfrom'../models/company';exportdefaultclassUserService{asyncSignup(user){constuserRecord=awaitUserModel.create(user);constcompanyRecord=awaitCompanyModel.create(userRecord);// needs userRecord to have the database id constsalaryRecord=awaitSalaryModel.create(userRecord,companyRecord);// depends on user and company to be created...whateverawaitEmailService.startSignupSequence(userRecord)...domorestuffreturn{user:userRecord,company:companyRecord};}}
Pub/Sub レイヤーも利用する
pub / subパターンは,、ここで提案されている従来の3層アーキテクチャを超えていますが、非常に便利です。
すぐにユーザーを作成できるシンプルなnode.js APIエンドポイントは、分析サービスであったり、あるいは電子メールシーケンスの開始などのサードパーティサービスを呼び出そうとするかもしれません。
遅かれ早かれ、そのシンプルな「作成」の操作はいくつかのことを実行し、1,000行にも及ぶコードがすべて1つの関数中で実行されることになるでしょう。
それは単一責任の原則に反しています。
したがって最初から責任を分離しておくほうが良く、それによってコードの保守性を維持できます。
importUserModelfrom'../models/user';importCompanyModelfrom'../models/company';importSalaryModelfrom'../models/salary';exportdefaultclassUserService(){asyncSignup(user){constuserRecord=awaitUserModel.create(user);constcompanyRecord=awaitCompanyModel.create(user);constsalaryRecord=awaitSalaryModel.create(user,salary);eventTracker.track('user_signup',userRecord,companyRecord,salaryRecord);intercom.createUser(userRecord);gaAnalytics.event('user_signup',userRecord);awaitEmailService.startSignupSequence(userRecord)...morestuffreturn{user:userRecord,company:companyRecord};}}
依存サービスへの呼び出し命令は、最良の方法ではありません。
ここでより良いアプローチは、イベントを発行することです。(例.「ユーザーはこのメールでサインアップしました」)
これで完了です。リスナーの仕事は、リスナーの責任としています。
importUserModelfrom'../models/user';importCompanyModelfrom'../models/company';importSalaryModelfrom'../models/salary';exportdefaultclassUserService(){asyncSignup(user){constuserRecord=awaitthis.userModel.create(user);constcompanyRecord=awaitthis.companyModel.create(user);this.eventEmitter.emit('user_signup',{user:userRecord,company:companyRecord})returnuserRecord}}
イベントハンドラー/リスナーを複数のファイルに分割できています。
eventEmitter.on('user_signup',({user,company})=>{eventTracker.track('user_signup',user,company,);intercom.createUser(user);gaAnalytics.event('user_signup',user);})
eventEmitter.on('user_signup',async({user,company})=>{constsalaryRecord=awaitSalaryModel.create(user,company);})
eventEmitter.on('user_signup',async({user,company})=>{awaitEmailService.startSignupSequence(user)})
awaitステートメントをtry-catchブロックにラップする、もしくは単に失敗処理として” unhandledPromise “プロセスとして処理することもできます。
依存性の注入 (D.I.)
依存性の注入(D.I.)、または制御の反転(IoC)は、クラスまたは関数の依存関係をコンストラクターに「注入」または渡すことで、コードの編成に役立つ一般的なパターンです。
このようにすることで、例えばサービスの単体テストを作成するときや、サービスが別のコンテキストで使用されるとき、「互換性のある依存関係」を注入する柔軟性が得られます。
D.I. なしのコード
importUserModelfrom'../models/user';importCompanyModelfrom'../models/company';importSalaryModelfrom'../models/salary';classUserService{constructor(){}Sigup(){// Caling UserMode, CompanyModel, etc...}}
手動でD.I. を実装したコード
exportdefaultclassUserService{constructor(userModel,companyModel,salaryModel){this.userModel=userModel;this.companyModel=companyModel;this.salaryModel=salaryModel;}getMyUser(userId){// models available throug 'this'constuser=this.userModel.findById(userId);returnuser;}}
これでカスタマイズされた依存関係を注入できます。
importUserServicefrom'../services/user';importUserModelfrom'../models/user';importCompanyModelfrom'../models/company';constsalaryModelMock={calculateNetSalary(){return42;}}constuserServiceInstance=newUserService(userModel,companyModel,salaryModelMock);constuser=awaituserServiceInstance.getMyUser('12346');
サービスが持つことのできる依存関係の量は無限で、新しく追加する際にいちいちインスタンス化をリファクタリングするのは、退屈でエラーが発生しやすいタスクです。
そういうわけでDI フレームワークが作成されました。
これにより、クラスで依存関係を宣言し、そのクラスのインスタンスが必要な場合には、 'Service Locator'を呼び出すだけでよくなります。
“ typedi“を用いてnode.jsにDIをもたらすnpmライブラリの例を見てみましょう。
“ typedi “の使用方法の詳細については公式ドキュメントをご覧ください。
注意: typescript での例
import{Service}from'typedi';@Service()exportdefaultclassUserService{constructor(privateuserModel,privatecompanyModel,privatesalaryModel){}getMyUser(userId){constuser=this.userModel.findById(userId);returnuser;}}
ここでtypediはUserServiceが必要とする依存関係を解決します。
services/user.js
import{Container}from'typedi';importUserServicefrom'../services/user';constuserServiceInstance=Container.get(UserService);constuser=awaituserServiceInstance.getMyUser('12346');
サービスロケーター呼び出しの乱用はアンチパターンです
Node.jsのExpress.jsでDIを使用する
express.jsでDIを使用する
これがnode.jsプロジェクトアーキテクチャのパズルの最後のピースです。
ルーティングレイヤー
route.post('/',async(req,res,next)=>{constuserDTO=req.body;constuserServiceInstance=Container.get(UserService)// Service locatorconst{user,company}=userServiceInstance.Signup(userDTO);returnres.json({user,company});});
Awesome! 素晴らしいプロジェクトになりました!
とても整理されていて、「今すぐ何かをコーディングしたい!」という気持ちになりますね。
サンプルのレポジトリにアクセスする
単体テストの例
DI とこれらの設計パターンを使用することにより、単体テストは非常にシンプルになります。
リクエスト / レスポンス オブジェクトのモックや “ require … “ などの呼び出しを行う必要はありません。
例:サインアップユーザーメソッドの単体テスト
tests/unit/services/user.js
importUserServicefrom'../../../src/services/user';describe('User service unit tests',()=>{describe('Signup',()=>{test('Should create user record and emit user_signup event',async()=>{consteventEmitterService={emit:jest.fn(),};constuserModel={create:(user)=>{return{...user,_id:'mock-user-id'}},};constcompanyModel={create:(user)=>{return{owner:user._id,companyTaxId:'12345',}},};constuserInput={fullname:'User Unit Test',email:'test@example.com',};constuserService=newUserService(userModel,companyModel,eventEmitterService);constuserRecord=awaituserService.SignUp(teamId.toHexString(),userInput);expect(userRecord).toBeDefined();expect(userRecord._id).toBeDefined();expect(eventEmitterService.emit).toBeCalled();});})})
Cronジョブと定期的なタスク
ここまででビジネスロジックがサービスレイヤーにカプセル化されたので、Cronジョブから使用するのが簡単になりました。
node.js のsetTimeout
や、その他の原始的なコード実行を遅らせる方法に頼るのではなく、ジョブやデータベース内での処理を永続化するフレームワークを使用するべきです。
こうすることで、失敗したジョブの制御や、成功した人のフィードバックを得ることができます。
node.js.
別の記事で、これらのグッドプラクティスについて既に書いていますので、こちらのガイドを確認してください。
構成情報及びシークレット
node.jsにおいて研鑽された概念である「Twelve-Factor App」に従えば、 APIキーとデータベース文字列の対応情報を保存するもっとも良い方法は、dotenvを使用することです。
決してコミットしてはいけない .env
ファイルを配置すると(ただし、リポジトリにデフォルト値で存在する必要があります)、 npm パッケージのdotenv
は
.env
ファイルをロードし、変数を node.js のprocess.env
オブジェクトに挿入します。
これでも十分かもしれませんが、もうワンステップ加えたいと思います。
npmパッケージの dotenv が 参照するディレクトリ(今回の例では /config)配下に" index.js "ファイルを配置し、.env
ファイルを読み込むことで 、変数を格納するオブジェクトを使用できます。これで構造とコードの自動補完を保持できます。
config/index.js
constdotenv=require('dotenv');// config() will read your .env file, parse the contents, assign it to process.env.dotenv.config();exportdefault{port:process.env.PORT,databaseURL:process.env.DATABASE_URI,paypal:{publicKey:process.env.PAYPAL_PUBLIC_KEY,secretKey:process.env.PAYPAL_SECRET_KEY,},paypal:{publicKey:process.env.PAYPAL_PUBLIC_KEY,secretKey:process.env.PAYPAL_SECRET_KEY,},mailchimp:{apiKey:process.env.MAILCHIMP_API_KEY,sender:process.env.MAILCHIMP_SENDER,}}
こうすることでprocess.env.MY_RANDOM_VAR
によってコード記述の氾濫を回避でき、自動補完によって環境変数の命名方法を知る必要がなくなります。
サンプルのレポジトリにアクセスする
ローダー
このパターンはW3Techマイクロフレームワークから取得しましたが、そのパッケージには依存していません。
このアイデアでは、node.jsサービスの起動プロセスをテスト可能なモジュールに分割することが可能です。
古典的なexpress.jsアプリの立ち上げ手順を見てみましょう
constmongoose=require('mongoose');constexpress=require('express');constbodyParser=require('body-parser');constsession=require('express-session');constcors=require('cors');consterrorhandler=require('errorhandler');constapp=express();app.get('/status',(req,res)=>{res.status(200).end();});app.head('/status',(req,res)=>{res.status(200).end();});app.use(cors());app.use(require('morgan')('dev'));app.use(bodyParser.urlencoded({extended:false}));app.use(bodyParser.json(setupForStripeWebhooks));app.use(require('method-override')());app.use(express.static(__dirname+'/public'));app.use(session({secret:process.env.SECRET,cookie:{maxAge:60000},resave:false,saveUninitialized:false}));mongoose.connect(process.env.DATABASE_URL,{useNewUrlParser:true});require('./config/passport');require('./models/user');require('./models/company');app.use(require('./routes'));app.use((req,res,next)=>{varerr=newError('Not Found');err.status=404;next(err);});app.use((err,req,res)=>{res.status(err.status||500);res.json({'errors':{message:err.message,error:{}}});});...morestuff...maybestartupRedis...maybeaddmoremiddlewaresasyncfunctionstartServer(){app.listen(process.env.PORT,err=>{if(err){console.log(err);return;}console.log(`Your server is ready !`);});}// Run the async function to start our serverstartServer();
ご覧のとおり、アプリケーションのこの部分は非常に煩雑化しています。
これに関して効果的な対処法は以下です。
constloaders=require('./loaders');constexpress=require('express');asyncfunctionstartServer(){constapp=express();awaitloaders.init({expressApp:app});app.listen(process.env.PORT,err=>{if(err){console.log(err);return;}console.log(`Your server is ready !`);});}startServer();
ここでローダーは、簡潔な目的を持つ単なる小さなファイルです
loaders/index.js
importexpressLoaderfrom'./express';importmongooseLoaderfrom'./mongoose';exportdefaultasync({expressApp})=>{constmongoConnection=awaitmongooseLoader();console.log('MongoDB Intialized');awaitexpressLoader({app:expressApp});console.log('Express Intialized');// ... more loaders can be here// ... Initialize agenda// ... or Redis, or whatever you want}
express ローダー
loaders/express.js
import*asexpressfrom'express';import*asbodyParserfrom'body-parser';import*ascorsfrom'cors';exportdefaultasync({app}:{app:express.Application})=>{app.get('/status',(req,res)=>{res.status(200).end();});app.head('/status',(req,res)=>{res.status(200).end();});app.enable('trust proxy');app.use(cors());app.use(require('morgan')('dev'));app.use(bodyParser.urlencoded({extended:false}));// ...More middlewares// Return the express appreturnapp;})
mongo ローダー
loaders/mongoose.js
import*asmongoosefrom'mongoose'exportdefaultasync():Promise<any>=>{constconnection=awaitmongoose.connect(process.env.DATABASE_URL,{useNewUrlParser:true});returnconnection.connection.db;}
ローダーの完全な例はこちらをご覧ください
最後に..
ここまでで、私達は実績のあるnode.jsプロジェクトストラクチャについて深く理解できました。要約すると下記のような内容でしたね。
- 3層アーキテクチャを使用する
- ビジネスロジックをexpress.jsコントローラーに入れない
- PubSubパターンを使用してバックグラウンドタスクのイベントを発行する
- 負担を減らすためDI を実装する
- パスワード、シークレット、APIキーなどを漏らさないために構成マネージャーを使用する
- node.jsサーバー構成を、個別にロードできる小さな- モジュールに分割する
リポジトリの例はこちらからご覧ください。
ちょっと待って!まだ続きがあります。
この記事を楽しんでいただけたら、他の有益な情報も見逃すことがないように、私のメーリングリストを購読することをお勧めします。
何かを売りつけるようなことはしません。約束します!
今後の投稿もお見逃しなく!きっと気に入ってくれると思います :)
この記事のような、すごい記事がたくさんあるので、是非私のブログに来てください。
翻訳協力
Original Author: Sam Quinn
Thank you for letting us share your knowledge!
この記事は以下の方々のご協力により公開する事が出来ました。
改めて感謝致します。
選定担当: @aoharu
翻訳担当: @upaldus
監査担当: @aoharu
公開担当: @posaune0423
私たちと一緒に記事を作りませんか?
私たちは、海外の良質な記事を複数の優秀なエンジニアの方の協力を経て、日本語に翻訳し記事を公開しています。
活動に共感していただける方、良質な記事を多くの方に広めることに興味のある方は、ぜひご連絡ください。
Mailでタイトルを「参加希望」としたうえでメッセージをいただく、もしくはTwitterでメッセージをいただければ、選考のちお手伝いして頂ける部分についてご紹介させていただく事が可能です。
※ 頂いたメッセージには必ずご返信させて頂きます。
ご意見・ご感想をお待ちしております
今回の記事は、いかがだったでしょうか?
・こうしたら良かった、もっとこうして欲しい、こうした方が良いのではないか
・こういったところが良かった
などなど、率直なご意見を募集しております。
いただいたお声は、今後の記事の質向上に役立たせていただきますので、お気軽にコメント欄にてご投稿ください。Twitterでもご意見を受け付けております。
みなさまのメッセージをお待ちしております。