今さらですが、Spring FrameworkでWebアプリケーションを作るときに基本となる、Web三層について、実装を交えてまとめます。
そもそもWebアプリケーションって?
Webアプリケーションの種類にもよりますが、Webアプリケーションは基本的に次の流れに沿って処理するものが大半です。
データベースの内容をブラウザに表示
利用者が表示された内容に対して、アクションを起こす
利用者のアクションの内容を処理・データベースに反映
処理結果をブラウザに表示
これを、ショッピングサイトに例えると、次のようになります。
データベースから商品一覧をブラウザに表示
利用者が購入する商品を購入
購入内容から、在庫を確認し、在庫があれば注文結果をデータベースに保存
注文を受け付けたことをブラウザに表示
ほとんどのWebアプリケーションは、この手順の繰り返しになります。
Web三層アプリケーションって?
Webアプリケーションを作るときに、アプリケーションを次の3つの層に分けたアプリケーションを「Web三層アプリケーション」と呼びます。
Application層
ブラウザへの画面表示、ブラウザの入力内容の受け取りをするための層です。
具体的には次の処理を主に実装します。
画面の入力内容のチェックとエラーメッセージの表示
画面に表示するときの表示形式の変換
Domain層
業務処理を実装するための層です。
画面で入力されたデータを処理し、次に画面に表示するデータを用意します。
Infrastructure層
データベースや外部サービスとのやり取りを実装するための層です。
基本的には、データの取得や保存処理のみ実装します。
各層に実装するSpring Frameworkのコンポーネント
各層には処理を実装するためのクラスと、層をまたいでデータを受け渡すためのBeanを実装します。
この図を見てもわかるように、各層にクラスとBeanが1つずつ登場していることがわかります。
ただし、この図の登場人物をすべて実装することが必須ではありません。例えば、Application層からDomain層に渡すデータが単一の項目の場合、わざわざDTO Beanを使わずに、Service Classのメソッドの引数に指定する場合もあります。
ぱっと見、登場人物が多くて、複雑な図に見えるかもしれませんが、データを受け渡すためのBeanを省略して、処理の流れだけにフォーカスすると、とてもシンプルな流れであることがわかります。
Spring FrameworkでWeb三層アプリケーションを実装
では、実際にSpring Frameworkを使って、Web三層アプリケーションを実装します。
今回は次のような画面を持つアプリケーションを実装します。
プロジェクトの構成
Web三層アプリケーションのプロジェクト構成に特に規定はありませんが、各層をパッケージで分けるのがわかりやすいです。
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' }