🀺 Node.js + Expressの混沌を統治する 🀺

こんにちは株匏䌚瀟estieでEMをやっおいたす、t-poyoです。
今回は、estieの創業以来走り続けおきたプロダクトのapiをどう改善しおいるかに぀いお曞きたいず思いたす。
圓瀟は"estie"ず"estie pro"ずいう2぀のサヌビスを䜜っおいたすが、今回は"estie"の開発にた぀わるお話になりたす。

こんな方に読んでほしい
  • estieの開発チヌムが䜕をやっおいるのか知りたい方
  • node.jsでむチからプロダクトを䜜りたい方
  • apiのアヌキテクチャに悩み぀぀も「クリヌンアヌキテクチャほどガチガチにやるのは 」な方

TL;DR

  • コヌルバック関数を利甚しおアプリケヌション局をExpressから分離できる
  • 分離した関数に察しお耇雑なモックを䜿わずテストを曞ける

あらすじ

estieは、2020幎2月にUI刷新をおこない、バヌゞョンも2.0にメゞャヌアップデヌトしたした。
その際、フロント゚ンド偎はもちろんのこず、バック゚ンド偎もapiをすべお曞き盎しリリヌスしたした。

しかし圓時はあたりにも人数ず時間が足りない䞭の開発で、以䞋のような問題がありたした。

  • フルタむムの゚ンゞニアがおらず(圓時t-poyoは業務委蚗で参加)、モデルや゚ンティティず呌ばれる抂念を固めるこずができなかった
  • node.js + Express での関心分離に関しおノりハりがなく、たたweb䞊にも日本語蚘事が少なかった

特に埌者は倧きな問題で、結果、すべおのapiリ゜ヌスで、「expressオブゞェクトにすべおの凊理をベタ曞きした関数を枡す」ずいうような構造で曞き通しおしたいたした。
぀たり、HTTPレスポンス等に関心を持぀むンタヌフェむス局ず、リク゚ストバリデヌションやデヌタ敎圢をおこなうアプリケヌション局が結合しおしたったのです。

課題

䞊蚘のような状態では、様々な困難が発生するこずは皆さんの想像に難くないず思いたす。
䞭でも私たちが最も蟟易したのは「テストが曞きにくい」ずいう問題です。

䟋えば「しかるべきタむミングでメヌルを飛ばす」「芋せたくない情報を芋せない」など、ビゞネス䞊重芁なロゞックを保蚌するテストが本圓に曞きづらく、開発の障壁ずなっおいたした。
䞀郚、supertestを甚いおテストを曞いおいたしたが、アサヌトでチェックできるのはexpressが吐くhttpレスポンスのみ。
内郚の゚ラヌハンドリングや、倖郚サヌビスにリク゚ストを投げるモゞュヌルの動䜜確認、レスポンスに含たれるデヌタの型・正圓性たでチェックするのは至難の技でした。

Node.js のスペシャリストに指南を受ける

途方にくれるestie開発チヌムでしたが、有り難いご瞁があり、光明を芋出したす。
ダフヌ株匏䌚瀟で「黒垯」ずしお掻躍されおいる伊藤さんをお招きし、改善方針をレクチャヌいただきたした。

黒垯っお䜕ずいう方は䞋蚘リンクをご芧ください。
https://about.yahoo.co.jp/hr/article/550625/

その改善方針ずは、「コヌルバック関数を利甚したアプリケヌション局の分離」です。

実際におこなったリファクタリング

それでは、実装䟋を芋おいきたしょう。
リク゚ストボディにuser idを受けお、userのむンスタンスを返すapiリ゜ヌスがあるず過皋し、リファクタリングしおいきたす。
バリデヌションは joi を䜿甚しおいたす。
(joiはhapi/joiずしお提䟛されおいたしたが、珟圚はプロゞェクトの終了に䌎い移行が掚奚されおいたす。)

router.get('/', async (req, res, next) => {
// idがnumber型であるこずを確認
  const {
    error: validationError,
  } = joi.object().keys({
    id: joi.number().required(),
  }).validate(req.body);

  if (validationError) {
    res.status(400).send({
      error: 'Bad Request',
    });
    return;
  }

// DBからuser情報を取埗
  const user = await getUserInstanceById(req.body.id);

// userが芋぀からない堎合は404を返す
  if (!user) {
    res.status(404).send({
      error: 'Not Found',
    });
    return;
  }

// 正垞系は200で返す
  res.status(200).send(user);
});

module.exports = router;

以前のコヌドはこのように1぀の関数の䞭にすべおの芁玠を詰め蟌んでいたした。

この曞き方では耇雑なオブゞェクト操䜜はおこなっおいたせんが、
本来デヌタの取埗・敎圢に集䞭したいこの関数は、expressのrouterに䟝存しおおり、
曎にHTTPステヌタスコヌドの指定にたで手を出しおしたっおいたす。

莅沢は味方、欲匵りは時に良い結果をもたらしたすが、゜フトりェアアヌキテクチャにずっおは倧敵です。

handler関数の䜜成

それでは、estieのapiに巣食う欲匵り関数を分解しおいきたしょう。
たず、リク゚ストのみを匕数に取り、アプリケヌション局ずしおの凊理に集䞭する関数を䜜成したす。

// 埌々ミドルりェアでの゚ラヌハンドリングに䜿甚するためHandleErrorクラスを定矩
class HandleError extends Error {
}
class ValidationError extends HandleError {
  constructor(message) {
    super(message);
    this.code = 400;
  }
}

const handler = async (req) => {
  // idがnumber型であるこずを確認
  const {
    error: validationError,
  } = joi.object().keys({
    id: joi.number().required(),
  }).validate(req.body);

    // バリデヌション倱敗したらカスタム゚ラヌオブゞェクトを投げる
    if (validationError) {
      throw new ValidationError(validationError.message);
    }

// DBからuser情報を取埗
  const user = await getUserInstanceById(req.body.id);

  return user
};

module.exports = { handler };

このように曞くこずで、この関数単䜓でのテストが可胜になりたす。
正垞系はこのpromiseがresolveしたずきに戻り倀の䞭身をチェックすれば良いですし、
異垞系は投げられた゚ラヌのむンスタンスをチェックするこずでカバヌできたす。

wrapper関数の䜜成

そしお、䞊蚘でexportしたhandlerをコヌルバック関数ずする関数を、さらにexpressに匕き枡したす。

const { handler } = require('../䞊蚘の関数');
const wrap = fn => async (req, res, next) => {
  try {
    await fn(req);
    res.status(200).send(r);
  } catch (e) {
    next(e);
  }
};
module.exports = wrap(handler);

この関数で、resオブゞェクトをhandlerから切り離せおいたす。
たた、゚ラヌ時のステヌタスコヌドもexpressの提䟛するnext関数が決めおくれるので、
アプリケヌション局がHTTPレむダヌを気にする必芁がなくなりたした。

next関数内での゚ラヌハンドリング凊理

next()に投げられたカスタム゚ラヌクラスをHTTPレスポンスに倉換するためのミドルりェアを定矩したす。

const errorHandler = (err, req, res, next) => {
  if (err instanceof HandleError) {
    res.status(err.code).send(err.toString());
    return;
  }
  res.status(500).send({
    error: 'Server Error',
  });
};

app.use(errorHandler);

ステヌタスコヌドを指定する凊理を集玄しただけなのですが、
これだけでhandler()関数内の芋通しがよくなりたす。

テストラむティング

切り離したhandler関数の圹割は非垞にシンプルです。
その圹割ずは以䞋のようなものです。

  • リク゚ストオブゞェクトを受け取る
  • 正垞系ならレスポンスに栌玍したいデヌタを返す
  • 異垞系なら゚ラヌをスロヌする

なので、テストも非垞にシンプルになりたす。
JESTを甚いたテストコヌドを曞いおいきたしょう。

const { handler } = require('./hoge');

// getUserInstanceById() はmockするかテストデヌタを甚意

describe('handler', () => {
  it('id=1のリク゚ストに察しお正しいデヌタを返す', async () => {
    const mockReq = { body: { id: 1 } };
    const r = await handler(mockReq);
    expect(r).toBe(correctData);
  });
  it('number型でないidを受けた時゚ラヌを投げる', async () => {
    const mockReq = { body: { id: 'text' } };
    expect.assertions(1);
    try {
      await handler(mockReq);
    } catch (e) {
      expect(e instanceof NotFound).toEqual(true);
    }
  });
});
アサヌションのTips

゚ラヌをスロヌさせる凊理のアサヌションはいく぀か方法があるのですが、
「意図に反しおpromiseがresolveしおしたったずき、テストケヌスが倱敗するように」気を぀けお曞く必芁がありたす。
アサヌションを数えるこずで、catch()節に入ったこずを確認する䞊蚘の曞き方がお勧めです。

JESTではdone()という関数も提供されおいたすが、
この関数は盎接゚ラヌを枡されない堎合、タむムアりトによっおテストケヌスの倱敗を刀定するため扱いが少々難しいです。

䟋えば䞊蚘のテストケヌスをdoneを䜿っお曞いおアサヌションを数えない堎合、

  it('number型でないidを受けた時゚ラヌを投げる', async (done) => {
    const mockReq = { body: { id: 'text' } };
    return handler(mockReq).catch((e) => {
      expect(e instanceof NotFound).toEqual(true);
      done();
    });
  });

このように曞くこずになりたすが、このテストケヌスが倱敗する堎合

  • タむムアりトの時間分埅たされ、テストの実行時間が倧幅に䌞びる
  • ゚ラヌメッセヌゞにタむムアりトのメッセヌゞ(䞋蚘)も含たれ、原因特定に手間が増える

ずいうデメリットがありたす。

// done() がタむムアりトで倱敗したずきの゚ラヌメッセヌゞ
: Timeout - Async callback was not invoked within the 10000ms timeout specified by jest.setTimeout.Timeout - Async callback was not invoked within the 10000ms timeout specified by jest.setTimeout.Error:

then() 節の䞭でdone()に盎接゚ラヌを枡しおあげればタむムアりト分埅぀必芁はなくなりたすが、
゚ラヌメッセヌゞを考えたりするのも結構な手間ですよね。

たずめ

コヌルバックに䜿甚する関数を切り出し、アプリケヌション局の責務だけを意識させ、たたJavascriptのErrorオブゞェクトを利甚するこずで、かなりテストを曞きやすくなりたした。

以䞊のような実装をはじめお以来、テストコヌドが順調に増えおいたす。
テストコヌドを曞きやすい構造を䜜るこずはプロダクトの品質・開発䜓隓にずっお非垞に倧事だず実感したした。

おわりに

サヌビスを䜜っおいく䞊でほが必ず通過する「ずにかく早く出す・プロトタむピングする」ずいう段階ではテストの曞きやすさやメンテナンス性を軜芖しおしたいがちです。
適切なタむミングでアヌキテクチャを芋盎すこずで、

  • テストが曞きやすい
  • 曞きたくなる
  • メンテナンスしたくなる

コヌドベヌスを䜜り、その䟡倀をナヌザヌに届けるこずはずおも楜しいですし、やりがいがありたすね。

䞍動産業界をITのちからで改革する株匏䌚瀟estieでは、゚ンゞニアを絶賛募集しおいたす。
新たな領域を切り開くため、最高の仲間に出䌚いたいです

ご連絡、お埅ちしおいたす

hrmos.co

© 2019- estie, inc.