Help us understand the problem. What is going on with this article?

仕様の変更に強いコードを書きたいよねって話

この記事は NIJIBOX Advent Calendar2019の13日目の投稿です。

背景

何かしらのロジックを作る際に、仕様変更に強いコードを書きたいぞい!ってエンジニアだったら思いませんか。今の仕様なら動くけど、もし仕様が変わり、そのために関数全書き直しとかしんどみが深すぎます。今回はこのしんどみを少しでも回避できるように柔軟なコードを書くぞい!って記事です。

ページネーションコンポーネントを例にしますが、なぜページネーションなのかというと僕が最近業務でページネーションを作り、かつ仕様の変更に強いコードの大切さを実感したからです。

そもそもページネーションとは

ページネーション(pagination)とは、日本語で丁付け、ページ割りという意味で、Web制作においては、検索結果一覧など、内容の多いページを複数のWebページに分割し、各ページへのリンクを並べてアクセスしやすくするために設置するものです。

「ページネーション実装5つのポイント!Webサイトの効果を高める優れたページネーションとは」より引用

スクリーンショット 2019-12-10 13.56.28.png

Google より引用

こういうやつですね。文章が長かったり、画像が多いページは、ページのレンダリングに時間がかかり、コンテンツが表示されるまでの時間が遅くなってしまうので、ユーザーの離脱率も高くなりがちです。ページを分割し、1ページあたりのコンテンツを少なくするのがこのページネーションです。
ページネーションといっても様々な種類があるのですが、今回は上記の画像のようなシンプルなものと思っていただければ良いです。

今回フォーカスするところ

ページネーションを構成するのは前に戻るボタン次へ進むボタンに、分割されたページへのリンクで構成されています。今回は分割されたページへのリンクを作るところに焦点を当てたいと思います。

以下のページネーションは、現在いるページが6ページ目でリンク表示数が10つ、かつ総ページ数が10ページ以上の時のものです。
現在いるページ、リンク表示数、総ページ数という3つの条件から1~10の数字の配列を取得できれば、ページネーションは実装できます。この配列を取得する関数(以下、getPageNums関数)を今回は作ったので紹介していきます。

スクリーンショット 2019-12-10 13.59.36.png

Googleより引用

書くこと

  • ページネーションのロジック部分のgetPageNums関数。
  • ロジックを考える時に気をつけた方が良いなと思ったこと。

参考にしたもの

手順

※ 以下currentは現在いるページ、totalは総ページ数

分量の多いコードはないか気をつけず、関数を愚直に実装した

以下のコードは僕が最初に何も考えずに条件を頭から満たすように作ったスパゲッティコードです。

Pagination.tsx
function getPageNums(current: number, total: number) { 
 const itemCount = Math.min(5, total); 
  const ranges = []; 
   let start = 1; 

   if (total < 5) { 
     start = 1; 
   } else if (current === 2) { 
     start = current - 1; 
   } else if (current === total - 1) { 
     start = current - 3; 
   } else if (current === total) { 
     start = current - 4; 
   } else if (current > 5) { 
     start = current - 2; 
   } 

   for (let i = start; i < start + itemCount; i++) { 
     ranges.push(i); 
   } 
   return ranges; 
}

上記コードはそもそも表示するページネーションのリンクの数が変更になった場合に、毎回この関数の中の数字を書き換えないといけないです。加えて、if文が乱立していて可読性が低いですね。完全に分量の多すぎるコードです。リーダブルコードの言葉を借りるなら、ダイオウイカ状態ですね。

また、ページネーションの仕様はページサイズが変更されるだけじゃなく、例えば1ページ目と最終ページ目が出るようになる、などの変更もあり、全てを実装だけで完結させることは無理です。
つまり、「愚直なコードを書くのではなく、仕様変更に強いコードを考えて定式化して実装すること」が大事と思います。良いコードというのは色んな側面があります。
例えば仕様変更に強い=変更容易性も一つの尺度だし、性能も一つの尺度だし、読みやすさっていうのもあります。

ということで上記コードをリファクタしていきたいと思います。

リファクタするぞい

正直ここが一番書きたくてこの記事を書こうと思いました。
リーダブルコードから得られることは素晴らしいということが言いたいです。そして同時に仕様変更につよつよのコードも書けます。

満たさなければいけないパターン

以下の条件をクリアできていれば良い。

# 一番最初の要件
- current = 3, total = 5
 [1, 2, 3, 4, 5] を返す
- current = 4, total = 6
 [2, 3, 4, 5, 6] を返す
- current = 4, total = 9
 [2, 3, 4, 5, 6] を返す
- current = 4, total = 8
 [2, 3, 4, 5, 6] を返す
- current = 2, total = 3
 [1, 2, 3] を返す

しかし、よくよく要件を整理すると、ページサイズが可変であり、最初は5件だったが、6件にしたいなどの要件が入りそうなことがわかったとします。
また、上記のスパゲッティコードには、振り返ってみると、マジックナンバーとして5件であることが組み込まれていました。そのため、ページサイズの変更という仕様変更に弱い実装であることがわかります。

ここが気が付きポイントで、ただ要件を満たすだけではなく、要件には明文化されていない可変な要素を予め考えることが大切というのがわかります。
これが 変更容易性 に気をつけるというところの一歩目です。

ここで表示するページサイズを可変にするため、 size という概念を作り、これを引数から渡せるように変更します。

- current = 3, total = 5, size = 5 の時
 [1, 2, 3, 4, 5] を返す
- current = 4, total = 6, size = 5 の時
 [2, 3, 4, 5, 6] を返す
- current = 3, total = 8, size = 6 の時
 [1, 2, 3, 4, 5, 6] を返す
- current = 4, total = 8, size = 6 の時
 [ 2, 3, 4, 5, 6, 7] を返す
- current = 4, total = 8, size = 3 の時
 [ 3, 4, 5 ] を返す

1.巨大な式を分割する

説明変数を導入する

式を簡単に分割するには、式を表す変数を使えばいい。この変数を「説明変数」と呼ぶこともある。式の意味を説明してくれるからだ。

「リーダブルコード 8章 巨大な式を分割する」 より引用

  • リンクの最初をstartとしているので、変更にも弱くなってしまっている。リンクの始まり、終わりと2つ用意すればスッキリします。
Pagination.tsx
 const start = "startの条件を書く";
 const end = "endの条件を書く";
  • 次に、sizeより小さい時とtotalより大きい時の条件に分け、ネーミングする。

sizeは表示するリンクの数、totalは総ページ数のこと

Pagination.tsx
  const lessThanSize = current - size / 2 < 0;
  const greaterThanTotal = current + size / 2 > total;

1度に複数のことをしない。

スパゲッティコードでは、1つのif文内で全ての条件をクリアしようとして、カオスな状況になってしまっています。(ぱっと見何やってんだ...?ってなる)説明変数を作ったのでこれを組み合わせて、整理しましょう。
加えて、sizeを2で割った数分増減させれば関数を定式化できます。

  • スコープを考慮して一時変数化してしまう。
Pagination.tsx
  const s = current - size / 2;
  const e = current + size / 2;
  • ページネーションのリンクの始まりと終わりに分けて考える。
Pagination.tsx
  const start = s < 0 ? 0 : e > total ? total - size : s;
  const end = s < 0 ? size : e > total ? total : e;

この関数でやるべきことを明確化する。

  • ゴールから考える。

返り値として欲しいのはstartからendまでの配列です。
そして、引数に必要なのは、現在いるページのcurrent、総ページ数のtotalです。

Pagination.tsx
function getPageNums(current: number, total: number) {
 //返り値に配列が欲しいので配列を作成する。1からtotalまでの配列だよね。
  const ranges = [...Array(total).keys()].map(n => n + 1);

 //startからendまでの配列をリターンする
  return ranges(start, end);
}

実際はこれだけでは、仕様変更に最強に強いというわけではなく、
例えば、総ページ数が1000とか10000になった場合といった、totalが大きくなった時に、メモリを大幅に使ってしまうという問題点があり、富豪的なコードと言えます。

また、ranges 部分に JS idiom 的な構文が多くて、ひと目見て何やってるかがわかりにくいです。

そこで ranges を作る関数を別な部分に切り出し、可読性と性能の両面を上げるようにします。次の段階にいくぞい!

2.range関数の切り出し

上記リファクタリングで、仕様変更に強い形にしました。表示するリンクの数が変更になるというあるあるの仕様変更に上記コードなら対応できます。

ここで可読性も良くなったとは思えますが、僕個人の主観に寄るところなので、数値的根拠を循環的複雑度を用いてみたいと思います。
また、複雑度に基づいて、関数を分割すると 凝集度が高くしやすいです。

循環的複雑度とは

ソースコードの一部の循環的複雑度は、ソースコード内の線形独立な経路の数である。実際、if文やfor文のような分岐点のないソースコードの場合、その複雑度は 1 であり、そのコードには1つの経路しかない。コードに1つのif文が含まれていれば、コードには2つの経路があることになる。つまり、一方はif文での条件が真となる場合の経路で、もう一方はそれが偽となる場合の経路である。
一般に、あるモジュールを完全にテストしようとした場合、全ての実行経路を通す必要がある。これの意味するところは、循環的複雑度の大きいモジュールの方が経路数が多く、従ってテストケースも多く必要になるということである。また、複雑度の大きいモジュールは、ソースコードの意味を理解するのに多くの経路を追わなければならず、読解がより困難になる。

循環的複雑度 - Wikipediaより

計測してみると、

/Users/jsonHardCoder/work/js/before.js
  1:1  Function 'getPageNums' has a complexity of 7. 

getPageNums の複雑度は7ですね。

ではこのgetPagesNum 関数からさらに、ranges 関数を切り出してみたいと思います。

after.tsx
function range(start, end) {
  const r = [];
  for (let i = Math.floor(start); i < Math.floor(end); i++) {
    r.push(i+1);
  }
  return r;
}
function getPageNums(current, total, size) {
  const s = current - size / 2;
  const e = current + size / 2;
  const start = s < 0 ? 0 : e > total ? total - size : s;
  const end = s < 0 ? size : e > total ? total : e;
  const ranges = range(start, end);
  return ranges;
}

分けたことによって、range の元のロジックがわかりやすくなりましたね。
ここで、分けたコードの循環的複雑度をみてみたいと思います。

/Users/jsonHardCoder/work/js/after.js
  1:1  Function 'range' has a complexity of 2. 
  9:1  Function 'getPageNums' has a complexity of 5. 

循環的複雑度で計測すると、最初は7だったが、今は5の複雑度になり、改善されています。また range 関数は複雑度が2になっています。 読みにくい関数1つよりも,読みやすい関数2つのほうが 保守しやすい ということがわかります。

最後に自分がやった関数がちゃんと仕様を満たしているかを計測するためにテストを書いていきたいと思います。

3.テストコードの追加

最後にgetPageNum関数のtestコードを書いてみます。先ほど説明した満たさなければならないパターンを全てテストしてみます。

今回は、Facebook社がOSSとして開発を進めている Jest を使います。

getPageNums.test.ts
import assert from "assert";
import { getPageNums } from "../pagination";

describe("pagination", () => {
  test("current = 3, total = 5, size = 5 の時", () => {
    const expected = [1, 2, 3, 4, 5];
    const result = getPageNums(3, 5, 5);
    assert.deepStrictEqual(result, expected);
  });

  test("current = 4, total = 6, size = 5 の時", () => {
    const expected = [2, 3, 4, 5, 6];
    const result = getPageNums(4, 6, 5);
    assert.deepStrictEqual(result, expected);
  });

  test("current = 3, total = 8, size = 6 の時", () => {
    const expected = [1, 2, 3, 4, 5, 6];
    const result = getPageNums(3, 8, 6);
    assert.deepStrictEqual(result, expected);
  });

  test("current = 4, total = 8, size = 6 の時", () => {
    const expected = [2, 3, 4, 5, 6, 7];
    const result = getPageNums(4, 8, 6);
    assert.deepStrictEqual(result, expected);
  });

  test("current = 4, total = 8, size = 3 の時", () => {
    const expected = [3, 4, 5];
    const result = getPageNums(4, 8, 3);
    assert.deepStrictEqual(result, expected);
  });
});

結果

jsonHardCorder$ npx jest -c jest.config.js src/server/services/util/__tests__/getPageNums.test.ts
 PASS  src/server/services/util/__tests__/getPageNums.test.ts
  util::pagination
    ✓ current = 3, total = 5, size = 5 の時 (2ms)
    ✓ current = 4, total = 6, size = 5 の時
    ✓ current = 3, total = 8, size = 6 の時
    ✓ current = 4, total = 8, size = 6 の時 (1ms)
    ✓ current = 4, total = 8, size = 3 の時

Test Suites: 1 passed, 1 total
Tests:       5 passed, 5 total
Snapshots:   0 total
Time:        1.854s, estimated 8s
Ran all test suites matching /src\/server\/services\/util\/__tests__\/getPageNums.test.ts/i.

自分が作った関数がちゃんと仕様を満たしているかを計測するために、テストを書いてみました。これにより、仕様変更が発生した場合でも元の仕様がコードから理解できるようになります。

まとめ

  • ロジックを考えてそっくりそのままif文にするとクソコードを爆誕させてしまうので注意。
  • 何がクリアすべき条件かをまず考える。
  • 条件になる箇所を説明的な変数で置き換える。
  • この関数は何をやるための関数かというのを考える - 最初に引数と返り値を書く。
  • 細かい条件を列挙し、まとめることが可能なものはまとめる。
  • どうしても複雑すぎる条件分岐になったら複雑な箇所を別関数に分けられないか考える。
  • コード自体は書こうと思えば愚直にも書けるが、可読性、性能、変更容易性が必要であり、それをきちんと実装することが重要だということがわかった。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away