2023.04.10

サーバーサイドのクリーンアーキテクチャを考える

こんにちは、T.Iです。最近、担当プロジェクトで少しずつクリーンアーキテクチャ化を行っています。今回はその設計についてお話しさせてください。

概要

設計について、ここではロジックの実装を行う部分とデータの保持を行うEntityに分けて説明します。

まずはロジックの実装を行う部分は以下のようにレイヤー分けて行います。構成は以下のようになっています。


 

レイヤーは大きく分けて「Repository」、「Service」、「Controller」の大きく3つに分かれます。これらの役割は以下のようになっています。

Repository

外部のデータの操作を行います。ここにデータベースや外部のAPIへのアクセスの実装を行います。

Service

ここにビジネスロジックを実装します。しかし、外部データへのアクセスはここからは行わず、全てRepositoryを通して行うようにします。

Controller

サーバーのインターフェースにあたります。ここでレスポンスステータスの制御、Serviceを通してビジネスロジックの呼び出しを行います。ここにはビジネスロジックは実装しません。

次にEntityについてです。Entityは大きく分けて、「DAO」、「Model」、「DTO」として表現します。それらは以下のようになっています。

DAO

DataAccessObjectの略。外部から取得したデータをそのまま保持しておくためのオブジェクトです。Repository内でのみ扱います。

Model

DAOをアプリケーション内で扱いやすい形に詰め替えたオブジェクトです。Repositoryの外からController内ではデータをこの形式で扱います。

DTO

DataTransferObjectの略。Modelをアプリケーションのレスポンスとして返す際に扱いやすい形に詰め替えたオブジェクトです。Controllerの外つまりサーバーサイドのインターフェースではデータをこの形式で扱います。

ロジックの実装について

ここではロジックの実装を行う際のレイヤーについてそれぞれ詳しく説明します。

Repository

Repositoryは外部データへのアクセスを行います。主にRepositoryの役割は外部データの読み書きとなります。外部データのアクセスは、外部APIへのアクセスやデータベースへのアクセスなどサーバーサイド内部で完結しないものは全て担当し、それ以外のロジックは実装しません。Repositoryは扱うデータ、つまりEntityごとに実装します。

それでは実際のコードの例を見てみます。データベースにItemというテーブルがある場合のデータを扱うためのRepositoryを考えてみましょう。javaで表現すると以下のようになります。
public class ItemDAO
{
    private int id;
    private string name;

    public Item setId(int id)
    {
        this.id = id;
        return this;
    }

    public Item setName(string name)
    {
        this.name = name;
    }

    public int getId()
    {
        return this.id;
    }

    public int getName()
    {
        return this.name;
    }
}

public class Item
{
    private int id;
    private string name;

    public Item setId(int id)
    {
        this.id = id;
        return this;
    }

    public Item setName(string name)
    {
        this.name = name;
    }

    public int getId()
    {
        return this.id;
    }

    public int getName()
    {
        return this.name;
    }
}

interface IRepository<R, S, T>
{
    List<T> read(R read);
    boolean write(W write);
}

public class ItemsRepositoryReadInput
{
    private List<int> ids;
    public ItemsRepositoryReadInput(List<int> ids)
    {
        this.ids = ids;
    }
    public List<int> getIds()
    {
        return this.ids;
    }
}

public class ItemsRepositoryWriteInput
{
    private List<Item> items;
    public ItemsRepositoryWriteInput setItems(List<Item> items)
    {
        this.items = items;
    }

    public List<Item> getItems()
    {
        return this.items;
    }
}

public class ItemsRepository 
implements IRepository<ItemsRepositoryReadInput, 
                       ItemsRepositoryWriteInput, 
                       Item>
{
    private NamedParameterJDBCTemplate itemsNamedJDBC;

    public ItemsRepository(NamedParameterJDBCTemplate itemsNamedJDBC)
    {
         this.itemsNamedJDBC = itemsNamedJDBC;
    }
    
    public List<Item> read(ItemsRepositoryReadInput input)
    {
        string query = "select * from items where id in :ids;";
        Map<String, Object> params = new HashMap<>();
        params.put("ids", input.getIds());
        return this.itemsNamedJDBC.query(query, 
                                         params,
                                         BeanPropertyRowMapper(ItemDAO.class))
                                         .stream()
                                         .map(item => 
                                             new Item().setId(item.getId())
                                                       .setName(item.getName()));
    }
    public boolean write(ItemsRepositoryWriteInput input)
    {
        string query = "update items set name = :name where id = :id";
        for(Item item : input.getItems())
        {
            Map<String, Object> params = new HashMap<>(); 
            params.put("id", item.getId());
            params.put("name", item.getName());
            this.itemsNamedJDBC.update(query, params);
        }
        return true;
    }
}


ItemsRepositoryに必要なコードを書くと上記のようになります。

まずポイントとしては、Repositoryに対してInterfaceを作っていることです。このようにRepositoryに対してInterfaceで実装できるパブリックメソッドを制限することで、実装者ではなくても読みの場合はread、書きの場合はwriteなど目的に応じてどのメソッドを使えばいいのかすぐわかるようにします。

次にそれぞれ専用のInputを作成し、EntityはRepositoryの外と内でModelとDAOにデータを詰め替えて扱うようにします。まず先述したようにロジックをController、Service、Repositoryの3つのレイヤーに分けた理由はそれぞれのレイヤーが修正された際に呼び出し元のレイヤーに影響を与えないことが大きな目的の一つです。そのため入力はオブジェクトにして必要なパラメーターが増えてもServiceレイヤーに影響を与えないようにします。

また、Entityは配列で扱い、役割に対して一つだけメソッドを実装することで管理するメソッドを減らします。できるだけ共通化することで外部APIやデータベース変更された際にテストしなければいけないメソッドをできるだけ減らします。例えば、読みメソッドを実装する際はreadById、readByNameなどをたくさん実装するのではなく、readメソッドのみを実装し読みに必要なパラメータをreadInputに追加します。

最後に、実装したメソッド内でのSQLのクエリには一切ロジックは入れずに担当するEntityのみを取得するようなものにし、できるだけシンプルにします。他のRepositoryが担当しているEntityをjoinしたり、プロパティを変換するようなロジックは書きません。基本的には複雑なソートなどもアプリケーション側に寄せるのが良いです。もし他のEntityを用いたロジックが必要な場合はその都度ビジネスロジックでそれぞれ担当のRepositoryからModelを取得して処理を実装します。これはSQLの難読化によりコードの管理が難しくなることを防ぐのと、他のEntityの影響をなくしてデータベースのテーブル変更を容易にするためです。

Service

Serviceレイヤーにはビジネスロジックの実装を行います。この時、Serviceの実装は行うタスクごとに行い、直接外部のデータへのアクセスは行わずに全てRepositoryを通して行います。

それでは実際のコードの例を見てみます。IdでItemを取得するタスクを考えてみます。
interface IService<I, O>
{
    O execute(I input);
}

public class ItemsGetServiceInput
{
    private List<int> ids;
    public ItemsGetServiceInput setIds(List<int> ids)
    {
        this.ids = ids;
    }
    public List<int> getIds()
    {
        return this.ids;
    }
}

public class ItemsGetServiceOutput
{
    private List<Item> items;
    public ItemsGetServiceOutput setItems(List<Item> items)
    {
        this.items = items;
    }
    public List<Item> getItems()
    {
        return this.items;
    }
}

public class ItemsGetService
implements IService<ItemsGetServiceInput, ItemsGetServiceOutput>
{
    private IRepository<ItemsRepositoryReadInput, 
                        ItemsRepositoryWriteInput, 
                        Item> itemsRepository;
    public ItemsGetService(IRepository<ItemsRepositoryReadInput, 
                                       ItemsRepositoryWriteInput, 
                                       Item> itemsRepository)
    {
        this.itemsRepository = itemsRepository;
    }
 
    public ItemsGetServiceOutput execute(ItemsGetServiceInput input)
    {
        return new ItemsGetServiceOutput
               .setItems(this.itemsRepository.read(new ItemsRepositoryReadInput()
                                                   .setIds(input.getIds())));
    }
}


上記が先ほどのRepository周りに加えて必要なコードです。

まず、こちらもRepositoryと同様にinterfaceを設けて実装できるメソッドを制限します。この時、実行できるメソッドはexecuteメソッドのみにし、ServiceはItemを取得するならItemsGetService、Itemを保存するならItemsWriteServiceなどタスクごとにServiceを実装するようにします。これは、Repositoryど同様の理由で実装者ではない人が使用する際に何を呼び出せばいいのかすぐにわかるようにすることと、ビジネスロジックはコード量が長くなりがちなのでそれらを分散させてコードを見やすくするためです。Serviceはタスクごとに実装するので共通して呼び出す部分は新たにServiceとして実装しておいてServiceからServiceを呼び出すという実装もできます。ここではコードで例は書きませんが、仮にログインしているユーザーが保持するItemを取得したいとなると、LoginUsersGetServiceを実装しそれをItemsGetServiceから呼び出すことで他でも使いそうなLoginUsersGetServiceをモジュールとして扱うことができます。

ビジネスロジックについては今回の例だとItemを取得するというロジックなので、データアクセス周りのルールからデータ自体はItemsRepositoryから取得してそれをそのまま返すといったロジックとなっています。

また、Input、Outputはパラメータ変更時インターフェースが変更されないようにObjectにしています。

Controller

Controllerはサーバーサイドの外部に対するインターフェースにあたります。ここでビジネスロジックの呼び出しやレスポンスとリクエスト周りの処理を行います。ControllerもRepositoryと同様にEntity単位で実装します。

上記のServiceと同じくidからItemを取得するロジックを考えると、コードは以下のようになります。
public class ItemDTO
{
    private string name; 

    public Item setName(string name) 
    {
        this.name = name; 
    } 
 
    public int getName() 
    { 
        return this.name; 
    }
}

public class ItemsController
{
    private IService<ItemsGetServiceInput, ItemsGetServiceOutput> itemsGetService;
    public ItemsContoller(IService<ItemsGetServiceInput, ItemsGetServiceOutput> itemsGetService)
    {
        this.itemsGetService = itemsGetService;
    }
    @RequestMapping("get_items")
    public getItems(@PathVariable("id") int id)
    {
        List<int> ids = new ArrayList<>();
        ids.add(id);
        List<ItemDTO> items = this.itemsGetService.execute(new ItemsGetServiceInput()
                                                  .setIds(ids))
                                   .getItems()
                                   .stream().map(item => new ItemDTO()
                                                         .setName(item.getName()));
        if(!items.size())
        {
            throw new Exception("Item not found");
        }
        return items.get(0);
    }
}

Service、Repositoryの実装と合わせて必要なコードは上記です。

先述した通りControllerは外部に対するインターフェースなのでこちらには一切ビジネスロジックを実装しません。必要なタスクを実装されたServiceを呼び出すことで処理を行います。しかし、実装するリクエスト、レスポンスに対しての処理はControllerで実装します。そのため、今回の例だとGetパラメータのIdをServiceInputに詰める処理はControllerに記載しています。

また、Entityを扱う際は必ずビジネスロジックとセットであるはずなので、ControllerでEntityを取得するというServiceつまりビジネスロジックをスキップして直接RepositoryからEntityを読んだりするような実装はしてはいけません。そのためControllerがRepositoryを直接呼び出したりプロパティとして持つことはないです。

最後に今回のレイヤーのおいて共通の決まりが一つあります。それは全てconstructor injectionを使用している点です。constructor injectionはモジュールをコンストラクターの引数として受け取ってプロパティで持つような実装です。これを行うことでモジュールをMockなどに置き換えてテストをしやすくすることができます。

Entityについて

ここではEntityについて詳しく説明します。この設計方針では「DAO」、「Model」、「DTO」で表します。前章に倣ってItemを表すと以下のようになります。


DAOが主にデータベースのテーブルに沿ってプロパティを決めます。Modelはそこからアプリケーション内で使用するプロパティのみを詰めて渡します。そのためcreatedAtやdeletedAtなどアプリケーションで使用しなさそうなプロパティは削ぎ落としています。DTOはさらにそこから外部に渡して問題ないプロパティのみに絞ります。図ではアプリケーションの外でidを使わなそうなのでidを消してnameだけ渡しています。

先述したように、外部からデータを受け取る際はDAO、アプリケーション内はModel、レスポンスとして返す際はDTOにプロパティを詰め直して使用します。getItemsのシーケンスで表すと以下のようなデータの移り変わりとなります。


図のようにItemsRepositoryではItemDAOとしてItemsテーブルのレコードを受け取り、ItemServiceにItemModelに詰め替えて渡しています。ItemsGetServiceそのままItemModelとしてItemControllerに渡しています。ItemControllerはItemをアプリケーション外に渡すのでItemDTOに詰め直してレスポンスとして返しています。

このようにわざわざレイヤーごとにプロパティを詰め直している理由は、各レイヤーで使用するEntityのプロパティが変わっても別のレイヤーで影響を与えずにそのまま使用できるようにするためです。ここでは具体的な図を書きませんが、例えば上記でItemのnameをメタデータとして別テーブルで持ちたいというようにデータベースのテーブル構造が変える場合、Entityをレイヤー間で共有していると全てのレイヤーでロジックを見直す必要があります。しかしDAO、Model、DTOで分けていた場合はDAOを二つに分けるだけで済み、Repositoryより上のレイヤーには影響を与えずにテーブル構造を変えることができます。Controllerレイヤーでも同様に外部に対してのインターフェースが変わったとしても基本的には内部のServiceレイヤー以下のロジックを変更しなくてもそれに対応することができます。

まとめ

本記事ではサーバーサイドのクリーンアーキテクチャについて説明しました。ポイントはロジックの実装部分とデータ保持のEntityに分けて考えると、ロジックの実装部分は外部へのインターフェースにあたるController、ビジネスロジックを実装するService、外部データへのアクセスを担うRepositoryに大きく分けて実装し、Entityは外部インターフェスではDTO、アプリケーション内ではModel、外部から取得したデータはDAOとして保持することです。これにより、各レイヤーの実装の変更や外部のデータのインターフェースの変更はあっても他のレイヤーに影響を与えずに該当レイヤーを修正することができます。

さいごに

次世代システム研究室では、グループ全体のインテグレーションを支援してくれるアーキテクトを募集しています。インフラ設計、構築経験者の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ募集職種一覧からご応募をお願いします。 皆さんのご応募をお待ちしています

 

  • Twitter
  • Facebook
  • はてなブックマークに追加

グループ研究開発本部の最新情報をTwitterで配信中です。ぜひフォローください。

 
  • AI研究開発室
  • 大阪研究開発グループ

関連記事