2
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Docker+PostgreSQL+Go+Airで実現する快適サーバーサイド開発環境構築のすゝめ

Last updated at Posted at 2024-09-25

はじめに

先日、研究室の後輩たちと技育CAMPハッカソンに参加してきました。
ハッカソンで何を開発したか等は、後輩が記事にまとめてくれたので、良ければ以下の記事をご覧ください。

この記事では、Go言語の経験者がほとんどいない中、バックエンドの開発言語にGoを採用した今回のハッカソンで、入門Gopherのためにも快適な開発ができるよう環境構築をしたので、その紹介をさせていただきます。

紹介するにあたって、Goでのサーバーサイド開発に使えるテンプレートリポジトリも作成しました。Goを使ってみたいけど環境構築はめんどくさい方や、ハッカソン等でパッと使えるGoのテンプレートリポジトリを探している方は、使ってみてください。

リポジトリの説明

GO + PostgreSQL での開発を、Docker と Air により快適なものとするためのテンプレートリポジトリです。
Go で開発する API サーバー用のコンテナと、PostgreSQL による DB 用のコンテナ、DB に対してGUIで確認・操作を行えるPGAdmin用のコンテナの3つを Docker Compose により立ち上げます。また、Air によるホットリロードのおかげで、Go のソースコードに対する変更は、立ち上げている API サーバー用のコンテナに反映されます。
以下が作成したリポジトリです。

手元で動かしてみよう!

1つ1つのファイルの説明よりも、動きを見た方がわかりやすいかと思いますので、さっそく動かし方を紹介します!
細かいファイルの説明などは後半で記載していますので、気になる方はご覧ください。

環境構築

  1. Dockerをインストール
  2. 本リポジトリをクローンするか、テンプレートとして選択してリポジトリを作成
  3. docker network create api-db-network
  4. docker network create db-pgadmin-network
  5. docker compose build

Dockerのビルドに成功すると下記のような出力が得られます。

> docker compose build
2024/09/25 13:34:29 http2: server: error reading preface from client //./pipe/docker_engine: file has already been closed
[+] Building 0.0s (0/0)  docker:default
[+] Building 18.9s (10/10) FINISHED                                                                                                                                                                                       docker:default
 => [api internal] load build definition from go.dockerfile                                                                                                                                                                         0.0s
 => => transferring dockerfile: 239B                                                                                                                                                                                                0.0s 
 => [api internal] load metadata for docker.io/library/golang:1.23.1-alpine3.20                                                                                                                                                     1.5s 
 => [api internal] load .dockerignore                                                                                                                                                                                               0.0s
 => => transferring context: 2B                                                                                                                                                                                                     0.0s 
 => [api internal] load build context                                                                                                                                                                                               0.0s 
 => => transferring context: 149B                                                                                                                                                                                                   0.0s 
 => [api 1/5] FROM docker.io/library/golang:1.23.1-alpine3.20@sha256:ac67716dd016429be8d4c2c53a248d7bcdf06d34127d3dc451bda6aa5a87bc06                                                                                               5.7s 
 => => resolve docker.io/library/golang:1.23.1-alpine3.20@sha256:ac67716dd016429be8d4c2c53a248d7bcdf06d34127d3dc451bda6aa5a87bc06                                                                                                   0.0s 
 => => sha256:ac67716dd016429be8d4c2c53a248d7bcdf06d34127d3dc451bda6aa5a87bc06 10.29kB / 10.29kB                                                                                                                                    0.0s 
 => => sha256:3058c543b93017c20123f4ffe8ca779c88ade0102361ab9a290bf7d590360fc4 1.92kB / 1.92kB                                                                                                                                      0.0s 
 => => sha256:b5ada884192f173018fcb39688bd70545669ab105941231067ea5dbed4ac6914 2.07kB / 2.07kB                                                                                                                                      0.0s 
 => => sha256:43c4264eed91be63b206e17d93e75256a6097070ce643c5e8f0379998b44f170 3.62MB / 3.62MB                                                                                                                                      0.6s 
 => => sha256:ab19dfae90efdd651c37b568f750d129c6d7c47d3a902982eefcd7c6567ccd5d 290.88kB / 290.88kB                                                                                                                                  0.3s 
 => => sha256:e7bff916ab0c126c9d943f0c481a905f402e00f206a89248f257ef90beaabbd8 74.00MB / 74.00MB                                                                                                                                    2.9s 
 => => sha256:78cee99375e3e0bb16a7bcb00218932b90225708c1a53e97b98b5230fe87b86a 125B / 125B                                                                                                                                          0.7s 
 => => extracting sha256:43c4264eed91be63b206e17d93e75256a6097070ce643c5e8f0379998b44f170                                                                                                                                           0.1s 
 => => sha256:4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1 32B / 32B                                                                                                                                            0.8s 
 => => extracting sha256:ab19dfae90efdd651c37b568f750d129c6d7c47d3a902982eefcd7c6567ccd5d                                                                                                                                           0.1s 
 => => extracting sha256:e7bff916ab0c126c9d943f0c481a905f402e00f206a89248f257ef90beaabbd8                                                                                                                                           2.6s 
 => => extracting sha256:78cee99375e3e0bb16a7bcb00218932b90225708c1a53e97b98b5230fe87b86a                                                                                                                                           0.0s 
 => => extracting sha256:4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1                                                                                                                                           0.0s 
 => [api 2/5] COPY ./app /go/app/                                                                                                                                                                                                   0.2s 
 => [api 3/5] WORKDIR /go/app/                                                                                                                                                                                                      0.0s 
 => [api 4/5] RUN go mod download && go mod tidy                                                                                                                                                                                    4.5s 
 => [api 5/5] RUN go install github.com/air-verse/air@latest                                                                                                                                                                        6.4s 
 => [api] exporting to image                                                                                                                                                                                                        0.5s 
 => => exporting layers                                                                                                                                                                                                             0.5s 
 => => writing image sha256:920413d3115f1a2d59f591588e3b0e7531a80f92a57f4f17f7553433e37e54e0                                                                                                                                        0.0s 
 => => naming to docker.io/library/go-dev-template-api

実行方法

下記コマンドを実行し、Docker Composeを実行します。

docker compose up

実行時のログを確認すると、Airが起動していることや、テーブルの作成やテストデータの挿入、APIサーバーからデータベースの接続にも成功していることが確認できます!

> docker compose up     
[+] Running 2/0
 ✔ Volume "go-dev-template_pgadmin-data"  Created                                                                                                                                                                                   0.0s 
 ✔ Volume "go-dev-template_db"            Created                                                                                                                                                                                   0.0s 
 - Container db                           Created                                                                                                                                                                                   0.1s 
 - Container api                          Created                                                                                                                                                                                   0.0s 
 - Container pgadmin4                     Created                                                                                                                                                                                   0.0s 
Attaching to api, db, pgadmin4
db        | The files belonging to this database system will be owned by user "postgres".
db        | This user must also own the server process.
db        |
db        | The database cluster will be initialized with locale "en_US.utf8".
db        | The default database encoding has accordingly been set to "UTF8".
db        | The default text search configuration will be set to "english".
db        |
db        | Data page checksums are disabled.
db        |
db        | fixing permissions on existing directory /var/lib/postgresql/data ... ok
db        | creating subdirectories ... ok
db        | selecting dynamic shared memory implementation ... posix
db        | selecting default max_connections ... 100
db        | selecting default shared_buffers ... 128MB
db        | selecting default time zone ... Etc/UTC
db        | creating configuration files ... ok
db        | running bootstrap script ... ok
api       |
api       |   __    _   ___
api       |  / /\  | | | |_)
api       | /_/--\ |_| |_| \_ v1.60.0, built with Go go1.23.1
api       |
api       | watching .
api       | !exclude tmp
api       | building...
db        | performing post-bootstrap initialization ... ok
db        | syncing data to disk ... ok
db        |
db        |
db        | Success. You can now start the database server using:
db        |
db        |     pg_ctl -D /var/lib/postgresql/data -l logfile start
db        |
db        | initdb: warning: enabling "trust" authentication for local connections
db        | initdb: hint: You can change this by editing pg_hba.conf or using the option -A, or --auth-local and --auth-host, the next time you run initdb.
db        | waiting for server to start....2024-09-25 07:21:19.690 UTC [48] LOG:  starting PostgreSQL 16.4 (Debian 16.4-1.pgdg120+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 12.2.0-14) 12.2.0, 64-bit
db        | 2024-09-25 07:21:19.693 UTC [48] LOG:  listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
db        | 2024-09-25 07:21:19.703 UTC [51] LOG:  database system was shut down at 2024-09-25 07:21:19 UTC
db        | 2024-09-25 07:21:19.707 UTC [48] LOG:  database system is ready to accept connections
db        |  done
db        | server started
db        | CREATE DATABASE
db        |
db        |
db        | /usr/local/bin/docker-entrypoint.sh: running /docker-entrypoint-initdb.d/init.sql
db        | CREATE TABLE
db        | INSERT 0 1
db        | INSERT 0 1
db        | INSERT 0 1
db        | INSERT 0 1
db        | INSERT 0 1
db        |
db        |
db        | waiting for server to shut down...2024-09-25 07:21:20.019 UTC [48] LOG:  received fast shutdown request
db        | .2024-09-25 07:21:20.025 UTC [48] LOG:  aborting any active transactions
db        | 2024-09-25 07:21:20.026 UTC [48] LOG:  background worker "logical replication launcher" (PID 54) exited with exit code 1
db        | 2024-09-25 07:21:20.029 UTC [49] LOG:  shutting down
db        | 2024-09-25 07:21:20.033 UTC [49] LOG:  checkpoint starting: shutdown immediate
db        | 2024-09-25 07:21:20.157 UTC [49] LOG:  checkpoint complete: wrote 931 buffers (5.7%); 0 WAL file(s) added, 0 removed, 0 recycled; write=0.022 s, sync=0.082 s, total=0.128 s; sync files=304, longest=0.004 s, average=0.001 s; distance=4270 kB, estimate=4270 kB; lsn=0/1915BB0, redo lsn=0/1915BB0
db        | 2024-09-25 07:21:20.162 UTC [48] LOG:  database system is shut down
db        |  done
db        | server stopped
db        |
db        | PostgreSQL init process complete; ready for start up.
db        |
db        | 2024-09-25 07:21:20.233 UTC [1] LOG:  starting PostgreSQL 16.4 (Debian 16.4-1.pgdg120+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 12.2.0-14) 12.2.0, 64-bit
db        | 2024-09-25 07:21:20.233 UTC [1] LOG:  listening on IPv4 address "0.0.0.0", port 5432
db        | 2024-09-25 07:21:20.233 UTC [1] LOG:  listening on IPv6 address "::", port 5432
db        | 2024-09-25 07:21:20.236 UTC [1] LOG:  listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
db        | 2024-09-25 07:21:20.240 UTC [66] LOG:  database system was shut down at 2024-09-25 07:21:20 UTC
db        | 2024-09-25 07:21:20.244 UTC [1] LOG:  database system is ready to accept connections
api       | running...
api       | データベース接続成功
api       | [GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.
api       |
api       | [GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
api       |  - using env:       export GIN_MODE=release
api       |  - using code:      gin.SetMode(gin.ReleaseMode)
api       |
api       | [GIN-debug] GET    /ping                     --> main.main.func1 (3 handlers)
api       | [GIN-debug] GET    /get                      --> main.main.func2 (3 handlers)
api       | [GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
api       | Please check https://pkg.go.dev/github.com/gin-gonic/gin#readme-don-t-trust-all-proxies for details.
api       | [GIN-debug] Environment variable PORT is undefined. Using port :8080 by default
api       | [GIN-debug] Listening and serving HTTP on :8080
pgadmin4  | NOTE: Configuring authentication for DESKTOP mode.
pgadmin4  | pgAdmin 4 - Application Initialisation
pgadmin4  | ======================================
pgadmin4  |
pgadmin4  | ----------
pgadmin4  | Loading servers with:
pgadmin4  | User: pgadmin4@pgadmin.org
pgadmin4  | SQLite pgAdmin config: /var/lib/pgadmin/pgadmin4.db
pgadmin4  | ----------
pgadmin4  | Added 0 Server Group(s) and 1 Server(s).
pgadmin4  | postfix/postlog: starting the Postfix mail system
pgadmin4  | [2024-09-25 07:21:33 +0000] [1] [INFO] Starting gunicorn 20.1.0
pgadmin4  | [2024-09-25 07:21:33 +0000] [1] [INFO] Listening at: http://[::]:80 (1)
pgadmin4  | [2024-09-25 07:21:33 +0000] [1] [INFO] Using worker: gthread
pgadmin4  | [2024-09-25 07:21:33 +0000] [121] [INFO] Booting worker with pid: 121

http://localhost:8080/getにアクセスしてみると、以下のレスポンスを得られ、データベースに挿入したテストデータが取得できます。

{
  "id": "1",
  "name": "a1_name",
  "password": "a1_password"
}

http://localhost:81にアクセスしてみると、PGAdminでDocker Compose実行時に挿入したテストデータを確認することができます。上記で取得したデータがデータベース内にも存在することが確認できますね。

スクリーンショット 2024-09-25 162235.png

リポジトリの説明

一応、ほぼすべてのディレクトリやファイルについて説明していますが、冗長な説明部分も多いため、実際にリポジトリをクローンして手元で触ってみて、よくわからない部分だけ説明を読む形を推奨します。

go-dev-template/
├── app/ # Go関連のファイルを置くディレクトリ
│   ├── tmp/      # Airのホットリロードのために自動生成される
│   ├── .air.toml # Airの設定ファイル
│   ├── go.mod    # Goで使うモジュールの依存関係を管理するファイル
│   ├── go.sum    # go.modの整合性を担保するためのファイル
│   └── main.go   # APIサーバー起動時のエントリーポイントとなるファイル
├── config/
│   └── servers.json # PGAdminのための設定ファイル
├── sql/
│   └── init.sql       # Docker起動時に実行するSQLファイル
├── docker-compose.yml # APIサーバー、DB、PGAdminの3つのコンテナに関する設定ファイル
└── go.dockerfile      # GoのAPIサーバー用のDocker設定ファイル

docker-compose.yml

Goで実装するAPIサーバー、PostgreSQLのデータベース、PGAdminの3つのDockerコンテナを立ち上げるための設定ファイルです。
3つのコンテナを立ち上げた上で、各コンテナを接続するために、dockerネットワークを生成・設定してあげる必要があります。

docker network create api-db-network
docker network create db-pgadmin-network

今回のハッカソンでは、バックエンド開発の経験がほとんどないメンバーもいたため、できるだけコマンドラインでデータベースの確認などをしなくても良いようにしたいと考え、PGAdminというGUIでデータベースに対する操作を行えるツールを導入しました。

services:
  api:
    container_name: api
    build:
      context: .
      dockerfile: go.dockerfile
    ports:
      - 8080:8080
    depends_on:
      - db
    tty: true
    volumes:
      - ./app:/go/app
    networks:
      - api-db-network

  db:
    container_name: db
    image: postgres:16
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: db-user
      POSTGRES_PASSWORD: db-password
      POSTGRES_DB: db-name
    volumes:
      - db:/var/lib/postgresql/data
      - ./sql:/docker-entrypoint-initdb.d
    networks:
      - api-db-network
      - db-pgadmin-network

  pgadmin4:
    container_name: pgadmin4
    image: dpage/pgadmin4:8.4
    ports:
      - 81:80
    volumes:
      - pgadmin-data:/var/lib/pgadmin
      - ./config/servers.json:/pgadmin4/servers.json
    environment:
      PGADMIN_DEFAULT_EMAIL: user@example.com
      PGADMIN_DEFAULT_PASSWORD: password
      PGADMIN_CONFIG_SERVER_MODE: "False"
      PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: "False"
    depends_on:
      - db
    networks:
      - db-pgadmin-network

volumes:
  db:
  pgadmin-data:

networks:
  api-db-network:
    external: true
  db-pgadmin-network:
    external: true

go.dockerfile

GoのAPIサーバー用のDocker設定ファイルです。
app/をgo/app/にコピーして、作業用のディレクトリとしています。また、新たにgoのサードパーティ製パッケージを導入したい場合には、通常通りapp/下でgo install ~もしくはgo get ~とすれば、go.modファイルに追加されるため、Dockerコンテナ起動時に必要なパッケージすべてがインストールされます。

FROM golang:1.23.1-alpine3.20

COPY ./app /go/app/

WORKDIR /go/app/

RUN go mod download && go mod tidy
RUN go install github.com/air-verse/air@latest

CMD ["air", "-c", ".air.toml"]

EXPOSE 8080

app/

Goで実装するファイルはこのディレクトリ下に置いていきます。
ルートディレクトリに直接main.goをおいても良かったのですが、今後マイグレーション用で別モジュールを作りたくなった際などに便利だと考えたため、appディレクト下にまとめています。

tmp/

Airでホットリロードを有効にするため、Goファイルをビルドした実行ファイルなどが自動生成されます。

.air.toml

Airの設定を記述しています。
air initで生成したファイルとほぼ同じです。

root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"

[build]
  args_bin = []
  bin = "tmp/main.exe"
  cmd = "go build -o ./tmp/main.exe ."
  delay = 0
  exclude_dir = ["assets", "tmp", "vendor", "testdata"]
  exclude_file = []
  exclude_regex = ["_test.go"]
  exclude_unchanged = false
  follow_symlink = false
  full_bin = ""
  include_dir = []
  include_ext = ["go", "tpl", "tmpl", "html"]
  include_file = []
  kill_delay = "0s"
  log = "build-errors.log"
  poll = true
  poll_interval = 0
  post_cmd = []
  pre_cmd = []
  rerun = false
  rerun_delay = 500
  send_interrupt = false
  stop_on_error = false

[color]
  app = ""
  build = "yellow"
  main = "magenta"
  runner = "green"
  watcher = "cyan"

[log]
  main_only = false
  time = false

[misc]
  clean_on_exit = false

[proxy]
  app_port = 0
  enabled = false
  proxy_port = 0

[screen]
  clear_on_rebuild = false
  keep_scroll = true

main.go

基本的な処理しか記述していないため、詳細な説明は省略します。
データベースとしてはPostgreSQLを採用し、フレームワークにはGinを採用しています。
データベース接続に成功した上で、データベースからレコードを取得できることを確認するために、/getエンドポイントを用意しています。このエンドポイントを叩くとaccountsテーブルからレコードを1行取得し、レスポンスとして返します。

実際にこのリポジトリをテンプレートリポジトリとして使う場合には、データベース情報は環境変数として.envファイルなどに記載するようにしてください。

package main

import (
	"database/sql"
	"fmt"
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"
	_ "github.com/lib/pq"
)

// データベースからレコード取得する際に用いる変数
var (
	id int
	name string
	password string
)

func main() {
	// データベース接続
	dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable",
		"db", "db-user", "db-password", "db-name", "5432")
	db, err := sql.Open("postgres", dsn)
	if err != nil {
		panic("データベース接続失敗")
	} else {
		fmt.Println("データベース接続成功")
	}

	r := gin.Default()

    // GET /get
	r.GET("/get", func(ctx *gin.Context) {
		row := db.QueryRow("SELECT id, name, password FROM accounts;")
		if row.Err() != nil {
			ctx.JSON(http.StatusInternalServerError, gin.H{"error": row.Err().Error()})
			return
		}
		err := row.Scan(&id, &name, &password)
		if err != nil {
			ctx.JSON(http.StatusInternalServerError, gin.H{"error": err})
			return
		}
		ctx.JSON(http.StatusOK, gin.H{
			"id": strconv.Itoa(id),
			"name": name,
			"password": password,
		})
	})
	r.Run()
}

configディレクトリ

servers.jsonファイル

このファイルには、データベースに関する設定を記述することで、PGAdminでのログイン作業などを削減しています。

{
  "Servers": {
    "1": {
      "Name": "db-name",
      "Group": "Servers",
      "Host": "db",
      "Port": 5432,
      "MaintenanceDB": "postgres",
      "Username": "db-user",
      "SSLMode": "prefer"
    }
  }
}

sqlディレクトリ

init.sqlファイル

Docker Compose実行時に、テーブルおよびテストデータを挿入するためのSQLファイルです。
accountsテーブルを作成し、4件のテストデータを挿入します。

CREATE TABLE accounts (
  id SERIAL NOT NULL,
  name VARCHAR(20) NOT NULL,
  password VARCHAR(50) NOT NULL,
  PRIMARY KEY (id)
);

INSERT INTO accounts (name, password) VALUES ('a1_name', 'a1_password');
INSERT INTO accounts (name, password) VALUES ('a2_name', 'a2_password');
INSERT INTO accounts (name, password) VALUES ('a3_name', 'a3_password');
INSERT INTO accounts (name, password) VALUES ('a4_name', 'a4_password');

トラブルシューティング

私の手元で環境構築する際に起こったエラーについて、解決策を記載しておきます。

Dockerのビルドに失敗する

> docker compose build
2024/09/25 13:29:24 http2: server: error reading preface from client //./pipe/docker_engine: file has already been closed
[+] Building 1.5s (3/3) FINISHED                                                                                                                                                                    docker:default
 => [api internal] load build definition from go.dockerfile                                                                                                                                                   0.0s
 => => transferring dockerfile: 232B                                                                                                                                                                          0.0s 
 => ERROR [api internal] load metadata for docker.io/library/1.23.1-alpine3.20:latest                                                                                                                         1.5s 
 => [api auth] library/1.23.1-alpine3.20:pull token for registry-1.docker.io                                                                                                                                  0.0s
------
 > [api internal] load metadata for docker.io/library/1.23.1-alpine3.20:latest:
------
failed to solve: 1.23.1-alpine3.20: failed to resolve source metadata for docker.io/library/1.23.1-alpine3.20:latest: failed to authorize: failed to fetch oauth token: unexpected status from GET request to https://auth.docker.io/token?scope=repository%3Alibrary%2F1.23.1-alpine3.20%3Apull&service=registry.docker.io: 401 Unauthorized

対処する上で、こちらの記事が参考になりました。

Docker構成ファイルを削除

rm ~/.docker/config.json

Dockerキャッシュを削除

rm  ~/.docker/buildx

Dockerログアウト

docker logout

Dockerログイン

docker login

Dockerコンテナの立ち上げに失敗する

> docker compose up   
[+] Running 2/0
 ✔ Volume "go-dev-template_db"            Created                                                                                                                                                                                   0.0s 
 ✔ Volume "go-dev-template_pgadmin-data"  Created                                                                                                                                                                                   0.0s 
 - Container db                           Creating                                                                                                                                                                                  0.0s 
Error response from daemon: Conflict. The container name "/db" is already in use by container "db0b3ae760659a491f77e4a91c38128804931d6618b836cb9ac6f5547b22a0a7". You have to remove (or rename) that container to be able to reuse that name.

以下の記事を参考に解決しました。

Dockerコンテナのリストを確認

> docker ps -a
CONTAINER ID   IMAGE                                        COMMAND                   CREATED        STATUS                      PORTS     NAMES
db0b3ae76065   postgres:16                                  "docker-entrypoint.s…"   2 weeks ago    Created                               db
1f81507429d9   mybrary-backend-api                          "air -c .air.toml"        3 weeks ago    Exited (1) 3 weeks ago                api

名前がコンフリクトを起こしていると言われたDockerコンテナを削除

> docker rm db0b3ae76065
db0b3ae76065
> docker rm 1f81507429d9
1f81507429d9

おわりに

まだまだGopherとなって日が浅いため、改善する余地があると思いますので、もっとこうした方が良いなどの意見がありましたら、コメントしていただけると嬉しいです。

2
4
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
2
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?