Delphiでもテストコード書きたい
API呼び出しのニーズは増える中、DelphiをApiClientとし、テスト可能なアーキテクチャにしたいニーズはあるはず。世の中的にDDDやClean Architectureにしたいという機運がある中、Delphiを使ってのサンプルコードは少なく、なんか取り残されている。そこで、ちょっとでもノウハウを共有できたらなと。
なお、次のエントリでは「DUnitX」と「Delphi Mocks」を使いテストコードの実装方法も紹介できたらと考えてます。
前提
テスト可能なアーキテクチャの書き方を何となく理解してるのが前提です。
書くコードとアーキテクチャ論
今回はWebAPIのデータ取得まわりを抽象化しテスト可能としていきます。コードをがっつり書くとキリないんで、掻い摘んで説明できたらなと。WebAPIを使って実装したApplication Service(UseCase)のテストコードを書いてく感じです。
WEBアプリケーションとクライアントアプリケーションを連携する場合、WEB APIを経由 or WebViewで画面を表示する方法が考えられます。WEB APIを呼び出すとき薄いラッパー用のDLLを作成することでモジュール化でき、複数サービス間で共有できます。
関数の呼び出し規約は「stdcall」とし開発言語特有の型は使用しないことで、開発言語やプラットフォームに依らない呼び出しが可能になります。昔のC#(.NET Framework)でDLL作ると、他の言語で作ったアプリから呼び出せません。たとえば、Delphiで作ったアプリ(EXE)はC#のDLLを呼べません(´;ω;`)
DLLを作るにあたり、Delphiって結構使える言語と思ったり思わなかったり(ただし、.NETもAOT対応したことで今後はそちらも選択肢に入ってくる?)。
全体設計
- RESTな操作を抽象化するクラスを実装
- ↑を使って各APIに特化したApiClientを実装
- ↑で作ったApiClientを使ってApplication Service(UseCase)を実装
アクセストークンを取得後、そのトークンで設定情報を取得するアプリケーションを作成します。
REST操作の実装と抽象化
REST操作の実行結果を定義
HTTPステータスコードやJSONなどの結果をまとめたクラスを定義する。
unit RestResponseU;
interface
type
TRestResponse = class
private
FStatusCode: Integer;
FResultStr: string;
function GetIsSucceeded: Boolean;
public
constructor Create(AStatusCode: Integer; AResultStr: string);
property IsSucceeded: Boolean read GetIsSucceeded;
property StatusCode: Integer read FStatusCode;
property ResultStr: string read FResultStr;
end;
implementation
constructor TRestResponse.Create(AStatusCode: Integer; AResultStr: string);
begin
inherited Create;
FStatusCode := AStatusCode;
FResultStr := AResultStr;
end;
function TRestResponse.GetIsSucceeded: Boolean;
begin
Result := FStatusCode = 200;
end;
end.
オブジェクトはイミュータブルにします。
REST操作のインターフェイス
GETやPOSTといったHTTPメソッドを抽象化したインターフェイスを定義します。
テスタビリティを担保します。
unit IRestClientU;
interface
uses
System.Classes, RestResponseU;
type
{$M+}
IRestClient = interface
['{B8062E7E-E6C5-4EFB-9ED6-94947B5B87D7}']
function Get(AUrl: string; AnAccept: string): TRestResponse; overload;
function Get(AUrl: string; AnAccept: string; AnAccessToken: string): TRestResponse; overload;
function Post(AUrl: string; AnAccessToken: string; AnAccept: string;
AContentType: string): TRestResponse; overload;
function Post(AUrl: string; AnAccessToken: string; AnAccept: string;
AContentType: string; APostStream: TStringStream): TRestResponse; overload;
end;
{$M-}
implementation
end.
ここで、「{$M+}」と」「{$M-}」で括るのはDelphi Mocksを使えるようにするためです。
REST操作の実装
定義したインターフェイスを実装します。詳細な実装は省いていて、イメージは以下。
unit RestClientU;
interface
uses
System.Classes,
System.Net.URLClient,
IRestClientU,
RestResponseU;
type
/// <summary>
/// HTTPリクエストの種類
/// </summary>
THttpMethod = (Get, Post);
TRestClient = class(TInterfacedObject, IRestClient)
private
function SendData(AHttpMethod: THttpMethod; AUrl, ARequestStr,
AnAccessToken, AContentType, AnAccept: string): TRestResponse; overload;
function SendData(AHttpMethod: THttpMethod; AUrl, ARequestStr,
AnAccessToken, AContentType, AnAccept: string;
APostStream: TStringStream): TRestResponse; overload;
public
function Get(AUrl: string; AnAccept: string): TRestResponse; overload;
function Get(AUrl: string; AnAccept: string; AnAccessToken: string): TRestResponse; overload;
function Post(AUrl: string; AnAccessToken: string; AnAccept: string;
AContentType: string): TRestResponse; overload;
function Post(AUrl: string; AnAccessToken: string; AnAccept: string;
AContentType: string; APostStream: TStringStream): TRestResponse; overload;
end;
implementation
{ TRestClient }
uses
System.Net.HttpClient,
System.JSON,
System.SysUtils,
System.IOUtils,
System.Generics.Collections,
System.NetConsts,
IdURI,
IPPeerAPI,
IdStack,
IdMultipartFormData;
function TRestClient.Get(AUrl, AnAccept: string): TRestResponse;
begin
Result := SendData(THttpMethod.Get, AUrl, string.Empty, string.Empty,
string.Empty, string.Empty);
end;
function TRestClient.Get(AUrl, AnAccept, AnAccessToken: string): TRestResponse;
begin
Result := SendData(THttpMethod.Get, AUrl, string.Empty, AnAccessToken,
string.Empty, AnAccept);
end;
function TRestClient.Post(AUrl, AnAccessToken, AnAccept,
AContentType: string): TRestResponse;
begin
Result := SendData(THttpMethod.Post, AUrl, string.Empty, AnAccessToken,
AContentType, AnAccept);
end;
function TRestClient.Post(AUrl, AnAccessToken, AnAccept,
AContentType: string; APostStream: TStringStream): TRestResponse;
begin
Result := SendData(THttpMethod.Post, AUrl, string.Empty, AnAccessToken,
AContentType, AnAccept, APostStream);
end;
function TRestClient.SendData(AHttpMethod: THttpMethod; AUrl, ARequestStr,
AnAccessToken, AContentType, AnAccept: string): TRestResponse;
begin
Result := SendData(AHttpMethod, AUrl, ARequestStr, AnAccessToken,
AContentType, AnAccept, nil);
end;
function TRestClient.SendData(AHttpMethod: THttpMethod; AUrl, ARequestStr,
AnAccessToken, AContentType, AnAccept: string;
APostStream: TStringStream): TRestResponse;
begin
// THttpClientでGETやPOSTする。実装は省略。
end;
end.
end.
REST操作の失敗時の例外
REST操作の失敗時は例外を出力します。例外を実装することで「何に失敗したか」の表現力を向上します。
unit ApiExceptionU;
interface
uses
System.SysUtils;
type
EApiException = class(Exception)
private
FErrorCode: Integer;
public
/// <summary>
/// コンストラクタ
/// </summary>
constructor Create(ErrorCode: Integer); overload;
/// <summary>
/// エラーコード
/// </summary>
property ErrorCode: Integer read FErrorCode;
property Message;
end;
implementation
{ EApiException }
/// <summary>
/// コンストラクタ
/// </summary>
constructor EApiException.Create(ErrorCode: Integer);
begin
inherited Create(string.Empty);
Self.FErrorCode := ErrorCode;
end;
end.
ApiClientの実装と抽象化
APIの戻り値のオブジェクト化
APIの結果の表現力を高めるため、APIの戻りをオブジェクトで表現します。
unit ApiResultU;
interface
type
TApiResult = class
private
FIsSucceeded: Boolean;
FApiResult: string;
FHttpStatusCode: Integer;
FErrorCode: Integer;
FErrorMessage: string;
public
constructor Create(AIsSucceeded: Boolean; AnApiResult, AnErrorMessage: string;
AHttpStatusCode, AnErrorCode: Integer);
property IsSucceeded: Boolean read FIsSucceeded;
property ApiResult: string read FApiResult;
property HttpStatusCode: Integer read FHttpStatusCode;
property ErrorCode: Integer read FErrorCode;
property ErrorMessage: string read FErrorMessage;
end;
implementation
{ TApiResult }
constructor TApiResult.Create(AIsSucceeded: Boolean; AnApiResult,
AnErrorMessage: string; AHttpStatusCode, AnErrorCode: Integer);
begin
FIsSucceeded := AIsSucceeded;
FApiResult := AnApiResult;
FErrorMessage := AnErrorMessage;
FHttpStatusCode := AHttpStatusCode;
FErrorCode := AnErrorCode;
end;
end.
もちろん、イミュータブル。
ApiClientのロギングについて
調査用に(エラー or 情報)ログの出力ロジックはほしいところ。「なににロギングするか?」を抽象化した方がベターで、これによりテスト可能とする。実装の例示は割愛します。
unit ILoggerU;
interface
uses
System.SysUtils;
type
{$M+}
ILogger = interface
['{C76E7EDA-92A4-4F9C-BECE-C100F9FAEAEE}']
procedure WriteInfo(AMessage: string);
procedure WriteError(AExeption: Exception); overload;
procedure WriteError(AMessage: string); overload;
end;
{$M-}
implementation
end.
ApiClientのインターフェイス
RestClientを使って各APIのApiClientを実装します。
こちらもテスタビリティを担保するため、インターフェイスを定義します。
unit IApiClientU;
interface
uses
ApiResultU;
type
{$M+}
IApiClient = interface
['{00428085-040A-47DA-BEE2-1065321F9B0E}']
/// <summary>
/// ユーザー名・パスワードから認証情報を取得します。
/// </summary>
function GetToken(AUrl, AUserId, APassword: string): TApiResult;
/// <summary>
/// 設定情報を取得します。
/// </summary>
function GetSettings(AUrl, AnAccessToken: string): TApiResult;
end;
{$M-}
implementation
end.
ApiClientの実装
IApiClientUを継承して実装します。
unit ApiClientU;
interface
uses
IRestClientU,
ILoggerU,
IApiClientU,
ApiResultU;
type
TApiClient = class(TInterfacedObject, IApiClient)
private
FRestClient: IRestClient;
FLogger: ILogger;
public
constructor Create(
ARestClient: IRestClient;
ALogger: ILogger);
/// <summary>
/// ユーザー名・パスワードから認証情報を取得します。
/// </summary>
function GetToken(AUrl, AUserId, APassword: string): TApiResult;
/// <summary>
/// 設定情報を取得します。
/// </summary>
function GetSettings(AUrl, AnAccessToken: string): TApiResult;
end;
implementation
uses
System.SysUtils,
System.Classes,
System.JSON,
System.NetEncoding,
Web.HTTPApp,
RestResponseU,
ApiExceptionU;
{ TApiClient }
constructor TApiClient.Create(ARestClient: IRestClient;
ALogger: ILogger);
begin
FRestClient := ARestClient;
FLogger := ALogger;
end;
function TApiClient.GetToken(AUrl, AUserId, APassword: string): TApiResult;
begin
// リクエストのBodyを構築する。
var LPostStream: TStringStream := TStringStream.Create(
'granttype=password' +
'&userName=' + TNetEncoding.URL.Encode(UTF8Encode(AUserID)) +
'&Password=' + TNetEncoding.URL.Encode(UTF8Encode(APassword)) +
'&clientid=' + 'CLIENT_ID' +
'&clientsecret=' + 'CLIENT_SECRET' +
'&scope=' + 'SCOPE');
var LResponse: TRestResponse := nil;
var LErrorJson: TJSONObject := nil;
try
var LRequestUrl: string := AUrl;
try
LResponse := FRestClient.Post(LRequestUrl, string.Empty, 'application/json',
'application/x-www-form-urlencoded', LPostStream);
except on E: EAPiException do
begin
Result := TApiResult.Create(False, string.Empty, E.Message, 0, E.ErrorCode);
FLogger.WriteError('予期せぬネットワークエラーが発生しました。');
Exit;
end;
end;
if LResponse.IsSucceeded then
begin
Result := TApiResult.Create(True, LResponse.ResultStr, string.Empty,
LResponse.StatusCode, 0);
end else
begin
// 以下はエラーハンドリング(詳細は割愛)
Result := TApiResult.Create(False, string.Empty, 'error',
LResponse.StatusCode, 200);
FLogger.WriteError('APIの実行に失敗しました。'
+ Format('HttpStatus:%d', [LResponse.StatusCode]));
end;
finally
if Assigned(LPostStream) then
begin
FreeAndNil(LPostStream);
end;
if Assigned(LResponse) then
begin
FreeAndNil(LResponse);
end;
if Assigned(LErrorJson) then
begin
FreeAndNil(LErrorJson);
end;
end;
end;
function TApiClient.GetSettings(AUrl, AnAccessToken: string): TApiResult;
begin
var LResponse: TRestResponse := nil;
var LErrorJson: TJSONObject := nil;
try
try
// APIを投げる。
LResponse := FRestClient.Get(AUrl, 'application/json', AnAccessToken);
except on E: EAPiException do
Result := TApiResult.Create(False, string.Empty, E.Message,
LResponse.StatusCode, E.ErrorCode);
end;
if LResponse.IsSucceeded then
begin
Result := TApiResult.Create(True, LResponse.ResultStr, string.Empty,
LResponse.StatusCode, 0);
end else
begin
// 以下はエラーハンドリング(詳細は割愛)
var LErrorMessage: string := '情報の取得に失敗しました。';;
Result := TApiResult.Create(False, string.Empty, LErrorMessage,
LResponse.StatusCode, 2010);
FLogger.WriteError('APIの実行に失敗しました。'
+ Format('HttpStatus:%d', [LResponse.StatusCode]));
end;
finally
if Assigned(LResponse) then
begin
FreeAndNil(LResponse);
end;
if Assigned(LErrorJson) then
begin
FreeAndNil(LErrorJson);
end;
end;
end;
end.
HTTPリクエストで予期しないエラーが起こった場合、RestClient側でEApiExceptionを投げ、ApiClient側でcatchすることでコードの表現力を高めた。
Application Service(UseCase)の実装
ApiClientで実装したロジックをもとに、トークンを取得し、そのトークンで設定値を取得します。
unit GetSettingsUseCaseU;
interface
uses
IApiClientU,
ILoggerU;
type
TGetSettingsUseCase = class
private
FApiClient: IApiClient;
FLogger: ILogger;
public
constructor Create(AnApiClient: IApiClient; ALogger: ILogger);
function Execute(AUserId, APassword: string;
out ASettingsResult: string): Integer;
end;
implementation
uses
System.SysUtils,
System.JSON,
ApiResultU;
{ TGetSettingsUseCase }
constructor TGetSettingsUseCase.Create(AnApiClient: IApiClient; ALogger: ILogger);
begin
FApiClient := AnApiClient;
FLogger := ALogger;
end;
function TGetSettingsUseCase.Execute(AUserId, APassword: string;
out ASettingsResult: string): Integer;
begin
ASettingsResult := string.Empty;
var LGetTokenApiResult: TApiResult := nil;
var LApiResult: TApiResult := nil;
try
var LAccessToken: string := string.Empty;
LGetTokenApiResult := FApiClient.GetToken('url', AUserId, APassword);
if LGetTokenApiResult.IsSucceeded then
begin
LAccessToken := LGetTokenApiResult.ApiResult;
end else
begin
Result := 1;
FLogger.WriteError('トークン取得に失敗');
Exit;
end;
LApiResult := FApiClient.GetSettings('url', LAccessToken);
if LApiResult.IsSucceeded then
begin
ASettingsResult := LApiResult.ApiResult;
Result := 0;
end else
begin
Result := 1;
FLogger.WriteError('設定取得に失敗');
end;
finally
if Assigned(LGetTokenApiResult) then
begin
FreeAndNil(LGetTokenApiResult);
end;
if Assigned(LApiResult) then
begin
FreeAndNil(LApiResult);
end;
end;
end;
end.
言うまでもないが、コンストラクタでApiClientとロギング処理の依存性を注入することでテスト可能としている。
そこに、大義はあるか。
はてさて、こんな感じでオブジェクト指向を頑張ってみたわけだが。。。実装してて辛過ぎた。
私はC#erでVisual Studioでの開発に慣れてると、RAD Studioでの開発にストレスがたまり、もはや開発生産性が低下してるのでは?とすら感じました。とくに、インターフェイスを使うと実装先に飛ぶため、頑張って探さなきゃならない。VSならすぐに飛べるのに...
本当にDDDやClean Architectureライクな開発手法がDelphiでの開発で効果的なのか?そんな議論がQiitaで巻き起こるくらいにコミュニティが活性化してくれたらなぁー(;'∀')