このエントリーをはてなブックマークに追加

カルテット開発部の後藤(@hidenorigoto)です。今回は、カルテットでの開発プロセス寄りの取り組みと、ちょっとしたプラクティスを紹介します。

(いきなり本題を読みたい方は、見えてきたことへ飛んでください。)

プロローグ

とある企業の、とある開発現場の日常風景


プログラマー「そろそろ #3396 のプルリク、マージして頂きたいんですけどー」
レビュアー「ごめんごめん、今日見とくから!」


3日後の朝礼にて

プログラマー「#3396 のプルリク、見ていただけました?」
レビュアー「あー、あのプルリク、チラ見したんですけどすごく育っちゃってますよね・・・、なかなか腰を落ち着けて見る時間とれなくて :sweat_smile:
プログラマー「あれをマージしていただかないと、後続の作業を進められないんですよー。なんとかお願いします」
レビュアー「あー、はい(と言われても・・・)。なんとか小さいPRに分割ってできません?」
プログラマー「(今更?)えーっ、できなくはないですけど・・・ごにょごにょ(以下略)」
レビュアー「あー、では仕方ないので、なんとか今日中に見てマージしますね」

(うーん、これは細かいところ把握できないな^^; テストもまあまあ書いてあってCI通過してるし、、、えーい、ままよっ! ポチッ(Merged))


その日の午後

テスト担当「今日マージされた◯◯機能の動作を確認してて、〜〜のところがおかしいような気がするので仕様を確認したいのですが」
マネージャー「なるほど! そのケースは確かに考慮してませんでした。POに仕様確認してきますー」


1時間後

マネージャー「というわけでプログラマーさん、#3396 の機能で考慮漏れがあったので、修正お願いします :bow:
プログラマー「えっ、そうなるのですか! レビューしていただいているので大丈夫だと思ってました。その修正だと、ちょっと今の実装では簡単にはできなさそうですよ」
マネージャー「そうですか、、、でもこの修正を適用しないとリリース不可なので、なんとかお願いします :bow:
プログラマー「分かりました、、、(今更?)」


1日後

プログラマー「うーん、あちら立てればこちら立たず・・・ブツブツ・・」


1日後

プログラマー「うーん、今のまま暫定で行くこともできるけど、、、、これはいろいろリファクタリング、いや設計を考えなおさないとキツイ! キーッ」
レビュアー「ビクッッッ」



4日後

PO「◯◯機能のリリース、まだですか?」
マネージャー「す、すみません、修正に時間がかかってしまってまして」
PO「そうですか、仕方ないですね。では軽微な方の△△だけでも現場で使いたいので、先にリリースってできませんか?」
マネージャー「す、すみません、、、あのー実装上◯◯の修正といろいろ関係している部分でして、◯◯の方が完了しないと△△もリリースできないんです、何卒もう少しお待ちください :sweat_drops:
PO「うーん、仕方ないですね。」


不穏ですね・・・ :sweat:
「ソフトウェア開発プロジェクト健全度チェック」があったとしたら、いかにもひっかかりそうなポイントがたくさんありますね。
こんな状況、皆さんの周りで見たことないでしょうか?
こんな状況に置かれたとき、皆さんならどのように解決していきますか?

アジャイル宣言の背後にある原則』という有名な文章があります。 ここに書かれている原則をチームメンバーが理解して実践できていたとしたら、いろいろな事が上手くいきそうに思えますよね。

しかし、、、、この原則を読んでアレコレ考えた経験がいくらあったとしても、本当に現場で実践できるレベルに「理解する」ことは難しいことだと思います。実際に私自身、上記の原則は何度も読んで知識として知ってはいました。それでも、カルテットの開発現場で起こっている問題に対してすぐに役立てることはできませんでした。

地道にチームメンバーと問題について解決策を話し合って実施し、試行錯誤していく過程で、ようやく自分たちのソフトウェア開発においては、何に注力するのが最良かが、実感と結びつきながら分かってきたように思います。結局のところ、自分が立ち向かっている問題や置かれている状況、そこから得られた経験などとつながりができる瞬間がないと、本当の理解は得られないということなのでしょうね。

こんな風にして苦労しながらも得た、しかも、読み返してみればわりとありきたりに見えてしまう、私たちなりのルールというのを紹介します。

前提としている環境

カルテット開発部で開発しているのは、自社サービスであるLisketです。3年育てたSymfonyプロジェクトの現状で紹介したように、そこそこのサイズのコードベースに成長しています。この開発の特徴は、次のようになります。

  • 自社の業務でも利用しており、サービスの改善が自社の業務効率に直結している
    • 社内ユーザーからの要望で、機能の改善・追加を行う頻度が高い
    • 業務に直結しているため、当然だが高い可用性が求められる
  • 常に成長させ続ける開発(機能面、規模面)

一般ユーザー向けに提供しているサービスですが、それを最もヘビーに利用しているのが自社、という構造になっています。この構造は開発にも活かされていて、例えば、新規または追加実装された機能は、すべて一旦社内ユーザーのみが利用する段階を設け、そこで一定の品質が担保された機能を順次一般ユーザーへ提供するというリリースステップを踏んでいます。

Lisketの開発当初は、以降で紹介するようなプラクティスは意識的には実施していませんでした。そのためか、特に大きめの機能のリリースタイミングの周辺で、それに引きづられて他の修正のリリースがずれ込んだり、多くの手戻りが発生するといったことが何度か発生していました。せっかく自社にヘビーユーザー/ベータテスターがたくさんいるのに、それを有効活用してスムーズに高品質なソフトウェアをリリースしていくというプロセスにおいて、私たちは未熟でした。

気づき

何度か失敗するうちに、1つの事にあらためて気づきました。それは、「自社ユーザーでベータテストをし、実務に耐えうる品質が確認できたものを一般リリースする」ことが可能なのが私たちカルテット開発部の強みであるということです。だからこそ、自社ユーザーでベータテストをしつつスムーズにリリースしていくという開発サイクルは、第一に上手く回さなければならないポイントだということです。これを土台に位置づけてプロセスや判断基準を整備すれば上手くいきそうです。そうして、いくつかのプラクティスを実施していくうちに、私たちの活動の基準が見えてきました。

見えてきたこと

  • 明示的で小さなソフトウェア開発
  • 素早いリリースサイクル

明示的で小さなソフトウェア開発とは、ソフトウェア開発のさまざまなアクティビティにおいて、対象、関連、影響範囲が明示的に小さくあるように選択・設計・行動することを表します。

私たちが関わっているのは、解決しなければならない問題が次々に生まれるビジネス現場です。そのような現場で使われるソフトウェアは、大きく複雑になる宿命を避けられません。その宿命は受け入れ、しかし、決して人海戦術に頼らず、最適なチームメンバーで効率的にソフトウェアを開発し続けるための戦略として、明示的な小ささを選択していくようにします。

また、明示的な小ささと同時に、素早いリリースサイクルを維持することも常に念頭に置くようにします。

この2つは、互いに影響しあっています(カルテットの現場ではそのように機能しています)。明示的で小さなソフトウェア開発を実践することで、リリースサイクルを素早く回し続けられます。逆に、リリースサイクルが素早く回る状態を維持することで、明示的で小さなソフトウェア開発を実施することを促進できます。

この2つを基本としてさまざまな判断基準に組み込むことで、カルテットの開発プロセスは次第に上手く回るよう改善しました。

次に、2つの基本を適用した3つのプラクティスを、例とともに紹介します。3つのプラクティスは、

  • 明示的で小さな変更
  • 明示的で小さな範囲
  • 素早いリリースサイクル

です。

明示的で小さな変更

大きな変更が必要な場合でも小さな変更にブレークダウンし、1つ1つの変更の影響範囲や後方互換性が明示的であるようにします。

取り組み例:マージしやすいプルリクエストのプランニング

状況
新機能の実装や既存機能の修正の際、それによって動作が大きく変わってしまうとしたら、それをリリースするための準備などさまざまな点で労力も時間もかかります。そのような修正を1つのプルリクエストで実装すると、そのプルリクエストはなかなかマージできず、かつ、細かな修正などでプルリクエストが成長していくこともあると思います。プルリクエストが成長してmasterからの差分が大きくなると、変更インパクトが大きくなって、結局なかなかマージできないという悪循環に陥ります。

解決策
この悪循環を解消するために、次のようにプルリクエストを小さな単位で作成して、細かくマージしていけるようにします。

  • 既存機能の動作に影響がない、新規のコード(クラス、メソッド)の追加のみのプルリクエストを先に作るようする。
  • 影響範囲がある程度の大きさになるデータ構造の変更が必要な場合、次のように段階を踏む。
    • 新しいデータ構造と古いデータ構造が共存する状態にする(プログラムの修正なし)。
    • 新しいデータ構造から、古いデータ構造をエミュレートできるようにする(エミュレートの追加のみで、他のプログラムの変更なし)。
    • 新しいデータ構造側にプログラムを対応させる。
    • 古いデータ構造を削除する。
  • 既存機能に大きな修正が必要な場合は、共通インターフェイスで別バージョンのクラス群を用意する。共通のインターフェイスかつ入出力の後方互換を維持した状態で開発する(旧コードには @deprecated マーク)。
    • 可能であれば機能内部の整理も行い、新バージョン側でも再利用できる部分を取り出す(リファクタリングのプルリクエスト)。
    • 新バージョン側のクラス群を実装するプルリクエストを作成する。この段階では既存機能に影響なし。
    • 何らかの条件(特定のユーザー、フィーチャートグル等)によって、新バージョンの機能側を実行できるようにする。
    • 安定動作を確認できた後に新バージョンに切り替え、旧コードの削除を行うプルリクエストを作成する。
  • UIレベルでも追加機能を非公開/限定公開の状態でリリースできるように、フィーチャートグルを使う。

このようにプルリクエストをステップに分けて作成するためには、実装タスクごとに、単に「どうあるべきか」という内容を明確にするだけでは十分ではありません。そこからさらに、「どのようにマージし、リリースしていくのか」「どの部分が既存機能とバッティングするのか、しないのか」といったことを考え、計画してから実装に入る必要があります。

リリースまでを意識するというのは、計画が大変に思えるかもしれません。しかし、そのようなことを考えるメリットの方が遥かに大きいと思います。

  • どのようにリリースしていくのかというのは、開発のどこかで必ず考えなくてはいけないことであり、それを早期に考えることになる。
  • 1つ1つのプルリクエストが小さくなることで、内容に自信が持てるようになる。安心して変更できるようになる。
  • 実装担当者がリリースのことまで意識するようになる。プロダクトに対する当事者意識の醸成。

ただし、最初からプルリクエストの分割のことばかりを考えすぎると、触っている部分のコード全体としてのバランスに作業者の目が届きづらくなる場合もあります。そのような広い視野での設計の見直し等は、別途時間を設けて取り組むなどできるとよさそうですね。

明示的で小さな範囲

次に挙げるような範囲を小さくし、かつ、その範囲が明示的であるようにします。

  • 作業対象の範囲
  • プログラムのパラメータや戻り値の範囲(定義域=ドメイン、値域)
    • より狭い値の範囲
    • クラスよりもインターフェイス
  • データの利用範囲

取り組み例:エンティティを小さくする設計

状況
Lisketの機能には、複数の機能に渡ってよく似た形のデータを扱うケースがいくつかあります。 データの形が似ているからといって、共通化のためにテーブルやエンティティクラスを1つにまとめてしまうと、(複雑度にもよりますが)通常はコード量が減らせるため最初の実装コストは下がりますが、後々次のような事が発生します。

  • 機能ごとに微妙に異なるデータ要件が出てくる。
  • データ要件の差異に応じて、機能ごとの差が大きくなる。

それぞれの機能はどんどん成長していきます。共通したテーブルやエンティティクラスを使っていると、そのテーブル/エンティティクラスの変更に対して、影響する範囲が次第に大きくなっていきます。そのようなクラスに対する修正は慎重にならざるをえず、単純な面ではリリースサイクルの速度を低下させてることにつながります。また、場合によっては「そこを変更するよりも別の箇所で安易に対応」といった、判断の歪みにつながることも考えられます。

解決策
それぞれの機能の利用目的やライフサイクルが異なっている場合は、たとえデータの形がほとんど同じでも、別々のエンティティ(テーブル)に分けるようにします。これには、次のようなメリットがあります。

  • テーブルのスキーマ変更の影響を、個別の機能単位に絞りこめる。
  • 似て非なる複数の機能をすべて調整して一度にリリースするのではなく、機能ごとに修正作業を完結できる。

取り組み例:プログラミングにおいて型を利用

この取り組みは、まだまだ初期段階です。

Lisketでは、サーバーサイドはPHP、フロントエンドはJavaScriptを使っています。PHPは静的型付け言語ではありませんが、型情報のための機能が徐々に強化されてきています。まだ導入には至っていませんがPHP7以降で利用できる静的解析ツールPhanの利用を検証中です。今後はより積極的に型を使って、メソッドの引数の定義域の絞り込みなどに取り組んでいく計画です。稼働環境のPHPは現在バージョン7.0系なので、7.1以降に上げた後、戻り値の型宣言(nullableな戻り値も指定できるようになる)も含めて取り組んでいきたいです。

フロントエンドでは一部新規機能の開発にTypeScriptの利用を開始しています。既存のJavaScriptのコードにもJSDocの記述を追加するようにしています。これにより、IDEで補完を利用したり、将来的にコンパイラによるチェックを通すことも視野に入れています(ただし基本路線としてはTypeScriptへの移行を優先に考えています)。

取り組み例:連想配列撲滅運動

先の「型を利用」と似ていますが、特にPHPにおいて、連想配列の利用を減らすように随時リファクタリングを行っています。指針としては、クラスのpublicメソッドの引数や戻り値で受け渡しされるものに連想配列が使われていたら、連想配列の構造をオブジェクトでの表現に書き直すようにしています。利用がprivateメソッド、つまり特定のクラスの内部表現に留まる場合は問題としません。

素早いリリースサイクル

リリースを素早く行えるということを、常に判断基準に組み込むようにします。

取り組み例:Symfonyのユーザーロールを使ったシンプルなフィーチャートグル

プロダクトでフィーチャートグルを実現するためには、最も安直には定数やif文を使うことになりますが、ある程度ポリシーを持っておかないと、フィーチャーのための分岐を管理できなくなってしまいます。 フィーチャートグルのためのサードパーティのサービスとしてLaunchDarklyなどが有名です。ただしLisketで利用したい形態とは合わなかったため利用していません。その代わり、Symfonyのユーザーロールを利用したシンプルな形でフィーチャートグルを行っています。

Lisketの場合、機能の公開は3ステップで、それぞれのロール構造と合わせて次のようになっています。

  • (A) Lisketの管理者権限ユーザー向けに公開
    • 対象ユーザーのロールは ROLE_ADMIN (ROLE_QUARTETROLE_USER を含む)
  • (B) カルテットの社員向けに公開
    • 対象ユーザーのロールは ROLE_QUARTET (ROLE_USER を含む)
  • (C) 一般ユーザー向けに公開
    • 対象ユーザーのロールは ROLE_USER

機能は最初 ROLE_ADMIN または ROLE_QUARTET 向けにのみ公開し、その段階での検証を経て ROLE_USER 向けへの公開、つまり一般公開となります。例えば「HOGE」という機能を開発しているとすると、これをロール ROLE_FEATURE_HOGE で表し、公開段階に応じて ROLE_ADMIN または ROLE_QUARTET 配下に所属させます。アプリケーションのコンフィギュレーションファイルの書き換えによる適用になるため、カルテットの場合はこのファイルを直接書き換えることはしておらず、基本的にはすべてプルリクエストによる変更になり、アプリケーションのリリースデプロイによって反映する形です。

(A) の場合の security.yml

    role_hierarchy:
        ROLE_USER:        ROLE_MANAGED_CUSTOMER
        ROLE_QUARTET:
          - ROLE_USER
        ROLE_ADMIN:
          - ROLE_ALLOWED_TO_SWITCH
          - ROLE_USER
          - ROLE_QUARTET
          - ROLE_FEATURE_HOGE   <-- ココにフィーチャーのロールを記述

(B) の場合の security.yml

    role_hierarchy:
        ROLE_USER:        ROLE_MANAGED_CUSTOMER
        ROLE_QUARTET:
          - ROLE_USER
          - ROLE_FEATURE_HOGE   <-- ココへフィーチャーのロールを移動
        ROLE_ADMIN:
          - ROLE_ALLOWED_TO_SWITCH
          - ROLE_USER
          - ROLE_QUARTET

(C) の一般公開の場合は、フィーチャートグルの分岐コードを削除しています。

Twigのテンプレートでは、機能の公開段階を問わず、次のようにロールをチェックして条件に置き換えてJavaScriptで使う等しています。

var featureHogeEnabled = {% if is_granted("ROLE_FEATURE_HOGE") %}true{% else %}false{% endif %};

エピローグ

定例ミーティングにて


PO「△△の機能の動作確認は、来週火曜からでよい?」
マネージャー「その予定ですね。来週月曜のリリースで社内ユーザー向けに公開されますので、火曜から確認をお願いします。この機能ですと確認は3日くらいでしょうか?」
PO「そうだね、いや2日くらいでできるよ。多分問題ないと思うので、翌週のリリースで一般公開だね。」
マネージャー「OKです。」

プログラマー「この機能の実装ですが、先にこの部分をリファクタリングしておきたいのですけどー」
レビュアー「なるほど:exclamation: そうしておくとたしかにスッキリしますね :smile:
プログラマー「はい!多少インターフェイスの変更も行いたいので、ここでしか使っていない既存のクラスにはレガシーマークをつけるいつものやり方で進めますね :relieved:
レビュアー「よろしく!」

司会「他に何か、コメントなどある方どうぞ」

プログラマー「最近、レビュアーさんのレビューの進み具合が良いんですよ:exclamation:
レビュアー「プログラマーさんが、上手にプルリク作ってくれてるからですよ:smile:
プログラマー「てへっ:relaxed: あ、でも、要件の段階でいろいろ考慮していただいているので、その分私はプルリクの作り方を考える余裕ができた気がしますー」
マネージャー「それは良かった :hand:
PO「みんな、よくやってくれてるね :+1: 先月リリースした機能もユーザーさんに好評だよ! そこで次の要望、いや今後1年くらいかけて作りたい機能の構想なんだけど(以下略」


カルテット開発部では、自分たちのチームのやり方を真摯に改善していく仲間を募集しています!


このエントリーをはてなブックマークに追加

googleads/googleads-php-libが新しくなりました

PHPからAdWordsのAPIを呼び出すとき、公式に提供されているクライアントライブラリである googleads/googleads-php-lib を使うことが多いと思います。(自前で頑張る場合は別) googleads/googleads-php-libは2016年12月に新バージョンが公開されて、従来とは利用方法が大きく変わりました。 個人的には特別な設定無しでcomposer requireできるようになったり、各APIバージョン別のnamespaceがついたことでPhpStormなどのIDEでコードが追いやすくなるなど、嬉しいことがいっぱいの更新でした!

従来のバージョンのgoogleads/googleads-php-libは 2017年7月末日でサポートが終了 しました。(従来版の最終バージョンは2017年6月にリリースされた v19.0.0 ) 旧バージョンからも利用できるAPIバージョンはv201705が最終となり、2017年8月にリリースされたAdWords API v201708以降は新バージョンからのみ利用できるようになっています。 今後もAdWords APIを使っていくのであれば、従来のバージョンを使っていた方も、そろそろ移行を考えなくてはいけません。

と言っても、長らく従来のバージョンを使っていた開発者から見ると、あまりに大きく変わりすぎてコードの書き直しが多数発生するため、更新に踏み切るには勇気が要るものです。 そこで、googleads/googleads-php-lib移行時に気をつけること(私が引っかかったポイント)をいくつか公開したいと思います。これから移行する開発者の皆様の、転ばぬ先の杖になれば幸いです。

アップグレード時に気をつけること

Predicateのパラメータは配列

たとえ演算子がEQUALSで、パラメータとして単独の値しか渡さない場合であってもパラメータ部分(第三引数)は必ず配列にする必要があります。 紛らわしいですが、WSDLから自動でPHPコードを生成しているので変更不可とのことです。( https://github.com/googleads/googleads-php-lib/issues/227

誤り

<?php
$predicate = new Predicate('hoge', 'EQUALS', 'foo')

正しい

<?php
$predicate = new Predicate('hoge', 'EQUALS', ['foo'])

トークンの手動リフレッシュは不要

今後はgoogleads/googleads-php-libの外ではrefresh_tokenのみを管理すれば良いことになります。

<?php

// tokenは配列形式を保存しておく必要あり
$token = [
  'access_token' => 'xxx',
  'refresh_token' => 'yyy',
  'expires' => 123456789, // timestamp
  'clientId' => 'aaaa',
  'clientSecret' => 'bbbb',
];
// リフレッシュが必要かどうか確認
$handler = $adWordsUser->GetOAuth2Handler();
if ($handler->CanRefreshAccessToken($token) && $handler->ShouldRefreshAccessToken($token)) {
    // リフレッシュ
    $token = $handler->RefreshAccessToken($token);
}

<?php

// tokenはリフレッシュトークン文字列だけ保存しておけば良い
$refreshToken = 'yyy';
// リフレッシュトークンを渡すだけで良い。リフレッシュが必要な場合は自動でリフレッシュ。
$credential = (new OAuth2TokenBuilder())
    ->withClientId($clientId)
    ->withClientSecret($clientSecret)
    ->withRefreshToken($refreshToken)
    ->build()
;
$adWordsSession = (new AdWordsSessionBuilder())
    ->withDeveloperToken($developerToken)
    ->withOAuth2Credential($credential)
    ->build();

XXXOperationのコンストラクタ引数の順序変更

コンポーネントの操作を行うOperationクラス群(たとえば、CampaignOperation, AdGroupAdOperation等)のコンストラクタ引数の順番が、従来とは変更されています。

旧(たとえば v19.0.0のCampaignOperation

<?php

class XXXOperation
{
    public function __construct($operand, $operationType = null, $operator = null)
    {
        // ...
    }
}

新(たとえば v29.0.0のCampaignOperation

<?php

class XXXOperation
{
    public function __construct($operator = null, $operationType = null, $operand = null)
    {
        // ...
    }
}
  • $operatorSET, REMOVE
  • $operationType は基本的にnull指定で可
  • $operand が実際に操作するオブジェクト(たとえば、Campaign, AdGroupAd 等)

実際に使わない値である$operationTypeのほうが$operandより先に来るのはしっくり来ませんが、やはりWSDLから自動で生成されているために変更不可のようです。

まとめ

新バージョンでは、多少引っかかっても前述したようにIDEでgoogleads-php-lib内のコードを追いやすくなったので、丁寧にコードを読んでいけば解決しやすくなっていると思います。 移行を終えてしまうと間違いなく良い方向に進化したと確信できるので、ためらっている方もAdWords API v201705が終了する(と予想される) 2018年5月 までに思い切って移行することをおすすめします。


このエントリーをはてなブックマークに追加

最近ようやく Angular & TypeScript でソースコードを書き始めました。型のおかげでどのような値が入る変数なのか推測しやすくなり、とても便利です。 さらに angular-cli を使うとターミナル上でプロジェクトやコンポーネントの雛形を作る事ができるため、同じようなコードを何度も繰り返し書かなくても良くなりました。

$ ng new my-project
$ cd my-project
$ ng serve -o

たったの3行で Angular のアプリケーションが動き出しブラウザに Welcome to app!! のテキストが表示されます。 何がどう動いているのか不思議に思いませんか?

今回の記事は angular-cli のコマンドと設定を元に Angular + TypeScript のプロジェクトがブラウザ上で動作するまでに何が起こっているのかを順に解説していきます。

2017/07/21 追記
タイトルを Angular2 として公開していましたが、現在の最新バージョンである Angular@4.0 で行った検証内容だったため訂正させていただきました。 Ryota Murakami さん、ご指摘ありがとうございました。

この記事を書いた環境

  • macOS Sierra
  • node v6.9.0

補足
angular-cli のコマンドを実行するには node >= 6.9 が必要です

1) angular-cliでプロジェクトを作成してみる

まず angular-cli をインストールします。 この記事を書いた時のバージョンは @angular/cli@1.2.1 です。

$ npm install -g @angular/cli

冒頭のコマンドを実行してプロジェクトを作成してみましょう。

$ ng new my-project
$ cd my-project
$ ng serve -o

angular-cli の詳しい使い方は公式ドキュメントをご覧ください。

angular-cli/wiki
https://github.com/angular/angular-cli/wiki

ディレクトリ構成

$ ng new {PROJECT_NAME} で作成された雛形は下記のようなディレクトリ構成になっています。 TypeScript に馴染みがないとディレクトリ構成だけ見ても何がどうなっているのか理解が難しいですよね。私もそうでした。

├── README.md
├── e2e
│   └── ...
├── karma.conf.js
├── node_modules
│   └── ...
├── package.json
├── protractor.conf.js
├── src
│   ├── index.html
│   ├── main.ts
│   └── ...
├── tsconfig.json
└── tslint.json

ルートディレクトリにはパッケージのインストールやユニットテストなど、さまざまな設定ファイルが集まっています。 これらを一度に理解しようとすると混乱してしまいます。まずは angular-cli のプロジェクトで使われている TypeScript について理解しましょう。

2) TypeSciptのトランスパイルを理解する

TypeScript
https://www.typescriptlang.org/index.html

TypeScriptJavaScript を拡張した言語です。構文は JavaScript によく似ていますが変数は静的型づけされ interfaceclass などの構文を使う事ができます。

JavaScript の場合、例えば <script> タグでファイルを参照するだけでブラウザがソースコードを解釈し実行してくれますが TypeScript はブラウザが解釈する事ができません。 このため TypeScript から JavaScriptトランスパイル したものを参照する必要があります。

tsc を使ったトランスパイル

// greeter.ts
class greeter {
  greet(name: string): string {
    return "Hello, " + name;
  }
}
$ npm install -g typescript
$ tsc greeter.ts

typescript には tsc というツールが付随しています。これが TypeScriptJavaScript に変換してくれる トランスパイラ です。 ファイルパスを渡すと JavaScript にトランスパイルしたファイルが出力されます。

// greeter.js
var greeter = (function () {
  function greeter() {
  }
  greeter.prototype.greet = function (name) {
    return "Hello, " + name;
  };
  return greeter;
}());

tsc にオプションを渡す

tsc コマンドを実行した時のカレントディレクトリに tsconfig.json が存在する場合、トランスパイルのオプションとして使用されます。 angular-cli のディレクトリ構成にもこのファイルは存在していますね。

// tsconfig.json
{
  "compileroptions": {
    "outdir": "./dist/out-tsc",
    "baseurl": "src",
    "sourcemap": true,
    ...

[詳しい解説] TypeScript in 5 minutes
https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html

tsc だけでは開発が難しい

tsc にはいくつかオプションがありますが、複数ファイルをまとめて処理したり、ひとつのファイルに集約するなどの複雑な動作はできません。 開発が進むたびに増えるファイルをひとつずつコマンドでトランスパイルするのは現実的ではないですよね。 そこで Gulpwebpack などのツールを使って開発の手助けをする必要があります。

方法その1:Gulp

開発用のタスクランナーです。 ソース(ファイルおよびディレクトリ)とそれらに対する連続した動作をタスクとして定義し gulp コマンドから呼び出します。 Gulp 自身は TypeScript のトランスパイル機能は持っていないためプラグインを組み合わせて使います。

設定を gulpfile.js に書く

// gulpfile.js

var gulp = require("gulp");
var ts = require("gulp-typescript");
var browserify = require("browserify");

// トランスパイルのオプションを読み込む
var tsProject = ts.createProject("tsconfig.json");

gulp.task("default", function () {
    return tsProject.src() // 対象となるソース
        .pipe(tsProject()) // トランスパイラにソースを渡す
        .js // トランスパイル
        .pipe(browserify({})) // ひとまとめにする
        .pipe(gulp.dest("dist/bundle.js")); // ファイル出力
...

ターミナルで実行

$ gulp
# ---> dist/bundle.js が出力される

[詳しい解説] TypeScript Tutorials Gulp
https://www.typescriptlang.org/docs/handbook/gulp.html

方法その2:webpack

アセット(JavaScript・HTML・CSS)を読み込んでバンドルする(ひとまとめにする)ツールです。 Gulp と同じように TypeScript のトランスパイル機能は持っていないため、ローダーとプラグインの組み合わせでトランスパイルを行います。

設定を webpack.conf.js に書く

// webpack.conf.js
module.exports = {
    entry: "./src/index.ts", // エントリポイント、このファイルから依存関係を辿る
    module: {
      rules: {
        "test": /\.ts$/, // 拡張子が.tsだったらローダーを使って読み込む
        "loader": "awesome-typescript-loader" // 読み込んだ時にトランスパイル
      },
    },
    output: {
        filename: "bundle.js", // ファイル出力
        path: __dirname + "/dist"
    },
    ...

ターミナルで実行

$ webpack
# ---> dist/bundle.js が出力される

[詳しい解説] TypeScript Tutorials React & Webpack
https://www.typescriptlang.org/docs/handbook/react-&-webpack.html

3) Angularはどのようにトランスパイルしているのか

ここまでで TypeScript がどのようにトランスパイルされるのかをご理解いただけたと思います。 では Angulartsc でトランスパイルしているのでしょうか?それとも Gulpwebpack

もう少し探るために $ ng serve コマンドの動作を追いかけてみましょう。

ng コマンドの正体

$ ng って何でしょうか。まずはここから探ってみます。

$ which ng
$ /usr/local/bin/ng

私の環境では ng コマンドは上記の場所に格納されていました。 このファイルは Node で実行される JavaScript のソースコードのため、テキストエディタで開いて読む事ができます。

ng serve

ソースコードを眺めると $ ng serve を実行した時に以下のような順番でファイルが呼び出されていました。

  • {$NODE_PATH}/@angular/cli/bin/ng
  • {$NODE_PATH}/@angular/cli/commands/serve.js
  • {$NODE_PATH}/@angular/cli/tasks/serve.js

3番目の tasks/serve.js のソースコードに注目してみます。

// {$NODE_PATH}/@angular/cli/tasks/serve.js

const WebpackDevServer = require('webpack-dev-server');
...
const server = new WebpackDevServer(webpackCompiler, webpackDevServerConfiguration);
return new Promise((_resolve, reject) => {
    server.listen(serveTaskOptions.port, serveTaskOptions.host, (err, _stats) => {
        ...
    });
})

webpack-dev-server のインスタンスを作成し接続を開いている事が分かります。

webpack-dev-serverNode 製の開発用サーバーです。 ローカルでwebサーバーを立ち上げ開発中のアプリケーションを動作させることができ、ソースコードの変更に伴うオートリロードなどの機能を持ちます。

webpack DevServer
https://webpack.js.org/configuration/dev-server/#components/sidebar/sidebar.jsx

つまり $ ng serve コマンドは下記のような動作をしている事になります。

webpack.conf.js はどこに?

webpackwebpack.conf.js に設定を定義するのが一般的です。 Angular もこのファイルを持っているのでしょうか?

ディレクトリ構成を見る限り webpack.conf.js は見当たらず $ ng serve は特定の webpack.conf.js を読み込んでいる訳ではないようです。

$ ng eject コマンドでスケルトンを取り出す機能が提供されているため、今回はこちらを使って webpack でどのような動作が行われるのかを調べてみましょう。

注意

eject コマンドを実行した後はそのプロジェクトにおいて $ ng serve コマンドを使うことができなくなります。 webpack.conf.js を開発者自身でカスタマイズし $ npm start でブラウザが開くように変更が加えられるためです。 この点にご注意ください。

$ ng eject
// package.json

"scripts": {
  "ng": "ng",
-  "start": "ng serve",
-  "build": "ng build",
-  "test": "ng test",
+  "start": "webpack-dev-server --port=4200",
+  "build": "webpack",
+  "test": "karma start ./karma.conf.js",
  ...
  "devDependencies": {
    "@angular/cli": "1.2.0",
    ...
    "typescript": "~2.3.3"
+    "webpack-dev-server": "~2.4.5",
+    "webpack": "~2.4.0",
+    "autoprefixer": "^6.5.3",
+    "css-loader": "^0.28.1",
    ...

eject コマンド実行後にルートディレクトリを確認すると webpack.conf.js が追加されています。 このファイルには Angular がどのように処理を行なっているのかが記述されています。

4) webpack.conf.js のスケルトンを探る

$ ng eject コマンドで取り出した webpack.conf.js に書かれた内容を探っていきましょう。 450行とボリュームの大きいファイルですので部分的に引用して紹介していきます。また説明のために実際のファイルとは異なる順番になっていますのでご了承ください。

各設定項目の詳細は下記をご覧ください。

webpack configuration
https://webpack.js.org/configuration/

require

const fs = require('fs');
const path = require('path');
const ProgressPlugin = require('webpack/lib/ProgressPlugin');

webpackNode で実行されるため設定ファイルは CommonJS 形式で記述されています。 require() は他のファイルから関数やオブジェクトをインポートする機能で、以下のいずれかを文字列で渡します。

  • Node のビルトインモジュール名
  • node_modules/ にインストールされたパッケージ名
  • ファイルパス

module.exports

module.exports = {
  ...(設定内容)
}

設定はオブジェクトリテラルで定義し module.exports で他のファイルから参照できるようにします。 これも CommonJS 形式の記述です。

entry

"entry": {
  "main": [
    "./src/main.ts"
  ],
  "polyfills": [
    "./src/polyfills.ts"
  ],
  "styles": [
    "./src/styles.css"
  ]
},

アプリケーションのエントリポイントを定義します。 オブジェクトが指定された場合はキーの数だけ Dependency Graph が作成され、それぞれのファイルを再帰的に辿ってファイル間の依存関係をツリー構造に分析します。 先頭の main.tsAngular のトップレベルの依存が記述されたファイルです。

resolve.modules

"resolve": {
  "modules": [
    "./node_modules",
    ...
  ],

依存関係を辿る時に、パッケージはこのフォルダから検索されます。 TypeScript のソースコードと照らし合わせて説明すると import { A } from 'foo' と記述されている場合 node_modules/foo が検索されるイメージです。

resolve.extensions

"resolve": {
  "extensions": [
    ".ts",
    ".js"
  ],

インポート文に拡張子が記述されない場合は、この拡張子を使ってファイル名の解決を試みます。 TypeScript の ソースコードでimport { A } from './foo' と記述されている場合 foo.ts foo.js の順番で検索され最初に一致したファイルが読み込まれます。

angular-cli で作成したプロジェクトの雛形は TypeScript のソースコードと JavaScript のパッケージで構成されているため .ts および .js が検索されるように設定されています。

module.rules

"module": {
  "rules": [
  {
    "test": /\.html$/,
    "loader": "raw-loader"
  },
  {
    "test": /\.css$/,
    "use": [
      {
        "loader": "css-loader",
        "options": ...
      },
      {
        "loader": "postcss-loader",
        "options": ...
      }
    ],
  },
  {
    "test": /\.ts$/,
    "loader": "@ngtools/webpack"
  }
  ...
],

解決したファイルは、拡張子ごとにローダーが割り振られ読み込まれます。 test にパターンマッチすると loader が割り振られる仕組みで、それぞれのローダーは node_modules/ から検索されます。

拡張子 .ts@ngtools/webpack という Angular 専用のローダーが読み込んでいます。 このローダーがトランスパイルを行なっているはずです。 ソースコードを見てみましょう。

// node_modules/@ngtools/webpack/src/loader.js

Object.defineProperty(exports, "__esModule", { value: true });
const path = require("path");
const ts = require("typescript");
const plugin_1 = require("./plugin");
...
  if (plugin && plugin instanceof plugin_1.AotPlugin) {
    const refactor = new refactor_1.TypeScriptFileRefactor(sourceFileName, plugin.compilerHost, plugin.program, source);
    ...
    const result = refactor.transpile(compilerOptions);
    ...
  }
  else {
    ...
    const compilerHost = ts.createCompilerHost(compilerOptions);
    ...
  }

それっぽい箇所だけ抜き出してみました。 require() で読み込んだ typescript モジュールは Compiler API という機能を提供しています。 @ngtools/webpack はこの機能を利用して、2通りのトランスパイルを行なっています。

typescript Using the Compiler API
https://github.com/Microsoft/TypeScript/wiki/Using-the-Compiler-API

plugin

ビルドパイプラインを指定します。トランスパイルされたソースコードは上から順に次々にプラグインに渡され、処理されていきます。

"plugins": [
  ...
  new HtmlWebpackPlugin({
    "template": "./src/index.html",
    "filename": "./index.html",
    ...
  }
  }),
  new BaseHrefWebpackPlugin({}),
  ...
  new AotPlugin({
    "mainPath": "main.ts",
    ...
    "exclude": [],
    "tsConfigPath": "src/tsconfig.app.json",
  })
  ...
],

HtmlWwebpackPlugin
https://github.com/jantimon/html-webpack-plugin

HTMLを生成するプラグインです。tempalte から読み込んだものを filename に出力しています。 index.htmlAngular アプリケーションが起動した時にブラウザに一番最初に表示されるページですね。

BaseHrefWebpackPlugin
https://github.com/dzonatan/base-href-webpack-plugin

アプリケーションのURIの基準になる <BASE> タグを出力します。 Angular アプリケーションでは <BASE> タグの設置が必須となっています。

HTMLに関するプラグインは他にも定義されていますが、これらを組み合わせる事により以下のようなHTMLが動的に生成されます。

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>MyProject</title>
  <base href="/">
  ...
</head>
<body>
  <app-root></app-root>
  ...
  <script type="text/javascript" src="main.bundle.js"></script></body>

AotPlugin
https://www.npmjs.com/package/@ngtools/webpack

さきほど @ngtools/webpack のソースコードに記述されていた AotPlugin が出てきました。

Angular には just-in-time (JIT)ahead-of-time (AOT) の2種類のコンパイラが存在します。 両者ともテンプレートのコンパイルを行うものですが、コンパイルを行うタイミングにより用途が分かれます。 それぞれの違いについて軽く理解しておきましょう。

詳細は公式ドキュメントをご覧ください
https://angular.io/guide/aot-compiler

JITコンパイラ

ブラウザからランタイムで呼ばれるコンパイラです。 コンパイル結果はファイル出力されずメモリ上でやり取りします。 ランタイムのため何度もコンパイル処理が実行されますが、修正がリアルタイムに反映されるメリットがあり開発環境で使用されます。

AOTコンパイラ

任意のタイミングで呼び出されるコンパイラです。 コンポーネントのテンプレートもコンパイラのチェック対象となり、記述ミスがある場合はコンパイルが失敗します。 コンパイル結果はファイル出力され、次にコンパイルが実行されるまで上書きされません。

コンパイラの使い分け

  • $ ng serve ローカル開発用コマンド、JITコンパイラが使われます。
  • $ ng serve --aot AOTコンパイラを使うようにオプション指定できます。
  • $ ng build プロダクション環境に配布するファイルを生成、AOTコンパイラが使われます。

output

"output": {
  "path": path.join(process.cwd(), "dist"),
  "filename": "[name].bundle.js",
  ...
},

webpack が処理した結果をファイル出力します。 [name] はプレースホルダでエントリポイントのキー名で埋められます。

まとめ

$ ng new my-project
$ cd my-project
$ ng serve -o

コマンドを実行してからブラウザが起動するまでの数秒の間に、何が行われているのかご理解いただけましたでしょうか。 簡単にまとめると、下図のように Angularwebpack を使って TypeScript のトランスパイルからファイルのバンドルまでを行い、またテンプレートなど独自のシンタックスを解析するためにオリジナルのプラグインを呼び出すように設定されています。

ローカル開発環境

プロダクション環境用の配布ファイル

かくいう私も Angular を触ったばかりですので、認識の間違いやドキュメントの読み違えがあるかもしれません。 間違いに気づかれましたら記事訂正のためご指摘をいただけると有難いです。

仕組みを理解する事は開発を進める上で大きな助けになると思っています。 Angular をこれから学習する方、またはトラブル解決の小さな助けになれば幸いです。