Perl Hackers Hub

第8回Perlによる大規模システム開発・設計のツボ(2)

mixiのアーキテクチャパターン

2011年4月現在、mixiは2004年2月のサービス開始から7年以上をかけて、最適化、バグ修正、新機能リリースなどを続けています。⁠2)では、こういった状況の中で、安定したサービスをみなさんにお届けするために取り入れているアーキテクチャ設計上の工夫や、失敗を修正していくための品質評価手法を紹介します。

システムの境界と階層化

mixiでは個々の機能について、次のようなパッケージレイアウトを採用しています。

Mixi::Voice::*

つぶやき機能に関するモジュール群

Mixi::Video::*

ビデオ機能に関するモジュール群

Mixi::Diary::*

日記機能に関するモジュール群

Mixi::Home::*

ホーム表示機能に関するモジュール群

以降では、これらのモジュールの集まりを「コンポーネント」と呼びます。これらのコンポーネントは、相互に連携や結合を持っています。

システム自体が大きくなり、コンポーネント同士の連携が複雑になるにつれて、1つの修正の影響範囲がかなりの広範囲に波及することになり、変更に対して鈍重なアーキテクチャになりがちです。

階層化

そのためmixiではコンポーネント間の連携とその手段を制限していく方針を決めました。

まず始めに、各コンポーネントに与えられた役割を4つに分類しました。以降では、コンポーネント名とは別に、この役割の名前を<<役割名>>といった形で表現します。

<<application>>

1つの機能を実現するために必要なモジュール群

<<framework>>

システム横断的に利用するユーザデータの取得手段

<<service>>

<<application>>間の連携の仲介を行うパッケージ

<<common library>>

<<application>>、<<service>>に依存しないユーティリティ

これらは図1の結合のみを許すようにしています。この階層化の定義によって、既存のコンポーネント同士の持つ結合の良し悪しを明確にでき、リファクタリングを進めるための指針がはっきりとしました。

図1 コンポーネントの連携
図1 コンポーネントの連携

以降では、<<service>>に当たる各アプリケーション間の連携手段について紹介していきます。

設定ファイルによる依存性注入

<<service>> に相当するコンポーネントは、各<<application>>の持っている機能の利用を仲介します。この際、どの機能を利用しているのかという依存性は、必ず設定ファイルに記述するようにしています。これによって「依存の形」を限定できるうえ、それ自体がドキュメントとしての役割を果たすことができます。これは、Javaなどのフレームワークで見られる「依存性の注入」Dependency Injectionというテクニックを参考にしています。

疑似RPCによるコンポーネント間連携

Perlでは一般に、プライベートな関数を定義するとき、その関数が外部のモジュールから呼ばれることを防ぐため、関数名に(アンダースコア)を付けるなどして表現します。

では、モジュールのパブリックな関数であれば、どのようなコンポーネントからでも利用してよいものでしょうか? 同一のコンポーネントの間であれば問題なく利用できるモジュールであっても、異なるコンポーネントから利用されていた場合では、テストの影響範囲や修正時の調査範囲が無制限に拡大してしまい、閉じた設計ができません。

そこで、コンポーネント間で連携するための仲介として、Procedureというしくみを導入しています。これはサブシステムとの結合をRPCRemote ProcedureCall遠隔手続き呼び出し)で行うのと同じように、同一のプロセス間であってもコンポーネント間をまたぐやりとりの場合は「擬似的なRPC」を通して行うというものです。

この擬似的なRPCは、

  • blessされたオブジェクトやコードリファレンスを受け付けない
  • システム横断でユニークな名前が付与できる
  • その処理の名前と実行するパッケージ名の紐づけをYAMLYAML Ain't Markup Languageで記述する

といった特徴を持っています。

---
methods :
  getEntryBody : Mixi::Voice::Adapter::Entry
  insertComment: Mixi::Voice::Adapter::Comment
  deleteComment: Mixi::Voice::Adapter::Comment
  insertFeedback: Mixi::Voice::Adapter::Feedback
  deleteFeedback: Mixi::Voice::Adapter::Feedback

my $result =
    Mixi::Service::Procedure::InternalGateway->call(
    'jp.mixi.voice.insertComment' => {
        owner_id => $xxx,post_time => $yyy
    }
);

システムでユニークな名前を持っていることは、コンポーネント外部からの利用を追跡する際にgrepやackなどでの発見を容易にします。

また、blessされたオブジェクトやコードリファレンスをパラメータとして受け付けないことで、テストの書きにくい入り組んだ依存関係を避けることができます。

記述に手間がかかりますが、この仲介を設けることによって、外から利用可能なインタフェースを限定し、むやみに内部的な構造が利用されることを防げます。

また、コンポーネントの内部実装が変化する場合でも、Procedureの挙動が維持されることだけを確認すればよくなるので、リファクタリングが容易になります。

Q4Mを用いたPub/Sub

サービスが拡大するにつれて、アプリケーションを実現するための処理以外にもコンポーネント間での結合が必要な処理が増えていきます。

次のような関数の例を見てみましょう。

sub add_entry {
    my ($self,$user,$entry) = @_;
    if( $user->is_official ) {
        : 公認アカウントのための処理
    }
    if( $body->is_open ) {
        : 全体公開日記のニュース登録
        : カスタマーサポートのための登録
        : 特徴語抽出
        : タイアップのための処理
    }
    : 友人フィードへの書き出し
    : 各種キャッシュクリア
    # 本来書きたい処理
    $self->db->insert_diary( $user->id ,$entry->body);
}

ユーザが日記を投稿するときに実行される関数内で、関連するコンポーネントの処理が連続し、本来書きたい処理がずいぶん後ろのほうに記述されています[3]⁠。こういったユーザの行動に紐づいた各種コンポーネントの処理を隠ぺいするしくみとして、mixi では「UserEvent」という<<service>>の機構を採用しています。

UserEventを利用すると、先ほどの関数は次のように記述できます。

sub add_entry {
    my ( $self, $user ,$entry ) = @_;
    $self->db->insert_diary( $user->id ,$entry->body);

    fire Mixi::UserEvent('diary.create',{
        diary_id => $entry->id,
        member_id => $user->id,
        body => $entry->body,
        title => $entry->title
    });
}

このようにシステム内でユーザが行った行動のCRUD(Create:生成、Read:読み取り、Update:更新、Delete:削除)に対して、それぞれ適切なパラメータを付与した「イベント」として取り扱えるようにします。

このイベント時に実行する処理をシステムの設定ファイルに次のように記述します。

# handlers
---
- Mixi::Official::YYY # 公認アカウントの処理のハンドラ
- Mixi::CS::XXX # CS系処理のハンドラ
- Mixi::Feed::ZZZZ # フィード系処理のハンドラ
---
# parameter interface
body : required
member_id : required
title : required
group_id : optional

各種ハンドラは、まず、パラメータから実行の可否を判断し、実行すべきジョブであればそのままQ4Mなどのジョブキューサーバにジョブとして登録します。

このしくみによって、件のコードでは「主たる関心」であるコンポーネント固有の処理を行い、⁠副次的な関心」であるほかのコンポーネントを利用した処理はUserEventを通じて個々のワーカが処理するため、影響を最小限に抑えながら、コードをシンプルに保つことができます。このようなイベントと付随する処理を分離する構造をPublisher/Subscriberパターンと言います。

単体テストによる境界の明確化

上述のような<<service>>に分類されるしくみを採用することで、各コンポーネントが対応する責務が明確になります。単体テストの際には、それらの責務以上のことを保証するテストは書かずに、ほかのコンポーネントに依存する部分を境界として定義するようにします。

UserEvent、Procedureは、それぞれテストのためのモックアップのしくみを持っています。UserEventであれば、イベントを適切なパラメータで発火することまでがそのコンポーネントの責務で、それに伴う外部コンポーネントの処理は別途単体でテストを記述します。Procedureであれば、外部への呼び出しはモックとして定義し、環境を固定してテストを行います。

自分と他人の境界をはっきりさせることで、コンポーネントが持つ機能、役割を明確にできます。

おすすめ記事

記事・ニュース一覧