0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【DevNav Handbook】Spring Bootバックエンド実装 Part1:記事APIの土台を作る

0
Posted at

はじめに

この記事は DevNav Handbook の記事です。

前回は、ReactからSpring Bootの users APIを呼び出し、PostgreSQLに登録した管理者ユーザーを画面に表示しました。

今回はReact側から一度離れて、Spring Bootバックエンド側の実装に戻ります。

DevNav Handbookの本体は、ユーザー情報ではなく、技術記事・開発手順の記事データです。

そのため今回は、articles テーブルを作成し、Spring Boot側で記事APIの土台を作ります。

今回実装するAPIは以下です。

GET /api/articles
GET /api/articles/{slug}
POST /api/articles
GET /api/articles/category/{category}
GET /api/articles/search?keyword=Spring

記事一覧取得、slugによる詳細取得、記事作成、カテゴリ検索、キーワード検索までを実装します。

今回はコードをすべて細かく説明するというより、Controller → Service → Repository → DB の流れと、Entity / DTO / JPQL / パラメータの受け取り方を整理することを重視します。


冒頭理解度チェック

以下を説明できる人は、この記事を読まなくても大丈夫です。

Controller / Service / Repository の役割を説明できる

EntityとDTOの違いを説明できる

@RequestBodyでPOSTリクエストのJSONを受け取れる

@PathVariableと@RequestParamの使い分けを説明できる

JpaRepositoryでDB操作ができる

@QueryでJPQLを書ける

JPQLではテーブル名ではなくEntity名を使うことを説明できる

この記事の後半に、上記チェック項目の答え合わせも入れています。

実装コードをすべて読まなくても、理解度チェックの答え合わせを見るだけで、今回のポイントは復習できます。


この記事のゴール

この記事のゴールは以下です。

Spring Bootでarticlesテーブルを扱う記事APIの土台を作る

具体的には、以下の構成を作ります。

Article Entity
ArticleRepository
ArticleResponse DTO
ArticleCreateRequest DTO
ArticleService
ArticleController

最終的には、Insomniaなどから以下のAPIを確認します。

GET /api/articles
GET /api/articles/{slug}
POST /api/articles
GET /api/articles/category/{category}
GET /api/articles/search?keyword=Spring

今回やること

今回やることは以下です。

articlesテーブルを作成する

Article Entityを作る

ArticleRepositoryを作る

ArticleResponse DTOを作る

ArticleCreateRequest DTOを作る

ArticleServiceを作る

ArticleControllerを作る

記事一覧取得APIを作る

slugによる記事詳細取得APIを作る

記事作成APIを作る

カテゴリ検索APIを作る

キーワード検索APIを作る

今回やらないこと

今回の記事では、まだ以下は扱いません。

Reactでの記事一覧表示

記事更新API

記事削除API

ページング

バリデーション

例外処理の共通化

Spring Security

Firebase Token検証

管理者権限チェック

今回は、まずSpring Boot側の「記事APIの骨格」を作ることに集中します。


全体の流れ

今回の実装は、以下の流れです。

Controller
↓
Service
↓
Repository
↓
DB

Controller はAPIの入口です。

リクエストを受け取り、必要な値を Service に渡します。

Service は処理の流れを担当します。

Repositoryから取得したEntityをDTOに変換したり、リクエストDTOからEntityを作成したりします。

Repository はDBアクセスを担当します。

今回であれば、articles テーブルに対して、一覧取得・詳細取得・作成・検索を行います。


EntityとDTOの位置づけ

今回出てくる主なクラスは以下です。

Article
→ articlesテーブルと対応するEntity

ArticleResponse
→ APIレスポンスとして返すDTO

ArticleCreateRequest
→ 記事作成APIで受け取るリクエストDTO

Article EntityはDBと対応するクラスです。

そのため、基本的にはDBのテーブル構造に近い形になります。

一方で、APIとしてフロントエンドに返すデータは ArticleResponse DTOに変換します。

Article Entity
↓
ArticleResponse DTO
↓
APIレスポンス

このように、Entityをそのまま返さずDTOを挟むことで、APIとして返す値を制御しやすくなります。


articlesテーブルを作成する

まず、PostgreSQLに articles テーブルを作成します。

CREATE TABLE articles (
    id BIGSERIAL PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    slug VARCHAR(255) NOT NULL UNIQUE,
    category VARCHAR(100) NOT NULL,
    summary TEXT,
    content TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

今回のカラムは以下です。

id
→ 記事ID

title
→ 記事タイトル

slug
→ URLなどで使う記事識別子

category
→ 記事カテゴリ

summary
→ 記事概要

content
→ 記事本文

created_at
→ 作成日時

updated_at
→ 更新日時

このテーブルをSpring Boot側では Article Entityとして扱います。


Article Entityを作る

articles テーブルに対応するEntityを作ります。

package com.example.devnav.article;

/**
 * 記事情報を表すEntity。
 */
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "articles")
public class Article {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false, unique = true)
    private String slug;

    @Column(nullable = false)
    private String category;

    @Column(columnDefinition = "TEXT")
    private String summary;

    @Column(nullable = false, columnDefinition = "TEXT")
    private String content;

    @Column(name = "created_at")
    private LocalDateTime createdAt;

    @Column(name = "updated_at")
    private LocalDateTime updatedAt;

    /**
     * アプリ側で新しいArticleを作成するためのコンストラクタ。
     *
     * @param title 記事タイトル
     * @param slug URLなどで利用する記事識別子
     * @param category 記事カテゴリ
     * @param summary 記事概要
     * @param content 記事本文
     */
    public Article(String title, String slug, String category, String summary, String content) {
        this.title = title;
        this.slug = slug;
        this.category = category;
        this.summary = summary;
        this.content = content;
    }
}

ここで重要なのは、DBの articles テーブルの1行を、Java側では Article オブジェクトとして扱えるようにしている点です。

articlesテーブルの1行
↓
Article Entity

@Entity によってJPA管理対象になり、@Table(name = "articles") によってDBの articles テーブルと対応します。


ArticleRepositoryを作る

次に、articles テーブルを操作するRepositoryを作ります。

package com.example.devnav.article;

/**
 * articlesテーブルを操作するRepository。
 */
public interface ArticleRepository extends JpaRepository<Article, Long> {

    /**
     * slugで記事を検索する。
     *
     * @param slug URLなどで利用する記事識別子
     * @return 該当する記事
     */
    Optional<Article> findBySlug(String slug);

    /**
     * カテゴリで記事一覧を検索する。
     *
     * @param category 記事カテゴリ
     * @return 該当する記事一覧
     */
    @Query("SELECT a FROM Article a WHERE a.category = :category")
    List<Article> findByCategory(@Param("category") String category);

    /**
     * タイトルにキーワードを含む記事一覧を検索する。
     *
     * @param keyword 検索キーワード
     * @return 該当する記事一覧
     */
    @Query("SELECT a FROM Article a WHERE a.title LIKE %:keyword%")
    List<Article> searchByTitle(@Param("keyword") String keyword);
}

findBySlug はSpring Data JPAのメソッド名による自動生成です。

一方、カテゴリ検索とタイトル検索では @Query を使ってJPQLを書いています。

findBySlug
→ メソッド名からクエリを自動生成

findByCategory
→ JPQLでカテゴリ検索

searchByTitle
→ JPQLでタイトル部分一致検索

RepositoryはDBアクセスの窓口です。

ControllerやServiceから直接SQLを書くのではなく、RepositoryにDB操作を寄せます。


JPQLで注意すること

JPQLでは、DBのテーブル名ではなく、Java側のEntity名を使います。

今回であれば、DBテーブル名は articles です。

しかしJPQLでは、articles ではなく Article を使います。

@Query("SELECT a FROM Article a WHERE a.category = :category")

ここで使っている Article は、DBテーブル名ではなくJavaのEntity名です。

SQL
→ articlesテーブル、categoryカラム

JPQL
→ Article Entity、categoryフィールド

SQLの感覚で articles と書きたくなりますが、JPQLではEntity名とフィールド名を書く点に注意します。


ArticleResponse DTOを作る

APIレスポンスとして返すDTOを作ります。

package com.example.devnav.article;

/**
 * 記事情報をAPIレスポンスとして返すためのDTO。
 */
@Getter
@AllArgsConstructor
public class ArticleResponse {

    private Long id;

    private String title;

    private String slug;

    private String category;

    private String summary;

    private String content;

    private LocalDateTime createdAt;

    private LocalDateTime updatedAt;
}

ArticleResponse は、APIレスポンスとして返すためのDTOです。

今回は Article Entityをそのまま返さず、ArticleResponse に変換して返します。

Entityをそのまま返すと、DB構造に近い情報をそのまま外に出すことになります。

小さい学習アプリでは動きますが、実務ではAPIとして返す値を制御したい場面が多いです。

そのため、レスポンス専用のDTOを用意します。


ArticleCreateRequest DTOを作る

記事作成APIで受け取るリクエストDTOを作ります。

package com.example.devnav.article;

/**
 * 記事作成APIのリクエストDTO。
 */
@Getter
@NoArgsConstructor
public class ArticleCreateRequest {

    private String title;

    private String slug;

    private String category;

    private String summary;

    private String content;
}

POSTリクエストのJSONは、この ArticleCreateRequest で受け取ります。

リクエストJSON
↓
ArticleCreateRequest
↓
Article Entity
↓
articlesテーブルへ保存

リクエスト用DTOとレスポンス用DTOを分けておくと、入力と出力の役割が分かりやすくなります。


ArticleServiceを作る

次に、記事取得・作成・検索の処理を書くServiceを作ります。

package com.example.devnav.article;

/**
 * 記事情報に関する業務処理を行うService。
 */
@Service
@RequiredArgsConstructor
public class ArticleService {

    private final ArticleRepository articleRepository;

    /**
     * 記事一覧を取得する。
     *
     * @return 記事一覧レスポンス
     */
    public List<ArticleResponse> findAll() {
        return articleRepository.findAll()
                .stream()
                .map(this::toResponse)
                .toList();
    }

    /**
     * slugで記事を1件取得する。
     *
     * @param slug URLなどで利用する記事識別子
     * @return 記事詳細レスポンス
     */
    public ArticleResponse findBySlug(String slug) {
        Article article = articleRepository.findBySlug(slug)
                .orElseThrow(() -> new RuntimeException("記事が見つかりません"));

        return toResponse(article);
    }

    /**
     * 記事を新規作成する。
     *
     * @param request 記事作成リクエスト
     * @return 作成した記事レスポンス
     */
    public ArticleResponse create(ArticleCreateRequest request) {
        Article article = new Article(
                request.getTitle(),
                request.getSlug(),
                request.getCategory(),
                request.getSummary(),
                request.getContent()
        );

        Article savedArticle = articleRepository.save(article);

        return toResponse(savedArticle);
    }

    /**
     * カテゴリで記事一覧を検索する。
     *
     * @param category 記事カテゴリ
     * @return 記事一覧レスポンス
     */
    public List<ArticleResponse> findByCategory(String category) {
        return articleRepository.findByCategory(category)
                .stream()
                .map(this::toResponse)
                .toList();
    }

    /**
     * タイトルにキーワードを含む記事一覧を検索する。
     *
     * @param keyword 検索キーワード
     * @return 記事一覧レスポンス
     */
    public List<ArticleResponse> searchByTitle(String keyword) {
        return articleRepository.searchByTitle(keyword)
                .stream()
                .map(this::toResponse)
                .toList();
    }

    /**
     * Article EntityをArticleResponse DTOへ変換する。
     *
     * @param article 記事Entity
     * @return 記事レスポンス
     */
    private ArticleResponse toResponse(Article article) {
        return new ArticleResponse(
                article.getId(),
                article.getTitle(),
                article.getSlug(),
                article.getCategory(),
                article.getSummary(),
                article.getContent(),
                article.getCreatedAt(),
                article.getUpdatedAt()
        );
    }
}

Serviceでは、Repositoryから取得した Article Entityを ArticleResponse DTOに変換しています。

この変換処理は複数箇所で使うため、toResponse メソッドにまとめています。

ControllerにRepositoryを直接書くこともできます。

しかし、処理が増えてくるとControllerが肥大化します。

そのため、Controllerは入口に集中させ、処理の流れはServiceに寄せます。


ArticleControllerを作る

最後に、APIの入口となるControllerを作ります。

package com.example.devnav.article;

/**
 * 記事APIのController。
 */
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class ArticleController {

    private final ArticleService articleService;

    /**
     * 記事一覧を取得する。
     *
     * @return 記事一覧レスポンス
     */
    @GetMapping("/articles")
    public List<ArticleResponse> findAll() {
        return articleService.findAll();
    }

    /**
     * slugで記事を1件取得する。
     *
     * @param slug URLなどで利用する記事識別子
     * @return 記事詳細レスポンス
     */
    @GetMapping("/articles/{slug}")
    public ArticleResponse findBySlug(@PathVariable String slug) {
        return articleService.findBySlug(slug);
    }

    /**
     * 記事を新規作成する。
     *
     * @param request 記事作成リクエスト
     * @return 作成した記事レスポンス
     */
    @PostMapping("/articles")
    public ArticleResponse create(@RequestBody ArticleCreateRequest request) {
        return articleService.create(request);
    }

    /**
     * カテゴリで記事一覧を検索する。
     *
     * @param category 記事カテゴリ
     * @return 記事一覧レスポンス
     */
    @GetMapping("/articles/category/{category}")
    public List<ArticleResponse> findByCategory(@PathVariable String category) {
        return articleService.findByCategory(category);
    }

    /**
     * タイトルにキーワードを含む記事一覧を検索する。
     *
     * @param keyword 検索キーワード
     * @return 記事一覧レスポンス
     */
    @GetMapping("/articles/search")
    public List<ArticleResponse> searchByTitle(@RequestParam String keyword) {
        return articleService.searchByTitle(keyword);
    }
}

ControllerはAPIの入口です。

今回のControllerでは、実際の処理はServiceに任せています。

Controller
→ リクエストを受け取る

Service
→ 処理の流れを担当する

Repository
→ DBにアクセスする

作成したAPI一覧

今回作成したAPIは以下です。

GET /api/articles
→ 記事一覧取得

GET /api/articles/{slug}
→ slugで記事詳細取得

POST /api/articles
→ 記事新規作成

GET /api/articles/category/{category}
→ カテゴリで記事検索

GET /api/articles/search?keyword=Spring
→ タイトルキーワード検索

これで、記事管理APIのRead / Create / Searchまで作成できました。


Insomniaで確認する

記事作成APIは、Insomniaなどから確認できます。

POST http://localhost:8080/api/articles

Headersは以下です。

Content-Type: application/json

Bodyには以下のようなJSONを送ります。

{
  "title": "Spring Bootで記事作成APIを作る",
  "slug": "spring-boot-create-article-api",
  "category": "Spring Boot",
  "summary": "Spring BootでPOST APIを作成し、articlesテーブルに記事を保存する手順です。",
  "content": "本文:@PostMapping、@RequestBody、Request DTO、Repository#saveを使って記事作成APIを実装します。"
}

成功すると、id 付きの記事データが返ります。

その後、以下で一覧取得を確認できます。

GET http://localhost:8080/api/articles

slugで1件取得する場合は、以下です。

GET http://localhost:8080/api/articles/spring-boot-hello-api

カテゴリ検索は以下です。

GET http://localhost:8080/api/articles/category/Spring%20Boot

キーワード検索は以下です。

GET http://localhost:8080/api/articles/search?keyword=Spring

@PathVariable@RequestParam の使い分け

今回、少し忘れやすいのが @PathVariable@RequestParam の使い分けです。

まずは以下で整理します。

記事1件を特定する値
→ @PathVariable

検索・絞り込み条件
→ @RequestParam

slugで記事を1件取得するAPIでは、@PathVariable を使います。

@GetMapping("/articles/{slug}")
public ArticleResponse findBySlug(@PathVariable String slug) {
    return articleService.findBySlug(slug);
}

URLは以下です。

GET /api/articles/spring-boot-hello-api

この場合、spring-boot-hello-api は記事1件を特定する値です。

そのため、URLのパスに含めて @PathVariable で受け取ります。

一方、キーワード検索では @RequestParam を使います。

@GetMapping("/articles/search")
public List<ArticleResponse> searchByTitle(@RequestParam String keyword) {
    return articleService.searchByTitle(keyword);
}

URLは以下です。

GET /api/articles/search?keyword=Spring

この場合、keyword は記事1件を特定する値ではありません。

記事一覧に対する検索条件です。

そのため、クエリパラメータとして @RequestParam で受け取ります。


category検索について

今回、カテゴリ検索は以下の形にしました。

GET /api/articles/category/{category}

この場合は、URLのパスに category が入っているため、Controllerでは @PathVariable を使っています。

@GetMapping("/articles/category/{category}")
public List<ArticleResponse> findByCategory(@PathVariable String category) {
    return articleService.findByCategory(category);
}

ただし、カテゴリは本来「記事1件を特定する値」ではなく、「記事一覧を絞り込む条件」です。

そのため、実務寄りにするなら以下の形も自然です。

GET /api/articles?category=Spring Boot

この場合は、@RequestParam を使います。

@GetMapping("/articles")
public List<ArticleResponse> findAll(@RequestParam(required = false) String category) {
    if (category != null) {
        return articleService.findByCategory(category);
    }

    return articleService.findAll();
}

今回の段階では、理解しやすさを優先して /articles/category/{category} として分けました。

後で検索条件が増えてきたら、RequestParam に寄せて整理する予定です。


冒頭理解度チェックの答え合わせ

ここで、冒頭理解度チェックの答えを整理します。

全部読まなくても、ここを見れば今回のポイントを復習できます。

Controller
→ APIの入口。
→ リクエストを受け取り、Serviceを呼び出す。

Service
→ 処理の流れをまとめる層。
→ Repositoryから取得したEntityをDTOに変換する。

Repository
→ DBアクセスを担当する層。
→ JpaRepositoryやJPQLでarticlesテーブルを操作する。

Entity
→ DBテーブルと対応するクラス。
→ 今回ならArticleがarticlesテーブルに対応する。

DTO
→ APIの入出力用のクラス。
→ ArticleResponseやArticleCreateRequestが該当する。

@RequestBody
→ POSTリクエストのJSONをJavaオブジェクトとして受け取る。
→ 今回ならArticleCreateRequestで受け取る。

@PathVariable
→ /articles/{slug} のようにURLの一部を受け取る。
→ 記事1件を特定する値に使う。

@RequestParam
→ ?keyword=Spring のように検索条件を受け取る。
→ 検索・絞り込み・ページングなどに使う。

JpaRepository
→ findAll、findById、saveなどのDB操作を簡単に使えるRepository。

@Query
→ 自分でJPQLを書くためのアノテーション。

JPQL
→ SQLではなくEntityを対象にしたクエリ。
→ テーブル名ではなくEntity名、カラム名ではなくフィールド名を使う。

特に忘れやすいのは、@PathVariable@RequestParam の使い分けです。

1件を指名するなら PathVariable

条件で探すなら RequestParam

まずはこの理解で十分です。


次回やること

次回は、今回作った記事APIの続きとして、Update / Deleteを実装します。

次回の候補は以下です。

ArticleUpdateRequestを作る

PUT /api/articles/{id} を作る

DELETE /api/articles/{id} を作る

Article Entityにupdateメソッドを作る

Insomniaで更新・削除を確認する

次回のゴールは以下です。

記事一覧取得

記事詳細取得

記事作成

記事更新

記事削除

ここまでできると、記事管理APIのCRUDが一通り揃います。


まとめ

今回は、Spring Bootバックエンド実装 Part1として、記事APIの土台を作りました。

構成は以下です。

Controller
↓
Service
↓
Repository
↓
DB

DBと対応する Article Entityを作り、APIレスポンスでは ArticleResponse DTOに変換して返しました。

また、POSTリクエストでは ArticleCreateRequest DTOでJSONを受け取る形にしました。

今回作成したAPIは以下です。

GET /api/articles

GET /api/articles/{slug}

POST /api/articles

GET /api/articles/category/{category}

GET /api/articles/search?keyword=Spring

これで、DevNav Handbookの本体である記事データを、Spring Boot側から取得・作成・検索できるようになりました。

次回は、記事更新APIと記事削除APIを追加し、記事管理APIのCRUDを完成に近づけます。

0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?