ぺんぎんらぼ

お笑いとマンガ好きなしょぼしょぼWeb系エンジニアの日記です。たまに絵を描きます。

お笑いとマンガ好きなしょぼしょぼWeb系エンジニアの日記です

Spring Frameworkで作るWeb三層アプリケーション

今さらですが、Spring FrameworkでWebアプリケーションを作るときに基本となる、Web三層について、実装を交えてまとめます。

そもそもWebアプリケーションって?

Webアプリケーションの種類にもよりますが、Webアプリケーションは基本的に次の流れに沿って処理するものが大半です。

  1. データベースの内容をブラウザに表示

  2. 利用者が表示された内容に対して、アクションを起こす

  3. 利用者のアクションの内容を処理・データベースに反映

  4. 処理結果をブラウザに表示

これを、ショッピングサイトに例えると、次のようになります。

  1. データベースから商品一覧をブラウザに表示

  2. 利用者が購入する商品を購入

  3. 購入内容から、在庫を確認し、在庫があれば注文結果をデータベースに保存

  4. 注文を受け付けたことをブラウザに表示

ほとんどのWebアプリケーションは、この手順の繰り返しになります。

Web三層アプリケーションって?

Webアプリケーションを作るときに、アプリケーションを次の3つの層に分けたアプリケーションを「Web三層アプリケーション」と呼びます。

Application層

ブラウザへの画面表示、ブラウザの入力内容の受け取りをするための層です。
具体的には次の処理を主に実装します。

  • 画面の入力内容のチェックとエラーメッセージの表示

  • 画面に表示するときの表示形式の変換

Domain層

業務処理を実装するための層です。
画面で入力されたデータを処理し、次に画面に表示するデータを用意します。

Infrastructure層

データベースや外部サービスとのやり取りを実装するための層です。
基本的には、データの取得や保存処理のみ実装します。

各層に実装するSpring Frameworkコンポーネント

各層には処理を実装するためのクラスと、層をまたいでデータを受け渡すためのBeanを実装します。

f:id:penguinlabo:20210507180535p:plain

この図を見てもわかるように、各層にクラスとBeanが1つずつ登場していることがわかります。
ただし、この図の登場人物をすべて実装することが必須ではありません。例えば、Application層からDomain層に渡すデータが単一の項目の場合、わざわざDTO Beanを使わずに、Service Classのメソッドの引数に指定する場合もあります。

ぱっと見、登場人物が多くて、複雑な図に見えるかもしれませんが、データを受け渡すためのBeanを省略して、処理の流れだけにフォーカスすると、とてもシンプルな流れであることがわかります。

f:id:penguinlabo:20210507180927p:plain

Spring FrameworkでWeb三層アプリケーションを実装

では、実際にSpring Frameworkを使って、Web三層アプリケーションを実装します。
今回は次のような画面を持つアプリケーションを実装します。

f:id:penguinlabo:20210507193423p:plain

プロジェクトの構成

Web三層アプリケーションのプロジェクト構成に特に規定はありませんが、各層をパッケージで分けるのがわかりやすいです。

f:id:penguinlabo:20210507195759p:plain

Application層の実装

Controllerクラス

パッケージ「controller」を作成して、そこにコントローラークラスを配置します。

package penguin.web.controller;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.stream.Collectors;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import penguin.web.controller.form.ProfileForm;
import penguin.web.service.ProfileService;

@Controller
@RequestMapping("profile")
public class ProfileController {

    @Autowired
    private ProfileService profileService;

    @GetMapping("list")
    public String list(Model model) {
        List<ProfileForm> profileList = profileService.getProfileList()
            .stream()
            .map(e -> new ProfileForm(e.getName(), e.getBirthday(),
                    ChronoUnit.YEARS.between(e.getBirthday(), LocalDate.now())))
            .collect(Collectors.toList());
        model.addAttribute("profiles", profileList);

        return "profile/list";
    }

    @GetMapping("add")
    public String add(ProfileForm profileForm) {
        return "profile/add";
    }

    @PostMapping("add")
    public String add(@Validated ProfileForm profileForm, BindingResult result) {
        if (result.hasErrors()) {
            return "profile/add";
        }

        profileService.addProfile(profileForm.getName(), profileForm.getBirthday());

        return "redirect:/profile/list";
    }
}
Form Bean

Form Beanは、「controller」パッケージ配下に「form」パッケージを作成して、そこに配置します。

package penguin.web.controller.form;

import java.time.LocalDate;

import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

import org.springframework.format.annotation.DateTimeFormat;

public class ProfileForm {

    @Size(min = 3, max = 15)
    private String name;

    @NotNull
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate birthday;

    private Long age;

    public ProfileForm(@Size(min = 3, max = 15) String name, @NotNull LocalDate birthday, Long age) {
        super();
        this.name = name;
        this.birthday = birthday;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public LocalDate getBirthday() {
        return birthday;
    }

    public void setBirthday(LocalDate birthday) {
        this.birthday = birthday;
    }

    public Long getAge() {
        return age;
    }

    public void setAge(Long age) {
        this.age = age;
    }
}
画面テンプレート

画面テンプレートはJavaファイルではないので、src/main/resourcesに「templates」フォルダを作成して、その下に配置します。
ここでは、「templates」フォルダ配下に「profile」フォルダを作成して、そこにテンプレートファイル「list.html」と「add.html」を作成します。

<!DOCTYPE html>
<html lang="ja" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>List</title>
<style type="text/css">
<!--
table {
    border-collapse: collapse;
}
th, td {
    border: 1px darkgray solid;
    padding: 0.2em 0.5em;
}
-->
</style>
</head>
<body>
<table>
    <caption>Profile List</caption>
    <thead>
        <tr>
            <th scope="col">Name</th>
            <th scope="col">Birthday</th>
            <th scope="col">Age</th>
        </tr>
    </thead>
    <tbody>
        <tr th:each="profile : *{profiles}">
            <td th:text="${profile.name}" />
            <td th:text="${profile.birthday}" />
            <td th:text="${profile.age}" />
        </tr>
    </tbody>
</table>
<a th:href="@{/profile/add}"><input type="button" value="add" /></a>
</body>
</html>
<!DOCTYPE html>
<html lang="ja" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Add</title>
<style>
.input-error {
    border: 1px solid red;
}
</style>
</head>
<body>
<form th:action="@{/profile/add}" th:object="${profileForm}" method="post">
    <p>
        <label for="name">名前</label>
        <input type="text" name="name" id="name" th:field="*{name}" th:errorclass="input-error">
        <span style="color: red;" th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></span>
    </p>
    <p>
        <label for="birthday">生年月日</label>
        <input type="date" name="birthday" id="birthday" th:field="*{birthday}" th:errorclass="input-error">
        <span style="color: red;" th:if="${#fields.hasErrors('birthday')}" th:errors="*{birthday}"></span>
    </p>
    <p>
        <a th:href="@{/profile/list}"><input type="button" value="Cancel" /></a>
        <input type="submit" value="Ok">
    </p>
</form>
</body>
</html>

Domain層の実装

Serviceクラス

パッケージ「service」を作成して、そこにサービスクラスのインターフェスと実装クラスを配置します。

package penguin.web.service;

import java.time.LocalDate;
import java.util.List;

import penguin.web.service.dto.ProfileDto;

public interface ProfileService {
    List<ProfileDto> getProfileList();

    Long addProfile(String name, LocalDate birthday);
}
package penguin.web.service;

import java.time.LocalDate;
import java.util.List;
import java.util.stream.Collectors;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import penguin.web.repository.ProfileRepository;
import penguin.web.repository.entity.ProfileEntity;
import penguin.web.service.dto.ProfileDto;

@Service
@Transactional(rollbackFor = Exception.class)
public class ProfileServiceImpl implements ProfileService {
    @Autowired
    private ProfileRepository profileRepository;

    @Override
    public List<ProfileDto> getProfileList() {
        return profileRepository.findAll()
            .stream()
            .map(e -> new ProfileDto(e.getId(), e.getName(), e.getBirthday()))
            .collect(Collectors.toList());
    }

    @Override
    public Long addProfile(String name, LocalDate birthday) {
        ProfileEntity profile = new ProfileEntity();
        profile.setName(name);
        profile.setBirthday(birthday);
        profile = profileRepository.save(profile);
        return profile.getId();
    }
}
DTO Bean

DTO Beanは、「service」パッケージ配下に「dto」パッケージを作成して、そこに配置します。

package penguin.web.service.dto;

import java.time.LocalDate;

public class ProfileDto {

    private Long id;

    private String name;

    private LocalDate birthday;

    public ProfileDto(Long id, String name, LocalDate birthday) {
        super();
        this.id = id;
        this.name = name;
        this.birthday = birthday;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public LocalDate getBirthday() {
        return birthday;
    }

    public void setBirthday(LocalDate birthday) {
        this.birthday = birthday;
    }
}

Infrastructure層の実装

Repositoryクラス

パッケージ「repository」を作成して、そこにリポジトリクラスのインターフェスクラスを配置します。

package penguin.web.repository;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import penguin.web.repository.entity.ProfileEntity;

@Repository
public interface ProfileRepository extends JpaRepository<ProfileEntity, Long> {
    List<ProfileEntity> findByName(String name);
}
Entity Bean

Entity Beanは、「repository」パッケージ配下に「entity」パッケージを作成して、そこに配置します。

package penguin.web.repository.entity;

import java.time.LocalDate;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

@Entity
@Table(name = "profile")
public class ProfileEntity {

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

    @Column(nullable = false)
    private String name;

    private LocalDate birthday;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public LocalDate getBirthday() {
        return birthday;
    }

    public void setBirthday(LocalDate birthday) {
        this.birthday = birthday;
    }
}

その他のファイル

Web三層アプリケーションの実装とは関係ありませんが、以下のファイルが、本プロジェクトで実装しています。

Spring Bootアプリケーションクラス

本プロジェクトはSpring Bootで作成したので、Spring Bootの起動クラスを作成しています。

package penguin.web;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
Spring Bootアプリケーションプロパティファイル
server.servlet.context-path=/penguin-web
spring.jpa.show-sql=true
Gradleファイル

本プロジェクトはGradleプロジェクトとして作成したので、build.gradleを以下の内容に編集しています。

plugins {
    id 'java-library'
    id 'org.springframework.boot' version '2.4.5'
}
apply plugin: 'war'
apply plugin: 'eclipse-wtp'
apply plugin: 'io.spring.dependency-management'

sourceCompatibility = '1.8'
targetCompatibility = '1.8'

compileJava.options.encoding = 'UTF-8'
compileTestJava.options.encoding = 'UTF-8'

eclipse.wtp {
    component.contextPath = 'penguin-web'
    facet {
        facet name: 'jst.java', version: '1.8'
        facet name: 'wst.jsdt.web', version: '1.0'
        facet name: 'jst.web', version: '3.1'
    }
}

repositories {
    jcenter()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'com.h2database:h2'
}