First Class Collection

この記事はDeNA Advent Calendar 2016 8日目の記事です。

はじめまして、オープンプラットフォーム事業本部のpospomeです。
普段は GAE/GO の環境でサーバサイドエンジニアとして働いています。
(´・ω・`)

この記事では「First Class Collection」という実装パターンを紹介します。

First Class Collection とは?

First Class Collection は「ThoughtWorksアンソロジー」という書籍で紹介されている「Array, Map をクラスでラップする」という実装パターンです。

以下のようなユーザーのゲームスコアを扱うArrayがあったとします。


$scores = Array(
    Array(
        'user_id' => 1,
        'team_id' => 1,
        'event_id' => 1,
        'score' => 100,
    ),
    Array(
        'user_id' => 2,
        'team_id' => 2,
        'event_id' => 2,
        'score' => 100,
    ),
);

このArrayをクラスでラップします。


class GameScoreList {
    private $scores;
    function __construct($scores) {
        $this->scores = $scores;
    }
}

これが First Class Collection です。
Array を直接触らず GameScoreList を介してArrayを触ります。

ラップすることによって以下の利点が生まれます。
・ArrayとArrayに対する処理を一緒に管理できる
・Arrayをイミュータブルにできる
・Arrayに名前を付けることができる

1つ1つ説明していきます。

ArrayとArrayに対する処理を一緒に管理できる

Arrayのようなデータの集合体は加工処理やフィルタリング処理をすることが多いと思います。
例えば、「特定の userId の score の合計を取得する」「合計 score が一番高いチームに所属する userId を全て取得する」などです。

このような場合、Arrayをそのまま利用してしまうと、Arrayと加工ロジックが分離してしまいます。
似たようなロジックをあちこちに書いてしまったり、
ちょっとしたロジックであれば、Controllerなどにべた書きしてしまうかもしれません。

First Class Collection を利用すると、Arrayに対する処理を一緒に管理できます。
これによってロジックが重複することもないですし、べた書きする必要もありません。


class GameScoreList {
    private $scores;

    function __construct($scores) {
        $this->scores = $scores;
    }

    //特定の user_id の score の合計を取得する
    function getTotalScoreByUserId($userId) {
        //ロジック省略
    }
}

クラスなので、interface を利用して並び替え処理を抽象化することもできます。
以下はイベントの種類によってスコアの並び替え処理が変わるケースを想定しています。


interface ISort {
    public Sort()
}

class xxxEventScoreList implements ISort{
}

class yyyBossEventScoreList implements ISort{
}

ゲームスコアはDBなどで永続化されている事が多いので、コードを書かずSQLで完結することもありますが、
場合によってはコードでフィルタリングすることもあると思います。
そういった場合にとても便利です。

Arrayをイミュータブルにできる

完全コンストラクタにすることで、Arrayをイミュータブルにすることができます。

要素の追加、削除、更新が必要になった場合は以下のように関数を用意してあげることで、
Arrayに対して副作用を与える操作を制限することができます。
追加対象のArrayに対してバリデーションをかけることも可能です。


class GameScoreList {
    //追加はできるが、削除、更新はできない
    function add($score) {
        $this->scores[] = $score;
    }
}

Arrayに名前を付けることができる

単なる Array では「それがArrayであること」しか表現できないので、具体的に「何のArrayなのか」は変数名でしか表現できません。


//単なるArray
$bigBossEventScores = Array();

First Class Collection ではクラス名で表現することができます。
普段チーム内で利用する言葉で命名すれば「それが何なのか」がすぐに分かります。
Array であることを意識する必要もありません。
チーム内で「ビッグボスイベントのスコア一覧」という言葉を利用するのであれば、
以下の BigBossEventScoreList のように言葉をそのまま表現することができます。


$list = new BigBossEventScoreList($arr);

以下のように BigBossEventScoreクラスのArrayである場合、
「ビッグボスイベントのスコアの集合」であることは分かるのですが、
「ビッグボスイベントのスコア一覧」という言葉を表現することはできません。


$bigBossEventScores = Array(new BigBossEventScore(), BigBossEventScore());

引数や戻り値にもクラス名が利用されるので、
単なる Array よりも「それが何なのか」を明確に表現することができます。
ユビキタス言語を利用するDDDでは必須の実装パターンですね。

以上が First Class Collection の利点です。

その他

クラスにラップすることで Array を foreach のようなループで回すことはできなくなりますが、
ループで回す目的はフィルタリングやデータ加工のはずです。
その処理をクラスに持たせることができるので、
Array をループで回せなくて困るというケースは少ないと思います。
ループは可能な限りクラス内に実装しましょう。

Arrayを直接触りたい場合は getter を用意してあげましょう。
ただし、getter で取得した Array に対する処理を外に置かないように気をつけてください。


class GameScoreList {
    private $scores;

    function get() {
        return $this->scores;
    }
}

終わりに

今回は First Class Collection を紹介しました。

Array, Map は int, string よりも保持している情報量が多いので、
それらに紐づく処理が必要になりがちです。
また、言葉で表現できるデータ集合体であることが多いので、
クラスにラップしてあげた方が分かりやすくなるケースもあると思います。

全ての Array, Map をラップするかは迷うところですね。

人によって好みが別れるところだと思うので、
「データに対する処理が必要になったらラップする」
「レイヤをまたぐ場合はラップする」
「ユビキタス言語で自然に表現できるもののみラップする」
など、色々と試行錯誤してみるのがいいかもしれませんね。

ちなみに、golangには primitive type というプリミティブ型に実装を持たせる機能があるので、
以下のようにサラッと実装することができます。
*Array に直接触れてしまいますが・・・


type UserScoreList []int

func (u UserScoreList) GetTotalScore() {
    //ロジック省略
}

長々と説明しましたが、
First Class Collection は簡単に幅広く活用できる実装パターンだと思うので、
知らなかった方は是非試してみて下さい。

明日は yuichi1004 さんです。(`・ω・´)ゞ

ツイート
シェア
あとで読む
ブックマーク
送る
メールで送る

TLS 1.3 と 0-RTT のこわ〜い話

DeNA Engineer Advent Calendar 2016 7日目の記事です。

こんにちは。奥一穂です。

DeNAのゲームサーバが元になっているオープンソースのHTTPサーバ「H2O」の開発や、IETFでプロトコル関連の標準化活動を行っています。

11月に韓国のソウルで開催されたIETF 97では、私たちが提案中のHTTP拡張(Cache Digests for HTTP/2103 Early Hints)の議論の他、TLSの次期バージョンであるTLS 1.3のプロトコル実装に関する相互運用性の確認を行ってきました。

その結果を先月末に開催されたhttp2勉強会 #10で発表してきましたので、こちらにて資料を公開させていただきます。

TLS 1.3 と 0-RTT のこわ〜い話 from Kazuho Oku

昨年制定されたHTTP/2に続き、TLS 1.3は制定秒読み段階、さらにQUICの標準化作業が開始されるなど、ウェブを支えるプロトコルは変革の時を迎えています。新しい技術の得失を理解し、使いこなす努力が求められているといえるでしょう。

ツイート
シェア
あとで読む
ブックマーク
送る
メールで送る

Atomパッケージを作ってみよう

この記事はDeNA Advent Calendar 2016 6日目の記事です。

こんにちは。エンジニアの加瀬です。 普段はモバイルゲームの開発をしております。

皆さんは開発をするときにどのエディタをメインに使っていますか? Vim、Emacs、それともIDEでしょうか。
昔は自分もVimを使っていたのですが、ちょっとしたプラグインを自分で作ってみようと思ったときにハードルの高さにぶつかってしまい、去年あたりからAtomをメインエディタとして使うようになりました。
AtomはCoffeeScript(後述しますが今はJSでもOKです)でパッケージ(プラグイン)を作ることができます。プラグイン作成のためにニッチな言語を習得する必要がないことからパッケージを作るハードルは比較的低いと言えます。

ですが、自分が実際に作り始めてみるとまだまだドキュメントやサンプルが少なかったことから少し苦労しました。ちょっとでもパッケージ作りに興味を持った人が最初の段階で躓いてしまわないように、基礎のところを解説したいと思います。

パッケージジェネレータ

それでは早速パッケージを作ってみましょう。 Atomが標準でパッケージのテンプレートを作るコマンドを用意してくれていますので、まずはそれを実行します。
コマンドパレット(もしくはメニューのPackagesから)からPackage Generator: Generate Packageを実行します。 保存場所とパッケージ名を尋ねられるので、ここではmy-packageとしておきます。
するといくつかのファイルとディレクトリが作られ、~/.atom/packagesと~/.atom/dev/packagesの下に今作られたmy-packageのディレクトリへのシンボリックリンクが貼られます。

packagesはAtom標準以外のサードパーティのパッケージが保存されているディレクトリで、Atom起動時にここのパッケージが読み込まれます。dev/packagesは開発モード時に読み込まれるディレクトリです。

自分が作ったパッケージを公開したり、あるいは誰かが公開しているパッケージをforkして開発する場合にはdev/packagesにシンボリックリンクを貼ってatom --devから開発モードで起動するといいでしょう。

CoffeeScriptかJSか

そもそもAtomが登場した当初はまだCoffeeScriptが流行っていた頃でしたのでCoffeeScriptで書かれたエディタとして話題を集めていたように記憶しています。なのでそのパッケージを作るにも当然CoffeeScriptと思われますが、2016年現在では素のJSを使うこともできます。それどころかES2015で書くことすらできます。

先ほどのGenerate Packageで作られたテンプレートはCoffeeScriptで書かれていました。もしCoffeeScriptではなくてJSで書きたい場合は、package-generatorというコアパッケージのSettingsでPackage Syntaxをjavascriptに切り替えてから再度Generate Packageを実行してください。

本記事ではJSでテンプレートを生成した前提で解説をしていきます。ですがCoffeeScriptの場合でも.jsを.coffee、.jsonを.csonに置き換えて見てもらえば後は文法が少し異なるだけで問題ありません。

package.jsonの解説

まずはpackage.jsonから見ていきましょう。 package.jsonにはこのパッケージのメタ情報や依存ライブラリの情報を書いていくことになります。


{
  "name": "my-package",
  "main": "./lib/my-package",
  "version": "0.0.0",
  "description": "A short description of your package",
  "keywords": [
  ],
  "activationCommands": {
    "atom-workspace": "my-package:toggle"
  },
  "repository": "https://github.com/atom/my-package",
  "license": "MIT",
  "engines": {
    "atom": ">=1.0.0 <2.0.0"
  },
  "dependencies": {
  }
}

大体のメタ情報は最初から書かれている内容を見れば分かると思うので、見慣れないactivationCommandsだけ解説します。

Atomは最初から全てのパッケージを読み込むわけではなく、遅延読み込みが可能になっています。
実は最初から書かれているこのactivationCommandsだけでそれが実現されており、atom-workspaceという場所でmy-packageのtoggleコマンドを実行しようとしたときに初めてmy-packageが読み込まれる、という意味になっています。

atom-workspaceはAtomのどこでもという意味で、toggleはGenerate Packageでテンプレートを生成したときに初めから用意されているコマンドです。 後ほど解説しますが、toggle以外のコマンドを自分で定義すればもちろんそれをactivationCommandsに割り当てることも可能です。

npmも使えるのか?

packages.jsonはパッケージのメタ情報を書くだけではなく、nodejsでの開発と同様にnpmに必要な情報を書くことも可能です。
依存ライブラリはnpm installdependenciesdevDependenciesに自動登録できますし、必要であればscriptsを作って自分でlintやwatchといったコマンドを登録しても問題ありません。

lib/my-package.js

次にパッケージのコマンドがどこで登録されているか見てみましょう。
lib/my-package.jsはパッケージの起動や終了時の処理、コマンドの登録を行う場所になっています。


// ES2015で書くために必要な記述
'use babel';

import MyPackageView from './my-package-view';
import { CompositeDisposable } from 'atom';

export default {

  myPackageView: null,
  modalPanel: null,
  subscriptions: null,

  // パッケージ起動時に実行される処理
  activate(state) {
    // toggleコマンドで表示されるモーダルのViewオブジェクト
    this.myPackageView = new MyPackageView(state.myPackageViewState);
    this.modalPanel = atom.workspace.addModalPanel({
      item: this.myPackageView.getElement(),
      visible: false
    });

    // イベントを管理するために便利なCompositeDisposable
    // Atomの各所で見かけるが今回は省略
    this.subscriptions = new CompositeDisposable();

    // コマンド登録
    this.subscriptions.add(atom.commands.add('atom-workspace', {
      'my-package:toggle': () => this.toggle()
    }));
  },

  // パッケージ終了時に実行される処理
  deactivate() {
    this.modalPanel.destroy();
    this.subscriptions.dispose();
    this.myPackageView.destroy();
  },

  // パッケージの次回起動時に状態を保存しておく処理
  // 今回は省略
  serialize() {
    return {
      myPackageViewState: this.myPackageView.serialize()
    };
  },

  // toggleコマンドの実際の処理
  toggle() {
    console.log('MyPackage was toggled!');
    // モーダルViewの表示、非表示
    return (
      this.modalPanel.isVisible() ?
      this.modalPanel.hide() :
      this.modalPanel.show()
    );
  }

};

自動的に生成されたMyPackageViewにはtoggleコマンドで表示されるモーダルのHTMLが記述されています。今回は解説しませんが、パッケージで使われるView要素はロジックとは別のモジュールに分けられていることが多いです。

my-packageに新しい機能を追加する

ここまででパッケージのコマンドの登録方法とその実装を見てきました。次はいよいよこのmy-packageに新しい機能を追加してみましょう。

今回はAtomが提供している通知機能を使ってこんな通知を出してみることにします。

atom-my-package-notify.png

Atom APIドキュメント

まずはAtomのAPIドキュメントから通知機能を使うためのAPIを調べましょう https://atom.io/docs/api/

通知機能はNotificationManagerというクラスです。ざっと見てみるとaddSuccessやaddInfo, addErrorというメソッドが並んでおり、いかにもそれっぽいですね。
このNotificationManagerはatom.notificationsからいつでも使えると書いてありますので早速試してみましょう。

コマンドの追加

今回はHello World!と表示させるだけのシンプルな通知を出すコマンドを追加します。
addInfo()を呼び出すnotify()というメソッドを作り、既にコマンド登録されているtoggleの下にnotifyを新しく追加します。


// 変更を加えたメソッドだけ抜粋

  activate(state) {
    this.myPackageView = new MyPackageView(state.myPackageViewState);
    this.modalPanel = atom.workspace.addModalPanel({
      item: this.myPackageView.getElement(),
      visible: false
    });

    this.subscriptions = new CompositeDisposable();

    this.subscriptions.add(atom.commands.add('atom-workspace', {
      'my-package:toggle': () => this.toggle(),
      // notifyを追加
      'my-package:notify': () => this.notify()
    }));
  },

  toggle() {
    console.log('MyPackage was toggled!');
    return (
      this.modalPanel.isVisible() ?
      this.modalPanel.hide() :
      this.modalPanel.show()
    );
  },

  notify() {
    atom.notifications.addInfo('Hello World!');
  }

コードを書き換えて動作確認をする前にAtomをリロードしてパッケージを再読込する必要があります。メニューのView→Developer→Reload Windowでリロードできるのですが、これから何度も使用することになるのでショートカットを覚えてしまいましょう。
Macであればctrl + option + command + Lです。

リロードできたらコマンドパレットを呼び出し、MyPackageを検索します。 toggleしか実行できるコマンドがありませんので、とりあえずtoggleを実行します。package.jsonで解説したようにmy-packageは起動コマンドとしてtoggleが設定されていますので、逆にtoggleを実行するまでは別のコマンドを実行できません。
toggle実行後に再度コマンドパレットで検索をすると今度はnotifyも選べるようになっており、実行するとHello World!という通知が出たはずです。とても簡単な機能ですが、my-packageに新しいコマンドを追加することができました!

activationCommands

新しいコマンドを追加することはできましたが、my-packageの機能を使う前に毎回toggleを実行するのはとても不便ですね。この問題はpackage.jsonのactivationCommandsを削除することで解決できます。
activationCommandsを削除してリロードしてから再度コマンドパレットでMyPackageと検索してみてください。今度は最初からnotifyを実行できるはずです。

activationCommandsが存在しない場合、Atom起動時にパッケージが自動的に読み込まれます。 便利になる反面、CPUやメモリをガンガン使う場合は何もしなくても'重い'パッケージになってしまうので、そのような場合はactivationCommandsを設定して必要になるときまでパッケージの起動を遅らせることができないか検討しましょう。

デバッグ

AtomではChromeと同じ開発者ツールを使ってデバッグができます。
メニューのView→Developer→Toggle Developer Toolsか、Chromeと同じでcommand + options + iのショートカットで起動できます(Macの場合)

JSのデバッグには欠かせないconsole.log()や、debuggerによるブレークポイントの設定ももちろん可能です。

開発者ツールのコンソールではAtomで使われているパッケージが読み込まれている状態なので、ここに直接 atom.notifications.addInfo('Hello World!') と入力しても先ほど実装したnotify()と同じように通知を出すことができます。 APIドキュメントを読みながら色々と実験をするときに重宝するでしょう。

テスト

テストはspecディレクトリに存在します。今回は割愛しますが、もしもパッケージを公開するのであればテストも追加した方がいいでしょう。 ターミナル上でapm testを実行するとspecに存在するテストが全て実行されます。

パッケージ公開

一通りの機能ができたらぜひパッケージを公開してみましょう。 今回は解説しませんが、Atomのドキュメントにパッケージを公開する方法が書いてありますのでぜひ挑戦してみてください。
http://flight-manual.atom.io/hacking-atom/sections/publishing/

終わりに

駆け足になってしまいましたが、パッケージ作成の基本を紹介しました。 Atom独自のお作法を覚えてしまえば、後はJS(CoffeeScript)とCSSで書くことができるので学習コストはそれほど高くないと思います。

ですが、パッケージについてのドキュメントは残念ながらそれほど充実している環境ではないというのが自分の感想です。
Atomのパッケージは公式、サードパーティのどちらもGitHub上で公開されていますので、他の人が作成したパッケージの構成やソースコードを読むことが理解への近道になるはずです。 普段自分が使用しているパッケージや、これから自分が作ろうとしているものに近い機能を提供しているパッケージを参考にするといいでしょう。

プライベートや業務でAtomにこんな機能あったらいいな、と思ったらぜひパッケージ作成に挑戦してみてください。

最後に、拙作ではありますがAtom上でVimのタブ機能を再現するパッケージを公開していますので興味を持った方は使ってみて頂けると嬉しいです。
ソースコードもGitHubに公開してありますので、こちらもぜひ見てみて下さい。
https://atom.io/packages/atom-vim-like-tab
https://github.com/Kesin11/atom-vim-like-tab

ツイート
シェア
あとで読む
ブックマーク
送る
メールで送る

JavaScript MVC フレームワーク Mithril をプロダクト導入した時の話

この記事はDeNA Advent Calendar 2016 5日目の記事です。

はじめに

初めまして、DeNA Games Osaka 技術編成部のさい(@sairoutine)です。DeNA Games Osaka は DeNA の大阪拠点です。今後ともよろしくおねがいしますね。

本記事では、JavaScript MVC フレームワーク Mithril を導入した際のお話をさせていただこうかと思います。

Mithril とは

Mithril とはクライアントサイドのMVCフレームワークです。2014年にLeo Horie氏によって公開され、現在も絶賛開発が進められています。(2016/12 現在、バージョンは0.2.5 です)

特徴として React と同様にレンダリングに仮想DOMを採用していること、またファイルサイズが軽く、処理速度/描画共に他フレームワークより速いことが挙げられます。

Mithril フレームワークについての詳細は手前味噌ですが、 Mobage Developers Blog にも記事がございますので、そちらも確認頂ければと思います。

最速フレームワーク Mithril 入門

採用経緯

ブラウザゲームにおけるレイドボスバトル(1つのボスを複数プレイヤーで攻撃する形式のバトル)において使用しました。このようなバトル画面では、下記の項目のように、変動する数値(一般に状態と呼ばれる)が多く、またバトルの攻撃演出を行う上で、CSSアニメーションを多用する特徴があります。

レイドボスバトルにおける状態の例一覧
1. ボスのHP
2. ボスの各種パラメータ(攻撃力/防御力etc...)
3. プレイヤーのデッキ
4. デッキの各ユニットのHP
5. 各ユニットの各種パラメータ
6. ユニットのスキル使用状況
etc...

担当プロジェクトでは、これまで tt.js という jQuery like な DOM 操作 JavaScript ライブラリを使って、ブラウザ上でのリッチなUXを提供してきましたが、今回の要件のように、多様な状態を管理するには、 DOM操作ライブラリ及び素の JavaScript では、状態を管理するコードを書くのは大変なため、JavaScript フレームワークを導入する運びになりました。

JavaScript フレームワークと言っても、BackboneAngular, React 等、多種多用なフレームワークが存在します。 その中でも Mithril は、処理速度が速くて CSS アニメーションを多用しても問題になりにくいこと、ブラウザ依存が少ないこと、そしてMithril フレームワーク自体のコード行数が2000行程度と少ないため、何らかのバグを踏んでも調査及びモンキーパッチを当てることが容易であることから、採用をしました。

Mithrilを採用して良かったこと

msx という HTML と親和性の高い View コンパイラ

React における jsx と同様に、Mithril においても msx という HTML like に仮想DOMを記述するためのコンパイラが存在します。 msx のおかげで、普段から HTML に慣れ親しんでいるマークアップエンジニアが View 周りを触ることが容易にできました。

MVC + 仮想 DOM がサーバーサイドエンジニアにとって親しみやすい

チーム体制として、専業のフロントエンドエンジニアがおらず、サーバーサイドエンジニアと マークアップエンジニア、そしてデザイナーによる開発でした。

元々 MVC というアーキテクチャに精通しているサーバーサイドエンジニアにとって、Mithril の MVC を習得することは容易でした。 また、仮想 DOM のおかげで、DOM の追加/変更/削除がロジック内に散らばらずに、View に DOM のあるべき姿を書くことができ、 これは、View 周りを PHP における Smarty テンプレートや、Node.js における Jade と同じような概念で書けるため、これも サーバーサイドエンジニアにとって習得が容易でした。

学習コスト低め

Mithril 自身が ES5 で記述されていることもあり、アプリケーションコードを ES5 or ES6 どちらでも記述することができます。 View については、msx という仮想DOM 記述のための独自構文を使用しましたが、それ以外については、 素の JavaScript で書けるため、例えば Angular のように独自の構文を覚えなくて済む点で、 本職のフロントエンドエンジニアでなくとも、学習が容易でした。

CSS アニメーションとの相性の良さ

バトル周りのアニメーションを実装するに当たって、HTML5 Canvas を使用する方法と、DOM を CSS3 Animation を使用する方法があります。 今回は、CSS を使ってアニメーションする方法を採用しました。CSS を使ったアニメーションは、基本的に DOM に css を適用することで アニメーションの実行が発生します。DOM の操作でアニメーションが行えるため、仮想 DOM の恩恵を最大限に受けることができました。

ブラウザ互換性の高さ

Mithril はブラウザ互換性が高く、es5-shim を併用することで、モバイル端末では、 iOS 4.x 系、Android 2.x 系などの古い端末でも動くことが確認できています。

課題点

msx の独自構文を覚えなくてはいけない点

良かった点として、msx が HTML like な点を挙げましたが、逆に言うと、あくまで HTML like であり、 HTML とまったく同じように記述できるわけではありません。

例えば、HTML の class 属性については、javaScript の予約語と重複するため、className 属性で記述する必要があります。また例えば br タグのような閉じタグのないタグについては、<br /> のように XHTML と同様に最後に / をつけないと msx のコンパイルに失敗する等といった HTML との差異があります。

仮想DOM という概念の難しさ

仮想DOMから実DOM へのレンダリングは全て Mithril が担当してくれます。このおかげで、 我々は仮想DOMを記述するだけで、実DOMをレンダリングすることができます。 しかしCSS アニメーションを多用する関係上、仮想DOMから実DOMへいつどのタイミングでレンダリングされるのか、 どのDOMが更新され、どのDOMが更新されないのか、というのをきちんと把握しながら実装する必要があるのですが、 このあたりは Mithril フレームワークのコードを読み解いていく必要がありました。

終わりに

以上、Mithril を導入した際のお話をさせていただきました。 ブラウザゲームにおける採用であったり、本業のフロントエンドエンジニアがいない中での実装であったり、 となかなか特殊な要件での採用でしたが、Mithril は要件とチームの現状に即した採用だったと思います。

仮想DOM といえば、React がある程度の立ち位置を確保してきた感じがありますが、 もし機会ございましたら、ぜひ Mithril も検討してみてください!

ツイート
シェア
あとで読む
ブックマーク
送る
メールで送る

Rails エンジンによる脱 Microservices 化のススメ

はじめに

こんにちは。ゲーム開発基盤部の池田です。

アドベントカレンダー4日目の今日は、複数の Rails アプリケーションを「ほどよく」マイクロサービス化する手法として、 Rails エンジンを用いた構成を紹介します。

Rails エンジン自体はそれほど目新しい技術ではありませんが、Ruby, Rails での開発経験のある方や、サーバサイドの開発者にとって、何かの参考になればよいな、と思います。

Rails エンジンとは何か

Rails エンジン は、Rails プラグインの一種ですが、単なるプラグインよりも強力で、 Rails アプリケーションとほぼ同等の機能を提供してくれます。

Rails エンジンは、Rails アプリケーションから次のコマンドで生成することができます。

$ bin/rails plugin new my_engine --full

実行すると、 my_engine/ ディレクトリ以下に、 app/config/routes.rb を含む Rails アプリケーションそっくりのディレクトリツリーが生成されます。

エンジンを生成した Rails アプリケーション側からは、まるで自身のアプリケーションコードの一部であるかのように扱うことができます。

そもそも、 Rails::Application クラス自体が Rails::Engine クラスを継承しており、その振る舞いを多く引き継いでいます。

なぜ Rails エンジンを採用したか

今回、担当した開発案件では、 API や管理画面機能を含む複数の Web アプリケーションが必要になることが、初めからわかっていました。

API と管理画面では、リクエスト・レスポンスのフォーマットも含め、フロントエンドに近いレイヤのニーズが大きく異なります。
従って、 View, Controller のレイヤは完全に切り離した方がよいだろうと考えられました。

一方で、データストアは共有するため、Model 層は共通化することができました。

そこで、Model 層を含む共通部分を Rails エンジンとして実装し、View, Controller を実装するそれぞれの Rails アプリケーションから利用する形を取ることにしました。

これにより、アプリケーションとしては分離しつつ、共通部分を再利用することで、開発を効率よく進めることができるようになりました。

アプリケーション構成

リポジトリとしては、Rails エンジンを共有する全ての Rails アプリケーションを、単一のリポジトリで管理しています。

ディレクトリ構成としては、以下のようになっています。

  • (root)
    • api/ ... API
      • Gemfile
      • app/
      • bin/
      • config/
      • config.ru
      • lib/
      • shared -> ../shared/
      • spec/
    • admin/ ... 管理画面
      • Gemfile
      • app/
      • bin/
      • config/
      • config.ru
      • lib/
      • shared -> ../shared/
      • spec/
    • shared/ ... 共通エンジン
         - app/
         - config/
         - lib/
         - spec/

個々の Rails アプリケーションと Rails エンジンをそれぞれ別々のリポジトリにするやり方も考えられ、実際に試行錯誤もしましたが、結局は今の形になりました。
個々の Rails アプリケーション同士は独立していますが、アプリケーションとエンジンは互いに密結合であるため、特に開発効率の点から、今の形がベストのように思います。

config/lib/ 以下は、全アプリケーションで共有可能なものはエンジン側に置き、アプリケーションごとに実装の異なるものは、アプリケーション側のディレクトリに置くようにしています。

複数アプリケーション同居のつらいところ

上記のようなディレクトリ構成は、開発上便利ではありますが、Rails アプリケーションの標準的なディレクトリ構成でないため、ときどきレールに乗れずにつらい思いをすることがあります。

特につらいのがデプロイです。
Capistrano を使っていますが、基本的に release_path 等の各種パスが「リポジトリルート = アプリケーションのルートディレクトリ」を前提としているケースが多いです。

そのため、利用している Capistrano のプラグインに対して、いくつかモンキー・パッチを当てて問題を回避しています。

しかし、その辺りは一度仕組みを作ってしまえば、普段のアプリケーション開発で意識する必要はないので、エンジンを共有するメリットの方が勝っていると感じています。

終わりに

以上、Rails エンジンを利用した複数のアプリケーション構成について、紹介しました。

エンジンを共有し、単一のリポジトリで完結させるという点では「モノリシック」に近い構成とも言えそうです。
サービスでより多くのアプリケーションが必要になったときは、必ずしも全てをこれに乗せるべきということはないでしょうが、アーキテクチャとして選択肢の一つにはなると思います。

何かの参考になれば幸いです。

明日、5日目は @haminiku さんです。お楽しみに!

参考

ツイート
シェア
あとで読む
ブックマーク
送る
メールで送る

レガシーなwebページをAngularで書き換えてみて良かったこと

この記事はDeNA Advent Calendar 2016 3日目の記事です。

はじめまして。DeNAでエンジニアをしています。平野です。 かつては大規模プラットフォーム決済システムのサーバーサイドを、 今は"マンガボックス"のサーバー/クライアントサイドを担当しています。

この記事では、 マンガボックスのアプリ内webviewで展開している機能をブラウザにも展開するために、 クライアントサイドをAngularで、サーバーサイドもそれに即すように作り変えた時の基本的な構成の紹介とちょっとしたTipsを記載しています。

自分と同じAngular初学者の方や、既存のサービスをAngularで書き換えるとなんかいいことあるんか?的な方の参考になれば幸いです。

背景

結論に行く前に軽く背景の説明です。レガシーなwebページという若干煽り気味な名前ですが、自サービスの構成のことを指しました。 マンガボックスのiOS/Androidアプリ上で提供している機能のうち、一部はアプリ内webviewで動いており、 とあるURLを叩けばHTMLが返ってくる、たまにAJAXで動的にHTMLを組み換え最終的にwebviewがレンダリングするといういわば普通のwebサイトな構成になっています。

今回その部分をアプリ外のSafariやChromeなどのブラウザでも使えるようにしたいということで、 既存のwebviewが叩いてるURLをブラウザからも叩けるように改修するという方針ではなく、このご時世だしクライアント側にMVCの概念を持たせる構成で再実装してみようというワリとTRY的な位置づけでプロジェクトがはじまりました。

結果的に良くなったこと

  • サーバーとの通信量が減ったことで体感速度が向上した
    • 旧構成では表示する分のHTMLを常にレスポンスとして要していた
    • angularでは表示に必要なデータ部分のみをレスポンスとして受け取り、事前に取得したHTMLにバインドする
  • アプリのwebview部分のネイティブ化への足がかりができた
    • アプリ側が今回用意したAPIを叩くように改修をすれば理屈上ネイティブ化が可能

以上の理由を今回のAngularの構成を紹介しつつ以下で説明していきます。

システムの構成

まず旧構成です。 以下の概念図のように、1画面1エンドポイントになっており、サーバー内で必要な情報をかき集めて最終的にHTMLを返しています。 not_angular.png

angularではこれが以下のようになります。 angular.png まず / へのアクセス時にangularのコードが返ってきますので、これをクライアント側が実行することになります。 その後 /#/top/ にアクセスすると対応したangularのcontrollerが必要な情報毎にサーバーに対してリクエストを送ります。 そして返ってきたJSONをすでに取得済みのHTMLにバインドしてレンダリングを行います。

またこのような構成にすることで、旧構成ではHTMLが返ってくるために困難だったサーバー側のend to endのテストもイメージしやすくなるはずです。

逆に改善したいこと

  • リリース時の考慮ポイントが増える
    • クライアント側またはサーバー側の挙動が変わるリリースの場合、クライアント側の状態を保証できないため
    • 後方互換性を意識した実装が必要
  • seo対策
    • クローラーがjavascriptを認識できない
    • 仮のHTMLを返す等の対応が必要

Tips

ブラウザバックの検知

静的なデータを扱う場合はブラウザバックで戻ってきたときに再度サーバーにデータを問い合わせる事は不要です。 AngularではAPIのレスポンスをキャッシュすることができるのですが、通常の設定では任意のAPIのキャッシュを常にするorしないのどちらかしか設定することはできません。 なのでブラウザバック等で戻ってきた時とそれ以外の時を以下のコードで判別して、その都度APIのキャッシュをするしないを設定しています。


myApp.run(function($rootScope, $state, $location, $document){
    $rootScope.$on('$locationChangeStart', function() {
        $rootScope.use_cache = ($rootScope.newLocation == $location.path()) ? 0 : 1;
    });

    $rootScope.$watch(function () { return $location.path(); }, function (newLocation, oldLocation) {
        $rootScope.oldLocation = oldLocation;
        $rootScope.newLocation = newLocation;
    });
});

おわりに

Angular化における良かった事悩ましい事をいくつか紹介させていただきました。 クライアント側にMVCモデルの概念を持ち込むことで実装上の役割が明確になり、非常にスッキリとコードを書けたことが印象的でした。 Javascript界隈は次から次へとトレンドが移り変わるイメージがありますが、このタイミングに乗れてよかったと思いました。と同時にこれからはもっと早めに乗っかろうとも思いました。

明日は4日目、key-amb さんです。 お楽しみに!

ツイート
シェア
あとで読む
ブックマーク
送る
メールで送る

UnityのNative Pluginを作るためのもろもろ

この記事はDeNA Advent Calendar 2016 2日目の記事です。

初めまして。DeNAでUnity基盤開発をしている、大竹(a.k.a. trapezoid)と申します。UniRxおじさんも兼業していたりします。 今回は、UnityでのNative Plugin開発の流れや勘所について紹介していこうと思います。

まえおき

Unityは非常に多彩なプラットフォームにほぼワンソースで対応出来る優秀なマルチプラットフォームゲームエンジンです。 Unityでゲームを作っていく場合、基本的にはC#でそのコードを記述していくわけですが、プラットフォーム固有の込み入った処理を書きたい場合や、高い性能が求められるような場合、Native Pluginとしてその処理を記述して、Unity本体とインテグレーションするような手法を取る必要があります。

DeNAでは、オーディオエンジンや同期通信用の内製ミドルウェアのSDKなど、ゲームエンジンに関わらず使われる独立したミドルウェアは、原則的にネイティブ開発したものをUnityにインテグレーションする手法をとっているものが多いため、積極的にNativePluginを開発/利用していっています。

Native Pluginをつくる

Native Pluginの形態

モバイルゲームにおいてであれば、やはりまずサポートされるべきターゲットはiOS/Androidとなります。 プラットフォームとC#実行エンジンの選択に従って、Native Pluginをどのようにゲーム本体とリンクするべきかは、以下のように変わります。

OS実行エンジンリンク形態
iOSMonoStatic Library
iOSIL2CPPStatic Library
AndroidMonoShared Library, jar(JNI経由の呼び出し)
AndroidIL2CPPStatic Library, Shared Library, jar(JNI経由の呼び出し)

iOSは基本的にStatic Libraryを作ってリンクします。これはiOS上では外部ライブラリの利用はスタティックリンクしか規約上許可されていないため、このような制約がかかっています。 Androidの場合は、Shared Libraryによるリンクと、JNI経由でJVM上のコードを呼び出す事が可能です。 IL2CPPを利用する場合は、iOSと同様にStatic Libraryを利用することができます。 (訂正:2016/12/02) Unityフォーラムの投稿によれば、Android IL2CPPの場合、Static Libraryはサポートされてないようです。

Native Pluginとしてビルドする

上記の前提にのっとって、まずはNative側の実装を作って、Unityから繋ぎこむべき先のライブラリをビルドします。 Androidの場合はAndroid NDKを、iOSの場合はXcodeを使っていくのが手軽です。cmakeを使うことで一元化することも出来ますが、これはこれで話が長くなるので今回は割愛します。

Native PluginとC#をつなぎこむ

https://docs.unity3d.com/ja/current/Manual/NativePlugins.html 基本的なことや具体的なコードは上記のUnity公式ドキュメントに記載されていますので割愛しますが、UnityのNative Pluginとのインターフェースは、.NETのP/Invokeの仕組みにのっとって行われます。 P/Invokeではマーシャリング手法を引数や返り値, 型毎に細かく指定する事が出来るので、コピー頻度やメモリアロケーション頻度を下げられるように気を使いながら、C#上からP/Invoke宣言を記述していきます。 StructLayout等を利用することも可能ですが、特にiOSの場合64bitアーキテクチャと32bitアーキテクチャに両対応する必要があるため、アライメント境界には強く気を使う必要があります。

EditorでもNative Pluginを使う

Unityを使った開発の強みは、Unity Editorによって動作確認が出来ることによる、開発サイクルの加速にあります。 そのために、Native Pluginによってゲーム中の機能を実現する場合、Unity Editorの内部でも、可能な範囲で動作確認が可能であるべきです。 つまり、Android/iOSに加えて、WindowsやmacOSに対してもマルチプラットフォームサポートを行う事が、実質的にはスタートラインになります。

UnityではmacOSはLoadable Bundle, WindowsはDLLの形で、それぞれネイティブプラグインを利用可能です。 Windowsは単純にDLLを作るだけなので一旦省くとして、Loadable Bundleは馴染みがない方も多いとは思います。Loadable Bundleを作るには、まず以下のようなInfo.plistを任意のパスに保存しましょう。


<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>CFBundleDevelopmentRegion</key>
        <string>en</string>
        <key>CFBundleExecutable</key>
        <string>$(EXECUTABLE_NAME)</string>
        <key>CFBundleIdentifier</key>
        <string>CompanyName.$(PRODUCT_NAME:rfc1034identifier)</string>
        <key>CFBundleInfoDictionaryVersion</key>
        <string>6.0</string>
        <key>CFBundleName</key>
        <string>$(PRODUCT_NAME)</string>
        <key>CFBundlePackageType</key>
        <string>BNDL</string>
        <key>CFBundleShortVersionString</key>
        <string>1.0</string>
        <key>CFBundleSignature</key>
        <string>????</string>
        <key>CFBundleVersion</key>
        <string>1</string>
        <key>NSHumanReadableCopyright</key>
        <string>Copyright © 2016年 Your Name. All rights reserved.</string>
        <key>NSPrincipalClass</key>
        <string></string>
</dict>
</plist>

次に、cmakeでコンパイル対象や必要なライブラリを指定した上で、 先程のInfo.plistを相対パスで指定する形で、ターゲットに対して(BuildTargetNameは適宜読み替えてください)cmake上の以下のようなプロパティ設定を追加します。


    set_target_properties(BuildTargetName PROPERTIES
        BUNDLE TRUE
        MACOSX_BUNDLE TRUE
        BUNDLE_EXTENSION bundle
        MACOSX_BUNDLE_INFO_PLIST ./Path/To/LoadableBundleResource/Info.plist
        )

こうした上で


cmake . -G"Xcode"

でcmakeを走らせれば、Loadable Bundleを生成できるxcodeprojを吐き出せます。(もちろん、手でXcodeからxcodeprojを生成してもかまいません) Windows/OSX共に、利用するUnity Editorのアーキテクチャに合わせて適宜64bitにするのを忘れないようにしましょう。

Native Pluginを配置する

ビルドした各Platform向けのNative PluginをUnityプロジェクト上のAssets/Plugins以下に配置します。 Inspectorから、それぞれのバイナリがどのプラットフォーム向けのものなのかを指定しておけば、完了です。

プラクティス

複雑な構造を手軽にマーシャリングする

非Bittableな値を多く転送するのであれば、どのみちマーシャリングコストはかかるので、マルチプラットフォームで高速なIDL定義型のシリアライゼーションライブラリを利用していくのも手です。代表例としてはProtocolBuffersやFlatbuffersが上がります。 この手のIDL定義型のシリアライゼーションライブラリは、IDLから各言語のパーサコードを生成してアクセスする形式のため、マーシャリングに関わる実装コストをざっくりと押しつけつつ、比較的安全にManaged-Native間のやりとりを行う事ができます。 連続的なメモリアドレスにパラメータを積み込めるので、バッチ的に複数の処理を一気に転送する、というパターンを行いたい場合にも有効です。

Native Pluginでの諸注意

Native Pluginの利用に関しては、いくつか注意するべき点がありますが、今回は

  • Native側からManagedのメソッドをコールバックとして呼び出させる場合、必ずUnityのメインスレッド内から呼び出すようにする
  • Editor上でのPreview再生の停止をしても、Native Pluginはアンロードされない

この二点について説明します。

コールバックは必ずUnityのメインループ内から呼び出す

Unityは多くの処理がメインスレッド、メインループ(Update, LateUpdateなど、Unityがエントリポイントとしてメインスレッド上から呼んでくれるメソッドから連なるコールスタック)から行われることが前提にされています。 uGUIなどは、CanvasRendererとの都合でこの外側からメッシュが編集される処理が走った場合、容易にクラッシュに至ります。 また、Editor上でNativePluginを使う場合には、別スレッドからコールバックが一度でも呼ばれてしまうと、以後MonoDevelop/VisualStudioからのデバッガーアタッチを行ったときにUnityがフリーズしてしまいます。(UnityのMonoのDebuggerのバグに起因しているようです) これらの事情から、コールバックは必ずUnityのメインループの内部から発火されるようにするのがベターです

Editor上でのPreview再生の停止をしても、Native Pluginはアンロードされない

これも非常に厄介なポイントなのですが、Editor上のNative Pluginは、一度ロードされるとPreview再生/停止をするだけでは初期化されません。 再生後に最初の呼び出しで初期化し、再生終了時には終端処理を必ず行うようにする必要があります。さらに、再生中にC#コードを編集してリコンパイルとアセンブリリロードが走った場合、終端処理がきちんと呼び出される保証もなくなるので、初期化処理は冪等性を保っておき、何度呼び出しても支障はない状態にしておく必要があります。

さいごに

長くなりましたが、UnityのNative Pluginを作る方法やその注意点について紹介していきました。

明日は hirashunshun さんです。

  • 2016/12/02 Android IL2CPPでのサポートされるリンク手法について追記と修正をしました。
ツイート
シェア
あとで読む
ブックマーク
送る
メールで送る

実務で使っているUnityの便利モジュール・実装パターン3選

DeNA Engineer Advent Calendar 2016 1日目の記事です。

こんにちは、ゲームエンジニアの宇塚です。
この記事では、自分がUnityでゲーム作りをする中で実装し実務で使っている便利モジュール・実装パターンとして以下3つを紹介します。

  • ユーザー入力を安全に防ぎ、デバッグしやすくする InputGuardManager
  • ユーザー選択を簡単に取得する汎用確認ダイアログ呼び出し DialogHelper
  • 複数の通信処理をまとめる処理のパターン

Unityと一部UniRxを前提としていますが、他言語や環境でも活用できると思います!

ユーザー入力を安全に防ぎ、デバッグしやすくする InputGuardManager

通信や大事な演出を待つために、ユーザーの入力を受け付けなくしたいことがあります。
ユーザーの入力を防ぐ処理に不具合があり入力を防いだ状態が解除されなくなったとしたら深刻な問題ですので、確実に解除される呼び出し方のみを提供するモジュール InputGuardManager を作りました。

InputGuardManager 本体

ユーザーの入力を防ぐ=ガードするメソッドが IDisposable を返し、それを Dispose することでガードが解除されるようになっています。



// ユーザーの入力を防ぐクラス。説明のために入力の種類を絞り簡略化しています。シングルトンです。
public class InputGuardManager : Singleton
{
    // モーダル(ダイアログなど)を表示するcanvas用のgraphicraycaster
    private GraphicRaycaster modalGraphicRaycaster;

    // 通常の2DUIを表示するCanvas用のGraphicRaycaster
    private GraphicRaycaster overlayGraphicRaycaster;

    // Androidのバックキーイベントを扱うクラス
    private BackKeyHandler backKeyHandler;

    // Modalのガードキーに対する参照カウントの辞書。
    private Dictionary modalGuardDict = new Dictionary();

    // Overlayのガードキーに対する参照カウントの辞書。
    private Dictionary overlayGuardDict = new Dictionary();

    // BackKeyのガードキーに対する参照カウントの辞書。
    private Dictionary backKeyGuardDict = new Dictionary();

    #region Public methods

    public void Initialize()
    {
        modalGraphicRaycaster = __取得処理__;
        overlayGraphicRaycaster = __取得処理__;
        backKeyHandler = __取得処理__;
    }

    // Modalのガードの有無。
    public bool IsModalGuarded {
        get { return modalGuardDict.Count() > 0; }
    }

    // Overlayのガードの有無。
    public bool IsOverlayGuarded {
        get { return overlayGuardDict.Count() > 0; }
    }

    // BackKeyのガードの有無。
    public bool IsBackKeyGuarded {
        get { return backKeyGuardDict.Count() > 0; }
    }

    // 指定したキーで、Modal・Overlay・BackKeyをガードする。
    // 返り値のIDisposableをDisposeしたときにガード解除する。
    public IDisposable Guard(string key)
    {
        GuardInternal(key);
        return Disposable.Create(() => UnguardInternal(key));
    }

    // 指定したキーで、Modalをガードする。
    // 返り値のIDisposableをDisposeしたときにガード解除する。
    public IDisposable GuardModal(string key)
    {
        GuardModalInternal(key);
        return Disposable.Create(() => UnguardModalInternal(key));
    }

    // 指定したキーで、Overlayをガードする。
    // 返り値のIDisposableをDisposeしたときにガード解除する。
    public IDisposable GuardOverlay(string key)
    {
        GuardOverlayInternal(key);
        return Disposable.Create(() => UnguardOverlayInternal(key));
    }

    // 指定したキーで、BackKeyをガードする。
    // 返り値のIDisposableをDisposeしたときにガード解除する。
    public IDisposable GuardBackKey(string key)
    {
        GuardBackKeyInternal(key);
        return Disposable.Create(() => UnguardBackKeyInternal(key));
    }

    #endregion

    private void GuardInternal(string key)
    {
        GuardModalInternal(key);
        GuardOverlayInternal(key);
        GuardBackKeyInternal(key);
    }

    private void UnguardInternal(string key)
    {
        UnguardModalInternal(key);
        UnguardOverlayInternal(key);
        UnguardBackKeyInternal(key);
    }

    private void GuardModalInternal(string key)
    {
        if (!modalGuardDict.ContainsKey(key)) {
            modalGuardDict.Add(key, 0);
        }
        modalGuardDict[key] = modalGuardDict[key] + 1;

        if (modalGuardDict.Count == 1 && modalGuardDict[key] == 1)
        {
            ChangeModalGuard(true);
        }
    }

    private void UnguardModalInternal(string key)
    {
        if (!modalGuardDict.ContainsKey(key) || modalGuardDict[key] <= 0) {
            return;
        }

        modalGuardDict[key] = modalGuardDict[key] - 1;

        if (modalGuardDict[key] <= 0) {
            modalGuardDict.Remove(key);
        }

        if (modalGuardDict.Count == 0) {
            ChangeModalGuard(false);
        }
    }

    private void GuardOverlayInternal(string key)
    {
        if (!overlayGuardDict.ContainsKey(key)) {
            overlayGuardDict.Add(key, 0);
        }
        overlayGuardDict[key] = overlayGuardDict[key] + 1;

        if (overlayGuardDict.Count == 1 && overlayGuardDict[key] == 1)
        {
            ChangeOverlayGuard(true);
        }
    }

    private void UnguardOverlayInternal(string key)
    {
        if (!overlayGuardDict.ContainsKey(key) || overlayGuardDict[key] <= 0) {
            return;
        }

        overlayGuardDict[key] = overlayGuardDict[key] - 1;

        if (overlayGuardDict[key] <= 0) {
            overlayGuardDict.Remove(key);
        }

        if (overlayGuardDict.Count == 0) {
            ChangeOverlayGuard(false);
        }
    }

    private void GuardBackKeyInternal(string key)
    {
        if (!backKeyGuardDict.ContainsKey(key)) {
            backKeyGuardDict.Add(key, 0);
        }
        backKeyGuardDict[key] = backKeyGuardDict[key] + 1;

        if (backKeyGuardDict.Count == 1 && backKeyGuardDict[key] == 1)
        {
            ChangeBackKeyGuard(true);
        }
    }

    private void UnguardBackKeyInternal(string key)
    {
        if (!backKeyGuardDict.ContainsKey(key) || backKeyGuardDict[key] <= 0) {
            return;
        }

        backKeyGuardDict[key] = backKeyGuardDict[key] - 1;

        if (backKeyGuardDict[key] <= 0) {
            backKeyGuardDict.Remove(key);
        }

        if (backKeyGuardDict.Count == 0) {
            ChangeBackKeyGuard(false);
        }
    }

    private void ChangeModalGuard(bool isGuarded)
    {
        modalGraphicRaycaster.enabled = !isGuarded;
    }

    private void ChangeOverlayGuard(bool isGuarded)
    {
        overlayGraphicRaycaster.enabled = !isGuarded;
    }

    private void ChangeBackKeyGuard(bool isGuarded)
    {
        backKeyHandler.enabled = !isGuarded;
    }
}

ユーザーの入力の種類が増えることに備え、Interfaceを作り入力毎のクラスを分けていくとより拡張しやすくなりますね。
使い方はこちら



IDisposable disposable = InputGuardManager.Instance.Guard("Login");
// ログインする通信処理をここで実行。この間ユーザー入力はガードされる。
disposable.Dispose();

InputGuardManager の入力ガード解除を忘れなくする拡張

上の使い方で Dispose をし忘れると、入力が防がれたままになってしまいます。
忘れないように以下のような使い方ができるようにしました。



Observable.ReturnUnit()
    .UsingInputGuardWith(
        "Login",
        _ => LoginProcess()
    )
    .Subscribe()
    .AddTo(subscriptions);

LoginProcess の処理を行っている間、指定したキー "Login" でユーザーの入力を防ぎます。
明示的に Dispose しなくてもガードが外れるので安心です。
実装はこちら



public static class Extension
{
    public static IObservable UsingInputGuardWith(
        this IObservable source,
        string guardKey,
        Func> observableFactory)
    {
        return source.Using(
            observableFactory, _ => InputGuardManager.Instance.Guard(guardKey)
        );
    }
}

ここで使われている Using は ReactiveX で提供されている機能を UniRx で使えるようにしたもので、明日の記事担当の @trapezoid さん作成のモジュールです。



public static class ObservableEx
{
    public static UniRx.IObservable Using(
        Func> observableFactory, Func resourceFactory)
    {
        return UniRx.Observable.Create (observer => {
            SerialDisposable disposable = new SerialDisposable();
            disposable.Disposable = resourceFactory();
            return StableCompositeDisposable.Create(
                observableFactory()
                    .Finally(() => disposable.Dispose())
                    .Subscribe(observer),
                disposable
            );
        });
    }

    public static UniRx.IObservable Using(
        this UniRx.IObservable source,
        Func> observableFactory,
        Func resourceFactory)
    {
        return source.SelectMany(
            value => Using(
                () => observableFactory(value), 
                () => resourceFactory(value)
            )
        );
    }
}

入力ガードしているのと同じ期間、画面上に読み込み中表示を出すという拡張も可能です。



// 接続中オーバーレイとInputGuardをUsingする。
// ここでは ConnectingOverlayManager の Show により読み込み中表示が出て、
// 返り値の IDisposable を Dispose することで表示解除されるとします。
public static IObservable UsingConnectingOverlayAndInputGuardWith(
    this IObservable source,
    string inputGuardKey,
    Func> observableFactory)
{
    return source.Using(observableFactory, _ => {
        return new CompositeDisposable(
            InputGuardManager.Instance.Guard(inputGuardKey),
            ConnectingOverlayManager.Instance.Show(
                ConnectingOverlayManager.OverlayType.Connecting
            )
        );
    });
}

InputGuardManager の入力ガード状況をリアルタイム反映する EditorWindow

InputGuardManager がユーザーの入力を防ぐ状況を UnityEditor でリアルタイム反映する EditorWindow です。
開発中に、意図した期間、意図したキーで、意図した入力が防がれていることを確認しやすくなります。



// InputGuardManager の入力ガード状況をリアルタイム表示する EditorWindow
// 情報の表示・変更のために InputGuardManager にデバッグ専用メソッドを幾つか足す必要があります。
public class InputGuardManagerEditor : EditorWindow
{
    private const float Height = 16f;
    private const float MinKeyColumnWidth = 120f;
    private const float FlagColumnWidth = 46f;

    [MenuItem("Window/InputGuardManagerEditor")]
    static public void OpenInputGuardManagerEditor()
    {
        EditorWindow.GetWindow(false, "InputGuard", true).Show();
    }

    // 秒間10回呼ばれるインスペクタのUpdateメソッド
    private void OnInspectorUpdate()
    {
        Repaint();
    }

    private void OnGUI()
    {
        if (!InputGuardManager.HasInstance()) {
            GUILayout.Label("InputGuardManagerが入力を防ぐ状況を表示・変更します。");
            return;
        }

        if (!Application.isPlaying) {
            GUILayout.Label("ゲームが起動していません。");
            return;
        }

        DrawGuardCondition();
    }

    private void DrawGuardCondition()
    {
        const float ColumnWidth = 60f;

        EditorGUILayout.BeginVertical();
        {
            EditorGUILayout.BeginHorizontal();
            EditorGUILayout.LabelField("Modal:   ", GUILayout.Width(ColumnWidth));
            EditorGUILayout.LabelField("Overlay: ", GUILayout.Width(ColumnWidth));
            EditorGUILayout.LabelField("BackKey: ", GUILayout.Width(ColumnWidth));
            EditorGUILayout.EndHorizontal();

            // 現在各入力が防がれているかどうかを表示
            EditorGUILayout.BeginHorizontal("AS TextArea", GUILayout.MinHeight(20f));
            EditorGUILayout.LabelField(InputGuardManager.Instance.IsModalGuardedForDebug ?
                "Guarded" : "__", GUILayout.Width(ColumnWidth));
            EditorGUILayout.LabelField(InputGuardManager.Instance.IsOverlayGuardedForDebug ?
                "Guarded" : "__", GUILayout.Width(ColumnWidth));
            EditorGUILayout.LabelField(!BackKeyManager.Instance.Enabled ?
                "Guarded" : "__", GUILayout.Width(ColumnWidth));
            EditorGUILayout.EndHorizontal();

            // 各入力をボタンで強制的に解除する
            // デバッグ中に入力ガードを外して動作確認するのに便利です
            EditorGUILayout.BeginHorizontal();
            {
                EditorGUILayout.BeginVertical(GUILayout.Width(ColumnWidth));
                if (GUILayout.Button("Clear", GUILayout.Width(ColumnWidth))) {
                    InputGuardManager.Instance.ClearAllModalGaurdUnsafe();
                }
                EditorGUILayout.EndVertical();

                EditorGUILayout.BeginVertical(GUILayout.Width(ColumnWidth));
                if (GUILayout.Button("Clear", GUILayout.Width(ColumnWidth))) {
                    InputGuardManager.Instance.ClearAllOverlayGuardUnsafe();
                }
                EditorGUILayout.EndVertical();

                EditorGUILayout.BeginVertical(GUILayout.Width(ColumnWidth));
                if (GUILayout.Button("Clear", GUILayout.Width(ColumnWidth))) {
                    InputGuardManager.Instance.ClearAllBackKeyGuardUnsafe();
                }
                EditorGUILayout.EndVertical();
            }
            EditorGUILayout.EndHorizontal();

            DrawKeys();
        }
        EditorGUILayout.EndVertical();
    }

    private void DrawKeys()
    {
        HashSet allKeySet
            = new HashSet(InputGuardManager.Instance.ModalGuardDictKeysForDebug);
        allKeySet.UnionWith(InputGuardManager.Instance.OverlayGuardDictKeysForDebug);
        allKeySet.UnionWith(InputGuardManager.Instance.BackKeyGuardDictKeysForDebug);

        if (allKeySet.Count == 0) {
            EditorGUILayout.LabelField("No keys found.");
            return;
        }

        DrawHeader();

        List keys = allKeySet.ToList();
        keys.Sort();

        foreach (string key in keys) {
            DrawKeyRow(key);
        }
    }

    private void DrawHeader()
    {
        EditorGUILayout.BeginHorizontal();
        {
            EditorGUILayout.LabelField("Registered Key", GUILayout.MinWidth(MinKeyColumnWidth));
            EditorGUILayout.LabelField("Modal", GUILayout.Width(FlagColumnWidth));
            EditorGUILayout.LabelField("Overlay", GUILayout.Width(FlagColumnWidth));
            EditorGUILayout.LabelField("BackKey", GUILayout.Width(FlagColumnWidth));
        }
        EditorGUILayout.EndHorizontal();
    }

    private void DrawKeyRow(string key)
    {
        EditorGUILayout.BeginHorizontal("AS TextArea", GUILayout.MaxHeight(Height));
        {
            EditorGUILayout.SelectableLabel(
                string.IsNullOrEmpty(key) ?
                "!!empty!!" :
                key, GUILayout.MinWidth(MinKeyColumnWidth), GUILayout.MaxHeight(Height));

            EditorGUILayout.LabelField(
                InputGuardManager.Instance.GetModalGuardCountForDebug(key).ToString(),
                GUILayout.Width(FlagColumnWidth),
                GUILayout.MaxHeight(Height));

            EditorGUILayout.LabelField(
                InputGuardManager.Instance.GetOverlayGuardCountForDebug(key).ToString(),
                GUILayout.Width(FlagColumnWidth),
                GUILayout.MaxHeight(Height));

            EditorGUILayout.LabelField(InputGuardManager.Instance.GetBackKeyGuardCountForDebug(key).ToString(),
                GUILayout.Width(FlagColumnWidth),
                GUILayout.MaxHeight(Height));
        }
        EditorGUILayout.EndHorizontal();
    }
}

この EditorWindow を使うとリアルタイムでガード状況がわかり、不具合でガードされたままになるときもボタンでひとまずガード解除してデバッグを続けられます。

InputGuardManagerEditor.png

実機で入力ガードされたままになったときの状況を確認するDebugMenu

実機動作確認やQAで入力ガードされたままになった場合にも、ガードしているキーを確認して情報を得たり、ひとまずガードを外したりできるように DebugMenu があると便利です。
QAから開発にDebugMenuのスクリーンショットを送ってもらえば、原因特定が容易になります。
DebugMenu の実装については割愛しますが、上述の EditorWindow とほぼ同様のコードとなります。

InputGuardDebugMenu.png

ユーザー選択を簡単に取得する汎用確認ダイアログ呼び出し DialogHelper

stringのメッセージを表示し、ユーザーの「はい」か「いいえ」さらにAndroidのバックキーの入力によって後続の処理を変える汎用確認ダイアログを開くモジュールです。

呼び出し方



CompositeDisposable subscriptions = new CompositeDisposable();

private void OpenBattleConfirmationDialog()
{
    subscriptions.Clear();

    DialogHelper.ShowConfirmDialog("戦いを挑みますか?")
        .Where(decisionType => decisionType == DecisionType.Yes)
        .Subscribe(GoToBattle)
        .AddTo(subscriptions);
}

実装はこちら



public static class DialogHelper
{
    // ユーザーの決定した行動タイプ
    public enum DecisionType
    {
        Yes,      // はい
        No,       // いいえ
        BackKey,  // Androidのバックキー
    }

    public static IObservable ShowConfirmDialog(string message)
    {
        // 汎用ダイアログのクラス
        Dialog dialog = GetDialog();

        // Androidのバックキーイベントを扱うクラス
        BackKeyHandler backKeyHandler = GetBackKeyHandler();

        // Observable.Zip は、複数のストリームに1つずつメッセージが来た時点でそれらを合成して流します
        // Observable.Zmb は、複数のストリームのうち、最も早く流れてきたものを流します

        return Observable.Zip(
            Observable.Amb(
                // Yesが押されたとき
                dialog.OnSelectYes.Select(_ => DecisionType.Yes),
                // Noが押されたとき
                dialog.OnSelectNo.Select(_ => DecisionType.No),
                // BackKeyが押されたとき
                backKeyHandler.OnBack.Select(_ => DecisionType.BackKey)
            ).Do(_ => dialog.Hide()),         // Yes、No、BackKeyのいずれかが押されたらダイアログを閉じ始める
            dialog.OnFinishHideAnimation,     // ダイアログが閉じるアニメーションが終わるまで待つ
            (decisionType, _) => decisionType // Yes、No、BackKeyのどれが押されたかを後ろに流す
        ).First();                            // 最初の選択だけが流れるようにする
    }
}

dialog.Hide() の後に SelectMany で dialog.OnFinishHideAnimation を続けていないのは、もしアニメーションが全く設定されておらず Hide() の呼び出しの中で即座に OnFinishHideAnimation に流れてきてしまっていた場合、ShowConfirmDialogが何も流さなくなってしまうからです。

複数の通信処理をまとめる処理

複数の通信APIを組み合わせた複雑な処理を隠蔽するクラスを作るパターンを例示します。
ゲーム仕様によるのでこのまま使い回しはできませんが、複雑な処理をまとめたいときによく使っている手法です。

ギルドからの脱退処理

仕様

  • ギルドメンバーはギルド脱退通信を呼び出せる。
  • ギルドメンバーでなければ、ギルド非所属だとメッセージを出す。
  • ギルドメンバーならば、脱退させて成功メッセージを出す。
  • ギルドマスターなら、マスターは脱退できないとメッセージを出す。
  • ギルドにギルドマスター1人しかいないときは、代わりにギルド解散通信を呼び脱退成功メッセージを出す。

上記の仕様を実現するための一連の処理を隠蔽するクラスを作ることを考えます。 サーバーサイドでロジックを組むという方法も考えられますが、ここでは既に用意された信頼性の高い「ギルド脱退」「ギルド除法取得」「ギルド解散」といった個別のAPIを組み合わせて実現することとします。

一部処理を省略したコードが以下です。



public class LeaveGuildService
{
    public IObservable StartProcess()
    {
        return LeaveGuild()
            .CatchIf(
                (GuildException e) => e.GuildErrorId == GuildErrorId.NOW_GUILD_MASTER,
                e => OnNowGuildMasterError()
            );
    }

    // ギルド脱退処理
    private IObservable LeaveGuild()
    {
        return new LeaveGuildProcess().StartProcess()  // ギルド脱退APIを叩く
            .RethrowIf((NetworkException e) =>  {
                switch (e.ErrorId) {
                case NetworkErrorId.NOT_GUILD_MEMBER:  // ギルドメンバーではない場合
                case NetworkErrorId.NOW_GUILD_MASTER:  // ギルドマスターではない場合
                    return true;
                }
                return false;
            }, e => new GuildException(e))
            .__通信のリトライ処理__
            .AsUnitObservable();
    }

    // ギルド脱退処理で、ギルドマスターであるエラーが帰ってきたときに呼ぶ処理
    private IObservable OnNowGuildMasterError()
    {
        return GetMyGuildInfoModel()
            .SelectMany(guildInfoModel => CheckGuildMemberCount(guildInfoModel))
            .SelectMany(_ => DeleteGuild())
            .AsUnitObservable();
    }

    // 自分の所属ギルドの情報を取得する処理
    private IObservable GetMyGuildInfoModel()
    {
        return new GetMyGuildRecordProcess().StartProcess()  // 所属ギルド情報取得APIを叩く
            .RethrowIf(
                (NetworkException e) => e.ErrorId == NetworkErrorId.NOT_GUILD_MEMBER,
                e => new GuildException(e)
            )
            .__通信のリトライ処理__;
    }

    // ギルドの所属メンバー数が1人だけであるかを調べ、1人でないならGuildExceptionを流す。
    private IObservable CheckGuildMemberCount(GuildInfoModel guildInfoModel)
    {
        if (guildInfoModel.MemberCount != 1) {
            return Observable.Throw(
                new GuildException("ギルドの所属メンバーが1人だけではない",
                GuildErrorId.GuildMemberCountNotOne)
            );
        }

        return Observable.ReturnUnit();
    }

    // ギルド解散処理
    private IObservable DeleteGuild()
    {
        return new DeleteGuildProcess().StartProcess()  // ギルド解散APIを叩く
            .RethrowIf((NetworkException e) => {
                switch (e.ErrorId) {
                case NetworkErrorId.NOT_GUILD_MEMBER:   // ギルドメンバーではない場合
                case NetworkErrorId.NOT_GUILD_MASTER:   // ギルドマスターではない場合
                    return true;
                }
                return false;
            }, e => new GuildException(e))
            .__通信のリトライ処理__
            .AsUnitObservable();
    }
}

例外によって処理を様々に分岐させる必要がある処理を簡潔にまとめることができます。
このクラスを利用する側では個別の通信が上手くいったかどうかを気にすることなく成功か失敗かを受け取ることができます。
後でギルド脱退を呼び出したい場所が増えても安心です。

こういったServiceクラスを共通の形式で作ることで、より複雑な一連の処理もServiceクラスを結合した新たなServiceクラスとし、DRYな状態を保てます。

ここで使われている RethrowIf と CatchIf も @trapezoid さん作成のモジュールです。



public static class ErrorHandlingExtensions
{
    // 条件にマッチするExceptionがOnErrorに流れてきた時、別の例外に変換して後続に流す
    public static UniRx.IObservable RethrowIf (
        this UniRx.IObservable source, 
        System.Predicate predicate,
        System.Func conversion
    )
        where TSourceException : System.Exception
        where TDestinationException : System.Exception
    {
        return source.Catch ((TSourceException e) => {
            if (predicate (e)) {
                return UniRx.Observable.Throw (conversion (e));
            } else {
                return UniRx.Observable.Throw (e);
            }
        });
    }

    // 条件にマッチするExceptionがOnErrorに流れてきた時、別のストリームに差し替える
    // Catchの条件付与版
    public static UniRx.IObservable CatchIf (
        this UniRx.IObservable source, 
        System.Predicate predicate,
        System.Func> errorHandler
    )
     where TException : System.Exception
    {
        var result = source.Catch (
                e => predicate (e) ? errorHandler (e) : UniRx.Observable.Throw (e)
        );
        return result;
    }
}

終わりに

今回は、自分がゲーム作りをする中で実装し実務で使っている便利モジュール・実装パターンとして

  • ユーザー入力を安全に防ぎ、デバッグしやすくする InputGuardManager
  • ユーザー選択を簡単に取得する汎用確認ダイアログ呼び出し DialogHelper
  • 複数の通信処理をまとめる処理のパターン

を紹介しました。

ぜひ活用してみてください!

ツイート
シェア
あとで読む
ブックマーク
送る
メールで送る

DeNAにおけるOpenStack運用#3

DeNAのOpenStackインフラ運用を担当している窪田です。 最後にご紹介するのはSDS Cephについてです。採用の背景と、これまで運用していく中で直面したパフォーマンスの問題、それをどのようにシューティングしたのかについてご紹介します。

採用の背景

第一回の小野の記事で、Ceph導入によって実現されたこととして、OpenStackとの連携、ストレージプールの外部化、ライブマイグレーションを取り上げました。実のところこれらのことはCephでなくても近年リリースされている商用ストレージやSDSをうたうプロダクトであれば大抵できることで、これらの実現の他にも重要視していることがありました。 決め手となったのはOSSプロダクトであることです。OSSだとなぜいいのかというと、ソースコードが公開されていることによって、やろうと思えばソフトウェアの振る舞いを詳細に把握することが可能ですし、そうすることでパフォーマンス上のトラブルが起きたときに自力で何がボトルネックとなっているのかを見極め、問題をうやむやにせず納得のいく対応策を練ることができる場合があるからです。これまでDeNAのインフラを運用してきた経験からその重要性を無視できず、中身がブラックボックスなものはできれば避けたいという思いがありました。

構成

本題に入る前に弊社環境の構成について触れておきます。弊社インフラの全てがこの構成で運用しているわけでなく、まだ社内の開発者用のインフラに限定しています。 Cephはブロックストレージ、オブジェクトストレージ、ファイルシステムの三用途に使えますが、弊社ではKVM仮想マシンのストレージとして使うのが目的なのでブロックストレージ機能のみ使っています。 Cephのバージョンは0.94.6のHammerリリースを使っています。最新版ではないのでこれから述べることは最新版では当てはまらない情報も含まれていますがそこはご容赦ください。

個々のサーバのハードウェアとソフトウェアの構成は下図のようになっています。

ceph-blog-node.png

ALL HDDです。ネットワークは物理的に一つのLANに全Ceph nodeがつながっています。 Cephのパフォーマンスをより引き出すには、Ceph nodeのクラスタにそれ専用のサーバを設け、Cephクラスタ内の通信は専用のネットワークを敷く(Cluster Network)など考えられますが、 まずは今あるハードウェア資産を活かして最小限のハードウェアコストで構成し、ボトルネックを見極め、どうしてもハードウェアリソースの増強が必要な場合は追加投資、あるいはCephの導入を見送る選択肢も残しつつ進めていくことにしました。 結果、後述するチューニングを経てこの物理構成を崩さず今に至っています。ですが問題がないわけではありません。ネットワークでいえば輻輳しやすい問題があります。

今はコストを抑えたハードウェア構成を組んでいますが、これで十分と考えているわけでなく、物理的なI/Oの性能が不足しているならSSDを導入することや、後述のfsync/fdatasyncの性能問題のようなCephならではの欠点を補えるようなストレージを導入してマルチバックエンドストレージ構成にするなども視野に入れて検討しています。

ネットワークの輻輳

各Ceph nodeで稼働しているOSDが相互にTCPセッションをはってメッシュ状に通信している上に、Ceph nodeはCompute nodeも兼ねているので、OSDはCephクライアントとなるNova instanceともTCPセッションをはっています。なので各node間の同時通信が発生しやすく、バーストトラフィックによるパケットの廃棄(パケロス)が起きやすい構成です。 パケロスを抑える効果的な手段は、大きなフレームバッファを搭載しているネットワークスイッチを導入する、CephのPublic NetworkとCluster Networkを物理的に分ける、など考えられますが、既存資産を限りなく使い切りたいので、発想を変えてパケロスを抑えるのを諦め、BigSwitchとLinuxのQoS機能を組み合わせ、Nova instanceのトラフィックを優先してユーザ影響を軽減するような仕組みにできないかを検証しています。

パフォーマンスチューニング

上図のようなハードウェア構成でぜひやるべきことが2つあります。

  • ジャーナルとデータ領域を一つの物理ディスクに共存させない
  • tcmallocのキャッシュサイズを拡張する

Cephは一つのOSDに対して二度の書き込みを行います。一度目がジャーナル領域、二度目がデータ領域です。ジャーナルに書き込まれた時点でOSDはクライアントに書き込み完了のレスポンスを返すことができるので、ジャーナルにSSDのような高速なハードウェアを使う方が良いという話を聞きますが、弊社ではSSDは使わず、4つのHDDを下図のように構成しています。

ceph-blog-disk.png

Linuxルートファイルシステムが保存されているディスクは冗長化のためHW RAIDでミラーリング(RAID1)し、同ディスクに全OSDのジャーナルをファイルとして置いた構成です。 ディスクの数にもよりますが、一つのディスクにジャーナルとデータ領域を共存させるよりも、ジャーナルはデータ領域とは物理的に別のディスクを使ったほうがI/Oスループットが向上します。ジャーナルへのI/Oはシーケンシャルwriteなので、ランダムI/O中心のデータ領域と比べてディスクへの負荷が低く、高いIOPSが出やすいです。

ご参考までに、ディスクの負荷がどの程度改善するのかを、データ領域側ディスクのiostatの%utilをグラフ化したもので比較すると、下のように50%前後で推移していたのが15%前後まで下がりました。

ジャーナルと共存した場合 ceph-blog-before.png

ジャーナルと共存しない場合 ceph-blog-after.png

あとこの構成にする上で設定しておくべきこととしてあるのが、ジャーナル側ディスクのLinux I/OスケジューラをDeadlineスケジューラにすることです。その他のI/Oスケジューラだと複数のOSDからくるシーケンシャルwriteを効率良くさばけず、パフォーマンスを維持できないのでDeadlineスケジューラは必須です。

tcmallocのキャッシュサイズを拡張するチューニングについてはCephコミュニティなどからも報告があり、詳細はそちらに譲ります。 tcmallocのキャッシュサイズ拡張は、あらゆるワークロードでパフォーマンスが向上するわけではなさそうですが、弊社環境では劇的な効果がありました。 弊社では前述したように28 Ceph node、56 OSDで約1,500のNova instance(KVM仮想マシン)がCeph上で常時稼働していますが、 Nova instanceが増えるにつれて1 OSDあたりがさばくメッセージ量が増え、tcmallocのキャッシュメモリの獲得、解放にかかるCPU処理が重くなり、 クライアントのI/Oリクエストが数秒から数十秒以上待たされる現象が頻発していたのですが、このチューニングによって秒単位の遅延がほとんど発生しなくなりました。 Cephクライアントを増やしていくとパフォーマンスが劣化するような現象にあたった場合は試してみるとよいでしょう。

ログ分析

パフォーマンスボトルネックの解析で実際に役立ったログの分析方法を一つご紹介します。 CephはI/Oのパフォーマンスが何らかの要因で劣化すると、クライアントからのI/Oリクエストの遅延間隔をログに記録します。slow requestと書かれたログです。弊社では遅延の要因を判断する上でslow requestをよくみています。slow requestのログの末尾にはOSDの何の処理に時間がかかっているのかがマーキングされており、チューニングの判断材料にしています。下記のようなログが書き出されます。

  • "currently no flag points reached"
  • "currently started"
  • "currently waiting for rw locks"
  • "currently waiting for subops from "
  • "currently commit_sent"

この中でも"currently commit_sent"はあまり深刻に受け止める必要のないログです。このログはジャーナルにwrite済みのデータをデータ領域にwriteするのが遅れていることを示しており、言い換えればジャーナルにデータが何秒滞留しているかを示しています。クライアントへのレスポンスが遅延しているわけではなく、ジャーナルのサイズに余裕がある場合はこのログが断片的に出ても問題ないとみなしており、弊社ではほぼ無視しています。

その他のログは重要です。ここでは"currently waiting for subops from "だけ取り上げてみましょう。 OSDのwrite処理はまずPrimary OSDがクライアントからwriteのメッセージを受けてSecondary OSDとTertiary OSDにwriteメッセージを投げます(レプリケーション数3で運用しています)が、投げたメッセージに対してSecondaryかTertiaryのどちらかのレスポンスが遅れるとこのログが出ます。ログのfromの後ろにSecondaryとTertiaryのIDが入るのでどのSecondaryまたはTertiaryかは特定できます。なのでこのログが出たときはログを出しているPrimaryに遅延の原因があるわけでなく、そのOSDとレプリケーションを組んでいるSecondaryかTertiaryのどちらかに原因があるはずです。

他にもslow requestのログに含まれる情報で役に立つものとしてRBD imageのIDがあります。RBD imageはCinder volumeでもあるので、どのCinder volumeのI/Oが遅延しているのかを特定できます。

fsync問題

前述のチューニングである程度パフォーマンスは改善するのですが、それでもなお課題として残っているのがfsync/fdatasyncシステムコールを多用するwrite処理が苦手なことです。そのようなアプリケーションで例をいえばMySQLのトランザクションのコミット処理が当てはまります。 なぜ苦手なのかというと、Cephは複数のI/Oを同時並行処理するようなワークロードが得意ですが、fsyncのようなデータ同期を挟まれてしまうと後続のwriteが待たされてしまいます。図にするとこのようなイメージです。

ceph-blog-write.png

Cephのようにネットワークまたぎで同期レプリケーションまでやるストレージだと、サーバローカルのストレージへのI/Oで完結する場合に比べてどうしてもレイテンシが長くなり、fsyncによるレスポンス待ちの影響が際立ちます。

弊社ではこの問題の根本解決は現状無理だと判断し、クライアントのローカルファイルシステム(ext4やxfs)のバリアオプションを無効にすることでfsyncによるパフォーマンスの劣化を抑えています。バリアオプションを無効化すればfsyncによるデータ同期を回避でき、同時並行writeを維持しやすく、writeレスポンス待ちの影響を軽減できます。ただしバリアオプションの無効化には副作用があり、リスクを許容できるかを検討した上で使う必要があるのでご注意ください。弊社では前述したように社内の開発者向け環境で、最悪、データが一部欠損するようなことがあっても大きな問題にはならないようなものなどに限定して使っています。

まとめ

Cephのパフォーマンスチューニングにフォーカスしてこれまで取り組んできたことをいくつかご紹介しました。冒頭でCephの良さの一つにソースコードが公開されていることをあげましたが、ここでご紹介したパフォーマンス問題の解析でもそれが大いに役立ってくれました。まだまだCephの運用経験が浅く、わかってないことも多いのですが、今後も経験を積むことで理解がどこまでも深まっていくと思います。この記事でCephを使ってみたくなった方が少しでも増えればと思っています。

最後に

DeNAのOpenStackインフラについて三回にわたってご紹介しましたがいかがでしたでしょうか。 宣伝ですが、2017年2月10日(金) に DeNA TechCon 2017 が開催されます。OpenStackに関するセクションも予定しており、ブログでご紹介しきれなかった内容もお話できればと思っているのでぜひご参加ください。

ツイート
シェア
あとで読む
ブックマーク
送る
メールで送る

DeNAにおけるOpenStack運用#2

こんにちは。IT基盤部でOpenStackの運用をしています酒井です。
私からは弊社OpenStack環境で使用しているSDNについてご紹介したいと思います。前回の記事で紹介しましたように、弊社ではBigSwitch Networks社のBig Cloud Fabric(以下BCF)を使用しています。本エントリーでは弊社がどのようにBCFをOpenStackとインテグレーションしているか、どのように運用しているのかについて紹介させていただきます。

OpenStackとSDNの導入の狙い

従来はネットワークのconfig変更をする場合、サーバエンジニアからネットワークエンジニアにその作業を「依頼」する形を取っていたのですが、この「依頼」を無くすことが狙いでした。依頼内容としてはスイッチへのVLAN設定やACL設定、LBへのオブジェクト追加など比較的簡単な作業です。しかし、依頼件数が多い時などはネットワークエンジニアがその依頼を作業するまでに数日要することもあり、スピード感に欠ける状況となっていました。それをOpenStackとSDNの導入によって、自動化したり、サーバエンジニアが自ら行うことができるようにすることで作業のスピードを上げることができ、またネットワークエンジニアもより高度なスキルを要する業務に集中できるのではないかと考えました。

構成

OpenStack + BCF 構成パターン

BCFをOpenStackと共に使用する方法としては3パターンあります。一つめはOpenStackのアンダーレイネットワークとしてBCFを使用する方法でこの場合はOpenStackとBCFの連携はありません。二つ目はP-Fabricと呼ばれる方法でOpenStackとBCFでLayer2の情報のみ連携しルーティングは物理ネットワーク上で行います。それに対しP+V FabricというパターンではLayer2, Layer3が連携され、各Compute Node上で分散ルーティングされます。P+V Fabricの方が柔軟なネットワーク構成が可能ではあるのですが、NATが必須となる仕様となっておりNATの運用に手間をかけたくなかったため、弊社ではP-Fabricのパターンを採用しました。 OpenStack_BCF_integration_pattern.png

BCFの構成

BCFはリーフ&スパイン型のスイッチ郡とBCFコントローラから構成されます。BCFコントローラが全スイッチを集中管理しており、設定作業をする際も個別のスイッチにログインする必要はなくBCFコントローラで全ての作業ができます。POD全体を一つの大きなスイッチ(文字通りBIG SWITCH)のように扱うことができる、というのが特徴です。また、BCFコントローラ、スイッチともLinuxが動作しているため、いざとなればサーバ管理者が慣れ親しんだLinuxコマンドでトラブルシューティングする、ということも可能です。弊社ではスイッチにはDELL社の製品を使用し、リーフ - スパイン間は40Gbps、リーフ - サーバ間は10Gbpsで接続しています。 ONE_BIG_SWITCH.png

OpenStackのネットワーク構成

OpenStackのネットワーク構成としては、ネットワークオプションの一つであるプロバイダーネットワークとして構成しています。Controller Nodeでneutron-serverが動作し、各Compute Nodeではneutron-openvswitch-agentが動作します。neutron-serverではbigswitch社のプラグインであるbsnstacklibと弊社内製プラグインのnetworking-bigswitch-l3-peを使用しています。内製プラグインを開発した背景については後述します。

OpenStackとBCFの連携

ここではOpenStackがどのようにBCFと連携をしているのかを具体的に紹介したいと思います。こちらがBCFのコンフィグの例になります。

tenant demo.openstack
  id 1a2cf63967ca4f26ae5356bb2e6c818c
  origination openstack
  !
  logical-router
    route 0.0.0.0/0 next-hop tenant system
    interface segment net_demo
      ip address 10.66.16.1/24
    interface tenant system
  !
  segment net_demo
    id 5310d836-c170-4fda-882c-8a61324d90c6
    origination openstack
    !
    endpoint 0485e7a0-198f-4b88-a877-ca79d3e882bc
      attachment-point interface-group osvdev0013 vlan 300
      ip 10.66.16.2
      mac fa:16:3e:ef:4f:f4
      origination openstack
    !
    member interface-group osvdev0013 vlan 300
      origination openstackdev

OpenStackとBCFのオブジェクトの対応は以下の通りです。

OpenStackBCF
Projecttenant
Networksegment
Portendpoint
Routerlogical-router

前述したようにP-fabricの構成ではLayer 2の情報が連携されます。具体的には、Project, Network, Portの情報がneutron-serverのbsnstacklibプラグインによってBCF Controllerに同期されます。しかし、Layer 3の情報に相当する logical-router のコンフィグは同期されない仕様となっています。そのため、プロジェクト毎にネットワークを作成する場合はlogical-router部分の設定を手動で追加する必要がありましたが、これでは不便なためnetworking-bigswitch-l3-peプラグインを開発しました。BCF ControllerにはAPIが用意されており、このようにpluginからAPIをコールしています。

障害事例

ここでOpenStack + BCF環境で弊社が遭遇した障害の一つとその解決方法についてご紹介しましょう。

障害内容

ある時、新規に作成したインスタンスが通信できないという障害が発生しました。各Compute Node上のneutron-openvswitch-agentのログを調べてみると、以下のようなneutron-serverに対するリクエストが失敗し続けていました。

2016-08-31 03:48:25.294 9269 ERROR neutron.plugins.ml2.drivers.openvswitch.agent.ovsneutronagent [req-57ee640e-5534-4c17-8818-4ec71381ab07 - - - - -] processnetworkports - iteration:1081082 - failure while retrieving port details from server

調査してみると、neutron-sererのbsnstacklibプラグインの不具合が原因となりリクエストがタイムアウトしていました(この不具合については既に修正済みとなっています)。neutron-openvswitch-agentはこのリクエストが失敗すると全ての管理情報を再同期しようとするため(ソースコードだとこの辺り)、さらにneutron-serverに大量のリクエストが集まり処理しきれず再度失敗する、ということを繰り返していました。

復旧方法

まずneutron-serverの負荷を減らしリクエストを捌ききるために、各Compute Node上のneutron-openvswtich-agentを1台ずつ止めていく、というオペレーションをしていきました。すると無事neutron-serverに溜まっていたリクエストがなくなりました。次は止めたneutron-openvswitch-agentを起動していくわけですが、しかし、ここで一つ問題ありました。その時使用していたneutron-openvswitch-agentのバージョン(liberty, 2:7.0.4-0ubuntu1~cloud0)だと、起動時にインスタンスの通信が数秒途切れることが分かっていました。neutronのリポジトリを調べてみるとこれらのパッチ(Cleanup stale OVS flows for physical bridges, Don't disconnect br-int from phys br if connected)で修正されていることが分かりました。これらのパッチを適用した後、止めていたneutron-openvswitch-agentを一つずつ起動しneutron-serverがリクエストを処理し終わるのを待つ、というオペレーションをしていくことで無事復旧することができました。

まとめ

OpenStack環境のSDNに使用しているBCFについて紹介させていただきました。当初の狙い通り、OpenStackとBCFを連携させることで、ネットワークエンジニアへ依頼をすることなく、サーバエンジニアが自らネットワーク構築や設定変更ができる環境を構築することができました。
BCFとのインテグレーションについてご紹介したように、製品自体がユースケースに合わない場合でもAPIを完備していることでユーザ自身がカスタマイズできることはSDN製品の良いところだと思います。またトラブルシューティングについても、OpenStackやそのプラグインはオープンソースなのでいざとなればユーザ自身が調査し問題解決できることもご理解いただけたかと思います。

次回はいよいよSDS編です。

ツイート
シェア
あとで読む
ブックマーク
送る
メールで送る