CQRS+ESについて細かい実装や考察をまとめてみた

前提

私は「エリック・エヴァンスドメイン駆動設計」を読んだのみで、「実践ドメイン入門」は未読(とても欲しい)の状態で書いています。 (「実践ドメイン入門」にはもっと深い洞察が書いてあるのだと思います…) また「エリック・エヴァンスドメイン駆動設計」と共に、参考リンク先をまず読んだことがある前提で書いている部分がありますのでご注意下さい。今後もっと良いアイデアが浮かんだり、実際に実装するにあたって浮かび上がった課題があれば随時追記・記事にできればいいなと思います。

参考

CQRS+ES

DDDから更にData Flowの概念を取り込んだarchitecture。 コマンドクエリ分離原則(CQS/Command Query Separation)に基づく。 CQRSは Command Query Responsibility Segregation の略。 ESはEvent Sourcingの略。

それぞれCommand側・Query側についてまとめていく。

Command (Write)側

Command側のフロー

まずCommand側のフローとしては以下のようになっている

  1. ユーザーは任意のアクションを実行する
  2. クライアントがアクションをCommandとしてサーバーへ投げる。(例:名前の変更/ChangeName)
  3. サーバーがCommandを受け取る
  4. ドメインモデルがCommandを解釈し、ドメインに基づいた処理を実行する(例:名前を変更する)
  5. ドメインモデルは結果となるEventを生成する(例:名前が変更された/ChangedName)
  6. Eventを保存する

Command受付からドメインモデルでの処理へ

Command と Event

Commandは指示、Eventは指示の結果として起きた出来事。意図がまったく異なるので混同しないように注意が必要。クラスとしても分けるべき。命名にもルールがあって、Commandは常に命令形で定義(例: Change Name)し、Eventは常に過去形で定義する(例: Changed Name)

タスクベースのUIで考える

コマンドとして扱うにはタスクベースのUIにするのが良い。

よく見かけるのはCRUDや管理画面等で表の状態を表示し、「保存」ボタンがあり、各formとデータが関連して定義されたDTO(DataTransferObject)が存在し、「保存」アクションで関連付けられたDTOをそのまま送信する、などのパターンだが、このようなものとは異なる。

例えば名前を変更するときは、名前を「保存/更新する」のではなく「名前の変更」というタスクとして切り分け、DTOをそのまま送信しない。「名前の変更」コマンドの中身は、変更者のユーザーID、コマンド名、変更後の名前のみとなる。

UIのためのデータ生成はコストが大きい

ドメインオブジェクトをDTOに変換するのはDataMapperを必要とするうえ、カプセル化して守られているはずの中身をさらけ出す行為に等しい。すべてのモデルから、dumpするためのメソッドを用意するか、このためだけにgetterを生やすことになってしまう。もっとキレイに書くこともできるかもしれないが、いずれにしてもカプセル化し閉じておくべきデータを外に出す入り口を作ってしまう。

[DomainObject] => data mapping => [Data Transfer Object] => client

CommandとQueryを分け、Query側には薄いDataLayerから直接DTOを生成すれば、ドメインオブジェクトはCommand側でのみ使われることになり、カプセル化を破ってまでDTOを生成する必要はなくなる。

EventStoreへの保存からSnapShot作成へ

CommandをEventに変換してEventStoreに保存してからの振る舞い、実際の実装方法においては詰めたい点が残る。CQRSについて述べているものを見ても、以下のように分かれている。

  • EventStoreを保存して、そのEventStoreから読み出してSnapshotを作る(By Greg)
  • EventStoreに保存すると同時にEventHandlerにEventを送る(By Nijhof)

両者とも理屈はわかる。まずGreg側はトランザクションに守られないならばflowは分岐せず1つであるべきという意見。この実装においてはどのEventをHandlingしたか、が分かるようにしなければならない。Gregによると通し番号(sequence)を付けておくのがよい、と書かれている。

対して、NijhofはRead側をRDBMSを想定しているのでインピーダンスミスマッチは起こるが吸収できれば問題なさそう。

EventStore Schema

Event Table, Aggregate Table, SnapShot Table が必要になりそう。

Event Table

発生したイベントを保存するテーブル。 INSERTで追記するのみで、UPDATE/DELETEはしない。 イベントはこの後でEventHandlerに渡すので、ドメイン処理した環境とは 別の環境でも読み取れるよう、JSONなどの汎用フォーマットにしておくとよさそう。

Name Data type Content
Sequence Number Big Int イベント通し番号 (Auto Increment) [PK]
Aggregate ID String 集約ルート ID (UUID)
Data Blob シリアライズされたEventデータ
Version Int 集約ルートデータバージョン
Created At Timestamp イベント発生日時

Aggregate Table

イベント対象となる集約ルートの最新バージョンを保持する。

Name Data type Content
Aggregate ID String 集約ルート ID (UUID) [PK]
Type VarChar 集約ルート名
Version Int 集約ルートデータバージョン
Updated At Timestamp 更新日時

Snapshot Table

イベントをversionの順に適用していった結果の集約ルート状態を保存しておくテーブル。 ドメイン処理した集約ルート状態を再現したインスタンスシリアライズしたものを入れておく。

Name Data type Content
Aggregate ID String 集約ルート ID (UUID) [PK]
Sequence Number Big Int イベント通し番号 (Auto Increment)
Serialized Data Blob シリアライズされた集約ルートのスナップショットデータ
Version Int 集約ルートデータバージョン
Updated At Timestamp 更新日時

格納手順

まずAggregateテーブルからAggregateIDでSELECTする

SELECT version
  FROM aggregates
 WHERE aggregate_id = [aggregateId]

空の場合はaggregate_idとversionを0にして結果セットとする

得たaggregate結果セットrowのversionをwhere句に追加し、一致したときにINSERTするようにする。 後続のeventのrowも続けて入れていく。

aggregatesの最新バージョンを得ておく

SELECT version
  FROM aggregates
 WHERE aggregate_id = [aggregate_id]
   FOR UPDATE

トランザクションを開始する

BEGIN

イベントを追加していくが、追加されるイベント数分のversionを上げて更新する

UPDATE aggregates
   SET version = version + [after_version]
 WHERE aggregate_id = [aggregate_id]
   AND version = [expected_before_version]

ただしく成功されたか(影響行数が1になっているか)確認して続ける。 FOR UPDATE 文なので大丈夫なはずだが、万一失敗したらここでrollbackする

個々のeventをinsertしていく

INSERT INTO events(aggregate_id, data, version) VALUES([aggregate_id], [serialized_event_data_1], [version_1]);
INSERT INTO events(aggregate_id, data, version) VALUES([aggregate_id], [serialized_event_data_2], [version_2]);
INSERT INTO events(aggregate_id, data, version) VALUES([aggregate_id], [serialized_event_data_3], [version_3]);
INSERT INTO events(aggregate_id, data, version) VALUES([aggregate_id], [serialized_event_data_4], [version_4]);

最後にcommitする

COMMIT

ここまでで Commandとしてのフローは完了するので、Clientにレスポンスを返していく。

SnapShot生成

backgroundのプロセスがeventテーブルを監視しておき、前回処理した最終のsequence_numberからすべてを取得していく

SELECT sequence_number, aggregate_id, data, version
  FROM events
 WHERE sequence_number > [last_processed_sequence_number]
 ORDER BY sequence_number ASC;

これを順次処理していく。取り出したaggregate_idから前回までのsnapshotを取り出す

BEGIN
SELECT aggregate_id, sequence_number, serialized_data, version
  FROM snapshots
 WHERE aggregate_id = [aggregate_id]
   FOR UPDATE

取り出したデータをunserializeして集約ルートを復元し、Eventを順次適用していく 最後のEventまで適用が終わったらupdateしていく

UPDATE snapshots
   SET sequence_number = [sequence_number],
       serialized_data = [serialized_data],
       version = [version]
 WHERE aggregate_id = [aggregate_id]

完了したらcommitする

COMMIT

SnapShotからの復元

まず最初は同様にAggregateテーブルからAggregateIDでSELECTする

SELECT version
  FROM aggregates
 WHERE aggregate_id = [aggregateId]

snapshotをAggregateIDから取得

SELECT aggregate_id, sequence_number, serialized_data, version
  FROM snapshots
 WHERE aggregate_id = [aggregate_id]

eventsからsnapshotからの差分を取得する。 得たeventを適用(replay)していき、集約ルートを構築していく

SELECT sequence_number, data, version
  FROM events
 WHERE aggregate_id = [aggregate_id]
 ORDER BY sequence_number ASC

データの流れで問題になりそうなところ

Command(Write)側では複雑なQueryは使えない

Gregも言っているとおりEventSourcingの場合、Command(Write)側で扱えるQueryはPrimaryKeyに基づくものだけになる。 このパターンはユーザーIDで紐づくのはUserの集約になるので、ほぼユーザーデータをまるごとになり、大きめのデータを扱うことになる。 Command側ではこの制約下でも問題ないように作る必要がある。

Eventを記録するときは状態を変化させない

これが一番理解に時間がかかった点ですが、納得してみれば確かにな、と思った箇所。

集約ルートがEventを受け取ったらEventStoreに登録(Apply)するだけにとどめ、集約ルート自身の状態を変化させない。 これまでの過去の結果(snapshot+events)である集合ルートに対して、Eventを生成し、EventStoreに登録するのみ。

これはつまり、ある集合ルートはnameプロパティを持ち、fooという文字列が設定されているとした場合、この集合ルートのnameをbarに変更するコマンドChangeNameCommandを渡したとすると、 nameをfooからbarに変更するイベントChangedNameをEventStoreに登録するが、実際にnameプロパティは変更せず、fooのままにしておくということです。

これは、集合ルートに適用したとしてもその結果を返すことはしないので、状態を変化させることに意味はないからだと考えます。 Snapshotを作る時には過去の結果(snapshot+events)に今回のEventを適用した状態をSnapshotにするので問題にはならないし、Snapshotを作る前であっても過去の結果(snapshot+events)の中に入ってくるのでこれも問題にはならない。

Push型か、Pull型か

クライアントからのCommandを実行し、Eventを登録するまでは同じですが、EventHandlerをkickする(push型)かpolling(pull型)かで処理の流れが異なる。

タイプ 特長
Push型 Commandの処理を行う際に、EventHandlerにEventを流す
Pull型 Commandの処理を行う際に、Eventの登録まで行いPollingして検出する

EventSchema(Write)側のデータから、ReadModel(Read)側へデータが流れるとき Read側がEventSourcingでなく、RDBMSで状態を表す伝統的な仕組みの場合は一度実行されたイベントが再度実行されてはならない。

Push型

EventHandlerにPushするならば、同期的にすべて行うとパフォーマンスに影響が出るので非同期的に行うほうが良い。ただ処理結果は待たずに返さないと結局処理を待つことになるので、指示だけを送って早々にレスポンスを返す事になりそう。

Pull型

Pull型のもっともシンプルな流れでは、下記の3段階でReadModelに適用が行われると思われる

  1. EventのQueueからの取り出し
  2. QueueからReadModelへの適用
  3. EventのQueueからの削除

このとき、2までは成功したが3に失敗したとき、2をrollbackできなくてはいけない。 QueueとReadModelにまたがってトランザクション処理が行えれば良いが、将来的なスケーラビリティを確保するためにも分離されていたほうが良いと思われる。

それぞれトランザクションを用意する

時系列でこんなかんじ

| => begin
o => commit
d => dequeue
p => published
@ => insert/update

Queue   ---|----d--------------p------o-------------------->
RDBMS   -----------|-----@----------------o---------------->

便宜上RDBMSのcommitはqueueを先にRDBMSを後にしたが、どちらが先でも構わない。 大事なのは、QueueもRDBMSも処理を終え、成功を確認したらcommitするということ。

EventHandlerもそれなりに大変

Eventを生成することもDDDドメインモデルの振る舞いが大切になるが、 EventをHandlingしてReadModelに落とす場面も注意深く実装しなくてはいけない。

Read側もEventSourceを格納するのか、正規化してRDBMSに落とし込むのか

からなずしもEventSourcing形式でなくても良いが、RDBMS側はしっかり考えて設計を行う必要がある。なんらかのCommandで、関連する集合を引く必要がある時に、EventStoreデータだけでは引けないことがある。この場合、ReadModelから引くがAggregateIDがないと集合ルートを発見できないので、RDBMSにもAggregateIDを関連させて保存しておく必要がある。

RDBMSに正規化して保存するなら、インピーダンスミスマッチを考慮しつつ、「読み込みやすいこと」「パフォーマンスが出ること」を優先して保存していく。場合によっては非正規化したViewを用意することも考慮に入れていく。

最初は1プロセスでRDBMS保存まで行ってもいい

最初からMicroservice化してしまうとコストが回収できない場合があるので、最初からやりすぎないことが大事。

Query (Read)側

ここまでで非常によく練った状態でRDBMSに格納していったので、Query側は取り出すだけでよい。というかそのようにする。パフォーマンス、スケーラビリティを考慮してなるべくシンプルに保つと良さそう。

まとめ

DDDの弱点であるコストが大きくかかる点であったり、DTOへの変換がOOP原則に照らして辛い部分などをクリアする、とてもよく考えられた設計だなと感じました。これまでのいわゆるMVCなどのアーキテクチャに慣れていた人(自分)には結構なマインド変換が必要なものだなと。 まだ実際には稼働させていないのですべて机上の考察なので、もっと深い考察が出て来るでしょうが、楽しくなりそうです。

Docker for mac で nginx を起動するサンプルが動かないとき

Docker for mac 入れる

docs.docker.com

こちらを参考にして入れてみる。

さくっとstableのdmgをDLしてインストール

Version

% docker --version
Docker version 1.13.1, build 092cba3
% docker-compose --version
docker-compose version 1.11.1, build 7c5d5e4
% docker-machine --version
docker-machine version 0.9.0, build 15fd4c7

Step 3 でエラーが起きる

nginxイメージを起動できない。

% docker run -d -p 80:80 --name webserver nginx
Unable to find image 'nginx:latest' locally
docker: Error response from daemon: Get https://registry-1.docker.io/v2/: dial tcp: lookup registry-1.docker.io on 192.168.65.1:53: server misbehaving.
See 'docker run --help'.

githubのissueにもある問題っぽい

github.com

matsnow.hatenablog.com

で、上記の参考にさせてもらったブログを見ると、betaにすると治ったらしいのでbetaにする (stableをuninstallしてbetaをDL、インストール)

% docker --version
Docker version 1.13.1, build 092cba3
% docker-compose --version
docker-compose version 1.11.1, build 7c5d5e4
% docker-machine --version
docker-machine version 0.9.0, build 15fd4c7

変わらないようにみえる…

気を取り直して再実行

% docker run -d -p 80:80 --name webserver nginx
Unable to find image 'nginx:latest' locally
latest: Pulling from library/nginx
5040bd298390: Pull complete
333547110842: Pull complete
4df1e44d2a7a: Pull complete
Digest: sha256:f2d384a6ca8ada733df555be3edc427f2e5f285ebf468aae940843de8cf74645
Status: Downloaded newer image for nginx:latest
62ed7ad57e72753812205e627cb9b35ae0ee66a54a51bd4f895cf1ede9337628

入った。 が、 http://localhost:80/ を叩いても接続できない。 docker ps を叩いても、ちゃんと大丈夫そうに見える。

% docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                         NAMES
89ec878940a4        nginx               "nginx -g 'daemon ..."   12 minutes ago      Up 12 minutes       0.0.0.0:80->80/tcp, 443/tcp   webserver

Mac本体のportを見てもListenしてるように見える。

% sudo lsof -i -P | grep LISTEN | grep 80rpcbind     280         daemon    4u  IPv4 0x7fa9588305b9f605      0t0  TCP *:111 (LISTEN)
// 略
com.docke 35368            edy   25u  IPv4 0x7fa958831ccb1605      0t0  TCP *:80 (LISTEN)
// 略

stackoverflowに聞いてみると同様の人がいる。

stackoverflow.com

http://127.0.0.1 だと動くかもっていう話だったから試したら動いた。マジで…。 そもそもnginxの設定にlocalhostで受けるようになってないだけ?

2017/02/18追記

ホストMacのhostsにlocalhostIPv6で解決する記述があったため、これをコメントアウトしたら動いた。

% cat /etc/hosts
# 略
#::1     localhost

本来はまともにIPv6対応すべきなんだと思いますが、開発環境構築で頑張るのは面倒でコメントアウトでお茶を濁す。。

docs.docker.com

Swift用依存管理マネージャのCarthageを導入する

Swift用依存管理マネージャのCarthageを導入する

前回のCocoaPodsは結局採用せずCarthageを採用した理由は以下の通り。

  • 元々のxcodeprojを使えないので、何かあった時に情報が少なそう(Carthageは元のxcodeprojを使うのでシンプル)
  • xcworkspaceについての情報が少ない(Carthageはxcodeprojのまま)
  • AlamofireがCarthageに対応している

Carthageとは?

CocoaPodsと同様に、Cocoaライブラリ依存管理ツールです。下記の2記事が最も詳しく解説されています。

qiita.com

Carthage: Swift対応の新しいライブラリ管理

(ちなみにCarthageとググるカルタゴが出てきますが、チュニジア共和国の古代都市国家だそうで。何故この名前になったのだろう。)

Carthageの仕組み

上記にも言及されていますが、Carthageはdynamic frameworkを作成するとのこと。

一方、CarthageはリポジトリXcodeプロジェクトの情報をそのまま利用してdynamic frameworkを作成します。

dynamic frameworkiOS8以上 対応なのでそれ以下は導入できないらしい。 これ以下のiOSに対応する事はできないので注意が必要。

dynamic frameworkってつまり何?と思ってググると、dynamic library とかstatic library とかがごちゃ混ぜになりつつ、わかってる体で話が進んでいる記事が多いので、 dynamic frameworkとはなんぞや、から探してみると、stackoverflowがヒット。

ios - Library? Static? Dynamic? Or Framework? Project inside another project - Stack Overflow

libraryはコードのみ、frameworkはassetsを含められる。staticはコンパイル時、dynamicはランタイム時ということらしい。static/dynamicはごく普通なので問題なかったですが、framework/libraryの違いがわかってよかった。

Carthageのインストール

homebrewでインストールできる。

$ brew update
$ brew install carthage

以上。

Cartfileを作成

BundlerでいうGemfileみたいな感じで依存性をCartfileというファイルに書いていく。

OriginとVersion requirementを追加

Originはgithubリポジトリとして書くか、gitのrepositoryパスを指定する。 Version requirementはバージョン番号のほか、gitのbranch名やcommitのhashを指定できる。

今回は前回のCocoaPodsの時と同様に、Alamofireを導入する。

$ cat Cartfile
github "Alamofire/Alamofire" >= 1.2

カンタン!

carthage updateを実行

Cartfileに追加したライブラリを実際に利用できるようにする。

$ carthage update
*** Cloning Alamofire
*** Checking out Alamofire at "1.2.2"
*** xcodebuild output can be found in /var/folders/2s/zm_3tqmd2kl9dfk44q55g0s00000gn/T/carthage-xcodebuild.uyO8hk.log
*** Building scheme "Alamofire iOS" in Alamofire.xcworkspace
*** Building scheme "Alamofire OSX" in Alamofire.xcworkspace

carthage updateを叩くとCarthage/Checkoutsディレクトリが作成され、利用するそれぞれのライブラリ名でcloneされる。

$ ls Carthage/Checkouts/Alamofire
Alamofire.playground  Alamofire.xcodeproj   CHANGELOG.md          Carthage              LICENSE               Source                iOS Example.xcodeproj
Alamofire.podspec     Alamofire.xcworkspace CONTRIBUTING.md       Example               README.md             Tests

また、前述したようにdynamic frameworkとしてbuildされる。 ここまで自動でやってくれるので非常に便利。

$ ls Carthage/Build/iOS/Alamofire.framework
Alamofire      Headers        Info.plist     Modules        _CodeSignature

Xcodeに設定を追加

ライブラリを使えるよう、Xcodeでも設定を追加します。

  1. “General” -> “Linked Frameworks and Libraries” から、"+“をクリックし先ほどビルドしたCarthage/Build/iOS/Alamofir.frameworkを追加
  2. “Build Phases” -> “+” -> “New Run Script Phase” から、Scriptとして下記を追加
/usr/local/bin/carthage copy-frameworks

これだけでうごく!と思いきやエラーが。 同じエラーが出ている人もいるようで見てみる。

どうも皆 “General” -> “Embedded Binaries"にも追加しているので、やってみると成功した。 これでswfitから

import Alamofire

とすれば利用できます。

まとめ

  • CocoaPodsに比べると導入がちょっと手間がかかるけど、管理とかハマりを考えたら楽だと思う。
  • Carthageカンタン便利!対応しているならどんどん使っていくと良いと思う。
  • CarthageのついでにReactiveCocoaとか面白そうだけど、しょっぱなからこれはどうだろうという思いと、趣味だしいいんじゃねーの感の葛藤がある

Swift用依存管理マネージャのCocoaPodsを導入する

iOSアプリ開発時にHTTP通信が必要になったのでライブラリを探していると、CocoaPods と、同様のツールでわりと新しめのCarthageというのがあった。

最終的にはCarthageを採用したので別の記事に書きますが、CocoaPodsもインストール試してみたので、導入の備忘録と感想を残しておきます。

Cocoapodsとは?

cocoapods.org

CocoaPods Guides - Getting Started

CocoaPods manages library dependencies for your Xcode projects.

The dependencies for your projects are specified in a single text file called a Podfile. CocoaPods will resolve dependencies between libraries, fetch the resulting source code, then link it together in an Xcode workspace to build your project.

Ultimately the goal is to improve discoverability of, and engagement in, third party open-source libraries by creating a more centralised ecosystem.

頑張って訳すとこんな感じ?(間違ってたらすいません。)

CocoaPodsはあなたのXcodeプロジェクトのライブラリ依存性を管理します。 あなたのプロジェクトへの依存性はPodfileと呼ばれるひとつのテキストファイルで指定されます。 CocoaPodsはライブラリ間の依存性を解決し、結果のソースコードを取得し、その後あなたのプロジェクトをbuildするためにXcodeのworkspaceにリンクします。

最終的なゴールは、エコシステムを集中させることでサードパーティ製のオープンソースの検索性、関連性を改善することです。

要するに依存性解決ツールのようです。Rubyでいうとbundler、(私のように)PHPerからするとcomposerみたいな感じでしょうか。ライブラリも探せるようなので、雰囲気的にはpackagistも兼ねてる感じ。

まずはCocoaPodsをインストールしてみる

gemでインストールします。

$ sudo gem install cocoapods

sudoを使わない時は--user-installフラグをつけるなどが推奨されています。

Podfileに依存ライブラリを書く

Xcodeプロジェクトディレクトリ直下でpod initコマンドを叩くと、Xcodeプロジェクト設定が読み込まれ、Podfileが自動生成されます。

$ pod init
$ ls Podfile
Podfile

Xcode設定が読み込まれて自動的に設定されるようです。下記は

  • プロジェクト名が「hoge
  • 対応バージョンが6.0以上

の設定です。

$ cat Podfile
# Uncomment this line to define a global platform for your project
# platform :ios, '6.0'

target 'hoge' do

end

target 'hogeTests' do

end

Podfileにライブラリを設定する

HTTP通信ライブラリであるAlamofireを使いたいので、README.mdを参考に設定してみます。

source 'https://github.com/CocoaPods/Specs.git'
platform :ios, '8.0'
use_frameworks!

target 'hoge' do
  pod 'Alamofire', '~> 1.2'
end

ライブラリをインストールする

設定できたらpod installします。

$ pod install

*.xcworkspaceというのが出来上がるので、今後はいつものxcodeprojの代わりに、これを使います。

$ open App.xcworkspace #=> Xcodeが開く

CocoaPodsの感想

CocoaPodsは*.xcworkspaceというファイルを作るようで、はてxcworkspaceとは何ぞやということで調べるも、いまいちコレだという資料も見当たらず良くわからずじまい。 要するにこれを使うとライブラリをリンクした状態でプロジェクトを開けるらしいのだけど、仕組みがわからないとハマったときどうにもならなくなりそう。

iPhone/iPad実機からMacのdockerコンテナ環境にリクエストする

iOSアプリを開発するにあたって、APIを受けるサーバ側の開発環境を用意することにした。 Vagrantでもいいのだけど、Dockerにも触っておきたくて入れてみた時の備忘録的なアレです。 正直ネットワーク周りはあまり深く知らないので、もっとうまくやる方法がきっとあると思います。

まずはMacにdockerをインストール

docs.docker.com

dockerはLinuxカーネル機能を使っているため直接Macで使えないので、 dockerのdaemonを動かす専用の軽量VMとその管理ツール(boot2docker)が用意されています。 Macから動かすには、このboot2dockerを通じて動かします。

ざっくりこんなイメージ。

Mac <==> VirtualBox(VM) <==> Docker container

boot2dockerの導入

まずMacVirtualBoxを入れます。 Downloads – Oracle VM VirtualBox

次にこちらを参考にhomebrewからdockerとboot2dockerをインストールします。 qiita.com

初期化

  1. VMを作成。初回の1度のみ。
mac$ boot2docker init

VM起動

mac$ boot2docker up

環境変数の確認

VMが起動するたびに、VMの$DOCKER_HOSTが変動したりするので以下のコマンド結果を実行します。

mac$ boot2docker shellinit
Writing /Users/edy/.boot2docker/certs/boot2docker-vm/ca.pem
Writing /Users/edy/.boot2docker/certs/boot2docker-vm/cert.pem
Writing /Users/edy/.boot2docker/certs/boot2docker-vm/key.pem
    export DOCKER_CERT_PATH=/Users/edy/.boot2docker/certs/boot2docker-vm
    export DOCKER_TLS_VERIFY=1
    export DOCKER_HOST=tcp://192.168.59.103:2376

毎回手動で行うのも面倒なのでshellの*rcなどに書いておきます。

# to connect docker server
if [ "`boot2docker status`" = "running" ]; then
    eval $(boot2docker shellinit 2>/dev/null)
fi

状態の確認

mac$ boot2docker status
running

ここまででとりあえずboot2dockerの導入は終わり。

dockerを使う

使えるイメージを確認する。最初は空。

mac$ docker images

imageの用意

dockerコンテナの元にする、使いたいimageを落とす (debian:wheezyの例)

$ docker pull debian:wheezy

searchサブコマンドでdocker hubから探せる (例はdebian)

mac$ docker search debian

起動してみる

debian/wheezyから起動してみる。 -iはinteractive -tはttyの割り当て --nameはコンテナ名の割り当て

mac$ docker run -i -t --name www debian:wheezy /bin/bash

ポートを紐付けたい場合は-p。 例はホスト側8080をゲスト側80番にフォワードしてる状態。

mac$ docker run -i -t -p 8080:80 --name www debian:wheezy /bin/bash

起動するとシェルに入る

root@cbc18d9ebc9c:/#

-dでデタッチする

mac$ docker run -d -p 80:8080 --name www debian:wheezy /bin/bash

シェルに入りたいとき

mac$ docker exec -it www bash

状態確認

コンテナの存在確認はpsサブコマンドを使う。 停止されているコンテナも含めるときは-aオプションを指定する。

mac$ docker ps -a
#=> CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                     PORTS               NAMES
#=> cbc18d9ebc9c        debian:wheezy       "/bin/bash"         4 minutes ago       Exited (0) 3 minutes ago                       www

イメージづくり

Dockerfileを作る

できればここで頑張りすぎず、他のオーケストレーションツール(ansibleとかchefとか)に任せたほうが幸せな気がします。 下記では最新版のnginxを取ってくるためdotdebの設定を追加しています。

Instructions | Dotdeb

mac$ mkdir nginx
mac$ cd nginx
mac$ wget http://www.dotdeb.org/dotdeb.gpg
mac$ vi Dockerfile
FROM debian:wheezy
MAINTAINER edy <xxxxxxxxxxx@gmail.com>

RUN echo "deb     http://packages.dotdeb.org jessie all" >   /etc/apt/sources.list.d/dotdeb.list
RUN echo "deb-src http://packages.dotdeb.org jessie all" >>  /etc/apt/sources.list.d/dotdeb.list
RUN echo "deb     http://mirrors.teraren.com/dotdeb/ stable all" >   /etc/apt/sources.list.d/jp-mirror.list
RUN echo "deb-src http://mirrors.teraren.com/dotdeb/ stable all" >>  /etc/apt/sources.list.d/jp-mirror.list
RUN echo "deb     http://mirrors.teraren.com/dotdeb/ wheezy-php56 all" >   /etc/apt/sources.list.d/php56.list
RUN echo "deb-src http://mirrors.teraren.com/dotdeb/ wheezy-php56 all" >>  /etc/apt/sources.list.d/php56.list

ADD dotdeb.gpg /tmp/

WORKDIR /tmp

RUN apt-key add dotdeb.gpg && \
    apt-get update && \
    apt-get upgrade -y && \
    apt-get install -y nginx && \
    apt-get clean && \
    rm -rf /var/cache/apt/archives/* /var/lib/apt/lists/*

ENTRYPOINT /usr/sbin/nginx -g 'daemon off;' -c /etc/nginx/nginx.conf

こまかい記法はここを参考にしました。 www.atmarkit.co.jp

イメージをbuildする

mac$ docker build -t siny/debian:1.0 .

出来上がったら、イメージを指定して先ほどと同じようにrunします。

mac$ docker run -d -p 8080:80 --name www siny/debian:1.0

ポートフォワード設定

現状のネットワーク

ここまでの設定と状態を整理します。 LAN上のMacのIPは192.168.11.2となっている状態で、こんな感じになります。

mac$ ifconfig
...略
vboxnet1: flags=8943<UP,BROADCAST,RUNNING,PROMISC,SIMPLEX,MULTICAST> mtu 1500
        ether XX:XX:XX:XX:XX:XX
        inet 192.168.59.3 netmask 0xffffff00 broadcast 192.168.59.255
mac$ boot2docker ip
192.168.59.103
     iPhone
[192.168.11.3]
         ||
      ルータ
[192.168.11.1]
         ||
[192.168.11.2]
       Mac
[192.168.59.3]
         ||
[192.168.59.103]
   VirtualBox

まずMacのローカルポートをVMへフォワード

この状態でnginxにアクセスするには、Mac上からは192.168.59.103:8080でアクセスしなくてはいけません。 なので、まずlocalhostのポートをVMのポートへforwardします。

mac$ VBoxManage controlvm "boot2docker-vm" natpf1 "nginx,tcp,127.0.0.1,8080,,80"

qiita.com

これでlocalhost(Mac)の8080番がboot2docker(VM)の80へforwardされるようになり、 127.0.0.1:8080またはlocalhost:8080でアクセスできるようになります。

     iPhone
[192.168.11.3]
         ||
      ルータ
[192.168.11.1]
         ||
[192.168.11.2]
       Mac
[192.168.59.3]           localhost:8080
         ||                                 ||
[192.168.59.103]      192.168.59.103:8080
   VirtualBox

MacのLAN側からもフォワード

そしてようやく本題の実機からのアクセスを行うため、Macのいずれかのportを更にフォワードさせます。 色々やりかたはあると思いますが、今回はMacにnginxを入れて、8080ポートをListenしてlocalhost:8080へforwardさせることにしました。

Macにnginxを入れる

==> Downloading https://homebrew.bintray.com/bottles/nginx-1.6.2.yosemite.bottle.1.tar.gz
######################################################################## 100.0%
==> Pouring nginx-1.6.2.yosemite.bottle.1.tar.gz
==> Caveats
Docroot is: /usr/local/var/www

The default port has been set in /usr/local/etc/nginx/nginx.conf to 8080 so that
nginx can run without sudo.

To have launchd start nginx at login:
    ln -sfv /usr/local/opt/nginx/*.plist ~/Library/LaunchAgents
Then to load nginx now:
    launchctl load ~/Library/LaunchAgents/homebrew.mxcl.nginx.plist
Or, if you don't want/need launchctl, you can just run:
    nginx

WARNING: launchctl will fail when run under tmux.
==> Summary
🍺  /usr/local/Cellar/nginx/1.6.2: 7 files, 920K

configファイル確認

mac$ nginx -t
nginx: the configuration file /usr/local/etc/nginx/nginx.conf syntax is ok
nginx: configuration file /usr/local/etc/nginx/nginx.conf test is successful

リバースプロキシとしてlocalhostの8080番へフォワード

// 略
        location / {
            proxy_pass http://127.0.0.1:8080;
        }
}

最終的にはこんな感じ。

     iPhone
[192.168.11.3]
         ||
      ルータ
[192.168.11.1]
         ||
[192.168.11.2]           192.168.11.2:8080  <----- nginx listen
       Mac                              ||  <------ nginx forward
[192.168.59.3]           127.0.0.1:8080
         ||                                 ||  <------ virtualbox forward
[192.168.59.103]      192.168.59.103:8080
   VirtualBox

これで、iPhone実機から192.168.11.2:8080にアクセスすると見えるようになりました。 試した時はDHCPなので、安定稼働させるにはIPを固定化するとか、内部DNS立てるかすればよいと思います。

「webエンジニアでも出来る!iOSアプリのリファクタリングの全て」という勉強会に行ってきました

最近勉強会に顔を出したりしてますが、忘れないうちにメモを。

zigexn.co.jp 株式会社じげんの具志堅さんが掲題の講演されていました。

プロジェクトをどう管理しているか?

見つけたので貼っておきます。

www.slideshare.net

課題と背景

  • 非常に短期間 & 少人数で進めていたのでオレオレコードが多くなっていた
  • スケールするためにはどうしたらよいかを考えてみた

改善前

  • ViewController整理されてない
  • Controllerの肥大化 (あるある
  • 初期化処理多すぎる
  • ディレクトリがXcodeデフォ構成に引っ張られすぎた
  • 型宣言などの冗長な記述が多い
  • Array/Dictionary混在
  • Dev/Prod環境の分離ができてない

やったこと

コーディング規約を用意した

  • これを参考に独自に改変しつつ利用した

qiita.com

  • Xcodeで一括置換して対応した

Info.plist / Build Settings を活用

  • Info.plistに定数を定義
  • Build Settings の User-Defined 以下に Debug/Releaseを分けて設定した
    • DebugビルドとReleaseビルドを分けられるように

MVC構成

  • グループに分けた (Models、ViewControllers//ViewController)
  • Modelは状態の保持、加工のみの役割にした
  • ViewControllerはModelへ加工の指示や取得を行い表現する

Model例

  • ImageManager.swiftというモデルにし画像処理を行う

ViewController例

  • Modelに処理を委譲して結果を表示するのみ

このあたり、クラス名がHogeManagerとかになっていたので厳密にはModelというよりServiceも兼ねていた模様。 なのでMVCというよりも、とにかくViewControllerからのロジック分離を目指して

  • ViewController(VC)
  • ロジック

のように2分割したと理解した。

デザインパターンを活用した

まあこのへんは、ですよねっていう感じで

技術的負債

  • 抱えきれなくなる前に少しずつ解消していこう

Q&A

Swiftだから出来たリファクタリングはなにかあるか

規約をカスタマイズした過程を知りたい

  • リードエンジニアが叩きを作ってエンジニアで揉んだ

所感

  • Info.plist / BuildSettingsでの定数定義は良さそうなので利用したい
  • MVCのあたりは当然のことを当然にやるのが大事という話
    • Fat controller にしないようにするとか
      • このへんは個人的にはDDDにしたいので参考程度
  • コーディング規約便利そうだからみておく
  • Swiftデザパタ適用しやすいらしい、静的型付け言語のオブジェクト指向だしですよねーっていう感じ
  • 技術的負債はもう普通の話だった