#はじめに
こんにちは!
Spring Boot 2とAngularを用いて、アプリを作っていこう第二回です。
第一回はこちらです。
ハンズオン資料 - Spring Boot 2とAngularでアプリ作成 (1/2)
本ハンズオンの趣旨はそちらをみてくださいね!
第二回はフロントエンドに触れていきます。
#事前準備
ハンズオンを進める上で、次の事前準備が必要です。
・Java 8のインストール
・Gitのインストール
・Node.jsのインストール
また、必須ではないですが、次のIDEとエディタがインストールされていると望ましいです。
・IntelliJ IDEA
・Visual Studio Code
###Visual Studio Codeについて
Visual Studio Codeは、マイクロソフトにより開発されたソースコードエディタです。Windows、Linux、macOS上で動作します。JavaScript、TypeScript、Node.jsのサポートが豊富で、フロントエンドの開発者に人気の開発ツールです。
#大まかな流れ
ハンズオンは計2回に分けて行います。
第1回がサーバーサイド側のWeb API実装で、第2回が今回のフロントエンド側です。1
今回のハンズオンでは、次の流れでアプリを作成していきます。
- プロジェクトの用意
- サーバサイドのフロントエンド対応作業
- OpenAPI GeneratorによるAPIクライアントの自動生成
- IonicのインストールとIonicプロジェクト作成
- 画面の雛形作成
- レシピ選択画面の実装
- 必要素材画面の実装
- ログイン機能の実装
- レシピ編集画面の実装
- 本番ビルド
出来上がりのソースコードはGitHubにあります。
では、張り切っていきましょう!
#プロジェクトの用意
第一回のハンズオン環境がある方は、その環境をそのまま使用できます。
ない方は、適当なディレクトリで次のコマンドを実行してプロジェクトを用意してください。
git clone -b 0.0.1 https://github.com/kozake/kajitool-handson.git
なお、プロキシ環境の方はこちらです。
git clone -b 0.0.1-proxy https://github.com/kozake/kajitool-handson.git
cloneが完了したら、ブランチを作成しましょう!
> cd kajitool-handson
kajitool-handson> git checkout -b 0.0.2
ハンズオンで作成するアプリの認証機能はGithubを用います。
第一回のハンズオンをしていない方は、以下のリンクを参考にGithubを用いたOAuth2セキュリティ認証の設定を行ってください。
#サーバサイド側のフロントエンド対応作業
では、フロントエンド対応の為にサーバーサイド側を修正していきます。
決して第一回でやり忘れたわけではないぞ!2
##ApiOperationアノテーションの定義
ApiOperationアノテーションの定義を追加することで、APIスペックの情報を補充します。
この作業は、後々OpenAPI GeneratorでAPIクライアントを自動生成するために必要な作業です。
@GetMapping("/")
@ApiOperation(value="素材を返します。", nickname="material_getAll")
public ResponseEntity<List<Material>> getAll() {
@GetMapping("")
@ApiOperation(value="レシピ一覧を返します。", nickname="recipeListView_getAll")
public ResponseEntity<List<RecipeListView>> getAll() {
@PostMapping("/create")
@ApiOperation(value="レシピを作成します。", nickname="recipe_create")
public ResponseEntity<Void> create(@RequestBody final Recipe recipe)
:
@GetMapping("/{id}")
@ApiOperation(value="IDのレシピを返します。", nickname="recipe_get")
public ResponseEntity<Recipe> get(@PathVariable final long id) {
:
@PutMapping("/save")
@ApiOperation(value="レシピを更新します。", nickname="recipe_save")
public ResponseEntity<Void> save(@RequestBody final Recipe recipe) {
:
@DeleteMapping("/{id}")
@ApiOperation(value="IDのレシピを削除します。", nickname="recipe_remove")
public ResponseEntity<Void> remove(
valueプロパティでそのAPIの説明を記述します。
nicknameはAPIスペックのoperationIdとなります。
operationIdはAPI全体で一意にする必要があります。OpenAPI GeneratorはoperationIdからAPIクライアントのメソッド名を求めるのですが、これが重複すると、メソッド名が不正な名前(get、get1、get2のように自動で連番が振られてしまう)になります。ですので、クラス名を前に付与して一意になるように工夫しています。
ここまで出来たらコミットしましょう!
kajitool-handson> git add .
kajitool-handson> git commit -m "add ApiOperation"
##Accountサービスの追加
ログインアカウントの情報を取得するWeb APIを作成します。
今回は簡単にログインユーザの名前のみを返します。
このWeb APIは、フロント側からシステムにログインしているかどうかを判断する際に使用します。
まずはアカウントの情報を保持するクラスを作成します。
package kajitool.web.domain.model;
public class Account {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
次に、ログインユーザのアカウント情報を返すサービスを作成します。
package kajitool.web.service.account;
import kajitool.web.domain.model.Account;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
public class AccountService {
public Optional<Account> getCurrentUserLogin() {
SecurityContext securityContext = SecurityContextHolder.getContext();
return Optional.ofNullable(securityContext.getAuthentication())
.map(this::authenticationToAccount);
}
private Account authenticationToAccount(final Authentication authentication) {
if (authentication.getPrincipal() instanceof OAuth2User) {
OAuth2User user = (OAuth2User) authentication.getPrincipal();
Account account = new Account();
account.setName((String) user.getAttributes().get("login"));
return account;
}
return null;
}
}
ユーザ名は、login
属性から取得しています。
ここら辺の内容は、認証サーバにより異なり、これ以外にも様々な情報を取得することは出来ます。
ログインユーザのアカウント情報を返すコントローラを作成します。
ログインユーザが存在しない場合はエラーとしています。
このメソッドは認証していないと呼べないようにセキュリティ設定するので、これで問題ありません。
package kajitool.web.controller;
import io.swagger.annotations.ApiOperation;
import kajitool.web.domain.model.Account;
import kajitool.web.service.account.AccountService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/acount")
public class AccountResource {
private final AccountService service;
public AccountResource(AccountService service) {
this.service = service;
}
@GetMapping("/")
@ApiOperation(value="ログインアカウントを返します。", nickname="account_get")
public ResponseEntity<Account> get() {
return ResponseEntity.ok()
.body(service.getCurrentUserLogin().orElseThrow(
() -> new RuntimeException("Account could not be found")));
}
}
セキュリティを設定します3。
http.csrf().csrfTokenRepository(
CookieCsrfTokenRepository.withHttpOnlyFalse())
+ .and()
+ .exceptionHandling()
+ .authenticationEntryPoint((request, response, authException) -> {
+ // SPAとの連携を考慮し、認証エラー時は302ではなく401を返すようにする
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ })
.and().oauth2Login()
+ // SPAとの連携を考慮し、認証成功時のURLは固定にする
+ .defaultSuccessUrl("/", true)
.and().authorizeRequests()
+ .mvcMatchers("/api/v1/acount").authenticated()
.mvcMatchers(HttpMethod.POST, "/api/**/*").authenticated()
通常の設定では、認証エラー時はログインURLへリダイレクト(ステータス302)が返却されます。サーバサイドレンダリングの従来のWebアプリケーションの場合はそれでいいのですが、SPAの場合はAJAX通信をするのでリダイレクトを返されても困ります。そのため、認証不足(ステータス401)を返却するよう変更しています。
また、認証成功時の設定も変更しています。通常の設定では、前回認証不足でアクセスに失敗したURLへリダイレクトされるのですが、これも困ります(JSONデータが表示されても困る)。ですので、画面に遷移するよう、認証成功時のURLを固定しています。
では、kajitool-web
を起動しましょう。次のコマンドを実行してください。
kajitool-handson> gradlew :kajitool-web:bootrun
次のURLにブラウザでアクセスしてGitHub認証した後、
次のURLにアクセスしてアカウント情報が取得できればOKです。
ここまで出来たら、コミットしましょう!
kajitool-handson> git add .
kajitool-handson> git commit -m "Account Service"
##SQLの修正
フロント側の画面を作成するにあたり、前もっていくつかのデータを事前に用意しておきたいと思います。
次のとおり、SQLを修正してください。
insert into MATERIAL values(1, 'どうのこうせき');
insert into MATERIAL values(2, 'てつのこうせき');
insert into MATERIAL values(3, 'ぎんのこうせき');
insert into RECIPE values(RECIPE__ID_SEQ.NEXTVAL, 'どうのつるぎ', 1, SYSDATE);
insert into RECIPE_DETAIL values(RECIPE_DETAIL__ID_SEQ.NEXTVAL, RECIPE__ID_SEQ.CURRVAL, 1, 3);
insert into RECIPE values(RECIPE__ID_SEQ.NEXTVAL, 'てつのつるぎ', 1, SYSDATE);
insert into RECIPE_DETAIL values(RECIPE_DETAIL__ID_SEQ.NEXTVAL, RECIPE__ID_SEQ.CURRVAL, 1, 2);
insert into RECIPE_DETAIL values(RECIPE_DETAIL__ID_SEQ.NEXTVAL, RECIPE__ID_SEQ.CURRVAL, 2, 3);
insert into RECIPE values(RECIPE__ID_SEQ.NEXTVAL, 'せいどうのつるぎ', 1, SYSDATE);
insert into RECIPE_DETAIL values(RECIPE_DETAIL__ID_SEQ.NEXTVAL, RECIPE__ID_SEQ.CURRVAL, 1, 3);
insert into RECIPE_DETAIL values(RECIPE_DETAIL__ID_SEQ.NEXTVAL, RECIPE__ID_SEQ.CURRVAL, 2, 1);
その後、次のコマンドを実行してください。その際には、kajitool-web
は停止しておいてくださいね!
kajitool-handson> gradlew :kajitool-flyway:flywayClean
kajitool-handson> gradlew :kajitool-flyway:flywayMigrate
次のURLにアクセスして、レシピ一覧の情報が取得できればOKです。
ここまで出来たら、コミットしましょう!
kajitool-handson> git add .
kajitool-handson> git commit -m "create data"
#OpenAPI GeneratorによるAPIクライアントの自動生成
では、いよいよフロント側の作業です!
OpenAPI Generatorを用いてAPIクライアントを自動生成します。
OpenAPI Generatorとは、OpenAPI Spec(2.0と3.0の両方をサポート)からAPIクライアントライブラリを自動生成するOSSツールです。生成するプログラムは様々な言語に対応しています。
詳細は、次のリンク先を参考にしてください。
###Gradle pluginによるOpenAPI Generatorの準備
まずは、kajitool-api
プロジェクトを作成しましょう。
kajitool-handson> mkdir kajitool-api
OpenAPI Generatorを使うには、CLI、Maven Plugin、Gradle Pluginといくつか方法がありますが、ここではGradle Pluginを用います。次のbuild.gradle
を作成してください。
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath "org.openapitools:openapi-generator-gradle-plugin:3.3.4"
}
}
apply plugin: 'org.openapi.generator'
task downloadApiSpec doLast {
def file = new File("$projectDir/api-spec.json")
file.delete()
file.setText(groovy.json.JsonOutput.prettyPrint(
new URL('http://localhost:8080/v2/api-docs').getText('UTF-8')), 'UTF-8')
}
task clean(type: Delete) {
delete"$buildDir"
}
task genTypescriptAngular(type: org.openapitools.generator.gradle.plugin.tasks.GenerateTask) {
generatorName = "typescript-angular"
inputSpec = "$projectDir/api-spec.json".toString()
outputDir = "$buildDir/typescript-angular".toString()
additionalProperties = [
npmName: "@kajitool/kajitool-api",
npmVersion: "1.0.0",
ngVersion: "7.2.2",
providedInRoot: "true"
]
removeOperationIdPrefix = true
}
genTypescriptAngular.dependsOn clean
その後、kajitool-api
をサブプロジェクトに追加します。
include 'kajitool-web', 'kajitool-flyway', 'kajitool-dao', 'kajitool-api'
downloadApiSpec
タスクは、kajitool-web
の次のURLにアクセスして、OpenAPI Specをダウンロードするタスクです。
ダウンロードしたファイルは、api-spec.json
というファイルに見やすい形で整形してから保存して、gitで管理します。
では、ダウンロードしてみましょう。kajitool-web
がローカルで起動している状態で、次のコマンドを実行してください。
kajitool-handson> gradlew :kajitool-api:downloadApiSpec
api-spec.json
というファイルがkajitool-api
ディレクトリに作成されていたら成功です。
次に、genTypescriptAngular
タスクを実行します。
genTypescriptAngular
タスクは、OpenAPI Generatorを実行します。
それぞれの設定については、次のリンク先を参考にしてください。
removeOperationIdPrefix
について、少しだけ補足します。
このオプションを有効にすることで、operationIdの接頭辞を削除できます。例えば、config_getId
は、getId
となります。
ApiOperation
アノテーションを定義した際、operationIdが重複しないよう、nicknameにクラス名の接頭辞をつけました。このままソースコードを生成すると、クラス名もメソッド名に入ってしまいますので、それを防ぐために、このオプションを有効にしています。
では、実行しましょう。次のコマンドを実行してください。
kajitool-handson> gradlew :kajitool-api:genTypescriptAngular
kajitool-api\build
ディレクトリ配下にソースコードが生成されていれば成功です。
一旦、ここでコミットしましょう!
kajitool-handson> git add .
kajitool-handson> git commit -m "kajitool-api"
##APIクライアントのビルド
では、自動生成したAPIクライアントをビルドします。ですが・・・
このままだと、失敗するんですね~><。
OpenAPI Generatorの不具合だと思います。OpenAPI Generatorは様々な言語を有志がサポートしていることもあり、割とこういう系の不具合は多いです。経験上、フロントエンドの開発はこういうことが多いです。頑張って乗り切りましょう!
kajitool-api\build\typescript-angular
にカレントディレクトリを移します。
cd kajitool-api\build\typescript-angular
次のコマンドを実行してください。
kajitool-handson\kajitool-api\build\typescript-angular> npm i --save-dev tsickle
次に、package.json
のtypescriptのバージョンを修正してください。3
"devDependencies": {
:
- "typescript": ">=2.1.5 <2.8"
+ "typescript": "~3.1.6",
:
}
これで準備完了です!
次のコマンドを実行してビルドしてください。
kajitool-handson\kajitool-api\build\typescript-angular> npm install
kajitool-handson\kajitool-api\build\typescript-angular> npm run build
Built Angular Package!
と表示されれば成功です。
自動生成できたことを祝ってコミットしましょう!・・・っとその前に。
作成されたビルドファイルはgit対象外にします。
次の修正を加えてください。
build/typescript-angular/dist.tgz
では、改めてコミットしましょう!
kajitool-handson> git add .
kajitool-handson> git commit -m "build api"
#IonicのインストールとIonicプロジェクト作成
ようやくSPAを作成する下準備が整いました。長かった。。。
フロント画面はIonicで作成していきます。
あれ?Angularじゃないの?と思われる方もいるかもしれませんので言い訳説明を。
IonicはAngularベースにアプリ開発が出来るアプリケーションフレームワークです。
AngularのみでWebアプリケーションを作成する場合、色々と下準備が必要ですが、Ionicはアプリケーション作成に必要な部品が一式揃っています。モバイル向けのWebアプリケーションを素早く構築するのであれば、Ionicが向いてると思います。
個人的な所感 | |
---|---|
Angular | コンポーネント指向でSPAを作るための開発キット |
Ionic | Angularでモバイル向けWebアプリケーションを作成するためのアプリケーション基盤 |
実際に、当初はAngularのみで作成しようとしましたが、色々と面倒くさくてIonic使うことにしました。Ionic便利ですね!
Ionicを学びたい方は、こちらの書籍などいいのではないかと思います。
IonicV3の書籍ですが、V4を学ぶ上でも有効だと思います。
###Ionicのインストール
では、はじめましょう!
まずはIonicをインストールします。次のコマンドを実行してください。
kajitool-handson> npm install -g ionic
インストールに失敗した場合は、以下のリンク先の情報に従い、npmのプロキシ設定を見直してください。
次のコマンドを実行し、バージョンが表示されれば成功です。4
kajitool-handson> ionic --version
###Ionicプロジェクト作成
では、Ionicプロジェクトを作成します。
次のコマンドを実行してください。
このコマンドにより、最小構成(blank)のIonicプロジェクトのテンプレートが生成されます。
kajitool-handson> ionic start kajitool-ui blank
Error: getaddrinfo EAI_AGAIN ~
のような接続エラーで失敗した場合は、以下のリンク先の情報に従い、Ionicのプロキシ設定を実施してください。
一旦、コミットしましょう!
kajitool-handson> git add .
kajitool-handson> git commit -m "ionic start"
次に、先ほど生成したAPIクライアントを追加します。
kajitool-uiディレクトリにカレントディレクトリを移し、次のコマンドを実行してください。
kajitool-handson> cd kajitool-ui
kajitool-handson\kajitool-ui> npm i ../kajitool-api/build/typescript-angular/dist.tgz
実行確認をしましょう!
次のコマンドで画面が立ち上がるかを確認してください。
kajitool-handson\kajitool-ui> npm run start
次のURLにアクセスし、ブランク画面が表示されれば成功です。
画面が無事起動できることが確認出来たら、コミットしましょう!
kajitool-handson\kajitool-ui> git add .
kajitool-handson\kajitool-ui> git commit -m "install api"
#画面の雛形作成
では、画面の雛形を作成していきます!
以降の操作は、カレントディレクトリがkajitool-ui
にある状態で行います。
次のコマンドを実行してください。
kajitool-handson\kajitool-ui> ionic g page pages/selectRecipe
kajitool-handson\kajitool-ui> ionic g page pages/needMaterial
kajitool-handson\kajitool-ui> ionic g page pages/EditRecipe
画面の生成に成功したら、一旦コミットします。
kajitool-handson\kajitool-ui> git add .
kajitool-handson\kajitool-ui> git commit -m "generate pages"
###画面のルート構成
画面のルート構成を変更します。
次のファイルを修正してください。3
const routes: Routes = [
- { path: 'select-recipe', loadChildren: './pages/select-recipe/select-recipe.module#SelectRecipePageModule' },
- { path: 'need-material', loadChildren: './pages/need-material/need-material.module#NeedMaterialPageModule' },
+ // { path: 'select-recipe', loadChildren: './pages/select-recipe/select-recipe.module#SelectRecipePageModule' },
+ // { path: 'need-material', loadChildren: './pages/need-material/need-material.module#NeedMaterialPageModule' },
{ path: 'edit-recipe', loadChildren: './pages/edit-recipe/edit-recipe.module#EditRecipePageModule' },
];
次に、ホーム画面のルーティングに、先ほどコメントアウトした画面のルートを追加します。
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule } from '@ionic/angular';
import { FormsModule } from '@angular/forms';
import { Routes, RouterModule } from '@angular/router';
import { HomePage } from './home.page';
const routes: Routes = [
{
path: '',
component: HomePage,
children: [
{
path: 'select-recipe',
children: [
{
path: '',
loadChildren: '../pages/select-recipe/select-recipe.module#SelectRecipePageModule'
}
]
},
{
path: 'need-material',
children: [
{
path: '',
loadChildren: '../pages/need-material/need-material.module#NeedMaterialPageModule'
}
]
},
{
path: '',
redirectTo: '/home/select-recipe',
pathMatch: 'full'
}
]
}
];
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
RouterModule.forChild(routes)
],
declarations: [HomePage]
})
export class HomePageModule {}
ホーム画面を次のとおりに置き換えます。
<ion-tabs>
<ion-tab-bar slot="bottom">
<ion-tab-button tab="select-recipe">
<ion-icon name="checkmark"></ion-icon>
<ion-label>武器を選択</ion-label>
</ion-tab-button>
<ion-tab-button tab="need-material">
<ion-icon name="list"></ion-icon>
<ion-label>必要な素材</ion-label>
</ion-tab-button>
</ion-tab-bar>
</ion-tabs>
これで、ホーム画面でレシピ選択画面と必要素材画面をタブ切り替え出来るようになりました!
画面を起動して、修正が反映されているのを確認してください。
ここまで出来たら、コミットしましょう!
kajitool-handson\kajitool-ui> git add .
kajitool-handson\kajitool-ui> git commit -m "page route"
##サイドメニュー
画面にサイドメニューを追加します。
次の修正を加えてください。
<ion-app>
<ion-split-pane>
<ion-menu>
<ion-header>
<ion-toolbar>
<ion-title>メニュー</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-list>
<ion-menu-toggle auto-hide="false">
<ion-item>
<ion-icon slot="start" name="log-in"></ion-icon>
<ion-label>
ログイン
</ion-label>
</ion-item>
</ion-menu-toggle>
</ion-list>
</ion-content>
</ion-menu>
<ion-router-outlet main></ion-router-outlet>
</ion-split-pane>
</ion-app>
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-menu-button></ion-menu-button>
</ion-buttons>
<ion-title>武器鍛冶ツール</ion-title>
</ion-toolbar>
</ion-header>
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-menu-button></ion-menu-button>
</ion-buttons>
<ion-title>武器鍛冶ツール</ion-title>
</ion-toolbar>
</ion-header>
ion-menu-button
は、PCサイズの場合は表示されず、タブレットサイズの際に表示されます。
このボタンを押下することで、サイドメニューが表示されます。
画面を起動して、修正が反映されているのを確認しましょう。
ここまで出来たら、コミットしましょう!
kajitool-handson\kajitool-ui> git add .
kajitool-handson\kajitool-ui> git commit -m "side menu"
#レシピ選択画面の実装
###レシピサービス
では、レシピ選択画面を実装していきます!
まずは、レシピを扱うサービスクラスを作成します。
kajitool-handson\kajitool-ui> ionic g service services/Recipe
サービスクラスを次のとおり修正します。
import { Injectable } from '@angular/core';
import { RecipeListView, RecipeListViewResourceService } from '@kajitool/kajitool-api';
export interface RecipeListViewModel extends RecipeListView {
selected: boolean;
orderQuantity: number;
}
@Injectable({
providedIn: 'root'
})
export class RecipeService {
recipeList: RecipeListViewModel[] = [];
constructor(
private recipeListViewResource: RecipeListViewResourceService,
) { }
async init(): Promise<RecipeListViewModel[]> {
const list: RecipeListView[] = await this.recipeListViewResource.getAll().toPromise();
this.recipeList = [];
list.forEach(recipe => {
this.recipeList.push({selected: false, orderQuantity: 1, ...recipe});
});
return this.recipeList;
}
}
Angualr 6より、@Injectable
にprovidedIn
を指定できます。
provideIn: 'root'
はサービスがルートインジェクターに提供されるべきであることを指定しています。これにより、このサービスはAngularのDI フレームワークの仕組みによりroot 所属のサービスとして提供されます。root 所属のサービスは、全てのモジュールで同じインスタンスが利用されます(シングルトンです)。
レシピサービスは自動生成したAPIライブラリを使用します。
APIライブラリのサービスを有効にするために、ルートモジュールでApiModule
とHttpClientModule
をimportsします。3
:
import { RouteReuseStrategy } from '@angular/router';
+ import { HttpClientModule } from '@angular/common/http';
+ import { ApiModule, Configuration } from '@kajitool/kajitool-api';
:
+ export function apiConfiguration(): Configuration {
+ return new Configuration({basePath: ' '});
+ }
@NgModule({
declarations: [AppComponent],
entryComponents: [],
- imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule],
+ imports: [
+ BrowserModule,
+ IonicModule.forRoot(),
+ AppRoutingModule,
+ HttpClientModule,
+ ApiModule.forRoot(apiConfiguration)
+ ],
providers: [
StatusBar,
:
最後に、アプリ起動時にレシピサービスの初期処理を実行するコードを追加します。
これにより、アプリ起動時にレシピの一覧をWeb API呼び出しにより取得します。
次の修正を加えてください。3
constructor(
private platform: Platform,
private splashScreen: SplashScreen,
- private statusBar: StatusBar
+ private statusBar: StatusBar,
+ public recipeService: RecipeService,
) {
this.initializeApp();
}
initializeApp() {
this.platform.ready().then(() => {
this.statusBar.styleDefault();
this.splashScreen.hide();
+
+ this.recipeService.init();
});
}
###レシピ選択画面
次に、レシピ画面を修正します。レシピサービスをレシピ選択画面にインジェクションします。3
import { Component, OnInit } from '@angular/core';
+ import { RecipeService } from 'src/app/services/recipe.service';
:
export class SelectRecipePage implements OnInit {
- constructor() { }
+ constructor(
+ public recipeService: RecipeService
+ ) { }
:
そして、レシピ選択画面のテンプレートを次の通り修正します。
:
<ion-content padding>
<ion-list>
<ion-item-sliding *ngFor="let recipe of recipeService.recipeList">
<ion-item>
<ion-label>
<h2>{{recipe.name}}</h2>
<p>素材:{{recipe.materialCount}}つ</p>
</ion-label>
<ion-checkbox
slot="start"
[(ngModel)]="recipe.selected">
</ion-checkbox>
<ion-input
slot="end"
type="number"
inputmode="numeric"
class="text-right"
[(ngModel)]="recipe.orderQuantity"
min="1"
max="99"
>
</ion-input>
</ion-item>
<ion-item-options side="end">
<ion-item-option>
<div>
編集
</div>
</ion-item-option>
</ion-item-options>
</ion-item-sliding>
</ion-list>
<ion-fab vertical="bottom" horizontal="end" slot="fixed">
<ion-fab-button>
<ion-icon name="add"></ion-icon>
</ion-fab-button>
</ion-fab>
</ion-content>
注文数の入力テキストについては、右寄せにしたいのでCSSを定義してください。
.text-right {
text-align: right;
}
###プロキシの設定
Angularには、ng serve
というCLIが用意されています。このコマンドにより、ソースコードがビルドされ、テスト用サーバ上で画面の動作確認が出来ます。
このテスト用サーバは4200番ポートで起動して動作するのですが、このサーバにWebAPIを呼び出しても、当然のことながらHTTPステータス404(Not Found)が返って来ます。
そこで、テスト用サーバへのAPI呼び出しをkajitool-web
にプロキシする設定を行います。
proxy.conf.json
ファイルを追加してください。
{
"/api": {
"target": "http://localhost:8080"
},
"/oauth2": {
"target": "http://localhost:8080"
},
"/login": {
"target": "http://localhost:8080"
}
}
/api
から始まるリクエストは、"http://localhost:8080"
にプロキシするよう設定しています。
その他の設定はOAuth2認証のためです。
最後に、プロキシを有効にしてng serve
を起動するスクリプトを登録しましょう。
"scripts": {
:
"serve": "ng serve --proxy-config proxy.conf.json -o",
:
--proxy-config
で先ほど作成したプロキシ設定のjsonファイルを指定します。-o
オプションを指定することで、開発サーバ起動時にデフォルトブラウザが起動します。
では、実際に起動しましょう。kajitool-web
を起動させた後に、次のコマンドで開発用サーバを起動してください。
kajitool-handson\kajitool-ui> npm run serve
画面が起動して、レシピ一覧が表示されれば成功です!
ここまで出来たら、コミットしましょう!
kajitool-handson\kajitool-ui> git add .
kajitool-handson\kajitool-ui> git commit -m "select recipe page"
#必要素材画面の実装
では、武器を作るのに必要な素材を表示する画面を作成します。
まずはサービスの作成から。
###マテリアルサービス
次のコマンドを実行して、マテリアルサービスを作成してください。
kajitool-handson\kajitool-ui> ionic g service services/Material
次の修正をマテリアルサービスに加えてください。
import { Injectable } from '@angular/core';
import { Material, MaterialResourceService } from '@kajitool/kajitool-api';
@Injectable({
providedIn: 'root'
})
export class MaterialService {
mateials: Material[] = [];
constructor(
private materialResource: MaterialResourceService
) { }
async init(): Promise<Material[]> {
this.mateials = await this.materialResource.getAll().toPromise();
return this.mateials;
}
getMateial(id: number): Material {
return this.mateials.find(m => m.id === id);
}
getMateials(): Material[] {
return this.mateials;
}
}
レシピサービス同様、素材(マテリアル)の一覧をアプリ起動時に取得します。
次の修正を加えてください。3
:
import { StatusBar } from '@ionic-native/status-bar/ngx';
+ import { MaterialService } from './services/material.service';
import { RecipeService } from './services/recipe.service';
:
export class AppComponent {
constructor(
:
+ public materialService: MaterialService,
public recipeService: RecipeService,
:
initializeApp() {
:
+ this.materialService.init();
this.recipeService.init();
:
選択したレシピから必要な素材を求める処理をレシピサービスに追加します。
コードが長いので、レシピサービス全部のソースコードを載せます。
import { Injectable } from '@angular/core';
import { Observable, merge } from 'rxjs';
import { toArray } from 'rxjs/operators';
import {
Material,
Recipe,
RecipeListView,
RecipeListViewResourceService,
RecipeResourceService
} from '@kajitool/kajitool-api';
import { MaterialService } from './material.service';
export interface RecipeListViewModel extends RecipeListView {
selected: boolean;
orderQuantity: number;
}
export interface NeedMaterialModel {
material: Material;
needQuantity: number;
}
@Injectable({
providedIn: 'root'
})
export class RecipeService {
recipeList: RecipeListViewModel[] = [];
needMaterial: NeedMaterialModel[] = [];
constructor(
private recipeResource: RecipeResourceService,
private recipeListViewResource: RecipeListViewResourceService,
private materialService: MaterialService
) { }
async init(): Promise<RecipeListViewModel[]> {
const list: RecipeListView[] = await this.recipeListViewResource.getAll().toPromise();
this.recipeList = [];
list.forEach(recipe => {
this.recipeList.push({selected: false, orderQuantity: 1, ...recipe});
});
return this.recipeList;
}
async getNeedMaterial(): Promise<NeedMaterialModel[]> {
const obRecipes: Observable<Recipe>[] = this.recipeList
.filter(recipe => recipe.selected && recipe.orderQuantity > 0)
.map(recipe => {
return this.recipeResource.get(recipe.id);
});
const recipes: Recipe[] = await merge(...obRecipes).pipe(toArray()).toPromise();
this.needMaterial = this.buildNeedMaterial(recipes);
return this.needMaterial;
}
private buildNeedMaterial(recipes: Recipe[]): NeedMaterialModel[] {
const needMaterial: NeedMaterialModel[] = [];
recipes.forEach(recipe => {
const selectRecipe = this.recipeList.find(r => r.id === recipe.id);
if (!selectRecipe) {
return;
}
recipe.recipeDetails.forEach(recipeDetail => {
const material = needMaterial.find(m => m.material.id === recipeDetail.materialId);
if (material) {
material.needQuantity += selectRecipe.orderQuantity * recipeDetail.quantity;
} else {
needMaterial.push({
material: this.materialService.getMateial(recipeDetail.materialId),
needQuantity: selectRecipe.orderQuantity * recipeDetail.quantity
});
}
});
});
return needMaterial;
}
}
getNeedMaterial
メソッドで必要な素材を求めます。
選択されたレシピを求め、そのレシピ情報をサーバーに平行してリクエストし、その応答がすべて揃ったところでレシピの注文数に応じて素材ごとに必要な数を求めるという複雑なことをしています。
このような複雑なコードになったのは、Web APIの設計が汎用的だったからです。Web APIは、広く多数に公開するものと、公開範囲が狭く特定の用途で使うものがあります。前者の場合、汎用的な設計が用いられますが、後者の場合はそれに特化した設計がされます。書籍などでは、前者の設計方法について多く記載されていますが、後者のほうが、より簡易に作成できて効率的になる可能性があります。Web APIを設計する際は、前者であるか後者であるかを意識してみてください。
##画面
画面を修正します。次の修正を加えてください。3
import { Component, OnInit } from '@angular/core';
+ import { RecipeService } from 'src/app/services/recipe.service';
:
export class NeedMaterialPage implements OnInit {
- constructor() { }
+ constructor(public recipeService: RecipeService) { }
ngOnInit() {
+ this.recipeService.getNeedMaterial();
}
+ ionViewWillEnter() {
+ this.recipeService.getNeedMaterial();
+ }
}
ionViewWillEnter
メソッドは、Ionicのライフサイクルをハンドルします。
画面が表示される度に、このメソッドが呼ばれるので、画面表示の際に必要素材を計算しています。
画面のテンプレートも修正します。
:
<ion-content padding>
<ion-item-group>
<ion-item-divider>
<ion-label>作る武器</ion-label>
</ion-item-divider>
<ng-container *ngFor="let r of recipeService.recipeList">
<ion-item *ngIf="r.selected && r.orderQuantity > 0">
<ion-label>{{r.name}}</ion-label>
<ion-note slot="end" class="quantity">×{{r.orderQuantity}}</ion-note>
</ion-item>
</ng-container>
</ion-item-group>
<ion-item-group>
<ion-item-divider>
<ion-label>必要な素材</ion-label>
</ion-item-divider>
<ng-container *ngFor="let m of recipeService.needMaterial">
<ion-item>
<ion-label>{{m.material.name}}</ion-label>
<ion-note slot="end" class="quantity">×{{m.needQuantity}}</ion-note>
</ion-item>
</ng-container>
</ion-item-group>
</ion-content>
レシピサービス同様に、CSSも追加してください。
.quantity {
font-size: inherit;
}
では、実際に起動しましょう。kajitool-web
を起動させた後に、次のコマンドで開発用サーバを起動してください。
kajitool-handson\kajitool-ui> npm run serve
画面が起動して、レシピ一覧で選択した武器の必要素材が、必要素材画面に表示されれば成功です!
ここまで出来たら、コミットしましょう!
kajitool-handson\kajitool-ui> git add .
kajitool-handson\kajitool-ui> git commit -m "need material page"
#ログイン機能の実装
ログイン機能を実装していきます。
ログイン機能は、Githubを認証サーバとしたOAuth2認証で実現しています。
まずはアカウントサービスを作成しましょう!
###アカウントサービス
次のコマンドを実行して、アカウントサービスを作成してください。
kajitool-handson\kajitool-ui> ionic g service services/Account
次の修正をアカウントサービスに加えてください。
import { Injectable } from '@angular/core';
import { Account, AccountResourceService } from '@kajitool/kajitool-api';
@Injectable({
providedIn: 'root'
})
export class AccountService {
account: Account;
isLogined = false;
constructor(private accountResource: AccountResourceService) { }
async init() {
try {
this.account = await this.accountResource.get().toPromise();
this.isLogined = true;
console.log(`${this.account.name} login.`);
} catch (e) {
console.log('no login.');
}
}
}
Web APIを発行して、アカウント情報を取得するだけの簡単な処理です。
accountResourceの呼び出しは、認証済みでない場合はHTTPステータス401(Unauthorized)が返却されるため、catch
ブロックに処理が移ります。
このWeb API呼び出しが出来るかどうかで、認証済みかどうかを判断できます。
アカウントサービスをアプリ起動時に呼び出します。
次の修正を加えてください。3
:
+ import { AccountService } from './services/account.service';
import { MaterialService } from './services/material.service';
:
export class AppComponent {
constructor(
:
+ public accountService: AccountService,
public materialService: MaterialService,
:
initializeApp() {
this.platform.ready().then(() => {
this.statusBar.styleDefault();
this.splashScreen.hide();
+ this.accountService.init();
this.materialService.init();
this.recipeService.init();
});
}
+ goLogin() {
+ location.href = 'oauth2/authorization/github';
+ }
}
goLogin
メソッドを追加しました。
このメソッドを呼び出すことで、Githubの認証画面に遷移します。
メニューのログインをクリックした際に、Githubの認証画面に遷移するよう、テンプレートを修正しましょう。3
##画面
:
- <ion-label>
+ <ion-label (click)="goLogin()">
ログイン
+ <span *ngIf="accountService.isLogined">
+ ({{accountService.account.name}})
+ </span>
</ion-label>
:
レシピ編集画面には認証済みのユーザのみが遷移できるよう、レシピ選択画面を修正します。3
import { Component, OnInit } from '@angular/core';
+ import { AccountService } from 'src/app/services/account.service';
import { RecipeService } from 'src/app/services/recipe.service';
:
export class SelectRecipePage implements OnInit {
constructor(
+ public accountService: AccountService,
public recipeService: RecipeService
) { }
:
レシピ選択画面のテンプレートで、ログイン時のみ遷移する部品が表示されるようにします。3
:
- <ion-item-options side="end">
+ <ion-item-options side="end" *ngIf="accountService.isLogined">
<ion-item-option>
- <div>
+ <div [routerLink]="['/edit-recipe/' + recipe.id]">
編集
</div>
</ion-item-option>
</ion-item-options>
</ion-item-sliding>
</ion-list>
- <ion-fab vertical="bottom" horizontal="end" slot="fixed">
- <ion-fab-button>
+ <ion-fab vertical="bottom" horizontal="end" slot="fixed" *ngIf="accountService.isLogined">
+ <ion-fab-button [routerLink]="['/edit-recipe/new']">
<ion-icon name="add"></ion-icon>
</ion-fab-button>
</ion-fab>
</ion-content>
では、起動して認証を試してみましょう!。。。とその前に。
GithubのOAuth Appsの設定を修正する必要があります。Authorization callback URL
の設定が現在、http://localhost:8080/
になっていると思います。開発サーバは4200番ポートで動作するので、このままだとうまく動きません。http://localhost:4200/
に修正してください。
では、実際に起動しましょう。kajitool-web
を起動させた後に、次のコマンドで開発用サーバを起動してください。
kajitool-handson\kajitool-ui> npm run serve
画面が起動して、ログイン後のサイドメニューに名前がが表示されれば成功です!
ここまで出来たら、コミットしましょう!
kajitool-handson\kajitool-ui> git add .
kajitool-handson\kajitool-ui> git commit -m "login future"
#レシピ編集画面の実装
いよいよラストです。レシピ編集画面の実装です。
まずはルート設定を修正してください。3
const routes: Routes = [
:
- { path: 'edit-recipe', loadChildren: './pages/edit-recipe/edit-recipe.module#EditRecipePageModule' },
+ { path: 'edit-recipe/:id', loadChildren: './pages/edit-recipe/edit-recipe.module#EditRecipePageModule' },
];
##レシピサービス
レシピサービスに編集に必要な処理を追加します。
:
export class RecipeService {
:
// ここから追加箇所
newRecipe(): Recipe {
return {
id: null,
name: '',
recipeDetails: [],
updatedAt: null,
version: null
};
}
async get(id: number): Promise<Recipe> {
return this.recipeResource.get(id).toPromise();
}
async create(recipe: Recipe): Promise<any> {
return await this.recipeResource.create(recipe).toPromise();
}
async save(recipe: Recipe): Promise<any> {
return await this.recipeResource.save(recipe).toPromise();
}
async remove(recipe: Recipe): Promise<any> {
await this.recipeResource.remove(recipe.id, recipe.version).toPromise();
const index = this.recipeList.findIndex(v => v.id === recipe.id);
if (index !== -1) {
this.recipeList.splice(index, 1);
}
}
// ここまで追加箇所
:
}
##レシピ編集画面
レシピ編集画面を修正します。
修正箇所が多いので、すべてのソースコードを載せています。
import { MaterialService } from './../../services/material.service';
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { NgForm } from '@angular/forms';
import { LoadingController } from '@ionic/angular';
import { Recipe, Material } from '@kajitool/kajitool-api';
import { RecipeService } from 'src/app/services/recipe.service';
@Component({
selector: 'app-edit-recipe',
templateUrl: './edit-recipe.page.html',
styleUrls: ['./edit-recipe.page.scss'],
})
export class EditRecipePage implements OnInit {
id: string;
isNew: boolean;
recipe: Recipe;
materials: Material[] = [];
constructor(
private route: ActivatedRoute,
private router: Router,
private loadingController: LoadingController,
public materialService: MaterialService,
public recipeService: RecipeService
) {
this.recipe = this.recipeService.newRecipe();
}
async ngOnInit() {
this.id = this.route.snapshot.paramMap.get('id');
this.isNew = this.id === 'new';
if (!this.isNew) {
this.recipe = await this.recipeService.get(Number(this.id));
}
this.materials = this.materialService.getMateials();
}
onAddMaterial() {
this.recipe.recipeDetails.push({
quantity: 1
});
}
onRemoveMaterial(i: number) {
this.recipe.recipeDetails.splice(i, 1);
}
canRegist(recipeForm: NgForm): boolean {
return !recipeForm.invalid && this.recipe.recipeDetails.length > 0;
}
async onCreate() {
const loading = await this.loadingController.create({
message: 'processing...'
});
await loading.present();
try {
await this.recipeService.create(this.recipe);
await this.recipeService.init();
} finally {
await loading.dismiss();
}
this.router.navigate(['/home']);
}
async onSave() {
const loading = await this.loadingController.create({
message: 'processing...'
});
await loading.present();
try {
await this.recipeService.save(this.recipe);
await this.recipeService.init();
} finally {
await loading.dismiss();
}
this.router.navigate(['/home']);
}
async onRemove() {
const loading = await this.loadingController.create({
message: 'processing...'
});
await loading.present();
try {
await this.recipeService.remove(this.recipe);
await this.recipeService.init();
} finally {
await loading.dismiss();
}
this.router.navigate(['/home']);
}
}
レシピ編集画面のテンプレートを修正します。
修正箇所が多いので、すべてのソースコードを載せています。
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="home"></ion-back-button>
</ion-buttons>
<ion-title>レシピ編集</ion-title>
</ion-toolbar>
</ion-header>
<ion-content padding>
<form ngForm #recipeForm="ngForm">
<ion-list>
<ion-item>
<ion-label>
武器名
</ion-label>
<ion-input
name="recipeName"
type="text"
placeholder="Input text"
[(ngModel)]="recipe.name"
required
></ion-input>
</ion-item>
</ion-list>
<ion-list>
<ion-item-group *ngFor="let detail of recipe.recipeDetails; index as i">
<ion-item-divider>
<ion-label> {{ i + 1 }}つめの素材 </ion-label>
<ion-button slot="end" fill="clear" (click)="onRemoveMaterial(i)">
<ion-icon name="trash"></ion-icon>
</ion-button>
</ion-item-divider>
<ion-item>
<ion-label>
素材名
</ion-label>
<ion-select
[name]="'materialId-' + i"
placeholder="Select one"
[(ngModel)]="detail.materialId"
required>
<ng-container *ngFor="let material of materials">
<ion-select-option
[value]="material.id"
[selected]="material.id === detail.materialId">
{{material.name}}
</ion-select-option>
</ng-container>
</ion-select>
</ion-item>
<ion-item>
<ion-label>
数量
</ion-label>
<ion-input
[name]="'quantity-' + i"
type="number"
inputmode="numeric"
class="text-right"
placeholder="Input number"
[(ngModel)]="detail.quantity"
required
min="1"
max="99"
></ion-input>
</ion-item>
</ion-item-group>
</ion-list>
</form>
<ion-toolbar>
<ion-buttons slot="end">
<ion-button color="primary" (click)="onAddMaterial()">
<ion-icon name="add"></ion-icon>
素材を追加
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-buttons slot="end">
<ion-button
*ngIf="this.isNew"
color="primary"
[disabled]="!canRegist(recipeForm)"
(click)="onCreate()">
<ion-icon name="add"></ion-icon>
レシピを追加
</ion-button>
<ion-button
*ngIf="!this.isNew"
color="primary"
[disabled]="!canRegist(recipeForm)"
(click)="onSave()">
<ion-icon name="add"></ion-icon>
レシピを更新
</ion-button>
<ion-button
*ngIf="!this.isNew"
color="danger"
(click)="onRemove()">
<ion-icon name="remove"></ion-icon>
レシピを削除
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-footer>
scssも修正しましょう!
.text-right {
text-align: right;
}
では、実際に起動しましょう。kajitool-web
を起動させた後に、次のコマンドで開発用サーバを起動してください。
kajitool-handson\kajitool-ui> npm run serve
画面が起動して、レシピが追加・編集・削除できることを確認しましょう!
ここまで出来たら、コミットしましょう!
kajitool-handson\kajitool-ui> git add .
kajitool-handson\kajitool-ui> git commit -m "edit recipe page"
#本番ビルド
最後に、本番ビルドの方法です。
ng build
で--prod=true
を指定することで、ビルド構成をプロダクションビルドに設定出来ます。プロダクションビルドは時間がかかりますが、最適化が施されて性能面が改善できます。アプリをリリースする際は、必ずprod=trueを有効にしましょう!
また、--outputPath
で出力先をkajitool-web
の静的Webコンテンツのフォルダを指定しています。これにより、kajitool-web
のコンテンツとして含めることが出来るようになります。
"scripts": {
:
"build:prod": "ng build --prod=true --outputPath=../kajitool-web/src/main/resources/public",
:
#まとめ
2回に分けて、Spring Boot 2とAngular(Ionic)という技術を用いてハンズオンを実施してきました。
如何でしょうか?かなりのボリュームがあったかと思います。
今回のハンズオンで作成したアプリは、簡易な要件にも関わらず、これだけのボリュームがありました。それでもなお、アプリにはいくつもの不具合があり(ハンズオンでは無視しました)、テストもされておらず、ビルドの自動化やデプロイ、運用後に必要な様々な機能(ログ出力すらない)も考慮されていません。
実際のシステム開発は、この何十、何百倍ものボリュームがあり、かつ複雑性も比ではありません。
今気づいたけど、本当システム開発って難しすぎますね!!
実際のプロジェクトでは、このようなシステムの開発にチームで取り組む必要があります。ほとんどのプロジェクトにおいて、充分な開発期間、チームメンバーのスキル、予算、ステークホルダーの理解、自身のスキルと、その全てが完全に揃うことはないでしょう。完璧なプロジェクトなどなく、全てのプロジェクトで有効な手法もありません。そのような中、システムアーキテクトはこれらの前提条件の中で、最適解を模索し、開発リーダーとしてプロジェクトを引っ張っていく責務があります。
外部環境をコントロールすることは難しいですが、自身のスキルはそれに比べるとコントロールしやすいです。
このハンズオンが、皆さんの様々な技術スキル習得のきっかけになれば幸いです。
長いハンズオン、おつかれさまでした!! 🍻(´∀`*v)