LoginSignup
5
3

More than 1 year has passed since last update.

Javaのアノテーションを駆使してSOLID実装してみた

Last updated at Posted at 2022-12-11

この記事は Qiita x Code Polaris共催!女性ITエンジニアが作るアドベントカレンダー Advent Calendar 2022 向けに書いたものです。


はじめに

Qiitaで初めて記事を書きます。というか、技術記事自体初めて書きます。
しのといいます。Java Developerを5年くらいやってます。普段はドイツに住んでいて、イギリス企業へフルリモートで働いてます。

先日Code Polaris内でClean Archtectureについての勉強会をやったので、私がやったパートの、クリーンアーキテクチャ的に考える幸福度の高い実装 の部分を記事でも残しておこうと思います。

動画はこちらでアーカイブが公開されています。喋っていることとほぼ同じ内容なのですが、少し補完などもあります。

SOLIDとは

SOLIDについて書き始めるとそれだけで一記事必要になるので、詳しくはググって下さい。この方の記事とか分かりやすくてオススメです

SOLIDとは

著者のボブおじさんことRobert C. Martinが考えたクリーンアーキテクチャの為のガイドラインです。これに沿って開発したらいい感じにクリーンアーキテクチャの思想に沿った開発ができるよってやつです。以下の5つの原則の頭文字を取ってSOLIDです。

  • Single Responsibility Principle (単一責任の原則)
  • Open-Closed Principle (オープン・クローズドの原則)
  • Liskov Substitution Principle (リスコフの置換原則)
  • Interface Segregation Principle (インターフェース分離の原則)
  • Dependency Inversion Principle (依存関係逆転の原則)

さらっと説明します

S:単一責任の原則 (SRP:Single Responsibility Principle)

There should never be more than one reason for a class to change.(変更するための理由が、一つのクラスに対して一つ以上あってはならない)

一つのクラスは一つの機能のみを担当するべきという原則です

O:オープン・クローズドの原則 (OCP:Open-Closed Principle)

software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
(→ソフトウェアの実体(クラス、モジュール、関数など)は、拡張に対して開かれているべきであり、修正に対して閉じていなければならない)

ある機能の追加などがある場合、既存のコードを変更せずにその機能の追加(拡張)が出来るように設計をするべしという原則です

L:リスコフの置換原則 (LSP:Liskov Substitution Principle)

Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it. (ある親クラスへのポインタないし参照を扱っている関数群は、その子クラスのオブジェクトの詳細を知らなくても扱えるようにしなければならない)

サブタイプ(S)とスーパータイプ(T)は「S is a T」の原則にのっとり、置換可能であるという原則です

I:インターフェイス分離の原則 (ISP:Interface Segregation Principle)

Many client-specific interfaces are better than one general-purpose interface.(汎用なインターフェースが一つあるよりも、各クライアントに特化したインターフェースがたくさんあった方がよい)

クライアントが、自分の必要な要素だけを継承できるするようにするべきという原則です。
一つのインターフェースに沢山の要素が入っていると、あるクライエントにとっては必要のないものが継承されるということが起きるからです。

D:依存関係逆転の原則 (DIP:Dependency Inversion Principle)

High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces), [not] concretions. (上位モジュールはいかなるものも下位モジュールから持ち込んではならない。双方とも具象ではなく、抽象(インターフェースなど)に依存するべきである)

外側のクラスが内側のクラスに依存するようなことはしてはいけないという原則です。
インターフェースを経由すると、依存関係を断ち切ることが出来るのでこの本では推奨されていますが、アノテーションを使うことでも回避できます。

以下、本編です

クリーンアーキテクチャ的に考える幸福度の高い実装

Javaのannotationやデザインパターンを使ってクリーンアーキテクチャの思想に則ったコードを実装してみました。
テーマは オンライン銀行口座サイトを実装せよ! です。
全体のコードはここで確認出来ます。
今回の仕様はシンプルに二つだけ

  1. アカウントの開設
    違うタイプのアカウントが開設できます。今回使うタイプはBasicAccount、SavingAccount、SuperSavingAccountの3つ。

  2. 預金
    これには二つの要素が組み込まれてます。まずはアカウントの特定。BankCode,BankBranch, AccountNumberの3つからアカウントの特定をします。
    次に、今回はインターフェース分離の原則の要素を組み込む為、預金できるアカウントとできないアカウントがある も仕様にしておきます。預金が出来ない銀行口座って何なの…って思われた方、ご都合主義なのですみません。

    • BasicAccountとSavingAccountは預金ができる
    • SuperSavingAccountは預金が出来ない

今回使用するフレームワークは以下の通り

  • Spring Framework
  • Lombok
  • JPA/Hibernate

サラッとだけ説明していきます。

Springとは

Javaでよく使われる基本のWEBフレームワーク (https://spring.io)
MVCでの開発や、依存性注入(dependency injection)、セキュリティなど、ありとあらゆる事が簡単に出来ます

Lombokとは

形式上省くことが難しいけどあると冗長になるコード(ボイラープレートコード)を省くことができます。

  • getter/setter
  • constructor
  • toString
  • builder
    etc…

Hibernate (JPA)とは

JPAとはオブジェクトとリレーショナルデータベース(RDB)の相互変換を行うマッパーです。それの一番有名な実装フレームワークがHibernateです。オブジェクトとテーブルのマッピングを行ってくれます。
-> DAOの実装に関してのデータベースの種類への依存がなくなるのでとても良いです。

annotation(アノテーション)ってそもそも何?

アノテーションとは、処理系に伝達したい付加的な情報(メタデータ)を注記するものです。
「ここはこういう役割」「こういう処理をしてね」ということをコンパイル中や実行中にコンピューターに伝えることができます。
基本は @.... という形でメタデータを使用したい場所にくっつけることで使えます。

例: @Controllerのアノテーションを付けると、そのクラスはWebサービスになり、外部との接続が可能になる

今回使うデザインパターンについて

以下の3つのデザインパターンを使います。

DTO (Data Transfer Object) とVO (Value Object)

データ受け渡しの為のクラスです。この二つはよく似ているので別に使い分ける必要はないんですが、DTOがmutable, VOがimmutableが基本です。DTOがエコバッグ的な存在、VOが段ボールで蓋された荷物的な存在だと思って下さい。私は今回、Controllerのparameterで受け取るのがVO、クラス間でのデータ受け渡しの為にDTOを使いました。

BankAccountVO
/**
 * Annotation を使うことで、余計なsetter, getter, constructorの変更やBuilderの付け加えなどをしなくてすむし、コードが見やすい
 */
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class BankAccountVO implements Serializable {

  @Serial
  private static final long serialVersionUID = 7042748482948192461L;

  private String bankCode;
  private String branchCode;
  private String accountNumber;
  private String accountType;

  private BigDecimal amount;
}

VOは、宣言通りSetterがなく、Getterのみです(@Getterというlombokのアノテーションを使ってるので、クラス内のGetterメソッドは省かれてます)

CreateBankAccountDTO
@Data
public class CreateBankAccountDTO {

  public CreateBankAccountDTO(BankAccountVO vo) {
    this.bankCode = vo.getBankCode();
    this.branchCode = vo.getBranchCode();
    setAccountType(vo.getAccountType());
    this.accountNumber = vo.getAccountNumber();
  }

  private String bankCode;
  private String branchCode;
  private AccountType accountType;
  private String accountNumber;
  private String myNumber;

  private void setAccountType(String accountType) {
    try {
      this.accountType = AccountType.valueOf(accountType);
    } catch (IllegalArgumentException e) {
      throw new RuntimeException("provided bank type [" + accountType + "] not found");
    }
  }
}

VO->DTOという使用の仕方をするので、Constructor内でVOを受け取ります。

このVOとDTOは実際にこんな感じに使われています。

BankAccountController#createAccount
@PostMapping("/create")
public BankAccount createAccount(@RequestBody BankAccountVO bankAccountVO) {
    // VO -> DTOに変換
    CreateBankAccountDTO dto = new CreateBankAccountDTO(bankAccountVO);
    // serviceクラスにdtoを受け渡します
    BankAccount account = service.createAccount(dto);
    return account;
}

このデザインパターンを使うメリットは、例えばデータ内での変更や拡張があった時に、上位モジュールを変更する必要がなくなります。例えば、口座を作るのに、マイナンバーが必要になるケース。この場合、DTOやVOの値を追加するだけで、このDTOやVOを使用しているクラスを変更する必要がありません。これは オープンクローズドの原則 (変更に対して閉じて、拡張に対して開かれているべき)に対応しています。

Factory Pattern

フレキシブルにインスタンスの生成をするためのデザインパターンです。インスタンスの生成をファクトリー(=工場)に任せます。
Factoryを使うことで、アカウント開設の時のアレコレをFactoryに丸投げ出来ます。これは単一責任の原則 (一つのクラスは一つの機能のみを担当する)に対応してます。

BankAccountFactory class
public BankAccount createBankAccount(CreateBankAccountDTO dto) {
    /*
     * open-closedの法則
     * BankAccountのタイプが増えたら、ここのcaseを増やすだけで良い
     */
    BankAccount.BankAccountBuilder<?, ?> accountBuilder = switch (dto.getAccountType()) {
      case BASIC_ACCOUNT -> BasicAccount.builder();
      case SAVING_ACCOUNT -> SavingAccount.builder();
      case SUPER_SAVING_ACCOUNT -> SuperSavingAccount.builder();
      default -> throw new RuntimeException("Unknown account type found during account creation");
    };
    //アカウントの生成 --- Builder Patternの説明の時に詳しく載せます
    return account;

更に、もしも将来BankAccountの種類が増えていった際、このFactory内のcase文を増やしていくだけで済みます。
今「修正あるじゃん!オープンクローズドの原則に反してるよ!」って思いました?? 全てを完全にSOLID対応にしていくのっておそらく無理なんですよ。 なので、そこのバランスを考えていくことが大事なんです。

実際、classpathを使ってクラスの名前からフレキシブルにクラスを生成していく方法も考えたのですが、それをするとコードが凄く読みにくくなってしまうんです。修正が少なければいいってことじゃなくて、あくまでも「維持が簡単でCleanなコードを書く」のが大切です

Builder Pattern

インスタンスの生成をコンストラクターに頼らずに出来ます。将来attributeが増えていっても、今あるインスタンス生成の為のコードは壊れずに使えます。Builderを使うことで、コンストラクターでの生成と違い、クラスの属性が増えても今あるコードは壊れずに使えます。

BankAccountFactory class
public BankAccount createBankAccount(CreateBankAccountDTO dto) {

    BankAccount.BankAccountBuilder<?, ?> accountBuilder = switch (dto.getAccountType()) {
      case BASIC_ACCOUNT -> BasicAccount.builder(); // 子クラスのBuilderは親クラスのBankAccountBuilderを継承している
      case SAVING_ACCOUNT -> SavingAccount.builder();
      case SUPER_SAVING_ACCOUNT -> SuperSavingAccount.builder();
      default -> throw new RuntimeException("Unknown account type found during account creation");
    };

    /**
     * Builderでインスタンスの生成をする場合、要素が増えても"修正"ではなく"追加"で対応できる (オープンクローズドの原則)
     */
    return accountBuilder
      .bankCode(dto.getBankCode())
      .branchCode(dto.getBranchCode())
      .accountNumber(dto.getAccountNumber())
      .accountType(dto.getAccountType())
      .amount(BigDecimal.ZERO)
      .build();
  }

今回はLombokの@SuperBuilderを使っています。
SuperBuilderを使うと、親クラスのBuilderを子クラスが継承することになります。なので、上記のコードのように親クラスのビルダーを基クラス、Switch文内で子クラスのビルダーを呼び出すっていうことが可能になります。そして、各子クラスのattributeは、親クラスと全て同じなので、呼び出したBuilderを使ってインスタンスの生成を行います。リスコフの置換原則 (親クラスと子クラスは置換可能にするべき)、がここで生きてきます。

ちなみに、Builder クラスを使うことで、後々BankAccountに新たな属性を付け加えた場合でも、既に使われている箇所では変更しなくても良くなります。例えば、BankAccount内に新たにmyNumberという属性が付け加えられた場合、.myNumber(dto.getMyNumber())をBuilderでインスタンスの生成を行う箇所に追加できますし、もし追加しない場合でも、既存のコードはコンパイルエラーを返しません。
もしコンストラクターを使用した場合、その新しい値がたとえ必要でなくても、コンストラクターを呼び出している全ての箇所の変更が必須となる可能性を考えると、やはりここはBuilder使うべきですね。
これはオープンクローズドの原則(変更に対して閉じていて、拡張に対して開かれているべき) に則ってますね。

Repository

DBとの接続には、Jpaのannotationの@Repositoryを使っています。これを使うと、ドライバーやコネクションの設定をしなくてもOKですし、どの種類のDBを使ってもこちら側は何も実装を変える必要がないのでとても便利です。オープンクローズドの原則 に沿っているんじゃないかなと思います。なので、Clean Architectureが推奨している、「決定を遅らせる」という部分にも大きく貢献していますし、テストのしやすさにも貢献しています。
よくあるのが、プロダクション環境ではMySQLを使って、テスト環境ではめ込みを使うパターンなどです。テスト中にDBの中身を自在にいじって実際の環境に影響を与えることもないし、逆にすでにDB内にあるデータのせいでテストが失敗した、みたいなことも起こりません。
こんな感じです。

@Repository
public interface BankAccountRepository extends CrudRepository<BankAccount, Integer> {

  BankAccount findByBankCodeAndBranchCodeAndAccountNumber(String bankCode, String branchCode, String accountNumber);
}

もしHibernateを使ったことない方からしたら、「え、これ実装じゃなくてinterfaceじゃん??」ってなると思います。これでいいんです。実装クラスはHibernateがRuntime中に勝手に生成してくれます。(お、ということは、Hibernateを使うことで勝手に依存性逆転の原則に沿っていることになるのか?)
ちなみにfindByBankCodeAndBranchCodeAndAccountNumberも、この文字列このreturn typeだけで勝手にHibernateが判断してDBから必要なデータを持ってきてくれます。超便利。

Controller

Controllerはめっちゃ大事&アノテーション祭りなので、大セクションです。
Controllerは外部との接続を担当します。基本動作は、リクエストを受け取って、レスポンスを返す、です。methodがクラス自体をリターンする場合、Springの@ResponseBodyを使うと、クラスをJSONに変換したものが返されます。

最初に、基本のアノテーションについて説明します。

  • @￰Controller, @￰RestController - これをくっつけると、そのクラスはWebサービスになり、外部との接続を行うことができます。RestControllerを使うと、自動出来にRESTFulなControllerになるので、returnの時に@ResponseBodyを付けなくてもよくなります。
  • @￰RequestMapping(“/bankAccount”) - URLを指定できます。上記の場合、http://localhost:8080/bankAccount で接続が出来るようになります。
  • @￰PostMapping, @￰GetMapping, @￰PutMapping etc… - RequestMappingの、http メソッドを指定したものです。
    こんな感じです。
BankAccountController
@RestController
@RequestMapping("/bankAccount")
public class BankAccountController {

  @Autowired
  private BankManagingService service;

  @PostMapping("/create")
  public BankAccount createAccount(@RequestBody BankAccountVO bankAccountVO) {
    CreateBankAccountDTO dto = new CreateBankAccountDTO(bankAccountVO);
    BankAccount account = service.createAccount(dto);
    return account;
  }

  @PutMapping("/deposit")
  public BankAccount deposit(@RequestBody BankAccountVO bankAccountVO) {
    HandleMoneyDTO dto = new HandleMoneyDTO(bankAccountVO);
    BankAccount account = service.deposit(dto);
    return account;
  }
}

@￰Autowired

Controllerは外部との接続、のみを担当して(単一責任の原則)、ビジネスロジックの部分は下位モジュールであるServiceクラスに任せます。なので、上記でBankManagingServiceが宣言されてますね。ですが、ここでサービスクラスのインスタンスの生成を行ってしまうと、依存性逆転の原則(上位モジュールは下位モジュールに依存するべきではない)に反します。アンクルボブは著書内で、この場合に「インターフェースを経由することで依存関係を断ち切る」というやり方を推奨していますが、ここでは@Autowiredを使うことで、直接のインスタンス生成を避け、ControllerがServiceに依存する状況を避けています。
@AutowiredはSpringのアノテーションで、これを付けた属性は、アプリ立ち上げの時に勝手にアサインメントをやってくれます。
(それをするためには、各クラスに@Componentのアノテーションをつける、またはConfig内でBeanの作成が必要になってきますが、今回それについての解説は割愛しますね。)

* Autowiredの位置については、属性への直接のwired upは推奨されておらず、ConstructorもしくはSetterへの付与が推奨されていますが、今回は見た目の分かりやすさ重視で属性へ直接Autowiredしてます。ご了承ください

Autowiredを付与された属性は、テストクラスでMockitoというフレームワークを使うとインスタンスをMockすることが出来ます。

BankAccountControllerIT
@RunWith(SpringJUnit4ClassRunner.class)
public class BankAccountControllerIT {

  private MockMvc mockMvc;

  private final Gson gson = new Gson();

  // ServiceクラスをMockする
  @Mock
  private BankManagingService service;

  @InjectMocks
  private BankAccountController controller;

  @Before
  public void setUp() {
    // MockされたServiceがControllerに注入されます
    MockitoAnnotations.openMocks(this);
    this.mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
  }

  @Test
  public void test() throws Exception {
    BankAccount createdBankAccount = BasicAccount.builder().id(1).build();
  // Serviceクラスのmethodが呼び出されたら、上記のcreatedBankAccountをリターンします
    when(service.createAccount(any(CreateBankAccountDTO.class))).thenReturn(createdBankAccount);
    BankAccountVO accountVO = BankAccountVO
      .builder()
      .accountNumber("12345")
      .branchCode("ED224")
      .bankCode("123")
      .accountType(AccountType.BASIC_ACCOUNT.toString())
      .build();

    this.mockMvc.perform(
        post("/bankAccount/create")
          .contentType(MediaType.APPLICATION_JSON)
          .content(gson.toJson(accountVO)))
      .andExpect(status().isOk());
  }
}

Service

Controllerは外部との接続の為、一つのクラスに一つの機能の単一責任の原則に沿うと、内部でのあれこれはこれと切り離す必要があるので、Serviceクラスを作ります。Serviceはビジネスロジックを担当します。@Serviceのアノテーションを付けます。

BankManagingService
@Service
public class BankManagingService {

  @Autowired
  private BankAccountFactory accountFactory;

  @Autowired
  private BankAccountRepository repository;

  public BankAccount createAccount(CreateBankAccountDTO dto) {
    // bankAccountの生成
    BankAccount account = accountFactory.createBankAccount(dto);
    // DBへの挿入
    return repository.save(account);
  }

  public BankAccount deposit(HandleMoneyDTO dto) {
    // accountの特定
    // 預金
    return account;
  }
}

こんな感じです。depositの中身の部分は後述のDepositableのセクションで詳しく見せます。

単一責任の原則 にのっとり、ServiceがDBへの接続や、バンクアカウントのインスタンス生成の責任を負わない用に配慮はします。なので、Service内で色々と指示を出すけど、下位モジュールが担当すべき箇所(インスタンス生成やデータベースの接続など)は、accountFactoryやrepositoryが担当します。

ちなみに、今回のプロジェクトは、ライブコーディングの為に分かりやすさ重視で書かれています。アンクルボブが推奨しているのは、ServiceをFacadeにして、Facadeが他のprocessManager等の実行用のクラスを呼び出す、というものです。今回はやってませんでしたが、もしこれを書き直すとしたらこんな感じで、Serviceと実行用クラスを切り離すと思います。

実行用クラスを呼び出す実装パターン
BankManagingService 実行用クラスを呼び出すパターン
@Service
public class BankManagingService {

    @Autowired
    private CreateBankAccountProcessManager createBankAccountProcessManager;
    
    @Autowired
    private DepositProcessManager depositProcessManager;
    
    public BankAccount createAccount(CreateBankAccountDTO dto) {
        return createBankAccountProcessManager.run(dto);
    }
    
    public BankAccount deposit(HandleMoneyDTO dto) {
        return depositProcessManager.run(dto);
    }
}
CreateBankAccountProcessManager 実行用クラスを呼び出すパターン
@Component
public class CreateBankAccountProcessManager {
  @Autowired
  private BankAccountFactory accountFactory;

  @Autowired
  private BankAccountRepository repository;

  public BankAccount run(CreateBankAccountDTO dto) {
    // bankAccountの生成
    BankAccount account = accountFactory.createBankAccount(dto);
    // DBへの挿入
    return repository.save(account);
  }
}
DepositProcessManager 実行用クラスを呼び出すパターン
@Component
public class DepositProcessManager {
    @Autowired
    private BankAccountRepository repository;
    
    public BankAccount run(HandleMoneyDTO dto) {
        // accountの特定
        BankAccount account = repository.findByBankCodeAndBranchCodeAndAccountNumber(dto.getBankCode(), dto.getBranchCode(), dto.getAccountNumber());
        
        // 預金
        if (account instanceof Depositable) {
            ((Depositable) account).deposit(dto.getAmount());
            repository.save(account);
        } else {
            throw new RuntimeException("このバンクタイプは、お金を引き出すことができません。");
        }
        return account;
    }
}

Depositable

今回の仕様に、「預金できるアカウントとできないアカウントがある」というのがありましたが、この部分の実装の為に、Depositableというシンプルなインターフェースを作ります。

Depositable
public interface Depositable {
  void deposit(BigDecimal amount);
}

これを、Depositが必要なクラスに継承します。

BasicAccount Depositableを継承している
public class BasicAccount extends BankAccount implements Depositable {

  @Override
  public void deposit(BigDecimal amount) {
    this.amount = this.amount.add(amount);
  }
}
SuperSavingAccount Depositableを継承していない
public class SuperSavingAccount extends BankAccount {
}

そして、serviceで預金(deposit)を行う箇所にて、depositの呼び出しをしてみます。

BankManagingService
public BankAccount deposit(HandleMoneyDTO dto) {
    // accountの特定
    BankAccount account = repository.findByBankCodeAndBranchCodeAndAccountNumber(dto.getBankCode(), dto.getBranchCode(), dto.getAccountNumber());

    // 預金
    if (account instanceof Depositable) {
      ((Depositable) account).deposit(dto.getAmount());
      repository.save(account);
    } else {
      throw new RuntimeException("このバンクタイプは、お金を引き出すことができません。");
    }
    return account;
  }

アカウントの特定には、親クラスであるBankAccountを使用しており、アカウント特定時点ではDepositableかどうかが不明なので、一度Depositableなのか確認してからクラスをカストし、depositのメソッドを呼び出します。
こうすることで、預金が出来ないクラスは必要のないdepositメソッドを継承する必要がなくなります。これは、インターフェース分離の原則に沿っています。

復習

やったこと SOLID
BankAccount とBasicAccount, SavingAccountは置換可能である リスコフの置換原則
Repository, Controller, Service, Factory などでそれぞれ一つのことを担当 単一責任の原則
Depositableを継承するのはdepositのメソッドが必要なクラスのみ インターフェース分離の原則
FactoryやBuilderのデザインパターン オープンクローズドの原則
@Autowiredを使って、上位モジュールでの直接のインスタンス生成を避ける 依存性逆転の原則

まとめ

色々と書きましたが、Javaは、フレームワークが超優秀なので、フレームワークの想定している使い方をすれば、自動的にSOLIDに対応されるように設計されてます。なので、そこまで難しく考えなくてもいいです。ただ、フレームワークの勉強はしっかりやりましょう。

ただ、やっぱりフレームワークでカバーしきれない部分(リスコフの置換原則とかインターフェース分離の原則とか)もあるので、コードを書く際に常にSOLIDを意識しておくと、綺麗なコードになるんじゃないかなと思います。

あと、上の私の設計が全部正解かと言われると、そういうわけではありません。今回上の記事では触れなかったんですが、@DiscriminatorValueというアノテーションは、これがあると(私のサンプルコードの場合は)綺麗に出来るから採用しましたが、きっと実際の現場で使われてたら発狂物の代物なんだろうなぁと思ってます。笑
きっともっと綺麗に書くやり方もあるし、全然違うやり方の方を推奨する人もいるでしょう。こういうのは好みの問題もあるし、実際の現場では同じように出来ない場合も沢山あります。あなたの思う綺麗なコードをこれからも書き続けて下さい。

それでは、メリークリスマス!!

(余談ですが、アドベントカレンダーなのにこのCode Polarisのアドベントカレンダーは31日までやってます。笑 クリスマス終わってもお見逃しなく!!)


参考及び引用文献

フレームワーク

参考記事

5
3
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
5
3