WebApi プラグインの設計思想 & 実装Tips
こちらは「裏 Unreal Engine 4 (UE4) Advent Calendar 2016」1日目の記事です。
裏 Unreal Engine 4 (UE4) Advent Calendar 2016 - Qiita
今回は 裏 ということで趣向を変え、公開中の WebApi プラグイン をベースに、新たに作成中の NekoNekoOnline プラグイン について紹介させて頂きます。
=== 追記 ===
やはりSandboxとは別にした方が良さそうだったので、これまで通りWebApiプラグインとして独立させました。
設計思想は代わりませんので、置き換えて読んで頂ければと。
タイトルも変えてあります。
NekoNekoOnline プラグインとは
NekoNekoOnline プラグインは UE4 のオンライン系機能を使いやすくするためのものです。
旧 WebApi プラグインは NekoNekoOnline プラグインの中の一機能として提供される形となります。
また、WebApi プラグインに同梱されていたスクリーンショット撮影APIやJSON関連のAPIは、NekoNekoGeneral プラグインに切り出されて使用する形に変更されます。
開発は GitHub 上で行っています。
ゲームにおける HTTP 通信の使われ方
WebApi 機能に関する紹介を行う前に、ゲームではどのように HTTP 通信を使用しているのかを簡単に紹介したいと思います。
本題に入る前の前置きのようなものなので、特に興味が無ければ読み飛ばしても結構です。
通信プロトコル
ゲームではクライアント/サーバー間で多岐に渡るデータを送り合う為、汎用性の高いプロトコルが用いられます。
最も多くプロトコルとして用いられているフォーマットは JSON です。
https://ja.wikipedia.org/wiki/JavaScript_Object_Notation
JSON は様々な言語でAPIが標準で用意されており、文字列で構成される事とシンプルな表記から、プロトコルとしてとても扱いやすいフォーマットとなっています。
UE4 でもJSONを扱うための JsonObject モジュールが備わっており、C++ からであれば多くの機能が使えます。
ただし、JSONは使いやすいが故、JSON文字列からオブジェクトに変換する際のデシリアライズコストが非常に大きいというデメリットがあります。
昨今のゲームだと受け渡しするデータが肥大化する傾向にあるため、このデシリアライズコストが無視できない状況になってきています。
そこで数年前から使われ始めたのが MessagePack というバイナリ形式のフォーマットです。
MessagePack: It's like JSON. but fast and small.
MessagePack はJSONと同じくListやMap等のデータ構造を用いることができる、プロトコルとして扱いやすいフォーマットです。
JSONと比較すると各言語のサポートがまだまだ充実していないという状況ではありますが、バイナリ形式故のデシリアライズコストの低さが特徴です。
僕も前職でソーシャルゲームを作っていた際は、クライアント/ゲームサーバー間の通信プロトコルには MessagePack を採用していました。
ちなみに UE4 では標準機能としては提供されていないため、提供されているC++向けのライブラリを用いるか、フォーマットが公開されているので自分でゴリゴリ書けば MessagePack を扱うことは可能です。
GitHub - msgpack/msgpack-c: MessagePack implementation for C and C++ / msgpack.org[C/C++]
通信データ圧縮
通信データ圧縮は必ずしも必要というわけではありません。
圧縮アルゴリズム次第ではありますが、少なからずデシリアライズコストは発生します。
元データが圧縮しやすいものであれば通信料を抑えるためにもあると良いですが、圧縮後のサイズがほとんど変わらないのであれば、いたずらにコストを掛けてしまうだけなので必要ありません。
圧縮アルゴリズムはいくつも種類がありますが、各言語でサポートされていて扱いやすさから gzip 圧縮を利用しているAPIをよく見かけます。
ちなみに前職では MessagePack を使っていた関係上、圧縮前後のサイズがほとんど変わらなかったため、通信データの圧縮は行っていません。
通信データ暗号化
悪質なユーザーによるデータ改竄はゲームの寿命を縮めます。
例えばランキングを競うゲームにおいて、とても獲得できないような得点でランキングに登録されると、普通に遊んでいるユーザーにとっては面白くありません。
特にクライアント/サーバー間で通信中にパケットを改竄された場合、各々は渡されたデータを信用するしかないため、余程おかしなデータでない限りはデータを改竄されてしまった事にすら気付くことができません。
ちなみに通信経路でのデータ改竄なんて、適当にプロキシを立ててしまえば簡単にできます。
専用のアプリすら存在しており、それを使うのは知識を持たない人間にもできてしまうため、軽く考えて放っておくと大変なことになります。
そこでデータ改竄をされにくくするための策として、通信データの暗号化があります。
一般的に用いられることが多いのは AES による暗号化です。
Advanced Encryption Standard - Wikipedia
AES も様々な言語で標準機能として提供されており、共通鍵(暗号化/復号する時に用いる任意の文字列)方式としては使いやすさと強度のバランスが良いです。
UE4 でもコアモジュールとして提供されています。(FAES::EncryptData/DecryptData)
暗号化アルゴリズムに関しては複数の方式を組み合わせたり、独自のものを用いることも多いです。
例えば、ある一定の法則に従って通信データのバイト列を入れ替えたりするだけでも少なからず効果があります。
ただし、AES のような共通鍵を用いる方式だと、なんらかの形で悪質なユーザーに共通鍵が知られてしまった場合に簡単に効能がゼロになります。
共通鍵の文字列がオンメモリになっていたり、アプリに埋め込まれた共通鍵をバイナリから逆アセンブルして抜き出すなんてこともできるので、同じ共通鍵を使い続けるのは非常に危険です。
そのためにクライアント/ゲームサーバー間の通信とは別に、認証サーバーを用意してセッション毎に別の共通鍵を生成するためのAPIを用意することもあります。
結局何が言いたいのか
つらつらと長ったらしく説明しましたが、つまりは API単位で仕様が異なるので汎用化しにくい ということです。
一つのゲーム内でもゲームサーバーのAPI、認証サーバーのAPI、プラットフォームのAPI等々、別仕様のAPIを用いることがほとんどです。
このため UE4 でも標準機能で提供されているのは単純なHTTPリクエストのみで、難しいことがやりたければゲーム側で実装というモジュール構成となっています。
WebApi機能の設計思想
NekoNekoOnline プラグインで提供される WebApi 機能には、これらを解決するための フィルター機能 が用意されています。
このフィルター機能とは、API単位での仕様の違いを吸収するためのものです。
フィルターには RequestFilter と ResponseFilter が存在し、クライアントからサーバーへ送られるリクエスト、サーバーから送られてくるレスポンスに対して任意のフィルターをかけられるような設計となっています。
RequestFilter / ResponseFilter は WebApi(API単位で定義するHTTP通信を利用する為のクラス)に対してコンポーネント式に複数設定する事ができます。
また、フィルターを設定するのは WebApi 自身であるため、例えAPIの仕様変更があったとしても該当するフィルターを付け外しをすることで対応でき、APIを使用する側(ゲームロジック側)での対応は必要ありません。
更に、サーバーと通信しないダミーAPIとして定義するという少し特殊な使い方も可能です。
一般的にゲームクライアント開発は、ゲームサーバー開発と並行で行われます。
そのためクライアント側の開発が先行してしまった場合、サーバーのAPIが用意されるまでは実装に待ちが発生してしまいます。
そこでダミーAPIとして設定しておくと、ゲームロジック側では実際にAPIとして用意されている/されていないに関わらず実装を進めることができ、後にサーバー側でAPIの実装が完了した段階でダミーリクエストに関するフィルターを外すだけでゲームロジック側の変更無く移行する事ができます。
NekoNekoOnline プラグインでの実装
NekoNekoOnline プラグインの WebApi 機能では、上記思想に基づいて下記クラスが用意されています。
UWebApi | API単位で定義するHTTP通信を利用する為のクラス。各APIはこれを継承して実装する。 |
UWebApiRequest | WebApi機能で使用するリクエストクラス。リクエスト実行時に引数として渡され、RequestFilterに通された後にサーバーにリクエストとして送信される。 |
UWebApiResponse | WebApi機能で使用するレスポンスクラス。UWebApi がサーバーからレスポンスを受け取った際に生成され、ResponseFilterに通された後にゲームロジック側に結果として通知される。 |
IWebApiRequestFilterInterface | サーバーへのリクエストに通すフィルター。リクエストの内容を変更できる。 |
IWebApiResponseFilterInterface | サーバーからのレスポンスに通すフィルター。レスポンスの内容を変更できる。 |
フィルター設定
リクエストフィルター実行
レスポンスフィルター実行
NekoNekoOnline プラグインを作るにあたっての実装Tips
1. UOnlineBlueprintCallProxyBase
UOnlineBlueprintCallProxyBase 素敵やん。簡単にレイテントノード作れるわ pic.twitter.com/HtBvJt2O7T
— ほげたつ (@HogeTatu) 2016年11月19日
まずはコレ。
使い方は超簡単で、UOnlineBlueprintCallProxy を継承して作ったクラスに、DYNAMIC_MULTICAST_DELEGATE として定義したデリゲートをプロパティとして宣言すると、自動的にレイテントノードとして作られます。
UE4 のデリゲートに関してはこちらのページが参考になります。
Activate 関数をオーバーライドして処理を記述し、その中で上記プロパティに対して Broadcast(実行)することで実行ピンを通すことができます。
TestCallProxy.h
#pragma once #include "OnlineBlueprintCallProxyBase.h" #include "TestCallProxy.generated.h" UCLASS(BlueprintType, Blueprintable) class NEKONEKOONLINE_API UTestCallProxy : public UOnlineBlueprintCallProxyBase { GENERATED_UCLASS_BODY() public: UPROPERTY(BlueprintAssignable) FEmptyOnlineDelegate OnExecute; public: UFUNCTION(BlueprintCallable, Category="NekoNeko|Online|WebApi", meta=(BlueprintInternalUseOnly="true")) static UTestCallProxy* ExecuteTest(); // UOnlineBlueprintCallProxyBase interface virtual void Activate() override; };
TestCallProxy.cpp
#include "NekoNekoOnlinePrivatePCH.h" #include "TestCallProxy.h" UTestCallProxy::UTestCallProxy(const FObjectInitializer& ObjectInitializer) : Super(ObjectInitializer) { } UTestCallProxy* UTestCallProxy::ExecuteTest() { UTestCallProxy* Proxy = NewObject<UTestCallProxy>(); return Proxy; } void UTestCallProxy::Activate() { OnExecute.Broadcast(); }
Blueprint
2. DefaultObject
UE4 では UObject を継承してクラスを定義した場合、起動時に必ず DefaultObject と呼ばれるインスタンスが一つ作られます。
これは本来であれば新しいインスタンスを作成される際のコピー元として利用されるものですが、例えば「リクエストAをリクエストBに変換するフィルター」等の場合だと、フィルター処理の際にインスタンスの状態を変更する必要が無いため、DefaultObject を使ってフィルター処理を行っても動作に支障はありません。
更に、DefaultObject を使う場合だと既に作成済みのインスタンスを使うため、インスタンシングコストを減らすことにも繋がります。
DefaultObject は以下のような記述で使用できます。
UWebApiConvToJsonFilter* UWebApiConvToJsonFilter::GetConvToJsonFilter()
{
return StaticClass()->GetDefaultObject<UWebApiConvToJsonFilter>();
}
3. 名前指定のプロパティコピー
UE4 ではコンパイル前処理でクラスの型情報を生成しているため、プロパティに対する柔軟な扱いが可能となっています。
今回はプロパティ名リストからのコピーを行いたかったため調べたところ、UProperty::CopySingleValue で別インスタンス間でのプロパティコピーが実現可能でした。
/** * プロパティコピー * @param SourceObject コピー元オブジェクト * @param DestObject コピー先オブジェクト * @param PropertyNames プロパティ名リスト */ UFUNCTION(BlueprintCallable, Category="NekoNeko|General") static void CopyObjectProperties(UObject* SourceObject, UObject* DestObject, const TArray<FString>& PropertyNames);
void UPropertyFunctionLibrary::CopyObjectProperties(UObject* SourceObject, UObject* DestObject, const TArray<FString>& PropertyNames) { const UClass* SourceClass = SourceObject->GetClass(); const UClass* DestClass = DestObject->GetClass(); if (DestObject->IsA(SourceClass)) { for (auto& PropertyName : PropertyNames) { auto SourceProperty = FindField<UProperty>(SourceClass, *PropertyName); if (!SourceProperty) { continue; } auto DestProperty = FindField<UProperty>(DestClass, *PropertyName); if (!DestProperty) { continue; } const void* Source = SourceProperty->ContainerPtrToValuePtr<void>(SourceObject); void* Dest = DestProperty->ContainerPtrToValuePtr<void>(DestObject); SourceProperty->CopySingleValue(Dest, Source); } } }