290
329

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

SpringBoot+Vue.js+ElementUI+Firebaseでマスタ管理アプリ入門

Last updated at Posted at 2018-07-29

この記事でできるようになること

  • SpringBootでCRUD操作可能なREST APIを開発(20分)
  • Vue.js+ElementUIでモダンなSPA開発(20分)
  • Firebaseで認証機能の作成(10分)

Untitled Diagram-Page-2.png

Webアプリをつくってみて全体像のイメージをつかもう

この記事は、Web開発初心者向けに書かれています。誰でも簡単に、1から本格的なWebアプリを開発できるようになることが本記事の目的です。

Webアプリというと、フロントエンド、サーバサイド、DBやネットワークなど、本格的に運用するには様々な知識が必要になりますが、まずは手を動かして1から作ってみることで、Webアプリの全体イメージを掴んでもらうことが大事だと思っています。

今の時代はツールが整ってきていて、小さなWebアプリケーションなら誰でも簡単につくれるということを実感していただければ嬉しいです。

この記事では簡単な画面でCRUDを実現することまでとし、知識をさらに深掘りしていく応用編や発展編は別の記事で補足していこうと思います。

この記事がきっかけでアプリ開発のスタートダッシュを切ってもらえたら嬉しいです。

補足知識が必要な箇所は「:pencil:」マークでリンクを記載しております。分からない場合はリンク先を参照の上読み進めてもらうと理解が深まるかと思います。

技術スタック

以下のツール、フレームワーク、ライブラリを使用します。
基本的な部分は説明を飛ばしているため、納得いかない部分はコメントいただけると助かります。

  • SpringBoot2
  • Java8
  • MySQL5.7
  • Vue.js2系
  • ElementUI2.4
  • Firebase

ステップとしては、

:one: サーバサイドAPIの作成
:two: フロント画面の作成
:three: 認証機構の作成
:four: まとめ

といった流れで進めていきます。

※実行環境はローカル端末のMacbookProです。IDEとして、SpringBootはIntellijIDEA、Vue.jsはWebStormを使用していますが、他のIDEでも開発できるようIDE固有の説明はなるべく記載しないようにしています。

サーバサイド

サーバサイドAPIの作成

サーバサイドでは、DBと接続してデータの読み込みや書き込みを行います。言語はJavaを使用し、フレームワークは現在Javaで一番メジャーなSpringBootを選定して実装してみます。

Java

今回作成するのは簡単なAPIの作成のみですので、個人的にJavaである必要はないと思いますが、かっちりとしたシステムのサーバサイドを開発する場合、特に大規模開発になるほど、Javaプロジェクトは今もまだまだ多いようです。ちなみに、言語別年収ランキング2018ではとうとう10位圏外となってしまいました。。

:pencil:「プログラミング言語別年収ランキング2018」

2018年の9月にはJava11が登場予定ですが、今回は安定版であるJava8を使用していきます。

SpringBoot

Spring Bootとは 高速にシステムを開発するというコンセプトで生まれたJavaのフレームワークです。

Springは重厚ですがよくできたフレームワークなので、設計思想を学ぶ目的で触れてみる価値は十分にあります。

:pencil: さくっと理解するSpringBootの仕組み

SpringBootは2.0からKotlinSupportが導入されたため、Javaの代替としてKotlinでも開発可能です。Intellijの場合は、JavaファイルをKotlinファイルへワンクリックで変換することが可能です。(僕はKotlinとGo推しです。)

:pencil: 8年運用しているサービスのサーバーサイドにKotlinを導入した件

SpringInitializer

雛形の作成

まずは、SpringBoot開発をする上でテンプレートとなるZipをインストールしましょう。SpringInitializerという雛形をダウンロードできるサイトがあるので、ここで必要なプラグインをチェックして雛形を作成します。(IntellijIDEAの有料版をお使いの方はIntellijの中でも作成できます。)

プロジェクト名は星座名から、ふたご座geminiを使って「gemini-api」とします。

※ちなみに、自分はかに座です。

FireShot Capture 54 - Spring Initializr - https___start.spring.io_.png

まずは、一番上のセレクトボックスで、Gradle Project / Java を選択してください。バージョンは2.0以降であれば基本的に問題ありません。Mavenでも構いませんが、以降はGradleを前提に操作していきますので、特にこだわりがなければGradleを選択することをオススメします。

Gradle

Gradleは、進化系のビルド自動化ツールです。Gradleは、ソフトウェアパッケージもちろん、その他様々な形式のプロジェクト(例えば自動生成された静的Webサイトやドキュメント等)のビルド・テスト・(ライブラリ等の)公開・デプロイ・その他を自動化します。

:pencil: Gradle入門
:pencil: Gradle (build.gradle) 読み書き入門

「Dependencies」からは、たくさんの依存関係が追加できますが、ひとまず上記5個(Lombok,Web,JPA,MySQL,Flyway)を選択してプロジェクトを作成しましょう。

Web: Spring MVCを使用するため選択。Spring MVCの代替案にWebFluxがある。

JPA: Spring Data JPAを使用するため選択。

Lombok: GetterやConstructorなど、定型記載を省略するために使用。

Flyway: DBマイグレーションツール。

MySQL: 一般的に使用されているRDB。

WebはSpring MVCを利用するために使用します。Spring MVCやSpring Bootなど、似た名前が出てきて初学者は混乱しがちですが、要はSpring MVCやSpring Data JPAなどの各種Springライブラリを、使いやすくラップしたものがSpringBootというイメージです。

Spring MVCは,Webアプリケーションを簡単に作るための機能を提供します。
Spring DATA JPAは、JPAの機能をベースに 汎用的な Repositoryの機能を提供します。

:pencil: Spring MVCの概要を理解する

※ Lombok,Flyway,JPAは下の方で説明を追加していますのでここでは読み流してください。

ダウンロードしたZipを任意のフォルダで展開し、IntellijIDEAから「Import project from external model」でGradleを選択します。※もちろんEclipseなど他のIDEを用いても構いません。IDEは好みです。

:pencil: 統合開発環境(IDE)はどれを使えば善いか?(独断と偏見の遥か彼方)

起動してみる

雛形テンプレートのSpringBootアプリを実行してみましょう。Gradleを使用しているので、プロジェクトのルートから./gradlew bootRunのコマンドを実行することで、アプリが実行されます。(Windowsの場合はgradlew.bat bootRun)

...見事に起動が失敗しますね。

エラーを見てみると、Datasourceが設定されていないと怒られています。接続先のDBを設定していないので当たり前ですね。

Failed to configure a DataSource: 'url' attribute is not specified and no embedded datasource could be configured.

MySQL

MySQLとは、世界でもっとも普及しているオープンソースデータベース(RDBMS)の1つです。

RDBの設定

MySQLを使ってローカルにDBを準備しましょう。MySQLのインストール方法はわかりやすい記事がたくさんあるので、こちらの記事等を参考にしてください。

:pencil: MacでMySQLのインストール
:pencil: WindowsでMySQLのインストール

※MacでMySQLが突如起動できなくなる場合は以下の問題が発生している可能性があります。

:pencil: 備忘録:MySQLが起動できない場合

MySQLがインストールできたら、以下の設定値でスキーマ作成を進めます。
個人的にはコマンドでスキーマを作成するのが面倒なので、GUIのMySQL Workbenchを利用しています。

:pencil: 便利な公式ツールMySQL Workbenchの使い方と日本語化方法

※実際に利用するパスワードは適切に設定の上、大事に管理してください。

username: root
password: mysql
scheme: springboot-flyway

YAML

YAML とは、構造化されたデータを表現するためのフォーマットです。設定ファイルを書くときやデータの保存をするときによく使われます。

SpringBootでDBの接続先情報はプロパティファイルに記載していきます。
src/main/resources/下にあるapplication.propertiesファイルをリネームし、application.ymlに変更して以下の記載を追加してください。

application.yml
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/springboot-flyway?autoReconnect=true&useSSL=false
    username: root
    password: mysql
    driver-class-name: com.mysql.jdbc.Driver

※properiesファイルでも問題なく動作しますが、近年の流れからymlで作成することをオススメします。

:pencil: YAMLとは何か?

Flyway

Flyway は、オープンソースのデータベースマイグレーションツールです。Flyway を使うことで、データベースの状態をバージョン管理できるようになります。

Flywayマイグレーションの設定

さらに、Flywayの設定を追加します。
FlywayはDBのマイグレーションツールで、テーブルの作成やデータの挿入をアプリ起動時に自動化してくれます。

:pencil: Spring Bootでflywayを使ってみた。

デフォルトのパスとしてsrc/main/resources/db/migration/が設定されているので、db/migration/V1__create_table.sqlファイルを作成してあげます。中身は一旦空で問題ありません。

V1__create_table.sql
-- 中身は空でOK

また、Flywayの実行戦略を設定してあげたいので、Javaファイルで少しだけ記述を追加してあげます。

新たにconfigフォルダを作成し、FlywayConfig.javaを作成します。現時点でのフォルダ構成は以下のようになります。

スクリーンショット 2018-07-07 20.09.33.png
FlywayConfig.java
package shunp.geminiapi.config;

import org.springframework.boot.autoconfigure.flyway.FlywayMigrationStrategy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class FlywayConfig {

    @Bean
    public FlywayMigrationStrategy strategy() {
        return flyway -> {
            // flyway_schema_historyの初期化
            flyway.clean();
            // マイグレーション実行
            flyway.migrate();
        };
    }
}

※注意

アプリケーション実行時に以下のエラーが発生する場合は、build.gradleファイルに一行追加して、./gradlewコマンドでビルドしましょう。

:pencil: How to resolve java.lang.NoClassDefFoundError: javax/xml/bind/JAXBException in Java 9

Invocation of init method failed; nested exception is java.lang.NoClassDefFoundError: javax/xml/bind/JAXBException
build.gradle
dependencies {
  ...
  compile('javax.xml.bind:jaxb-api:2.3.0')
}

この状態で再度実行してみます。プロジェクトルートから./gradlew bootRunを実行すると、以下のようなメッセージがコマンドラインに表示されます。Started {アプリケーション名}が表示されれば成功です。

2018-07-07 20:05:58.306  INFO 3005 --- [           main] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped URL path [/**] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
2018-07-07 20:05:58.631  INFO 3005 --- [           main] o.s.j.e.a.AnnotationMBeanExporter        : Registering beans for JMX exposure on startup
2018-07-07 20:05:58.633  INFO 3005 --- [           main] o.s.j.e.a.AnnotationMBeanExporter        : Bean with name 'dataSource' has been autodetected for JMX exposure
2018-07-07 20:05:58.639  INFO 3005 --- [           main] o.s.j.e.a.AnnotationMBeanExporter        : Located MBean 'dataSource': registering with JMX server as MBean [com.zaxxer.hikari:name=dataSource,type=HikariDataSource]
2018-07-07 20:05:58.694  INFO 3005 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2018-07-07 20:05:58.700  INFO 3005 --- [           main] shunp.geminiapi.GeminiApiApplication     : Started GeminiApiApplication in 5.674 seconds (JVM running for 6.173)

Entity

エンティティとは、一意なものを表現する概念です。

DDLの作成

仮想通貨情報を登録するマスタテーブルを用意します。

まず、V1__create_table.sqlのファイルにDDLを追加します。

V1__create_table.sql
-- テーブルが重複しないよう存在チェック、あれば削除します
DROP TABLE IF EXISTS currency;

-- 簡易なマスタテーブル
CREATE TABLE `currency` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `name` VARCHAR(64) NOT NULL,
  `symbol` VARCHAR(10) NOT NULL,
  `amount` DECIMAL(40, 20),
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

Entityクラスの作成

このテーブルと1対1で対応するのがCurrency.javaクラスで記載されたModelです。domainパッケージをconfigと同階層に作成して、以下のJavaファイルを作成しましょう。

Currency.java

package shunp.geminiapi.domain;

import lombok.Getter;
import lombok.Setter;

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

@Entity
@Setter
@Getter
public class Currency {

    /** 自動採番ID */
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    /** 仮想通貨名 */
    private String name;

    /** シンボル */
    private String symbol;

    /** 数量 */
    private BigDecimal amount;
}


Lombok

Lombokとは、Java特有の冗長なコードを簡潔にしてくれるライブラリです。

このプロジェクトではLombokを使用することで、記述コストを下げています。例えば、あるプライベート変数aには通常getterとsetterを記述しなければなりませんが、Lombokを使用することによりアノテーションのみで簡略化しています。

:pencil: Lombok 使い方メモ

JPA

 JPA(Java Persistence API)とはオブジェクトの世界からリレーショナルの世界へ、あるいはその逆への変換を行うためのAPIです。JPA 実装として代表的なのが Hibernate。最近だと EclipseLink も人気があります。

また、@Entity@IdはJPAのアノテーションで、RDBのテーブルとJavaのクラスをマッピングしています。

:pencil: JPA関連アノテーションの基本として-その1-

Repository

Repositoryは、Entityのライフサイクルを制御するための操作を提供します。DAOはRepositoryに対する実装です。

Repositoryインタフェースの作成

このEntityを取得するインタフェースがRepositoryです。

Currencyを操作するRepositoryであるため、CurrencyRepositoryとして作成してあげます。同domainパッケージに以下ファイルも作成してください。

CurrencyRepository.java
package shunp.geminiapi.domain;

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

@Repository
public interface CurrencyRepository extends JpaRepository<Currency, Long> {
}

ここまでで、DB操作の準備が整いました。

この時点でのフォルダ構成は以下のようになります。

スクリーンショット 2018-07-07 20.47.57.png

Service

Serviceは、トランザクション境界となる業務ロジックを提供します。

Repositoryを呼び出す、Service層を作成していきます。CurrencyRepositoryが継承しているJpaRepositoryfindAll()を呼び出すことで、Currencyの全データを取得することができます。

CurrencyService.java

package shunp.geminiapi.service;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import shunp.geminiapi.domain.Currency;
import shunp.geminiapi.domain.CurrencyRepository;

import java.util.List;

@Service
@RequiredArgsConstructor
public class CurrencyService {

    private final CurrencyRepository currencyRepository;

    public List<Currency> findAll() {
        return currencyRepository.findAll();
    }
}

このクラスは以下の階層に作成してあげましょう。

スクリーンショット 2018-07-07 20.55.48.png

このように、Spring Data JPAを使用することで、SQLを書かずにデータアクセスすることが可能です。

:pencil: Spring Data JPAによるデータアクセス徹底入門

REST API

REST APIでは、URL/URIですべてのリソースを一意に識別し、セッション管理や状態管理などを行わないません。同じURLに対する呼び出しには常に同じ結果が返されることが期待しています。

ここからREST APIのエンドポイントを作成していきます。

RestControllerの作成

Serviceクラスを呼び出すことで、仮想通貨情報全量を返すControllerを作成しましょう。Currencyの一覧をそのまま返すのではなく、Responseクラスを作成することでレイヤを切り離し保守性を高めます。

今回はフロントへリソースを返すだけのREST APIを作成するため、Controllerクラスに@RestControllerを付けてあげましょう。

:pencil: 0からREST APIについて調べてみた

CurrencyController.java

package shunp.geminiapi.controller;

import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import shunp.geminiapi.domain.Currency;
import shunp.geminiapi.service.CurrencyService;

import java.util.List;

@RestController
@RequiredArgsConstructor
public class CurrencyController {

    private final CurrencyService currencyService;

    @GetMapping("/")
    public ResponseEntity<CurrencyResponse> findAll() {
        List<Currency> currencies = currencyService.findAll();
        CurrencyResponse currencyResponse = CurrencyResponse.builder()
                .currencies(currencies)
                .build();
        return new ResponseEntity<>(currencyResponse, HttpStatus.OK);
    }
}

CurrencyResponse.java

package shunp.geminiapi.controller;

import lombok.Builder;
import lombok.Getter;
import shunp.geminiapi.domain.Currency;

import java.util.List;

@Getter
@Builder
public class CurrencyResponse {

    private List<Currency> currencies;
}

新たにcontrollerパッケージを追加して以下のような構成になりました。

スクリーンショット 2018-07-07 21.16.23.png

APIをコールしてみる

http://localhost:8080を実行すると、空のオブジェクトが返ってきます。

レスポンス結果
{"currencies":[]}

まだ何も登録していないので、リストが0件なのは当然です。

初期データを追加

初期データをFlywayで作成するため、V2__insert_data.sqlを作成します。

V2__insert_data.sql
INSERT INTO currency VALUES (10001, 'Bitcoin', 'BTC', 0);
INSERT INTO currency VALUES (10002, 'Ethereum', 'ETH', 0);
レスポンス結果
{
	"currencies": [
		{
			"id": 10001,
			"name": "Bitcoin",
			"symbol": "BTC",
			"amount": 0
		},
		{
			"id": 10002,
			"name": "Ethereum",
			"symbol": "ETH",
			"amount": 0
		}
	]
}

V2ファイルはV1と同じ階層に作成しています。

スクリーンショット 2018-07-07 21.29.58.png

ControllerでRepositoryを読んではいけない?

今回の例のようにシンプルなアプリの場合は、ControllerからServiceを呼び出さずに、直接Repositoryを呼ぶ方が早いです。しかし、Springではレイヤ毎に責務を設けています。

Serviceという層をTransaction単位にして、単純な処理でも統一的にServiceを呼び出すと決めているチームが多いようです。チームの開発方針によってControllerからRepositoryが呼ばれることも全然あり得ます。

:pencil: Spring での責務についてまず見てほしい一枚の絵

クライアントサイド

フロント画面を作成する

サーバサイドAPIが出来上がったので、APIをコールする画面を作成していきましょう。

NPM

NPMとは、Node.jsのパッケージ(Package )を管理する(Manager)ツールです。Node.jsのパッケージ(Package)とは、予め用意された便利な機能をまとめたものです。

npmのバージョンは6.1.0を使用しています。npmは今やWeb開発に必須と言っても過言ではないくらいよく使われるため、まだインストールしたことのない人はこの機会に学んでおきましょう。

:pencil: 便利なパッケージ管理ツール!npmとは【初心者向け】

Vue.js

Vue.jsとは、クライアントサイドJavascriptのフレームワークです。

Vueプロジェクトの作成

Vue.jsを手軽に扱うため、まずはvue-cliをインストールしましょう。

:pencil: 【VueCLIで出てくるファイルを概要図で理解】

vue-cliのインストール
$ npm install -g @vue/cli

次に下記コマンドで、Vueプロジェクトを作成します。

Vueプロジェクトの作成
$ vue create gemini-client

設定は基本的にデフォルトで問題ありませんが、今回はvue-routerを必ず選択してください。
選択した内容は以下の通りです。

vue-create
Vue CLI v3.0.1
? Please pick a preset: Manually select features
? Check the features needed for your project: 
 ◉ Babel
 ◯ TypeScript
 ◯ Progressive Web App (PWA) Support
 ◉ Router
 ◉ Vuex
 ◉ CSS Pre-processors
 ◉ Linter / Formatter
❯◯ Unit Testing
 ◯ E2E Testing
vue-create
Vue CLI v3.0.1
? Please pick a preset: Manually select features
? Check the features needed for your project: Babel, Router, Vuex, CSS Pre-proce
ssors, Linter
? Use history mode for router? (Requires proper server setup for index fallback 
in production) Yes
? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported 
by default): SCSS/SASS
? Pick a linter / formatter config: Basic
? Pick additional lint features: Lint on save
? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? In packag
e.json
? Save this as a preset for future projects? No

それでは、Vueプロジェクトを起動してみましょう。

Vueプロジェクトの起動
$ cd gemini-client
$ npm run serve

:pencil: Vue-cli(webpack)解剖 ーディレクトリ構成ー

https://localhost:8080 にアクセスすると、Vueのサンプル画面が表示されます。

なぜVue.jsなのか

Reactの人気も強いですが、小規模で作る場合は覚えることが少なく扱いやすいVue.jsが個人的におすすめです。小規模から大規模まで、プロダクトとしての採用実績も最近は増えてきています。

:pencil: なぜプロダクトに Vue.js を採用したのか? 運用してみてどうっだった? という話

ElementUI

ElementUIとは、Vue.js 2.0ベースのコンポーネントライブラリです。便利で高機能なUIコンポーネントがたくさん含まれています。

ElementUIのインストール

コンポーネントライブラリとしてElementUIをインストールします。ElementUIを使うことで、便利なUIコンポーネントを簡単に導入できます。

ElementUIのインストール
$ npm install --save element-ui

ElementUIをインストールしたら、main.jsでElementUIを使用できるように設定しましょう。下記設定を追加することで、<el>タグが使えるようになります。

main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'

import ElementUI from 'element-ui' // 追記
import locale from 'element-ui/lib/locale/lang/ja' // 追記
import 'element-ui/lib/theme-chalk/index.css' // 追記

Vue.config.productionTip = false
Vue.use(ElementUI, {locale}) // 追記

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app')

:pencil: ElementUI公式ページ

SPA

シングルページアプリケーション(以下SPA)とは,1つのHTMLをロードして,ユーザーインタラクションに応じて動的にページを更新するWebアプリケーションです。通常のWebアプリケーションでは,ページ遷移時にサーバへアクセスしコンテンツをロードしますが,SPAではページ遷移をクライアントサイドで行います。

vue-routerによるページ遷移

/で表示されるデフォルトのページから、Currencyページへ遷移してみます。
/currencyをrouter.jsに追加しましょう。

router.js
import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue'

Vue.use(Router)

export default new Router({
  mode: 'history',
  base: process.env.BASE_URL,
  routes: [
    {
      path: '/',
      name: 'home',
      component: Home
    },
    {
      path: '/about',
      name: 'about',
      // route level code-splitting
      // this generates a separate chunk (about.[hash].js) for this route
      // which is lazy-loaded when the route is visited.
      component: () => import(/* webpackChunkName: "about" */ './views/About.vue')
    },
    // ここを追加
    {
      path: '/currency',
      name: 'currency',
      component: () => import(/* webpackChunkName: "currency" */ './views/Currency.vue')
    }
  ]
})

ここで追加したCurrency.vueはまだ存在しないので、viewsフォルダ以下に作成していきます。

Currency.vue
<template>
    <el-row>
        <el-col :span="24">
            <el-card class="box-card">
                <div slot="header" class="clearfix">
                    <span>仮想通貨一覧</span>
                </div>
                <div>...</div>
            </el-card>
        </el-col>
    </el-row>
</template>

<script>
  export default {
    name: "Currency"
  }
</script>

<style scoped>

</style>

ここまでで画面を表示してみましょう。http://localhost:8080/currencyをブラウザに入力すると、以下のようなシンプルな画面が表示されます。

FireShot Capture 76 - gemini-client-2 - http___localhost_8081_currency.png

:pencil: Vue-routerを使って、SPAをシンプルにはじめてみる

ここまでで、プロジェクト構成は次のようになっています。

スクリーンショット 2018-08-18 12.52.04.png

axios

axiosは、HTTP通信を簡単に行うことができるJavascriptライブラリです。

サーバサイドAPIからデータを取得する

APIから取得したデータをテーブルに一覧で表示してみましょう。
サーバサイドからのデータ取得にはaxiosを使用します。

axiosはRESTを扱う上でよく使われているライブラリです。

axiosの追加
$ npm install --save axios

:pencil: axios の導入と簡単な使い方

Currency.vueが表示された時、サーバサイドからデータを取得できるようにします。createdはページがレンダリングされるときにフックされるため、ここでaxiosを使用してAPIをコールします。先ほどSpringBootで作成したAPIを呼び出しましょう。
※SpringBootは起動しておいてください。

Currency.vue
...
<script>
  /* eslint-disable no-console */

  import axios from 'axios'

  export default {
    name: "Currency",
    data () {
      return {
        currencies: []
      }
    },
    created: async function () {
      await this.refresh()
    },
    methods: {
      refresh: async function () {
        const res = await axios.get('http://localhost:8080/')
        this.currencies = res.data.currencies
        console.info(this.currencies)
      }
    }
  }
</script>
...

async/await

async/awaitとは、単なる同期的な処理を書いているように非同期処理を表現することができる手法です。

JavaScriptの記法として、axiosを使う場合はasync/awaitを使用できるようになりましょう。

:pencil: async/awaitを使ったモダンな非同期処理

CORS

CORS(Cross-Origin Resource Sharing)は、その名の通り、ブラウザがオリジン(HTMLを読み込んだサーバのこと)以外のサーバからデータを取得する仕組みです。

しかし、この時点では以下のようなエラーがコンソールに出力されます。

コンソールエラー内容
Failed to load http://localhost:20000/: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://localhost:8080' is therefore not allowed access.

今回はこのCORSエラーをサーバサイド側で対応していきます。SpringBootに以下の設定を追加してあげましょう。

サーバサイドのconfigファルダ下に以下のファイルを作成します。

WebMvcConfig.java
package shunp.geminiapi.config;

import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Bean
    public FilterRegistrationBean corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOrigin(CorsConfiguration.ALL);
        config.addAllowedHeader(CorsConfiguration.ALL);
        config.addAllowedMethod(CorsConfiguration.ALL);
        source.registerCorsConfiguration("/**", config);
        FilterRegistrationBean bean = new FilterRegistrationBean(new CorsFilter(source));
        bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
        return bean;
    }
}

SpringBootを再起動し、もう一度/currencyを表示してみます。

スクリーンショット 2018-08-18 13.14.19.png

V2ファイルで仕込んだ2件のデータが取得できています。

:pencil: CORS(Cross-Origin Resource Sharing)について整理してみた

画面に取得データを表示する

サーバサイドから取得してデータを画面に表示していきます。

Currency.vue
<template>
    <el-row>
        <el-col :span="24">
            <el-card class="box-card">
                <div slot="header" class="clearfix">
                    <span>仮想通貨一覧</span>
                </div>
                <el-table
                        :data="currencies"
                        style="width: 100%">
                    <el-table-column
                            prop="id"
                            label="通貨ID"
                            width="300"/>
                    <el-table-column
                            prop="name"
                            label="通貨名"
                            width="300"/>
                    <el-table-column
                            prop="symbol"
                            label="通貨単位"
                            width="300"/>
                    <el-table-column
                            prop="amount"
                            label="数量"
                            width="300"/>
                </el-table>
            </el-card>
        </el-col>
    </el-row>
</template>
...

画面を確認しましょう。

FireShot Capture 77 - gemini-client-2 - http___localhost_8081_currency.png

マスタとして登録された2件のデータが表示されていることがわかります。

画面からデータを追加する

画面から新たな通貨を追加できるように拡張していきましょう。

まず、追加ボタンを表示します。入力フォームと合わせて、以下のように追加してください。

Currency.vue
<template>
    <el-row>
        <el-col :span="24">
            <el-card class="box-card">
                <el-col :span="8">
                    <el-input
                            v-model="request.name"
                            placeholder="New Name..."
                            clearable>
                    </el-input>
                </el-col>
                <el-col :span="8">
                    <el-input
                            v-model="request.symbol"
                            placeholder="New Symbol..."
                            clearable>
                    </el-input>
                </el-col>
                <el-col :span="8">
                    <el-button
                            type="success"
                            @click="addCurrency">追加</el-button>
                </el-col>
            </el-card>
        </el-col>
        <el-col :span="24">
            <el-card class="box-card">
                <div slot="header" class="clearfix">
                    <span>仮想通貨一覧</span>
                </div>
                <el-table
                        :data="currencies"
                        style="width: 100%">
                    <el-table-column
                            prop="id"
                            label="通貨ID"
                            width="300"/>
                    <el-table-column
                            prop="name"
                            label="通貨名"
                            width="300"/>
                    <el-table-column
                            prop="symbol"
                            label="通貨単位"
                            width="300"/>
                    <el-table-column
                            prop="amount"
                            label="数量"
                            width="300"/>
                </el-table>
            </el-card>
        </el-col>
    </el-row>
</template>

<script>
  /* eslint-disable no-console */

  import axios from 'axios'

  export default {
    name: "Currency",
    data () {
      return {
        request: {
          name: undefined,
          symbol: undefined
        },
        currencies: []
      }
    },
    created: async function () {
      await this.refresh()
    },
    methods: {
      refresh: async function () {
        const res = await axios.get('http://localhost:8080/')
        this.currencies = res.data.currencies
        console.info(this.currencies)
      },
      addCurrency: async function () {
        await axios.post('http://localhost:8080/', this.request)
        await this.refresh()
      },
    }
  }
</script>

<style scoped>

</style>

見た目はこのような感じに仕上がります。

FireShot Capture 79 - gemini-client-2 - http___localhost_8081_currency.png

サーバサイドにも通貨追加用のAPIを作成します。

CurrencyController.java
...
    @PostMapping("/")
    public ResponseEntity<HttpStatus> save(@RequestBody CurrencyAddRequest request) {
        currencyService.save(request.getName(), request.getSymbol());
        return new ResponseEntity<>(HttpStatus.CREATED);
    }
...
CurrencyService.java
...
    public Currency save(String name, String symbol) {
        return currencyRepository.save(Currency.newCurrency(name, symbol));
    }
...
CurrencyAddRequest.java

package shunp.geminiapi.controller;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class CurrencyAddRequest {

    private String name;

    private String symbol;
}

Currency.java
...
    public static Currency newCurrency(String name, String symbol) {
        Currency currency = new Currency();
        currency.name = name;
        currency.symbol = symbol;
        currency.amount = BigDecimal.ZERO;
        return currency;
    }
...

nameに「Ripple」、symbolに「XRP」を入力して追加ボタンを押すと、以下のようにレコードが追加されました。

FireShot Capture 80 - gemini-client-2 - http___localhost_8081_currency.png

削除ボタンの追加

追加された情報をレコード単位で削除できるようにします。サーバサイド側に以下のAPIを追加しましょう。

CurrencyController.java
...
    @DeleteMapping("/{id}")
    public ResponseEntity<HttpStatus> delete(@PathVariable Long id) {
        currencyService.delete(id);
        return new ResponseEntity<>(HttpStatus.NO_CONTENT);
    }
...
CurrencyService.java
...
    public void delete(Long id) {
        currencyRepository.findById(id).ifPresent(currency -> currencyRepository.delete(currency));
    }
...

続いて、画面側のテーブルに「オペレーション」カラムと削除ボタンを追加します。

Currency.vue
...
                <el-table
                        :data="currencies"
                        style="width: 100%">
                    <el-table-column
                            prop="id"
                            label="通貨ID"
                            width="200"/>
                    <el-table-column
                            prop="name"
                            label="通貨名"
                            width="200"/>
                    <el-table-column
                            prop="symbol"
                            label="通貨単位"
                            width="200"/>
                    <el-table-column
                            prop="amount"
                            label="数量"
                            width="200"/>
                    <el-table-column
                            prop="operation"
                            label="Ops"
                            width="200"
                            align="left">
                        <template slot-scope="scope">
                            <el-button
                                    size="mini"
                                    type="danger"
                                    @click="deleteCurrency(scope.row.id)">×</el-button>
                        </template>
                    </el-table-column>
                </el-table>
...
Currency.vue
<script>
...
    deleteCurrency: async function (id) {
      await axios.delete('http://localhost:8080/' + id)
      await this.refresh()
    },
...
</script>

Sass

SassとはCSSを書きやすくしたメタ言語です。

Sassの記法にはSASSとSCSSの2種類が存在しますが、一般的にSCSS記法の方が広く普及しています。

:pencil: SassとSASSとSCSSの違いについて

:pencil: これからはcssはSassで書こう。

スタイルの追加

ここで、雑だった見た目を少し整えていきましょう。styles以下にCSSを追加していきます。今回は.scssファイルで作成しましょう。

styles/base.scss
.float-left {
    float: left;
}
.row-wrapper {
    margin-bottom: 20px;
}
.box-card-wrapper {
    margin-bottom: 20px;
}

作成したファイルは下記のようにインポートできます。

Currency.vue
...
<style scoped lang="scss">
    @import "../styles/base";
</style>
...

もちろん、外だしせずにCurrency.vueの中で直接CSSを書くことも可能です。

Currency.vue
...
<style scoped lang="scss">
.float-left {
    float: left;
}
.row-wrapper {
    margin-bottom: 20px;
}
.box-card-wrapper {
    margin-bottom: 20px;
}
</style>
...

Vue.jsの素晴らしい点の1つに、CSSが乱雑化しにくいところがあります。カプセル化と似た発想によって生まれています。

:pencil: スコープ付き CSS

それでは、作成したスタイルをDOMに当てていきます。通貨追加のカード部分を整えたいので、<el-card>box-card-wrapperを、<el-row>row-wrapperを当ててみます。

Currency.vue
...
        <el-col :span="24">
            <el-card class="box-card box-card-wrapper">
                <div slot="header">
                    <span>新規通貨追加</span>
                </div>
                <el-row class="row-wrapper">
                    <el-col :span="12">
                        <span>新規通貨名称</span>
                    </el-col>
                    <el-col :span="12">
                        <el-input
                                v-model="request.name"
                                placeholder="New Name..."
                                clearable>
                        </el-input>
                    </el-col>
                </el-row>
                <el-row class="row-wrapper">
                    <el-col :span="12">
                        <span>新規通貨シンボル</span>
                    </el-col>
                    <el-col :span="12">
                        <el-input
                                v-model="request.symbol"
                                placeholder="New Symbol..."
                                clearable>
                        </el-input>
                    </el-col>
                </el-row>
                <el-row class="row-wrapper">
                    <el-col :span="24">
                        <el-button
                                type="success"
                                @click="addCurrency">追加</el-button>
                    </el-col>
                </el-row>
            </el-card>
        </el-col>
...

ここまでで、以下のような見た目に仕上がりました。

FireShot Capture 83 - gemini-client-2 - http___localhost_8081_currency.png

エフェクトの追加

また、追加や削除処理が完了したときに、成功通知を画面に表示してあげましょう。

Currency.vue
<script>
...
      addCurrency: async function () {
        await axios.post('http://localhost:8080/', this.request)
        await this.refresh()
        this.$message({
          showClose: true,
          message: 'Add Currency Success!',
          type: 'success'
        })
      },
      deleteCurrency: async function (id) {
        await axios.delete('http://localhost:8080/' + id)
        await this.refresh()
        this.$message({
          showClose: true,
          message: 'Delete Currency Success!',
          type: 'success'
        })
      },
...
</script>

これを追加することで、登録処理のあと画面上部にメッセージが表示されます。ElementUIの「message」と呼ばれるものですが、こういったライブラリは簡単に使える便利なものが多いので興味があれば公式ページを読み漁ってみてください。

:pencil: ElementUI公式ページ-message

微修正

今の状態だと、新規通貨を追加した後でもフォームに入力文字が残ってしまいます。(上のスナップショットを参照)

refreshメソッドのconsole.infoを削除し、フォームを空にするよう修正してあげましょう。

Currency.vue
<script>
...
      refresh: async function () {
        const res = await axios.get('http://localhost:8080/')
        this.currencies = res.data.currencies
        this.request.name = undefined
        this.request.symbol = undefined
      },
...
</script>

認証機構

認証機構を作成する

Firebaseによる認証機能を追加していきましょう。まず、コンソールからfirebaseをインストールします。

Firebase

Firebaseとは、Webアプリケーションやモバイルアプリケーションのバックエンドで行う機能を提供するクラウドサービスです。BaaS(Backend as a Service)とも呼ばれています。

今回の記事では認証サービスとして利用しますが、次回の記事で紹介するホスティング用のサービスや分析サービスも提供しており、今も次々とサービスが追加されています。最近だと、2018/8/16にも「アプリ内メッセージングツール」等が追加されています。

:pencil: Firebaseの各機能を3行で説明する

Firebaseの準備

Firebaseインストール
$ npm install --save firebase

Firebaseの公式ページより、「プロジェクトの追加」で新しいプロジェクトを作成しましょう。

FireShot Capture 84 - Firebase console - https___console.firebase.google.com__hl=ja.png

左メニューの「Authentication」ページから、右上の「ウェブ設定」を押し、設定値情報を取得します。

これをmain.jsに埋め込んであげましょう。

main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'

import ElementUI from 'element-ui' // 追記
import locale from 'element-ui/lib/locale/lang/ja' // 追記
import 'element-ui/lib/theme-chalk/index.css' // 追記

import firebase from 'firebase' // 追記

Vue.config.productionTip = false
Vue.use(ElementUI, {locale}) // 追記

var config = {
  apiKey: "************************",
  authDomain: "************************.firebaseapp.com",
  databaseURL: "************************.firebaseio.com",
  projectId: "************************",
  storageBucket: "************************.appspot.com",
  messagingSenderId: "************************"
};
firebase.initializeApp(config);

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app')

※機密情報のため、「*」で隠しています。環境変数で値を代入するなど工夫して、間違ってもそのままGithubにプッシュすることがないようにしましょう。

また、Firebaseコンソールから「メール/パスワード」を有効にしてあげます。

FireShot Capture 85 - gemini – Authentication – Firebase con_ - https___console.firebase.google.co.png

これでFirebaseコンソールでの準備は完了です。簡単ですね。

ルーティングの追加

ユーザ登録ページとログインページへのルーティングをそれぞれ追加していきます。router.jsに以下を追加してください。

router.js
...
    {
      path: '/signup',
      name: 'signup',
      component: () => import(/* webpackChunkName: "singup" */ './views/Signup.vue')
    },
    {
      path: '/signin',
      name: 'signin',
      component: () => import(/* webpackChunkName: "singin" */ './views/Signin.vue')
    }
...

新規ユーザ登録ページの作成

signup.vue
<template>
    <div class="signup">
        <h2>Signup</h2>
        <div class="input-form-wrapper">
            <el-input type="text" placeholder="Username" v-model="username"/>
        </div>
        <div class="input-form-wrapper">
            <el-input type="password" placeholder="Password" v-model="password"/>
        </div>

        <el-button @click="signUp">Register</el-button>
        <p>Do you have an account?
            <router-link to="/signin">sign in now!!</router-link>
        </p>

    </div>
</template>

<script>
  import firebase from 'firebase'

  export default {
    name: "Signup",
    data () {
      return {
        username: undefined,
        password: undefined
      }
    },
    methods: {
      signUp: async function() {
        await firebase.auth().createUserWithEmailAndPassword(this.username, this.password)
          .then(() => {
            this.username = undefined
            this.password = undefined
            this.$message({
              showClose: true,
              message: 'Register User Success!',
              type: 'success'
            })
          })
          .catch(error => {
            this.$message({
              showClose: true,
              message: error,
              type: 'error'
            })
          })
      },
    }
  }
</script>

<style scoped lang="scss">
    @import "../styles/base";
</style>

base.scssにフォームのスタイルを追加しておきます。

base.scss
.input-form-wrapper {
  margin: 20px auto;
  width: 320px;
}

以下のようなシンプルな画面に仕上がります。

FireShot Capture 87 - gemini-client-2 - http___localhost_8081_signup.png

メールアドレスとパスワードを入力し、ユーザを登録してみてください。
正常に動作している場合、新規ユーザが登録されていることがFirebaseのコンソールから確認できます。

FireShot Capture 86 - gemini – Authentication – Firebase con_ - https___console.firebase.google.co.png

ログインページの作成

次に、ログインページを追加していきます。
ユーザ登録画面と構成はほとんど同じです。

signin.vue
<template>
    <div class="signin">
        <h2>Sign in</h2>
        <div class="input-form-wrapper">
            <el-input type="text" placeholder="Username" v-model="username"/>
        </div>
        <div class="input-form-wrapper">
            <el-input type="password" placeholder="Password" v-model="password"/>
        </div>
        <el-button @click="signIn">Signin</el-button>
        <p>You don't have an account?
            <router-link to="/signup">create account now!!</router-link>
        </p>
    </div>
</template>

<script>
  import firebase from 'firebase'

  export default {
    name: "Signin",
    data () {
      return {
        username: '',
        password: ''
      }
    },
    methods: {
      signIn: async function () {
        await firebase.auth().signInWithEmailAndPassword(this.username, this.password)
          .then(() => this.$router.push('/currency'))
          .catch(error => {
            this.$message({
              showClose: true,
              message: error,
              type: 'error'
            })
          })
      },
    }
  }
</script>

<style scoped lang="scss">
    @import "../styles/base";
</style>

1つ違う点は、認証後に/currencyページに飛ばしています。また、ログイン失敗した場合は、元のページでエラーメッセージを出力しています。

ナビゲーションガード

認証前ユーザのページ遷移を防ぐことを目的として、認証済みかを確認する共通処理を作っていきます。router.jsを以下のように変更しましょう。

router.js
/* eslint-disable no-console */
import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue'
import firebase from 'firebase'


Vue.use(Router)

let router = new Router({
  mode: 'history',
  base: process.env.BASE_URL,
  routes: [
    {
      path: '/',
      name: 'home',
      component: Home,
      meta: { requiresAuth: true }
    },
    {
      path: '/about',
      name: 'about',
      // route level code-splitting
      // this generates a separate chunk (about.[hash].js) for this route
      // which is lazy-loaded when the route is visited.
      component: () => import(/* webpackChunkName: "about" */ './views/About.vue')
    },
    {
      path: '/currency',
      name: 'currency',
      component: () => import(/* webpackChunkName: "currency" */ './views/Currency.vue'),
      meta: { requiresAuth: true }
    },
    {
      path: '/signup',
      name: 'signup',
      component: () => import(/* webpackChunkName: "singup" */ './views/Signup.vue')
    },
    {
      path: '/signin',
      name: 'signin',
      component: () => import(/* webpackChunkName: "singin" */ './views/Signin.vue')
    }
  ]
})

router.beforeEach((to, from, next) => {
  const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
  if (requiresAuth) {
    firebase.auth().onAuthStateChanged(function (user) {
      if (user) {
        next()
      } else {
        next({
          path: '/signin',
          query: { redirect: to.fullPath }
        })
      }
    })
  } else {
    next()
  }
})

export default router

beforeEachはルーティングの前にフックされるため、遷移先のページに認証が必要かどうかをメタ情報にて判断しています。今回はCurrencyページへ認証済みでないと遷移させたくないので、Currencyへのルーティングにmeta: { requiresAuth: true }を追加しています。認証済みでない場合、/signinへ遷移させ、ログイン処理を求めるようにしています。

ログアウト機能の作成

ログアウト処理自体はシンプルです。以下のメソッドを用意します。ログアウト後はログイン画面に遷移するようにしています。

...
    methods: {
      signout: function () {
        firebase.auth().signOut().then(() => {
          this.$router.push('/signin')
        })
      }
    }
...

問題は、これをどこに記述するかということです。CurrencyページのCurrency.vueに追加することもできますが、別のページが増える場合に同じログアウト処理を何度も書かなければならなくなります。そこで、共通ヘッダという位置づけで横断処理をページに埋め込んでいきます。

共通ヘッダの作成

今回はヘッダを2種類作成していきます。
1つはGlobalHeaderで、全てのページ共通で埋め込まれるものです。
もうひとつはSubHeaderとし、ログイン認証後のページでは共通で埋め込まれるものになります。
ログアウトボタンはSubHeaderに作成しましょう。

SubHeader
<template>
    <el-button type="text" class="signout-label" @click="signout">Signout</el-button>
</template>

<script>
  import firebase from 'firebase'

  export default {
    name: "SubHeader",
    methods: {
      signout: function () {
        firebase.auth().signOut().then(() => {
          this.$router.push('/signin')
        })
      }
    }
  }
</script>

<style scoped>

</style>

カラーは共通ファイルに記載していきます。

colors.scss
$HEADER_BACKGROUND: #111111;

$HEADER_LABEL: #FFFFFF;

GlobalHeaerにはタイトルを記載しました。

GlobalHeader
<template>
    <div class="global-header">
        <span class="global-header-label">Welcome to Gemini Application!</span>
    </div>
</template>

<script>
  export default {
    name: "GlobalHeader"
  }
</script>

<style scoped lang="scss">
    @import "../styles/colors";

    .global-header {
        background-color: $HEADER_BACKGROUND;
        height: 6.5vh;
    }
    .global-header-label {
        color: $HEADER_LABEL;
        font-weight: bold;
        line-height: 6.5vh;
    }
</style>

GlobalHeaderのレイアウトは、App.vueで指定する必要があります。<el-container>の中にある<el-header>にヘッダー部を、<el-main>にルーティングされる画面が表示されるようレイアウトしていきます。

App.vue
<template>
  <el-container id="app">
    <el-header id="nav"><global-header/></el-header>
    <el-main><router-view/></el-main>
  </el-container>
</template>

<script>
  import GlobalHeader from './components/GlobalHeader'
  export default {
    components: {
      GlobalHeader
    }
  }

</script>
<style lang="scss">
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}
#nav {
  padding: 10px;
  a {
    font-weight: bold;
    color: #2c3e50;
    &.router-link-exact-active {
      color: #42b983;
    }
  }
}
</style>

SubHeaderは<router-view/>の中で表示するため、Currency.vueに<sub-header/>を埋め込みます。

Currency.vue
<template>
    <el-row>
        <sub-header/>
        <el-col :span="24">
            <el-card class="box-card box-card-wrapper">
                <div slot="header">
                    <span>新規通貨追加</span>
                </div>
                <el-row class="row-wrapper">
                    <el-col :span="12">
                        <span>新規通貨名称</span>
                    </el-col>
 ...
Currency.vue
<script>
  import axios from 'axios'
  import SubHeader from "../components/SubHeader";
  export default {
    name: "Currency",
    components: {SubHeader},
    data () {
      return {
        request: {
          name: undefined,
          symbol: undefined
        },
        currencies: []
      }
    },
 ...
</script>

レイアウトは以下のようになります。

FireShot Capture 89 - gemini-client-2 - http___localhost_8081_currency.png

FireShot Capture 90 - gemini-client-2 - http___localhost_8081_signin_redirect=%2Fcurrency.png

認証後の画面には「Signout」が表示され、ログイン画面にはGlobalHeader部のみが表示されていることがわかります。

まとめ

このアプリでできるようになったこと

  • SpringInitializerでSpringBootプロジェクトを作成
  • MySQLと接続し、CRUD処理のREST APIを作成
  • vue-cliでVueプロジェクトを作成
  • クライアントからサーバサイドのAPIをコール
  • ElementUIでおしゃれな装飾
  • Firebaseでユーザ登録/認証機構を作成

ここまでのコードをGithubに残しておきます。

:pencil: サーバサイド側SpringBootアプリ
:pencil: クライアント側Vue.jsアプリ

次回は、今回作成したアプリをクラウド上で動かしていきます。

290
329
10

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
290
329

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?