このエントリーは、KLab Advent Calendar 2015 の12/10の記事です。
@uzzuです。
私は主にUnity向けの社内ライブラリの開発に従事しています。社内ライブラリと聞いてウッとなる方がいたら友達になれそうです。
ライブラリを開発する上で意識しなければならないことはたくさんありますが、本稿ではタイトルの通り、ライブラリ利用者のコードに一切手を加えず基盤実装を差し替える為の設計パターンについて紹介します。もう2015年なので恐らく当たり前のようにやってる話だと思いますが、あまりこういう話をWeb上で見かけないので、参考になれば幸いです。
そもそも、なぜ手軽に差し替える必要があるかといえば、以下の様な理由があると思います。
同期処理における基盤実装の差し替えは簡単なので、本稿では非同期処理を取り扱います。
非同期処理において基盤実装を手軽に差し替える為にどんな設計にすればよいか結論を先に書いてしまうと、「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
に積まれるWorkQueue
を消化するIWorker
実装クラスで処理を実施する(Execution)OnSuccess
でResponse
を処理する。失敗したらOnFailure
でError
を処理するなんとなく、というかだいたいActiveObjectパターンです。
このように、Invocation(Request#Send()
)とExecution(IWorker
)を分離することで、基盤実装である所のIWorker
実装クラスを用意し、使用するIWorker
を切り替えてライブラリ配布するだけで、利用者のコードに一切手を加えず、基盤実装を差し替える事ができます。
サンプルではUnityから提供されるdefine symbolでプラットフォーム毎に基盤実装の差し替えを行っていますが、代わりに、Configuration相当のファイルをライブラリに同梱し、利用者側で制御できるようにする等すれば、ライブラリ機能のβリリースに活用できたり、案件ごとの需要に対応できたり、より柔軟なライブラリ開発運用が可能になります。
この1年で、同等の設計パターンを適用した社内ライブラリで以下の実績を残しました。
UnityEngine.WWW
クラスを使用したIWorker
クラスを基盤実装として(とりあえず)リリースし、後追いで別のHTTP通信基盤実装に差し替えたこの設計パターンは他にもメリットがたくさんありますが、本筋からずれてしまうので割愛します。実際に適用してみて体感していただければと思います。
念の為、これからこの設計パターンを適用する方の為に、この設計パターンで気をつけなければならない事を残しておきます。
IWorker
実装クラスにすべてのExecutionを記述するわけではないIWorker
はExecutionのエントリポイントなので、その先はよしなにクラス設計をしながら実装しなければならない「Invocation(起動)とExecution(実行)を分離」する事で、実装基盤を手軽に差し替えられるようになり、柔軟なライブラリ開発運用が実現できています。 ソフトウェア設計最高!
明日はやまださんです。
KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。
合わせて読みたい
KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。