26
25

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 3 years have passed since last update.

学習にちょうど良いWebアプリを作成【SpringBoot+Thymeleaf+PostgreSQL】

Last updated at Posted at 2020-04-22

1. 対象読者

・Java/Spring Boot/Thymeleafを学んでいる
・練習のために簡単なWebアプリを作成したい

2. ゴール

顧客管理(登録/更新/削除/一覧)ができる、簡単なWebアプリを作成します。
認証認可/単体テストとかは、盛り沢山になっちゃうので省略です。
でも単体テストは時間があったら追記した方が良いかもしれない。

完成イメージは以下の通りです。
app.gif

3. 完成品

完成品を先に見たい方はどうぞ。

ソースはこちら
※application.ymlのDB接続情報はご自身の環境に合わせて変えてください。

動くものはこちら
※Heroku無償版なので初期表示に時間が掛かります。

4. 前提

開発環境とPostgreSQLのインストールは実施済みとします。
インストールしていない場合は、以下のリンクからどうぞ。

5. 全体構成

今回作成するWebアプリの全体構成は以下の通りです。
overview.png

5.1 利用技術

ざっくり説明すると・・・

Spring Boot
Webアプリ等を簡単に作成できるJavaのフレームワーク。
Thymeleaf
画面(HTML+JS+CSS)を作成できるテンプレートエンジン。
Bootstrap
CSSのフレームワーク。デザインに使う。
Gradle
ビルドツール。
PostgreSQL
オープンソースのデータベース。

5.2 構成

Model
DBデータを操作する役割のクラス。以下の2種類がある。

(1) Entityクラス
 DBのテーブル定義に対応するクラス。
 基本的には、クラス名=テーブル名、フィールド名=列名が対応する。

(2) Repositoryクラス
 DBの参照/登録/更新/削除をするメソッドを持つクラス。

View
ユーザに表示する画面。
具体的には、Thymeleafで定義したHTMLのテンプレートのこと。
ControllerによってModelのデータが流し込まれる。

Controller
ユーザからの入力を受け取り、次画面を返す役割のクラス。
具体的には、HTTPリクエストに応じてModelにDB参照/更新等を指示し、
Viewにデータを渡して、次画面のHTTPレスポンスを作成する。

設定ファイル
DB接続情報等の設定を保持する。

外部ライブラリ群
Spring Boot/JPA/JDBC等の様々なライブラリ群。

ビルド設定ファイル(build.gradle)
使用する外部ライブラリ等の設定を保持する。

6. 機能設計

開発がメインテーマなので、機能設計は簡単に書きます。

6.1 画面設計

画面一覧

No 画面名 説明
1 トップ画面 各画面への入口となる画面。
2 新規登録画面 顧客情報を新規登録する画面。
3 更新画面 顧客情報を変更する画面。
4 一覧画面 顧客情報を一覧表示、削除する画面。

画面レイアウト/画面遷移

2. ゴールの通り。

画面項目定義/インタラクション

分かると思うので省略。

6.2 URL設計

No URL HTTPメソッド 説明
1 / GET トップ画面
2 /customers GET 一覧画面
3 /customers/create GET 新規登録画面
4 /customers/create POST 新規登録実行
5 /customers/{id}/update GET 更新画面
6 /customers/{id}/update POST 更新実行
7 /customers/{id}/delete GET 削除実行

ここを参考に設計。

6.3 DB設計

テーブル一覧

No テーブル名 説明
1 customer 顧客情報を管理する。

テーブル定義(customer)

No 列名 PK 非Null 説明
1 id 顧客ID。シーケンスで自動採番。
2 name 顧客名。

※シーケンス名は「customer_id_seq」
※列を増やしたければ後から簡単に増やせるし、
 盛り沢山にならないようシンプルにしました。

7. 開発

それでは本題の開発に進んでいきます。

7.0 全体の流れ

Spring公式サイトから雛形をダウンロードして、開発します。
テーブルは、Spring JPAの機能でModelから自動生成します。
flow.png

7.1 雛形ダウンロード

flow1.png

Webアプリのプロジェクトの雛形を作成します。
Spring Initializrからダウンロードするのが簡単です。
以下のように入力して、「Generate」ボタンを押すとダウンロードできます。

spring01.PNG

7.1.1 入力内容の説明

Project
Gradleを選びます。Mavenが好きな方はMavenでもOKです。
Group / Artifact / Name
プロジェクト名とパッケージ名です。練習なので適当でOKです。
Dependencies
使用するライブラリを指定します。

7.1.2 Dependenciesの説明

使用する外部ライブラリ群を設定します。

Spring Boot DevTools
開発効率向上のためのライブラリです。
ソース変更の都度、自動でビルド&再起動して変更内容を反映してくれます。

lombok
開発効率向上のためのライブラリです。
getter/setter/equals等の定型的なメソッドをアノテーションでスッキリ記述できます。

Spring Configuration Processor
開発効率向上のためのライブラリです。
設定ファイル(application.yml)でコード補完ができるようになり、記述ミスを抑止できます。

Spring Web
Webアプリ/WebAPIのためのライブラリです。

Thymeleaf
HTMLのテンプレートエンジンです。前述の通りViewの役割ですね。

Spring Data JPA
DB関連のライブラリです。JPAベースのDB操作ができます。
JPAをざっくり説明すると、SQLを書かなくても簡単なDB操作ができる仕組みです。

PostgreSQL Driver
PostgreSQLのJDBCドライバです。これがないとPostgreSQL使えません。

7.1.3 補足

Eclipseのメニューからでも同様に作成できます。
具体的には、「ファイル」「新規」「その他」「Spring Boot」「Spring スターター・プロジェクト」です。

7.2 ビルド

flow2.png

ダウンロードした雛形をEclipseにインポートします。
具体的な手順は以下の通りです。

  1. ダウンロードした雛形(zip)を、Eclipseのワークスペースがあるフォルダに解凍。
  2. Eclipseを起動。
  3. 「ファイル」「インポート」「Gradle」「既存のGradleプロジェクト」「次へ」
  4. 「プロジェクト・ルート・ディレクトリ」に1のフォルダを指定して「完了」

インポートすると自動的にビルドされ、build.gradleのdependenciesの通りに、
外部ライブラリ群がダウンロードされます。

7.2.1 外部ライブラリ追加

Spring Initializrでは入らない外部ライブラリがあります。
具体的には、bootstrapのjava用ライブラリが入りません。
Eclipseでbuild.gradleファイルを開き、以下の通り追加します。

build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.webjars:jquery:3.3.1'        // 追加
	implementation 'org.webjars:bootstrap:4.3.1'     // 追加
	implementation 'org.webjars:font-awesome:5.13.0' // 追加
// 省略。完全版はhttps://github.com/tk230to/tksystem

【補足】追加した外部ライブラリの説明

org.webjars:jquery
jQueryのjava用ライブラリ。
org.webjars:bootstrap
Bootstrapのjava用ライブラリ。
org.webjars:font-awesome
Font Awesome(アイコンを利用)のjava用ライブラリ。

【補足】外部ライブラリの探し方

こういうライブラリはMaven Repositoryで探します。

7.3 各クラス作成

flow3.png

各クラスや設定ファイルを作成します。

7.3.1 作成対象

Model/View/Controller/設定ファイルを作成します。
このファイル何だったっけ?という場合は、5.2 構成を読み直してください。

7.3.2 Model(Entityクラス)作成

Customerクラス

customerテーブルに対応するクラスを作成します。
クラス名=テーブル名、フィールド名=列名になります。

Customer.java
package com.example.tksystem.model;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.SequenceGenerator;
import javax.validation.constraints.NotBlank;

import lombok.Data;

/**
 * 顧客クラス。
 */
@Entity
@Data
public class Customer {

  /** シーケンス名 */
  private static final String SEQUENCE_NAME = "customer_id_seq";

  /** ID */
  @Id
  @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = SEQUENCE_NAME)
  @SequenceGenerator(name = SEQUENCE_NAME, sequenceName = SEQUENCE_NAME, allocationSize = 1)
  private Long id;

  /** 名前 */
  @NotBlank
  private String name;
}
@Entity
Entityクラスであることを示すアノテーション。
@Data
getter/setter等を自動生成してくれるlombokのアノテーション。
@Id
PK列であることを示すアノテーション。
@GeneratedValue
シーケンスで自動採番することを示すアノテーション。
@SequenceGenerator
シーケンスを作成するためのアノテーション。

7.3.3 Model(Repositoryクラス)作成

CustomerRepositoryクラス

customerテーブルへのCRUD操作をするクラスを作成します。
JpaRepositoryを継承するだけです。

CustomerRepository.java
package com.example.tksystem.model;

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

/**
 * 顧客リポジトリクラス。
 */
public interface CustomerRepository extends JpaRepository<Customer, Long> {

}

以下のメソッド(抜粋)が使えるようになります。

List<Customer> findAll()
テーブルの全レコードを参照(SELECT)し、リストで返す。
Customer getOne(Long id)
顧客IDに該当するレコードを参照(SELECT)し、結果を返す。
Customer save(Customer entity)
引数の顧客情報を登録/更新(INSERT/UPDATE)し、結果レコードを返す。
void deleteById(Long id)
顧客IDに該当するレコードを削除(DELETE)する。

7.3.4 View作成

各画面のHTMLテンプレートを作成します。
上部のナビゲーションバーは全画面共通なので、共通レイアウトとして共通化します。

共通レイアウト

layout.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<head th:fragment="head(title)">

  <title th:text="'顧客管理 - ' + ${title}">tksystem</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />

  <link rel="stylesheet" th:href="@{/webjars/bootstrap/4.3.1/css/bootstrap.min.css}" />
  <link rel="stylesheet" th:href="@{/webjars/font-awesome/5.13.0/css/all.min.css}" />

  <script th:src="@{/webjars/jquery/3.3.1/jquery.min.js}"></script>
  <script th:src="@{/webjars/bootstrap/4.3.1/js/bootstrap.min.js}"></script>

</head>

<body>
  <nav class="navbar navbar-dark bg-dark navbar-expand-sm mb-3" th:fragment="navbar">

    <a class="navbar-brand" href="/">顧客管理</a>

    <button type="button" class="navbar-toggler" data-toggle="collapse" data-target="#Navbar" aria-controls="Navbar" aria-expanded="false" aria-label="ナビゲーションの切替">
      <span class="navbar-toggler-icon"></span>
    </button>

    <div id="Navbar" class="collapse navbar-collapse">
      <ul class="navbar-nav">
        <li class="nav-item">
          <a class="nav-link" th:href="@{/customers/create/}">
            <i class="fas fa-plus fa-lg" aria-hidden=”true”></i> 新規登録
          </a>
        </li>
        <li class="nav-item">
          <a class="nav-link" th:href="@{/customers/}">
            <i class="fas fa-list fa-lg" aria-hidden=”true”></i> 一覧
          </a>
        </li>
      </ul>
    </div>

  </nav>
</body>

</html>
th:fragment
他の画面でth:replaceで差し替えられるようにします。
その他のThymeleafの文法
こちらを参考にしてください。
Bootstrapの文法
公式ドキュメントを参考にしてください。

トップ画面

index.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<head th:replace="fragments/layout :: head('Home')">
</head>

<body>
  <div th:replace="fragments/layout :: navbar"></div>

  <div class="container-fluid">
    <h5>メニュー</h5>
    <hr>

    <div class="row">
      <div class="col-sm-6">
        <div class="card">
          <div class="card-body">
            <h5 class="card-title">
              <a class="nav-link" th:href="@{/customers/create}">
                <i class="fas fa-plus fa-lg" aria-hidden=”true”></i> 新規登録
              </a>
            </h5>
            <p class="card-text">顧客を新規登録します。</p>
          </div>
        </div>
      </div>

      <div class="col-sm-6">
        <div class="card">
          <div class="card-body">
            <h5 class="card-title">
              <a class="nav-link" th:href="@{/customers/}">
                <i class="fas fa-list fa-lg" aria-hidden=”true”></i> 一覧
              </a>
            </h5>
            <p class="card-text">顧客の一覧を表示します。</p>
          </div>
        </div>
      </div>

    </div>
  </div>

</body>

</html>

新規登録画面

create.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<head th:replace="fragments/layout :: head('新規登録')">
</head>

<body>
  <div th:replace="fragments/layout :: navbar"></div>

  <div class="container-fluid">
    <h5>新規登録</h5>
    <hr>

    <div class="row">
      <div class="col-sm-12">
        <form action="#" th:action="@{/customers/create/}" th:object="${customer}" method="post">
          <div class="form-group">
            <label for="name">名前 <span class="badge badge-danger">必須</span></label>
            <input type="text" id="name" class="form-control" placeholder="(例) 山田 太郎" th:field="*{name}" />
            <span th:if="${#fields.hasErrors('name')}" th:errors="*{name}" style="color: red"></span>
          </div>

          <button type="submit" class="btn btn-primary">確定</button>  
        </form>
      </div>
    </div>
  </div>
</body>
</html>

更新画面

登録画面とほぼ同様です。完成品はこちら

一覧画面

list.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<head th:replace="fragments/layout :: head('一覧')">
</head>

<body>
  <div th:replace="fragments/layout :: navbar"></div>

  <div class="container-fluid">
    <h5>一覧</h5>
    <hr>

    <div class="row">
      <div class="col-sm-12">

        <table class="table table-bordered table-hover">
          <thead class="thead-dark">
            <tr>
              <th width="10%">ID</th>
              <th width="80%">名前</th>
              <th width="10%">削除</th>
            </tr>
          </thead>
          <tbody>
            <tr th:each="customer:${customer}">
              <td th:text="${customer.id}"></td>
              <td>
                <a th:text="${customer.name}" th:href="@{/customers/{id}/update/(id=${customer.id})}">
                </a>
              </td>
              <td>
                <a th:href="@{/customers/{id}/delete/(id=${customer.id})}" onClick="return window.confirm('削除してよろしいですか?')">
                  <i class="far fa-trash-alt fa-lg" aria-hidden=”true”></i>
                </a>
              </td>
            </tr>
          </tbody>
        </table>
      </div>
    </div>
  </div>
</body>

</html>

7.3.5 Controllerの作成

IndexControllerクラス

トップ画面のリクエストを処理するクラスを作成します。

IndexController.java
package com.example.tksystem.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;

/**
 * index画面のコントローラクラス。
 */
@Controller
@RequestMapping("/")
public class IndexController {

  /**
   * index画面
   *
   * @param model モデル
   * @return 遷移先
   */
  @RequestMapping("index")
  public String index(Model model) {
    return "index";
  }
}
@Controller
Controllerクラスであることを示すアノテーションです。

@RequestMapping
HTTPリクエストのURLとクラス/メソッドをマッピングします。
この例だと、URLが"/index/"のとき、index(Model model)メソッドが呼ばれます。

メソッドの返り値
戻り値の文字列は、次画面のThymeleafのHTMLファイル名を示します。
"index"の場合、/src/main/resources/templates/index.htmlを示します。

CustomerControllerクラス

顧客画面(新規登録/更新/一覧画面)のリクエストを処理するクラスを作成します。

CustomerController.java
package com.example.tksystem.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
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.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import com.example.tksystem.model.Customer;
import com.example.tksystem.model.CustomerRepository;

/**
 * 顧客画面のコントローラクラス。
 */
@Controller
@RequestMapping("/customers")
public class CustomerController {

  /** 登録/更新/削除完了後のリダイレクト先URL */
  private static final String REDIRECT_URL = "redirect:/customers/";

  /** HTMLパス */
  private static final String PATH_LIST = "customer/list";
  private static final String PATH_CREATE = "customer/create";
  private static final String PATH_UPDATE = "customer/update";

  /** Modelの属性名 */
  private static final String MODEL_ATTRIBUTE_NAME = "customer";

  /** 顧客リポジトリ */
  @Autowired
  private CustomerRepository customerRepository;

  /**
   * 一覧画面を表示。
   *
   * @param model モデル
   * @return 遷移先
   */
  @GetMapping(value = "/")
  public String list(Model model) {
    model.addAttribute(MODEL_ATTRIBUTE_NAME, customerRepository.findAll(Sort.by("id")));
    return PATH_LIST;
  }

  /**
   * 登録画面を表示。
   *
   * @param model モデル
   * @return 遷移先
   */
  @GetMapping(value = "/create")
  public String create(Model model) {
    model.addAttribute(MODEL_ATTRIBUTE_NAME, new Customer());
    return PATH_CREATE;
  }

  /**
   * 登録を実行。
   *
   * @param customer 顧客画面入力値
   * @param result 入力チェック結果
   * @return 遷移先
   */
  @PostMapping(value = "/create")
  public String create(@Validated @ModelAttribute(MODEL_ATTRIBUTE_NAME) Customer customer,
      BindingResult result) {

    if (result.hasErrors()) {
      return PATH_CREATE;
    }

    customerRepository.save(customer);
    return REDIRECT_URL;
  }

  /**
   * 更新画面を表示。
   *
   * @param id 顧客ID
   * @param model モデル
   * @return 遷移先
   */
  @GetMapping(value = "/{id}/update")
  public String update(@PathVariable("id") Long id, Model model) {
    model.addAttribute(MODEL_ATTRIBUTE_NAME, customerRepository.getOne(id));
    return PATH_UPDATE;
  }

  /**
   * 更新を実行。
   *
   * @param customer 顧客画面入力値
   * @param result 入力チェック結果
   * @return 遷移先
   */
  @PostMapping(value = "/{id}/update")
  public String update(@Validated @ModelAttribute(MODEL_ATTRIBUTE_NAME) Customer customer,
      BindingResult result) {

    if (result.hasErrors()) {
      return PATH_UPDATE;
    }

    customerRepository.save(customer);
    return REDIRECT_URL;
  }

  /**
   * 削除を実行。
   *
   * @param id 顧客ID
   * @return 遷移先
   */
  @GetMapping(value = "/{id}/delete")
  public String list(@PathVariable("id") Long id) {
    customerRepository.deleteById(id);
    return REDIRECT_URL;
  }
}
@GetMapping/@PostMapping
@RequestMappingと同様です。
@Validated
入力チェックをすることを示すアノテーションです。
BindingResult result
入力チェック結果が入っています。

7.3.6 設定ファイルの作成

application.yml

DB接続情報等を設定するファイルです。
url/username/passwordは、PostgreSQLのデフォルトにしてあります。
デフォルトと違う場合は変更してください。

application.yml
spring:
  datasource:

    # PostgresのIPアドレス/ポート番号/DB名
    url: jdbc:postgresql://localhost:5432/postgres

    # Postgresのユーザ名
    username: postgres

    # Postgresのパスワード
    password: postgres

    # PostgresのJDBCドライバ
    driver-class-name: org.postgresql.Driver

  jpa:
    hibernate:

      # @Entityに対応するテーブルを常にdrop&createする。
      ddl-auto: create-drop
spring.jpa.hibernate.ddl-auto
ここを参照してください。

hibernate.properties

PostgreSQL×Spring JPAで発生する例外を回避する設定です。
無くても動作上問題無いですが、毎回例外出るのが気持ち悪いので設定した方がベターです。
例外の詳細はこちら

hibernate.properties
# PgConnection.createClob()メソッドの警告を回避
hibernate.jdbc.lob.non_contextual_creation = true

ValidationMessages.properties

入力チェックエラーメッセージのメッセージ定義です。
まだ日本語メッセージが提供されていないため自作します。
ここでプルリクエストが取り込まれているので、そのうち提供されると思います。

ValidationMessages.properties
javax.validation.constraints.AssertFalse.message     = false にしてください
javax.validation.constraints.AssertTrue.message      = true にしてください
javax.validation.constraints.DecimalMax.message      = {value} ${inclusive == true ? '以下の値にしてください' : 'より小さな値にしてください'}
javax.validation.constraints.DecimalMin.message      = {value} ${inclusive == true ? '以上の値にしてください' : 'より大きな値にしてください'}
javax.validation.constraints.Digits.message          = 値は次の範囲にしてください (<整数 {integer} 桁>.<小数点以下 {fraction} 桁>)
javax.validation.constraints.Email.message           = 電子メールアドレスとして正しい形式にしてください
javax.validation.constraints.Future.message          = 未来の日付にしてください
javax.validation.constraints.FutureOrPresent.message = 現在もしくは未来の日付にしてください
javax.validation.constraints.Max.message             = {value} 以下の値にしてください
javax.validation.constraints.Min.message             = {value} 以上の値にしてください
javax.validation.constraints.Negative.message        = 0 より小さな値にしてください
javax.validation.constraints.NegativeOrZero.message  = 0 以下の値にしてください
javax.validation.constraints.NotBlank.message        = 空白は許可されていません
javax.validation.constraints.NotEmpty.message        = 空要素は許可されていません
javax.validation.constraints.NotNull.message         = null は許可されていません
javax.validation.constraints.Null.message            = null にしてください
javax.validation.constraints.Past.message            = 過去の日付にしてください
javax.validation.constraints.PastOrPresent.message   = 現在もしくは過去の日付にしてください
javax.validation.constraints.Pattern.message         = 正規表現 "{regexp}" にマッチさせてください
javax.validation.constraints.Positive.message        = 0 より大きな値にしてください
javax.validation.constraints.PositiveOrZero.message  = 0 以上の値にしてください
javax.validation.constraints.Size.message            = {min} から {max} の間のサイズにしてください

WebConfig

上記ValidationMessages.propertiesを使う設定をします。
詳細はここを参照してください。

WebConfig.java
package com.example.tksystem;

import java.nio.charset.StandardCharsets;

import org.hibernate.validator.messageinterpolation.AbstractMessageInterpolator;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
import org.springframework.validation.Validator;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * Web設定クラス。
 *
 */
@Configuration
public class WebConfig implements WebMvcConfigurer {

  @Override
  public Validator getValidator() {

    // ValidationMessages.propertiesをUTF-8で設定できるようにする。
    ReloadableResourceBundleMessageSource messageSource =
        new ReloadableResourceBundleMessageSource();
    messageSource.setBasename(AbstractMessageInterpolator.USER_VALIDATION_MESSAGES);
    messageSource.setDefaultEncoding(StandardCharsets.UTF_8.name());

    LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean();
    validator.setValidationMessageSource(messageSource);

    return validator;
  }
}

7.4 起動&テーブル自動生成

flow4.png

全てのファイルが完成しましたので、Eclipseでプロジェクトを実行します。
手順は、プロジェクトを選択して「右クリック」「実行」「Spring Boot アプリケーション」です。

application.ymlで設定したddl-autoの設定により、
作成したEntityクラスの通りにテーブルが自動生成されます。

http://localhost:8080にアクセスして動作確認してみてください。
2. ゴールのように動作すれば成功です。

以上、お疲れ様でした。

26
25
0

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
26
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?