TypeScript だけで Web アプリケーションを作る

はてなでアプリケーションエンジニアをしている id:hatz48 です。この記事は はてなデベロッパーアドベントカレンダー の 13 日目です。 昨日は id:dekokun による

dekotech.dekokun.info

でした。私は去年は

developer.hatenastaff.com こんな記事を書いていました。

今年は、はてなのサービス開発合宿で TypeScript のみを使ってアプリケーション開発をした話をします。

はてなのサービス開発合宿

はてなのサービス開発合宿については はてなスタッフアドベントカレンダー一日目で紹介されています。三日間、いつもとは違うチームで、通常業務から離れ、集中して開発するというものです。

開発自体は、実は三日間で完成はしなかったのでいまでも隙間の時間で開発を続けています。とある社内システムに、ちゃんとしたアクセス制御をいれて実装しなおそうとしています。再実装も一つの目標ですが、もう一つの目標は「TypeScript のみでアプリケーションを構築する」ことでした。

なぜ?

私は 2012 年にはてなに入社し、そこから三年間 Perl を使ってアプリケーション開発をしていました。そして今年の四月からは Scala をメインに、Perl も書いています。ちなみに Perl の前は Java を書いていました。

Scala を書いているうちに、やはり静的型による恩恵は大きいと再実感するようになりました。動的片付け言語にも良いところはたくさんあるのですが、例えば Perl で大きなコードのリファクタリングをするのは困難で、苦痛を伴います。 Scala の様に静的に型のついている言語であれば、たとえ影響範囲がアプリケーション全域にわたるようなリファクタリングでも IDE の支援を借りて比較的短時間で済ますことが出来ます。コンパイルが通り、テストが通ればだいたい OK です。

しかしすべての場面で厳格に静的型チェックを行う言語でコードを書くべきかというとそれは yes とは言いがたいです。 個人的には、まだ当たるかどうかわからない新規サービスをつくるのに Scala で書き始めるのはちょっとオーバーキルだなと感じます。

後述する async/await 機能が TypeScript で使えるようになったこともあり、現在 Perl を使っている場面において TypeScript を使うことはできないだろうかと考えるようになりました。

TypeScript

最初に言っておくと私は TypeScript が好きなのでこの記事では TypeScript に対して贔屓目に見ている部分があるかもしれません。また、サーバー上での実行環境は 現実的には node.js です。以下 「TypeScript は」という文脈で node.js の特徴をあげることがあるかもしれません。 TypeScript は型チェックをすることができ、さぼることもでき、ブラウザでも動く。Web アプリケーションを作るには実用的と感じています。

TypeScript ( node.js ) と Perl と比較してメリット/デメリットを想像してみます。

  • 言語
    • 型をつけることができる
    • 開発が活発である
  • 開発支援
    • Visual Studio Code などの IDE があり、型情報に基づいた支援が受けられる
    • IDE に組み込みのものや node-inspector などの debugger が利用できる
  • 処理系
  • その他
    • クライアントとコードを共有することができる
  • 学習

対して考えられるデメリットをあげます

  • 言語
    • 開発が速いため自分たちのコードが陳腐化するのがはやい
  • 開発・保守
  • 開発・運用
    • 例外時にまともな stack trace をとることができない
  • 運用
    • 例外をハンドルしそこなうとプロセスが終了してしまう

まだ運用知見のない状態ですので「いやいや○○が大変なんだよ」という意見があったら是非教えてください。

async/await によるコールバック地獄の解消

さて上に上げたデメリットですが、開発からみた一番大きな問題はノンブロッキング IO に起因するコードの複雑化ではないでしょうか。 いわゆるコールバック地獄が解決できないのであれば、上にあげた様々なメリットが得られるとしても採用するには抵抗があります。 node.js の採用事例を見ても WebSocket でクライアントとやりとりする部分や、シンプルだけど高速に動作する API サーバーのみといった用途が多いのではないでしょうか。

コールバック地獄を解消出来る仕組みとして、 Promise があります。シンプルなインターフェースでノンブロッキング IO を直列・並行に繋ぐことができます。これはとてもうまく動作するのですが、new Promise(function(resolve, reject) { ... } ) then(function(value) { ... }) といった boilerplate なコードがアプリケーション内に多く出てきてしまいます。またブロッキング IO なコードとは書き方も変わりますので学習コストも必要になります。

ありがたいことに、これについては async/await の機能によって解消することが出来ます。 async/await は TypeScript というより ECMAScript仕様策定中 の機能ですが、TypeScript や Babel などでは既に利用することができます。

今回 TypeScript を使おうと思ったきっかけでもありますので、コード例を紹介しようとおもいます。雰囲気をつかんでいただけたら幸いです。

Express っぽいもののコントローラーのコードだとおもってください。指定された id の情報を更新するために

  • 指定された id が存在するかを確かめる
  • 指定された id の情報を更新する
  • 更新後の情報を取得し直して返却する

これをコールバックスタイルで書くと

app.put('/something/:id', async (req, res) => {
    var id = req.params.id;
    var someData = req.body.someData;
    findSomething(id, (someThing) => {
        if (!someThing) { res.sendStatus(404); return; }
        updateSomething(id, someData, () => {
            findSomething(id, (updated) => {
                res.json({ somthing: updated });
            });
        });
    })
});

このように、IO の回数だけネストが深くなっていきます。someThing を更新するための権限管理のために DB アクセスが必要になったりするとさらに複雑になります。PerlRubyPython などで書くコードとだいぶ違いますね。

今度は async/await を使ってみましょう

app.put('/something/:id', async (req, res) => {
    var id = req.params.id;
    var someData = req.body.someData;
    var someThing = await findSomething(id);
    if (!someThing) { res.sendStatus(404); return; }

    await updateSomething(id, someData);

    var updated = await findSomething(id);

    res.json({ somthing: updated });
});

まるでブロッキング IO をしているかのよう!違うのは async function await というキーワードがあることくらいです。

今度は実際に Perl のコードを移植した例です。GitHub - hatena/Plack-Middleware-HatenaOAuth: Plack: :HatenaOAuth - provide a login endpoint for Hatena OAuth これははてなで公開している Perl モジュールです。Hatena OAuth で認証を行うエンドポイントをマウントしてくれるというものです。これを TypeScript で Express 用に書き直したのが GitHub - hatz48/express-oauth-hatena: express middleware for oauth with Hatena です。実際に見比べてみてほしいのは

ここです。 言語とインターフェースの違いはあるもののほぼ同様のコードなのがわかると思います。async/await はまだ正式な仕様が決定していませんが、今後の JavaScript での開発を大きく変える機能だと思います。

これで上のデメリットのうち、大きな一つが解消できそうです。(どうでしょう、 node.js で開発をするのがかなり現実的に思えてきませんか?)

やってみて

ここまでの内容を想定して実際に開発合宿に挑みました。 ここからは実際に TypeScript のみでサーバーアプリケーションを書いてみて、よかった点/キビシイ点/コネタ などを紹介していきます

(Good) 型が付けられる

やはり何と言ってもこれに尽きます。 これは私の場合ですが、動的言語を書くときは脳内で型を合わせながら書いています。自分にミスって型が合っていなければエラーになるわけですし、そのエラーがどこに起因するのかはコードを辿ってみなければわかりません。 静的な型が付いている場合「こういうロジック」というのをざっと書いていってあとはコンパイルエラーをとればだいたい動くので、動的片付け言語よりも結果的に速くコードを書くことが出来たと思います。

型をつけなくてもいい

これは Good とも Bad とも言えるのですが。 合宿は三日間で、実際に作業ができるのは二日間くらいのスケジュールでした。コンパイルを通すためにあまり時間をとられると、それだけで合宿が終わってしまいます。なので合宿期間中は「こまったら any」というスタンスでやっていました。上で「結果的に速く」できたのは any を許容したからだとも言えます。

ただ、とにかく動けばいい、そういう感じで動的言語で開発したアプリケーションはのちのち保守が難しくなっていきます。しかし TypeScript は後から型がつけられるのでその点は安心です。any と書いたところに合宿後にちょっとずつ型をつけていって「ここ無茶苦茶なんで直します」というようなフローが出来ます

(Tips) server/client でのコードの共有

モデル(というと人によって捉えるものが違も気しますが、いわゆるMVCアークテクチャにおけるModelのことです)クラスのコードを server/client で共有しています。モデルのフィールドの値から状態を計算する、と言ったメソッドが server/client で再実装する必要がなく、故に実装の乖離もないため便利です。しかし実装の共有以上に便利なのが、型を共有できることです。サーバー側で返した JSON をクライアントのコードで受け取るときは

// model
interface IUser { ... }
class User implements IUser { ... }

// server
app.get('...', async function(req, res) {
    ...
    const user: User = ...
    ...
    res.json({ user: user })
})

// client
axios.get('...').then(function(res) {
    const user: IUser = res.data.user
})

このようにすることでクライアント側でも実態と乖離なく型をつけることが出来ます。これは本当に便利です。

(Bad) await を書き忘れる

今回一緒に開発してくれたメンバーは JavaScript や TypeScript を書いてはいても、TypeScript でついこの間まで experimental であった async/await を使ったコードを書いたことがある人はいませんでした。そのため await を書き忘れて予期せぬ動作になったりということが何度かありました。ただ、型を確認すればすぐにわかるのであまり深くはまることはなかったと思います。あとそのうち慣れます。

逆に、ブロッキングIO のコードを書いたことがある人であれば async/await はスムーズに導入できる、と感じられました

(Bad) UUID を DB から取得・・・できない?

ID を生成するのに、MySQL の UUID_SHORT 関数を使おうとしていました。

SELECT UUID_SHORT();

よくやりますよね。これで取得した ID をレコードのプライマリキーとして使おうとしたら、なぜか Dupulication エラーが多発するという事態に見舞われました。不思議に思ってこの取得した ID をコード上で出力してみたところ、なんと同じ UUID が複数回表示されるではありませんか!なにが UUID だ!!! ・・・と思っていたのですが、チームのインフラ担当が調べてくれたところどうやら MySQL はちゃんと UUID を返している様子(そりゃそうですよね) わかってみれば初歩的なことだったのですが、 JavaScript の number 64bit double であり整数としては 53 bit までしか精度がでなく、取得した ID は number に変換された時点で丸め誤差が出ていた、ということでした。普段つかっている Perl では遭遇しない出来事だったため面食らいました。(これは TypeScript は関係ないですね)

これは mysql のモジュールに

supportBigNumbers: true,
bigNumberStrings: true

これらのオプションをつけ、数値に変換しないことで対応できそうでした。 ただこの時点で ID を number として扱うコードが書かれてしまっており、開発開始時に「ID は string じゃなくていいの」といわれ「number でいいんじゃん?」と答えてしまっていた私はこの件で最後まで詰られることに・・・

(Bad) エラーが貧弱

これは想定されるデメリットとして上にあげましたが、やはり厳しいという感想になりました。 基本的にスタックトレースがとれないので、エラーが起きていても直接どこが悪いのかを特定することが出来ません。

丁寧に例外をハンドルすることで擬似的に stack trace のようなものを出すことは出来そうです。

async function a () {
    try {
        await b()
    } catch(e) {
        throw new Error(e.stack);
    }
}
async function b () {
    try {
        await c()
    } catch(e) {
        throw new Error(e.stack);
    }
}
async function c () {
    throw new Error('I am C')
}
a().catch((e) => {
    console.log(e.stack)
})

このように自分で stack trace を継ぎ足してあげれば、見辛くはあるものの必要な情報を得ることができました。 もしうまい解決方法があるのなら教えていただきたいです。

まとめ

TypeScript / node.js の検証については、まだ「まとめ」ることは出来ていません。個人的にはかなり可能性を感じておりいつかは使いたいと思っていますが、stack trace の件は解決しなければならないし、開発は良くても運用面で問題があるかもしれません。ただ、(TypeScript に取り入れられる) ECMAScript / (実質的な実行環境である) node.js の界隈は非常に活発になってきていて、今ある問題も解決されるのでは、という希望が持てます。 また、今回の記事とは関係ありませんが JavaScript が動作する環境が多くなってきました。ブラウザ、サーバー、デスクトップ、スマートフォンアプリ、JS board・・・活用機会が多いという意味で JavaScript で書かれたコードは、いまやもっとも資産価値の高いコードと言えるかもしれません。

2009 年に node.js が出てきて世を賑わせていたとき「JavaScript はいいけどあんなコールバックだらけでやってはいけない」と感じた人、動的片付け言語のスピード感は好きだけど型がなくてつらくなってい人、今こそ TypeScript + node.js という環境を検討してみてはいかがでしょうか。

この記事のまとめ

  • 開発合宿で TypeScript の実用性を検証しました。一応断っておきますが「はてなでは TypeScript + node.js の本番導入を検討している」という話ではありません。
  • 今回はアプリケーション開発面をまとめました。オペレーション面についてはもし知見が得られれば書くかもしれません。むしろ知りたい。

おわりに

はてなでは慎重に技術検証を行いつつ、社内の次世代の標準を模索していけるエンジニアを募集しています

hatenacorp.jp

はてなデベロッパアドベントカレンダー、明日は id:nobuoka です。お楽しみに!