【ソースコードあり】TypeScriptデコレータで実現する安全なリファクタリング

はじめに

コードの保守性向上や技術的負債の解消のため、リファクタリングは開発プロセスにおいて欠かせない作業です。しかし、新機能の作成やバグ修正を伴わない一方でデグレや障害の原因になり得るため、非エンジニアからは避けたがられる傾向にあります。
こういった背景から、特に複雑で重要な機能について、リファクタリングがなかなか行われず、しかも長い年月をかけて機能追加が行われ、よりリファクタリングがしづらくなっている、といった悪循環もあるでしょう。
開発初期からあるコアな機能などは、そういった傾向が強いように思います。

このような「秘伝のタレ」になっている機能に果敢に切り込むも、複雑なソースコードを十分に読み取れず、デグレを発生させてしまう、ということを恥ずかしながら何度か行ってしまいました。
この失敗を再発させないために、安全にリファクタリングをする仕組みを考え、TypeScriptのデコレータに着目し、リファクタリングのリスクを簡単に軽減する手法を考案しました。

リファクタリングのリスク

いろいろなところで語られている通り、リファクタリングとは「コードの振る舞いを変えずに、中身を書き換える」ことです。
しかし誤った方法を取ってしまうと、コードの振る舞いが変わってしまい、これまでできていたことができなくなるリスクがあります。
十分に網羅的なテストが用意されていれば防げる可能性は高まりますが、場合によっては既存のテストが不十分であったり、あるいは本番環境の特殊なデータによる想定外のケースでデグレが発生してしまう可能性は捨てきれません。

やりたいこと

安全にリファクタリングをするには?

リファクタリング前後で「本当に挙動が変わっていないこと」を示すことができれば、安全なリファクタリングを実現できるでしょう。そのため以下のようなフローを考えました。

// リファクタリング対象
function doSomethingAwesome() {
  // 古い処理が書かれている
  return result
}

これを以下のような実装にします

function doSomethingAwesome() {
  const oldResult = /* 古い処理 */
  const newResult = /* 新しい処理 */
  // 結果を比較してログに出す。イメージ実装なので === で比較してます。
  if (oldResult === newResult) {
    console.log('Refactoring OK')
  } else {
    console.log('Refactoring NG')
  }
  // 一致・不一致に関わらず古い結果を返す
  return oldResult
}

この状態でデプロイし、本番環境で流れている実際のデータで一致・不一致を検証します。
十分なサンプル数で一致することが確認されたら、古い処理を消して新しい処理に書き換えます。

function doSomethingAwesome() {
  // 新しい処理が書かれている
  return result
}

このフローの問題点

しかし、これを実現するのはあまり現実的ではありません。
例えば return が複数の場所で行われている場合、関数の切り出しなどの工夫が必要となります。
新しい処理のみで例外が発生する場合に備えて、適切にエラーハンドリングも必要でしょう。逆に古い処理で例外が発生した場合には エラーハンドリングしてはいけません。 エラーハンドリングすると挙動が変わってしまうからです。

他にも「新しい処理を追加するときに、古い処理の中身を書き換えてしまっていた」「古い処理を消すときに、新しい処理の中身を書き換えてしまっていた」といった可能性も捨てきれません。
これはレビューで担保する必要がありますが、しばしば差分が大きくなり、レビュワーの負荷および見逃しの確率は高まってしまいます。

デコレータを用いた解決

そこで、上記のようなフローを簡単・安全に行えるデコレータを作成することにしました。

TypeScriptデコレータとは

詳細な説明は、わかりやすい素晴らしい記事が他に多くあるため割愛します。 誤解を恐れずに一言で説明すると、対象の関数やクラスなどの実装を改変するものと言えるでしょう。 例えば

export function logging(target: any, propertyKey: any, descriptor: any) {
  const originalMethod = descriptor.value
  descriptor.value = function (this: any, ...args: any[]) {
    const result = originalMethod.apply(this, args)
    console.log(`Logging: args: ${args}, result: ${result}`)
    return result
  }

  return descriptor
}

という関数を用意すると

class TestClass {
  // @logging を付けることで、自動的に引数と結果が console.log で出力されるよう改変される
  @logging
  sampleMethod(arg1: string, arg2: number): string {
    return `Hello ${arg1}, your number is ${arg2}`
  }
}

といった使い方が可能です。

リファクタリング用デコレータ

ここからは実際に作ったデコレータについて説明します。以下のような使い方ができるデコレータを作成しました。

class SampleClass {
  // リファクタリング先を書いておく
  @refactoring('doSomethingNew')
  doSomething() {
    // 古い処理が書かれている
  }

  doSomethingNew() {
    // 新しい処理を書く
  }

ここで @refactoring はもともとの doSomething の挙動を自動的に以下のように改変します。

  1. オリジナルの doSomething を実行する
  2. リファクタリング先の doSomethingNew を実行する
  3. 結果を比較し、一致・不一致をログに出力する
  4. オリジナルの doSomething の結果を返す

一致・不一致の検証、ログ出力、エラーハンドリングなどを @refactoring 側で吸収することで、
新しい実装を追加する時は

  1. オリジナルに @refactoring('doSomethingNew') を付ける
  2. doSomethingNew を実装する

だけになります。古い実装を削除するときは

  1. doSomething の中身を doSomethingNew に置き換える
  2. @refactoring のデコレータを削除する

だけで済むようになります。
これにより差分が明確になるため、コードレビューを行う側の負荷も下がることになります。

デコレータのソースコード

以下の Gist にて公開しておきます。 https://gist.github.com/srd7/ab6f313ad5952d83ccc3a68d2c4904d3 ※ 実際に私達が使っているコードからは一部改変しています。

デコレータが対応していないこと・注意点

このデコレータは「新旧両方の処理を実際に行い、結果を比較する」というものです。
そのため、「行われなかったケース」については検証することは不可能です。特定の条件が整った場合のみに発生する極端なエッジケースなどは検知できないでしょう。
また以下のような性質を持った関数では、そもそもこのデコレータを使うことが不適切なケースもあります。

何らかのデータを作成・更新などする場合

データが2回作成・更新されます。
実装当初は問題なかったとしても、その後に更新系の処理を追加してしまう可能性はあります。
@refactoring がついている関数そのものに追加はしなくとも、その参照先の関数に追加してしまう、というのはあり得るでしょう。

@refactoring('doSomethingNew')
doSomething() {
  // ここでユーザーが作られる
  users.insert({ name: 'user' })
}

doSomethingNew() {
  // ここでもユーザーが作られる
  users.insert({ name: 'user' })
}

私達はこれを回避するために、 @refactoring がついている参照先を辿り、更新系の処理が入っていないことを確認するようにしました。
(機会があれば今後この話も書こうかと思います)

処理時間がシビアな場合

処理を2回行っている都合上、処理時間が単純に2倍になります。
重たい処理であれば影響が顕著になりますし、そうでなかったとしてもミリ秒を削りたいような場合には向いていません。
また実行リソースも単純に2倍使う点には注意しましょう。

@refactoring('doSomethingNew')
doSomething() {
  // 処理に10秒かかる
}

doSomethingNew() {
  // ここでも処理に10秒かかるので、合計20秒かかるようになる
}

再帰処理の場合

再帰的に呼び出して使う関数の場合、すべての呼び出しで2回呼び出されることになります。

@refactoring('doSomethingNew')
doSomething() {
  // 再帰的に呼び出されるたびに、doSomething と doSomethingNew が実行されてしまう
  doSomething()
}

doSomethingNew() {
  // doSomething を呼び出してしまうと、再帰処理のたびに呼び出し回数が2倍になり、指数関数的に処理が増えていく点に注意
  doSomethingNew()
}

乱数や現在時刻などで実行結果が不定な場合

処理の中身は同じでも、実行のたびに結果が変わることが期待される場合、当然に結果は不一致と判定されます。
ただしログからどこが不一致なのかを分析し問題ないと判断を下すことは、手間ではありますが一応可能ではあります。

デコレータが得意とするところ

このデコレータの考え方はブラックボックステストに近いです。
そのため、内部でいろんな関数を呼び出している手前側であったり、複雑な条件分岐があるなど単体テストで網羅しきるのが難しい関数に対しては効果が大きいです。
割と雑につけるだけで検証が可能になります。

また(実行時間を除いて)もともとの挙動に影響を与えないため、とりあえず変更後の関数をリリースしてみる(そして差分があれば結果を見ながら修正していく)といった使い方も可能です。

まとめ

  • 変更前後の両方を実行し結果を比較することで、リファクタリング先の品質を一定保証できる
  • しかしそれを愚直にやると、差分が大きくなる(レビュー難易度やエンバグリスクの増加)
  • デコレータを使うことで、差分を減らし、またエラーハンドリングを吸収させられる
  • 単体テストが網羅しきれない複雑な場所でも使用可能
  • 一方で使用できない場所もある(更新系、重い処理など)のでその点には注意

コミューンでは、私たちと一緒に働く仲間を募集しています! 少しでもコミューンの開発組織や職場環境に興味をお持ちの方、ぜひカジュアル面談でお話ししましょう。

docs.google.com