エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

「生成AIでサクッと!」というわけには行かなかったCoffeeScript → TypeScriptへの置き換え

本記事は、コンシューマーチームブログリレー 3日目です。

はじめまして。エンジニアグループ、コンシューマーチームの松本と申します。

本記事では、社内で開発・利用している内部ツールのフロントエンドを CoffeeScript + Backbone.js の構成から TypeScript + React の構成に置き換えた時の知見を共有できればと思います。

早速ですがまずは宣伝から

弊チームでは AskDoctors というサービスを開発しております。

https://assets.askdoctors.jp/assets/green/landing/landing_zyo_main-image03-sp-8e2fa7cb673b195d914edbffcae63e0d4c7fab1171ca9edd1fce1aaccf5fa0de.webp

「病気・症状のお悩みを実際の医師に聞ける」というサービスで、1つの質問に複数の医師から意見を伺うことができるサービスとなっております。実在の医師から意見をもらうことができるので、悩み事があればぜひ相談してください。

背景

弊グループで扱っているAskDoctorsというサービスはフィーチャーフォン時代から運営されており、もうすぐサービスローンチから20年になる長寿サービスです。当然、時代に合わせて技術スタックは更新し続けてきているわけですが、ユーザーに公開されない内部ツールの改善はどうしても後回しになりがちです。

本記事では、社内で開発・利用している内部ツールのフロントエンドを CoffeeScript + Backbone.js の構成から TypeScript + React の構成に置き換えた時の知見を共有できればと思います。

当時は最先端だったAltJS

「なんでCoffeeScript?」と疑問に持たれるかと思いますが、このツールが開発された当時はまだES2015も正式に策定されておらず、JavaScriptを出力できるベターな言語(AltJS)の開発が盛んでした。その中で最も注目されていたのがCoffeeScript でした。Ruby on Rails の標準に採用されていたため、バックエンドはRailsで、フロントはCoffee で、というのは当時のスタートアップの中ではメジャーな選択肢であったと記憶しています。

役目を終えたCoffeeScript

しかし御存知の通り、ES2015以後、ECMAScriptそのものにテコ入れが入るようになり、またTypeScriptがAltJSのデファクトスタンダートとしての地位を確立させたことにより、CoffeeScriptはその役目を終えました。CoffeeScript は 2022年4月に公開された2.7.0を最後に更新が止まっています。

あまり頻繁に更新が入らないツールだったので、それまでちょっとした改変で成立していたため、当時の技術スタックを知る人間が片手間でメンテナンスをし続けていました。しかし、新しい人がチームに入ってこのツールをメンテナンスするのは非常に学習コストの無駄が多いこと、時代の変化に合わせてサービスを変化させていく中で、このツールに大規模なテコ入れをする必要性が出てきたこと、をきっかけに、「エイヤ」と思いっきり置き換えることになりました。

作業の手順

なんだか懐かしい技術スタックが並んでいますね。そこそこ大きいツールのため、「一気にエイヤで作り直す」という選択肢を取るのは難しかったので、「部分的に徐々に徐々に置き換えていく」、というスタンスを取りました。

大まかに以下の手順で進めていくことになりました

  1. CoffeeScript を ES2015準拠のjsに置き換える
  2. Shakapacker によるビルドツールの導入
  3. Backbone.js への依存をやめる(Backbone.js のMVCにおけるVごとにReactComponent に置き換えていく)
  4. コードの整合性が取れた箇所からTypeScript に置き換える

CoffeeScript → ES2015準拠のjsへの置き換え

当初は、このCoffeeScriptのアセットコンパイルにwebpackすら利用されていませんでした。gemの都合で、一気に最新のECMAScriptへ置き換えることが難しかったため、まずはES2015準拠のコードに 置き換えます。

置き換えには decaffeinate というライブラリを利用します。

GitHub - decaffeinate/decaffeinate: Goodbye CoffeeScript, hello JavaScript!

このツールを用いて、一気にjsに置き換えます。しかし、言語仕様の違いから完全な置き換えはできず、できなかった部分には警告が出ます。そしてそれらを一つ一つ手で(あるいはAIで)置き換えます。

以下にdecaffeinate が吐く警告の例をいくつか紹介します

DS002: Fix invalid constructor

class Friend extends Person
  greeting: =>
    @respond('Hello!')
    return

decaffeinate後

class Friend extends Person {
  constructor(...args) {
    this.greeting = this.greeting.bind(this);
    super(...args); // ←Error
  }

  greeting() {
    this.respond('Hello!');
  }
}

手動修正後

class Friend extends Person {
  constructor(...args) {
    super(...args);
    this.greeting = this.greeting.bind(this);
  }

  greeting() {
    this.respond('Hello!');
  }
}

superの挿入が、constructorの末尾になってしまいます(なんで?) しかしこの程度ならClaudeに即修正してもらえます。

DS102: Remove unnecessary code created because of implicit returns

greetFriends = (people) ->
  for person in people
    if person.isFriendly()
      person.greet()

decaffeinate後

let greetFriends = people =>
  (() => {
    let result = [];
    for (let person of Array.from(people)) {
      if (person.isFriendly()) {
        result.push(person.greet());
      } else {
        result.push(undefined);
      }
    }
    return result;
  })();

手で修正

const greetFriends = (people) =>
{
    for (let person of people) {
      if (person.isFriendly()) {
        person.greet()
    }
}

CoffeeScript のブロックの末尾の式は暗黙体にreturn として扱うという仕様があり、return する必要ないメソッド(voidを返す等)では冗長な書き方になってしまいます。これらは可読性を下げるだけなので、このフェーズで解消しておきます。Claudeもある程度は直してくれますが、コンテキストが長くなると精度が落ちる(少なくともやった当時は落ちた)ので、ある程度手動で直すべき箇所を指示する必要があります。

DS206: Consider reworking classes to avoid initClass

class Circle
  PI = 3.14159265358979
  radius: 1
  setRadius: (@radius) ->
  getArea: ->
    PI * @radius * @radius

decaffeinate後

var Circle = (function() {
  let PI = undefined;
  Circle = class Circle {
    static initClass() {
      PI = 3.14159265358979;
      this.prototype.radius = 1;
    }
    setRadius(radius) {
      this.radius = radius;
    }
    getArea() {
      return PI * this.radius * this.radius;
    }
  };
  Circle.initClass();
  return Circle;
})();

手動で修正

const PI = 3.14159265358979;
class Circle {
  constructor() {
    this.radius = 1;
  }
  setRadius(radius) {
    this.radius = radius;
  }
  getArea() {
    return PI * this.radius * this.radius;
  }
}

CoffeeScriptはRubyのようにclass bodyに何でも式が書けてしまいます。この仕様が仇となってjsとの互換性がありません。Claudeでもある程度なんとかしてくれますが、コード全体を比較してリファクタが必要な箇所が多数あり、そのようなコンテキストが長くなりがちな作業ではClaudeはあまり良いコードを生成してくれないので、人間がロジックを参照しながら丁寧に直します。

Shakapacker によるビルドツールの導入

これはもう記載のとおりですね。shakapacker にすることで、直接コード管理がrails側と分離でき、node側のパッケージ管理ツールも利用できます(それまではminifyされたライブラリ(jQueryやBackbone.js等)が assets/vendor/ などに直接置かれていたんですね。懐かしい)

Backbone.js への依存を剥がす

Backbone.js は MVCの構造になっており、Model, Controllerを定義することで、Railsに定義されたAPIに対応するレスポンスを引き出せるようになります。Viewに当たる部分は、内部的にはjQueryで動作しており、該当するエレメントを見つけてappendしたりinsertしたりします。

そこそこのページ数が存在したので、これも一気に剥がすとビックバンリリースになってしまうため、1つのViewを1つの(あるいは複数の子要素からなる)ReactComponent に置き換えていきます。Backbone.js と ReactComponent が併存する期間が存在してしまうので、 これを ReactComponent in Backbone と言う荒業で乗り切ります。jQuery がReactComponent を appendするという…社内ツールなので多少計算資源が高く付きますが、一時的なものだと割り切って許容します。

ここの部分的な置き換えは生成AIだと機能が難しかったポイントです。jQuery がReactComponent を appendするという、普通はやらないことを許容して進めたため、ハルシネーションが非常に多くなってしまい、無駄にトークンを消費して何も解決しないという事態が続いてしまいました。

剥がせたところからTypeScriptにしていく。

MCの部分に関しては、aspida client を用いて、可能な限り最初からtsで定義するようにしました。Railsがどのようなレスポンスを返すのかを事前にTypeScriptで定義しておくと、先程述べたAIのハルシネーションがぐっと減るからですね。

しかし、この定義付けが大変だった……というのも、Rubyも型を持たない言語で、かつ、内部的に複雑な処理が走っていたため、状況によってレスポンスに含まれたり含まれなかったりする値、というのがたくさんありました。これらを調査し、一つ一つ、どの要素がどの項目に影響するかを調べる必要がありました。こういう調査こそいい感じにAIにやってほしいものですが、それが合ってるか間違ってるかは最後に人間が判断するしかないので、全部読むことに……*1

一つ一つをReactComponent に置き換えていき、全体的に整合性が取れてきたタイミングで、Backbone.jsを剥がし、jsをTypeScriptに置き換えます。と言っても、ここまで進めるたタイミングでjsのままだったコードはほんの一部で、かなりTypeScript化は進んでいました。

まとめ

このプロジェクトを実施するに当たって、以下の記事を大いに参考にさせていただきました。

ありがとうCoffeeScript。さようならBackbone.js

という流れで、弊チームの管理するコードから、全てのCoffeeScriptとBackbone.js がなくなりました。10年以上お世話になったソフトウェアに感謝を。Backbone.js を触ったのは本当に10年ぶりですが、このライブラリは後方互換性に優れており、現代でも全然しっかり動いていた良いライブラリでした。

生成AIの時代だからこそ、より読みやすいコードを

生成AIを使っていて思うのは、生成AIは「あくまで生成AI」です。生成するのは非常に得意ですが、読むのはあまり得意ではない印象があります。しかし、このプロジェクトを通じて感じたのは、「人間にとって読みやすいコードは、AIにとっても読みやすい」という至極当たり前のことです。

10年以上動いていたソフトウェアなので、秘伝のタレとも言うべきスパゲティコードが多数存在していました。人間にとって読みづらく解読に時間のかかるコードはAIにとっても辛かったようで、ここは気合と根性で頑張るしかありませんでした。TypeScriptによる型付け、コメント(自然言語)による補足、責務の分解を意識した再利用性の高いコード、これらを意識すればするほどAIコーディングの効率が段違いに上がっていくのを肌で感じたので、このプロジェクトを完遂できてよかったのではないかなと思います。

ここまでの長文にお付き合いいただき、ありがとうございました。

We Are Hiring!!!

冒頭にも述べましたが、AskDoctorsはもうすぐ20年続く長寿サービスです。しかし、それに甘んずることなく、次の10年、20年のユーザーの健康に寄り添ったサービスを開発して参ります。本記事ではフロントエンドの事を題材にしましたが、生成AIを活用した技術課題から事業全体の課題まで、多種多様でチャレンジングな環境があります。共に挑戦する仲間をいつも募集しておりますので、ぜひご応募をお待ちしております!!

jobs.m3.com

*1:AI時代に人間は責任を取らされる仕事しか回ってこないって言われますが、こういう時に実感しますね笑