はじめに
この記事は 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を完成に近づけます。