LoginSignup
1
0

DelphiでWeb API呼び出しを抽象化しテスト可能なアーキテクチャにしてみる

Last updated at Posted at 2023-10-14

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などの結果をまとめたクラスを定義する。

RestResponseU.pas
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メソッドを抽象化したインターフェイスを定義します。
テスタビリティを担保します。

IRestClientU.pas
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操作の実装

定義したインターフェイスを実装します。詳細な実装は省いていて、イメージは以下。

RestClientU.pas
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操作の失敗時は例外を出力します。例外を実装することで「何に失敗したか」の表現力を向上します。

ApiExceptionU.pas
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の戻りをオブジェクトで表現します。

ApiResultU.pas
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 情報)ログの出力ロジックはほしいところ。「なににロギングするか?」を抽象化した方がベターで、これによりテスト可能とする。実装の例示は割愛します。

ILogger.pas
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を実装します。
こちらもテスタビリティを担保するため、インターフェイスを定義します。

IApiClientU.pas
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を継承して実装します。

ApiClientU.pas
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で実装したロジックをもとに、トークンを取得し、そのトークンで設定値を取得します。

GetSettingsUseCaseU.pas
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で巻き起こるくらいにコミュニティが活性化してくれたらなぁー(;'∀')

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0