このエントリーは、KLab Advent Calendar 2015 の12/10の記事です。
@uzzuです。
私は主にUnity向けの社内ライブラリの開発に従事しています。社内ライブラリと聞いてウッとなる方がいたら友達になれそうです。
ライブラリを開発する上で意識しなければならないことはたくさんありますが、本稿ではタイトルの通り、ライブラリ利用者のコードに一切手を加えず基盤実装を差し替える為の設計パターンについて紹介します。もう2015年なので恐らく当たり前のようにやってる話だと思いますが、あまりこういう話をWeb上で見かけないので、参考になれば幸いです。
はじめに
そもそも、なぜ手軽に差し替える必要があるかといえば、以下の様な理由があると思います。
- 依存している基盤実装(レガシーコード、3rd-party製のライブラリ)に問題が発覚した時に、利用者に影響なく別の基盤実装に差し替えられるようにする為
- 辛い!苦しい!れがしぃは、れがしぃなのです。トゥッt(以下略)ってなった時に、パッと基盤実装を捨てる事ができるのはライブラリ開発者の精神的負荷を大きく軽減します
- 新しい技術や導入したい技術を積極的に導入・検証できるようにする為
- 利用者のスピード感に追いつく必要があります。依存ライブラリが足を引きずってはいけません(自分の首を絞める発言)
同期処理における基盤実装の差し替えは簡単なので、本稿では非同期処理を取り扱います。
非同期処理において基盤実装を手軽に差し替える為にどんな設計にすればよいか結論を先に書いてしまうと、「Invocation(起動)とExecution(実行)を分離」すれば良いです。これを達成すれば、できたも同然です。
設計サンプル
まずはInvocationを行うクラスから用意します。ここではRequestクラスと命名します。
using System;
public class Request
{
readonly Action<Request, Response> onSuccess; // 成功した時に何かするハンドラ
readonly Action<Request, Error> onFailure; // 失敗した時に何かするハンドラ
public Request() : this(null, null)
{
}
Request(Action<Request, Response> onSuccess, Action<Request, Error> onFailure)
{
this.onSuccess = onSuccess;
this.onFailure = onFailure;
}
public Request OnSuccess(Action<Request, Response> onSuccess)
{
return new Request(onSuccess, onFailure);
}
public Request OnFailure(Action<Request, Error> onFailure)
{
return new Request(onSuccess, onFailure);
}
public void Send()
{
Dispatcher.Dispatch(this);
}
internal void Success(Response response)
{
if (onSuccess == null)
{
return;
}
onSuccess(this, response);
}
internal void Failure(Error error)
{
if (onFailure == null)
{
return;
}
onFailure(this, error);
}
}(Dispatcherクラスについては後述します)
InvocationはRequest#Send()になります。このクラスに、実行に纏わるプロパティを用意しておくと良いでしょう。
Request#OnSuccess()やRequest#OnFailure()を追加する等して、Execution実行後の処理を移譲すると良いです。
次に、Executionを用意します。InvocationではInterfaceを用意しませんでした(不要な場合もある為)が、ExecutionはInterfaceを用意する必要があります。
ここではIWorkerと命名します。
public interface IWorker
{
// 実行可能?
bool Working { get; }
// Requestを捌く
void Work(Request request);
}非同期APIなので、Requestを順次Executionで処理していく為のキューを用意しておきます。WorkQueueと命名します。
using System.Collections.Generic;
using System.Linq;
public class WorkQueue
{
readonly IEnumerable<IWorker> workers;
readonly Queue<Request> requests;
public WorkQueue(IEnumerable<IWorker> workers)
{
this.workers = workers;
requests = new Queue<Request>();
}
public void Enqueue(Request request)
{
requests.Enqueue(request);
}
public void Process()
{
while (true)
{
// Requestがなければ何もしない
if (requests.Count <= 0)
{
return;
}
// 利用可能なIWorkerを探す
var worker = workers.First(w => !w.Working);
if (worker == null)
{
return;
}
// IWorkerにRequestを投げる
worker.Work(requests.Dequeue());
}
}
}実行キューまで用意したので、IWorkerの実装クラスを用意します。
public class FooWorker : IWorker
{
public bool Working { get { return request != null; } }
Request request;
public void Work(Request request)
{
if (Working)
{
throw new InvalidOperationException("still working");
}
this.request = request;
Execute(() => this.request = null);
}
void Execute(Action finishCallback)
{
bool succeeded;
// :
// Execution
// :
if (succeeded)
{
request.Success(new Response());
}
else
{
request.Failure(new Error());
}
finishCallback();
}
}最後に、InvocationからExecutionまでの橋渡しを行うクラスを用意します。Dispatcherと命名します。
using System.Collections.Generic;
using UnityEngine;
// このDispatcherの実装では、define symbolで利用するIWorkerを切り替えています
#if UNITY_EDITOR
using Worker = FooWorker; // UnityEditorではFooWorkerを使う
#elif UNITY_IOS
using Worker = BarWorker; // iOSではBarWorkerを使う
#elif UNITY_ANDROID
using Worker = BazWorker; // AndroidではBazWorkerを使う
#else
using Worker = FooWorker;
#endif
public class Dispatcher : MonoBehaviour
{
static Dispatcher Instance
{
get
{
Initialize();
return instance;
}
}
static Dispatcher instance;
static bool initialized;
static bool quitting;
WorkQueue queue;
public static void Initialize()
{
// アプリケーション終了時は処理しない
if (quitting)
{
return;
}
// 初期化済ならなにもしない
if (initialized)
{
return;
}
// Dispatcherを生成する。Hierarchy上に既に存在しているとかは割愛
var gameObject = new GameObject("Dispatcher");
DontDestroyOnLoad(gameObject);
instance = gameObject.AddComponent<Dispatcher>();
initialized = true;
}
public static void Dispatch(Request request)
{
// アプリケーション終了時は処理しない
if (quitting)
{
return;
}
Instance.queue.Enqueue(request);
}
protected Dispatcher()
{
// WorkQueueオブジェクトをを生成します
// Configクラスとかそういうので同時処理数を外から制御できるようにしておくと、利用者側の需要に合わせられて何かと便利です。
var workers = new List<IWorker>(Config.WorkersCapacity);
for (var i = 0; i < Config.WorkersCapacity; i++)
{
workers.Add(new Worker());
}
queue = new WorkQueue(workers);
}
void Update()
{
if (queue == null)
{
return;
}
queue.Process();
}
void OnDestroy()
{
instance = null;
initialized = false;
}
void OnApplicationQuit()
{
quitting = true;
}
}ライブラリ利用者が記述するコードは以下の様になります。
new Request()
.OnSuccess((req, res) =>
{
// 成功時の処理
})
.OnFailure((req, err) =>
{
// 失敗時の処理
})
.Send();設計サンプルまとめ
(一部実装を割愛していますが)ひと通り役者は揃いました。処理の流れは以下の様になります。
Requestを生成するRequest#Send()を実施(Invocation)Dispatcher上でよしなにRequestがWorkQueueに積まれる- Updateが呼ばれたタイミングで
WorkQueueを消化する - よしなな
IWorker実装クラスで処理を実施する(Execution) - Executionが成功したら
OnSuccessでResponseを処理する。失敗したらOnFailureでErrorを処理する
なんとなく、というかだいたいActiveObjectパターンです。
このように、Invocation(Request#Send())とExecution(IWorker)を分離することで、基盤実装である所のIWorker実装クラスを用意し、使用するIWorkerを切り替えてライブラリ配布するだけで、利用者のコードに一切手を加えず、基盤実装を差し替える事ができます。
サンプルではUnityから提供されるdefine symbolでプラットフォーム毎に基盤実装の差し替えを行っていますが、代わりに、Configuration相当のファイルをライブラリに同梱し、利用者側で制御できるようにする等すれば、ライブラリ機能のβリリースに活用できたり、案件ごとの需要に対応できたり、より柔軟なライブラリ開発運用が可能になります。
実際どうなの
この1年で、同等の設計パターンを適用した社内ライブラリで以下の実績を残しました。
UnityEngine.WWWクラスを使用したIWorkerクラスを基盤実装として(とりあえず)リリースし、後追いで別のHTTP通信基盤実装に差し替えた- アプリ内課金処理実装を、3rd-party製のライブラリを基盤実装としてリリースした
- その後、かゆい所に手が届かなくなり、やっぱり社内ノウハウを積まねばという事で、自前実装に差し替えた
- GameCenterとGooglePlayGameServicesを同一インターフェースで取り扱えるようにした
- その後、依存してた3rd-party製のライブラリに問題があった為、別のものに差し替えた
- 各OSのUnityEditor向けの対応がしんどくなったので、とりあえずスタブのままリリースし、ライブラリバージョンアップでしれっとUnityEditor対応をリリースした
- 新しい通信技術の検証を、利用者が開発したアプリのライブラリ部分だけ差し替えて実施した
この設計パターンは他にもメリットがたくさんありますが、本筋からずれてしまうので割愛します。実際に適用してみて体感していただければと思います。
念の為、これからこの設計パターンを適用する方の為に、この設計パターンで気をつけなければならない事を残しておきます。
- Invocation相当の処理を行うクラスへの機能追加をうっかり見誤ると、ライブラリ全体の設計がおかしくなって本当にどうしようもなくなる
- 利用者シナリオを十分に検討しなければならない。なるべくシンプルに保つ
IWorker実装クラスにすべてのExecutionを記述するわけではない- あくまでも
IWorkerはExecutionのエントリポイントなので、その先はよしなにクラス設計をしながら実装しなければならない - テスト書こう
まとめ
「Invocation(起動)とExecution(実行)を分離」する事で、実装基盤を手軽に差し替えられるようになり、柔軟なライブラリ開発運用が実現できています。 ソフトウェア設計最高!
明日はやまださんです。
コメント