こんにちは!
PHP × Unityを使った新規開発プロジェクトでサーバサイドエンジニアをしている仙道です。
今回の記事では、SwaggerというAPI開発フレームワークを使って、
サーバ(PHP)とクライアント(C#, Unity)それぞれで使うリクエスト / レスポンスクラスを自動生成することでAPIの繋ぎ込みを楽にするための取り組みについて紹介したいと思います。
Swaggerとは?
Swaggerとは、REST APIを記述するための仕様とその仕様に基づいた開発を支援するためのツール群を提供しているAPI開発フレームワークです。
Swagger SpecというAPIの設計図を基にWeb上で閲覧できるAPIドキュメントを生成することができたり(Swagger UI)、mustacheと呼ばれるテンプレートエンジンを使って記述されたテンプレートと合わせて使用することで、様々な言語のコードを自動生成(Swagger Codegen)することができます。
今回はSwagger Spec, Swagger UI, Swagger Codegenという3つのツールを使用していくため簡単に触れましたが、他にもたくさんのツールが提供されています。
その他のツールの概要や詳細は公式サイト(https://swagger.io/)を、今回触れた各ツールの導入方法などについてはGithubを参照してもらえればと思います。
- Swagger Spec(https://github.com/OAI/OpenAPI-Specification)
- Swagger UI(https://github.com/swagger-api/swagger-ui)
- Swagger Codegen(https://github.com/swagger-api/swagger-codegen)
また、mustacheの記法については下記リンクを参考にしてもらえればと思います。
- mustache公式ドキュメント(https://mustache.github.io/mustache.5.html)
- mustache記法について簡単にまとめてみた(https://qiita.com/sengok/items/1d958348215647a5eaf0)
APIの繋ぎ込みは大変
みなさんは、APIの繋ぎ込みと聞いてどんな印象を受けるでしょうか?
ドキュメントを読みながら慎重にサーバとクライアントとで型や名前、受け取る内容にずれが起きないようにコードを書く、それでも起きる齟齬やミスによるパースエラー、ドキュメントの更新・共有漏れ、口頭での一時的な修正により乖離していく実コードとドキュメント…と、なかなか大変だなーという印象を受ける方も少なくはないかと思います。
弊社においても、これまでいくつかのプロジェクトでサーバサイドエンジニアが手動でConfluenceにAPIドキュメントを作成して繋ぎ込みに活用していましたが、
上記のような事象によって何度もパースエラーが発生し、サーバ / クライアントで「大変だなー」「直してってまた言いに行くの気まずいなー」という思いをしたことがありました。
そこで、私が所属している新規開発プロジェクトではSwaggerを活用して、
サーバとクライアントそれぞれがAPI通信に使うリクエストクラスとレスポンスクラスを自動生成することによって解決を試みました。
仕様書からSwagger Spec(APIの設計図)ファイルの作成
私の所属しているプロジェクトでは、プランナーやデザイナーが作成した機能仕様書、ワイヤーフレームやUI仕様書を基にして、主にサーバサイドエンジニア主導でSwagger Specファイルの叩きを準備しています。
その後、クライアントエンジニアとSwagger Specファイルから生成されたAPIドキュメント(Swagger UI)を基に必要/不必要なデータの精査や変数の命名が適切かどうかなどを確認するためのすり合わせを行っています。
今回はサンプルとして、ゲーム内アイテムの詳細を取得するというAPIを想定して下記のようなSwagger Specファイルを用意してみました。
swagger: "2.0"
info:
description: "Generate Request & Response Class for c# & php"
version: "1.0.0"
title: "Griphone Engineer Blog Sample"
host: "localhost:3000"
basePath: "/"
schemes:
- "http"
paths:
/Sample/GetItemDetail:
post:
summary: "アイテムの詳細を取得"
description: "サーバに渡したアイテムIDに該当するアイテムがあれば詳細を取得します"
consumes:
- "application/json"
produces:
- "application/json"
parameters:
- in: "body"
name: "body"
description: "取得したいアイテムのItemId"
required: true
schema:
$ref: "#/definitions/GetItemDetailRequest"
responses:
200:
description: "successful operation"
schema:
$ref: "#/definitions/GetItemDetailResponse"
500:
description: "Server Error"
definitions:
GetItemDetailRequest:
type: "object"
properties:
ItemId:
description: "取得したいアイテムのItemId"
type: "integer"
format: "int64"
example: 1
x-serverType: "ItemId"
x-serverNameSpaceList:
- "Data\\Int\\ItemId"
GetItemDetailResponse:
type: "object"
properties:
Name:
description: "アイテム名"
type: "string"
x-serverType: "NotEmptyCustomString"
Flavor:
description: "アイテムのフレーバーテキスト"
type: "string"
x-serverType: "NotEmptyCustomString"
Amount:
description: "所持している個数"
type: "integer"
format: "int32"
x-serverType: "UInt"
x-serverNameSpaceList:
- "Data\\Int\\UInt"
- "Data\\String\\NotEmptyCustomString"
リクエスト / レスポンスクラスのためのテンプレートの作成
次に、Swagger Codegenを使ってサーバ(PHP)とクライアント(C#)それぞれで使うリクエスト / レスポンスクラスの生成するために使用するテンプレートの準備を行います。
生成にあたって、Swagger Codegenが生成してくれるコードをそのままプロジェクトに採用したい…ところですが、コーディング規約や堅牢性などの面で難しいため、サーバ(PHP)とクライアント(C#)それぞれの要望を満たすためカスタムテンプレートを使用しています。
特に、サーバサイドチーム(PHP)からは、例えばItemIdやAmountを定義する際、
単にint型で定義するのではなく、ItemId型(ItemIdの生成ルールに従った1以上の整数),Amount型(上限のある0以上の整数)といったように厳密な型定義をしたクラスを使用した状態のリクエストクラスとレスポンスクラスを生成してほしいという要望がありました。
こちらの要望については、Swagger Specが提供するプロパティに加えてユーザが独自にプロパティを定義できるVendorExtensionsという機能を活用することにしました。
(仕様書からSwagger Spec(APIの設計図)ファイルの作成リンクにおいてx-serverTypesやx-serverNameSpaceListというプロパティが独自に拡張したプロパティです)
以上を踏まえて今回作成したテンプレートが下記になります。
サーバ(PHP)
Request.mustache
{{#models}}{{#model}}<?php
namespace Client;
{{#vendorExtensions.x-serverNameSpaceList}}
use {{.}};
{{/vendorExtensions.x-serverNameSpaceList}}
/**
* {{classname}}クラス
*/
class {{classname}} extends RequestObjectBase
{
{{#vars}}
/**
* @var {{{vendorExtensions.x-serverType}}}
*/
protected ${{name}};
{{#hasMore}}
{{/hasMore}}
{{/vars}}
/**
* コンストラクタ
*
{{#vars}}
* @param {{{vendorExtensions.x-serverType}}} ${{name}}
{{/vars}}
*/
public function __construct(
{{#vars}}
{{{vendorExtensions.x-serverType}}} ${{name}}{{#hasMore}},{{/hasMore}}
{{/vars}}
) {
{{#vars}}
$this->{{name}} = ${{name}};
{{/vars}}
}
/**
* 連想配列からオブジェクトを生成します
*
* @param array $array 生成する元の連想配列
*
* @return mixed 生成されたオブジェクト
*/
public static function fromMap(array $array)
{
return new static(
{{#vars}}
new {{{vendorExtensions.x-serverType}}}($array['{{name}}']){{#hasMore}},{{/hasMore}}
{{/vars}}
);
}
}{{/model}}{{/models}}
AbstractResponse.mustache
{{#models}}{{#model}}<?php
namespace Client;
{{#vendorExtensions.x-serverNameSpaceList}}
use {{.}};
{{/vendorExtensions.x-serverNameSpaceList}}
/**
* {{classname}}クラス
*/
abstract class {{classname}} implements ResponseObjectInterface
{
{{#vars}}
/**
* @var {{vendorExtensions.x-serverType}}
*/
protected ${{name}};
{{#hasMore}}
{{/hasMore}}
{{/vars}}
/**
* コンストラクタ
*
{{#vars}}
* @param {{{vendorExtensions.x-serverType}}} ${{name}}
{{/vars}}
*/
public function __construct(
{{#vars}}
{{{vendorExtensions.x-serverType}}} ${{name}}{{#hasMore}},{{/hasMore}}
{{/vars}}
) {
{{#vars}}
$this->{{{name}}} = ${{name}};
{{/vars}}
}
/**
* オブジェクトから連想配列を生成します
*
* @return array オブジェクトを連想配列にしたもの
*/
public function toMap(): array
{
return [
'{{name}}' => [
{{#vars}}
'{{name}}' => $this->get{{nameInCamelCase}}(){{#hasMore}},{{/hasMore}}
{{/vars}}
]
];
}
{{#vars}}
/**
* {{name}}を取得します
*/
abstract protected function get{{nameInCamelCase}}();
{{#hasMore}}
{{/hasMore}}
{{/vars}}
}{{/model}}{{/models}}
ConcreteResponse.mustache
{{#models}}{{#model}}<?php
namespace Client;
/**
* {{classname}}クラス
*/
class {{classname}} extends Abstract{{name}}
{
{{#vars}}
/**
* {{name}}を取得します
*/
protected function get{{nameInCamelCase}}()
{
throw new NotImplementedException();
}
{{#hasMore}}
{{/hasMore}}
{{/vars}}
/**
* レスポンスのステータスコードを取得します
*
* @return StatusCode ステータスコード
*/
public function getStatusCode(): StatusCode
{
return StatusCode::getSuccess();
}
}{{/model}}{{/models}}
クライアント(C#)
Request.mustache
{{#models}}{{#model}}using System;
using System.Collections.Generic;
namespace Server
{
/* {{classname}}
* リクエストクラス
*/
[Serializable]
public class {{classname}}
{
{{#vars}}
{{#isContainer}}
public List<{{items.datatype}}> {{name}};
{{/isContainer}}
{{^isContainer}}
public {{datatype}} {{name}};
{{/isContainer}}
{{/vars}}
}
}{{/model}}{{/models}}
Response.mustache
{{#models}}{{#model}}using System;
using System.Collections.Generic;
namespace Server
{
/* {{classname}}
* レスポンスクラス
*/
[Serializable]
public class {{classname}}
{
{{#vars}}
{{#isContainer}}
public List<{{items.datatype}}> {{name}};
{{/isContainer}}
{{^isContainer}}
public {{datatype}} {{name}};
{{/isContainer}}
{{/vars}}
}
}{{/model}}{{/models}}
リクエスト / レスポンスクラスの自動生成
最後に、これまで準備したSwagger Specファイルとカスタムテンプレートを基にSwagger Codegenを使用してリクエスト / レスポンスクラスの生成を行います。
生成には下記のようなコマンドを叩いています。
java -jar ../modules/swagger-codegen-cli/target/swagger-codegen-cli.jar generate \
-t template/php \
-i specification/sample.yaml \
-l custom-php \
-o result/php
各オプションの説明は下記のとおりです。
- -t : 使用するテンプレートが入っているディレクトリを指定します。
- -i : 使用するSwagger Specファイルを指定します。
- -l : 使用するコードジェネレータの種類を指定します。
- -o : 生成したコードを吐き出すためのディレクトリを指定します。
今回は複数のテンプレートがあり、リクエストとレスポンスそれぞれで対応したテンプレートを使用して生成されたコードのみを正として残したいため、
単純にコマンドを叩くのではなく、Groovyを使って生成する言語やリクエスト/レスポンスに応じたオプションの付与 -> コード生成 -> 不要ファイル削除といった一連の処理を実行するためのGroovyスクリプトを別途用意して生成しています。
具体的なGroovyスクリプトの内容は割愛します。
実際に生成されたコードが下記になります。
サーバ(PHP)
Request.php
<?php
namespace Client;
use Data\Int\ItemId;
/**
* GetItemDetailRequestクラス
*/
class GetItemDetailRequest extends RequestObjectBase
{
/**
* @var ItemId
*/
protected $itemId;
/**
* コンストラクタ
*
* @param ItemId $itemId
*/
public function __construct(
ItemId $itemId
) {
$this->itemId = $itemId;
}
/**
* 連想配列からオブジェクトを生成します
*
* @param array $array 生成する元の連想配列
*
* @return mixed 生成されたオブジェクト
*/
public static function fromMap(array $array)
{
return new static(
new ItemId($array['itemId'])
);
}
}
AbstractResponse.php
<?php
namespace Client;
use Data\Int\UInt;
use Data\String\NotEmptyCustomString;
/**
* AbstractGetItemDetailResponseクラス
*/
abstract class AbstractGetItemDetailResponse implements ResponseObjectInterface
{
/**
* @var NotEmptyCustomString
*/
protected $name;
/**
* @var NotEmptyCustomString
*/
protected $flavor;
/**
* @var UInt
*/
protected $amount;
/**
* コンストラクタ
*
* @param NotEmptyCustomString $name
* @param NotEmptyCustomString $flavor
* @param UInt $amount
*/
public function __construct(
NotEmptyCustomString $name,
NotEmptyCustomString $flavor,
UInt $amount
) {
$this->name = $name;
$this->flavor = $flavor;
$this->amount = $amount;
}
/**
* オブジェクトから連想配列を生成します
*
* @return array オブジェクトを連想配列にしたもの
*/
public function toMap(): array
{
return [
'GetItemDetailResponse' => [
'name' => $this->getName(),
'flavor' => $this->getFlavor(),
'amount' => $this->getAmount()
]
];
}
/**
* nameを取得します
*/
abstract protected function getName();
/**
* flavorを取得します
*/
abstract protected function getFlavor();
/**
* amountを取得します
*/
abstract protected function getAmount();
}
ConcreteResponse.php
<?php
namespace Client;
/**
* ConcreteGetItemDetailResponseクラス
*/
class ConcreteGetItemDetailResponse extends AbstractGetItemDetailResponse
{
/**
* nameを取得します
*/
protected function getName()
{
throw new NotImplementedException();
}
/**
* flavorを取得します
*/
protected function getFlavor()
{
throw new NotImplementedException();
}
/**
* amountを取得します
*/
protected function getAmount()
{
throw new NotImplementedException();
}
/**
* レスポンスのステータスコードを取得します
*
* @return StatusCode ステータスコード
*/
public function getStatusCode(): StatusCode
{
return StatusCode::getSuccess();
}
}
※ getHoge系のメソッドがNotImplementedExceptionをスローするようにしているのは、
サーバで独自定義したクラスによってクライアントが解釈できるプリミティブ型に戻す処理が実装者や要件によって異なるケースが起き得るため、あえてこのような形を取っています。
クライアント(C#)
Request.cs
using System;
using System.Collections.Generic;
namespace Server
{
/* GetItemDetailRequest
* リクエストクラス
*/
[Serializable]
public class GetItemDetailRequest
{
public long ItemId;
}
}
Response.cs
using System;
using System.Collections.Generic;
namespace Server
{
/* GetItemDetailResponse
* レスポンスクラス
*/
[Serializable]
public class GetItemDetailResponse
{
public string Name;
public string Flavor;
public int Amount;
}
}
Swaggerを使用するメリット
ここまでの流れで生成したサーバ(PHP)とクライアント(C#)のリクエスト / レスポンスクラスは、全て単一のSwagger Specファイルから生成されているため、今後は型や名前が違うといったことが理由でパースエラーが起きてしまうことは格段に減るのではないかと思います。
(テンプレートは単一ではないですが、はじめに一度がっちり作ってしまえばAPIごとに変更するといったことは無いはずです)
また、私の所属している新規開発プロジェクトでは今後の課題となっているのですが、
Swagger SpecをGitリポジトリにpushした際に、Hookとしてリクエスト / レスポンスクラスの更新Pull Requestを送るようにするなどの工夫があると、API内容更新の共有漏れや口頭のみでの共有で後々困るといった問題も減らせるのではないかと考えています。
おわりに
本記事では、Swaggerを活用してサーバとクライアントそれぞれがAPI通信に使うリクエストクラスとレスポンスクラスを自動生成することによって、APIの繋ぎ込みを楽にするための取り組みを紹介しました。
自動生成を行うことでパースエラーによるAPIの繋ぎ込みの失敗を減らせたり、ドキュメントと実際のクラス構造が異なるといったケースを減らせたことからAPIの繋ぎ込みを楽にすることができました。
Swaggerは日本語の記事がまだまだ少なかったり、実際のプロジェクトに適用する場合、
工夫やカスタマイズが必要になるケースもありますが非常に強力なツールではないかと思います。
最後に、弊社エンジニアブログの別記事として「Swagger UI × Amazon EC2 × Dockerで開発初期からAPIの繋ぎ込みを意識できる環境を構築してみた(https://tech.griphone.co.jp/2017/10/08/swagger-mock-server)」も公開していますので、そちらも合わせて参照してもらえると嬉しいです。
それでは!