Entity Framework を使用して SQLite に migration するまで

  • 今回作成する Models の ER図(dbdiagram.io)
    • dotnet ef migrations script により作成した DDL から生成

セットアップ

まずは、3系の sdk を使用して .NET Core で開発できるようセットアップします。

dotnet --list-sdks

# sdk のバージョンを固定
dotnet new globaljson --sdk-version 3.1.408

Spring Initilizar 的な scaffolding 用のコマンドで webapi を開発します。

必要な tool や package を追加します。

dotnet new webapi -n sandbox

# tool のバージョンをディレクトリで固定
dotnet new tool-manifest

# tool の install 
dotnet tool install dotnet-ef --version 3.1.4

# package の追加
dotnet add package Microsoft.EntityFrameworkCore.Design --version 3.1.4

dotnet add package Microsoft.EntityFrameworkCore --version 3.1.4

dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 3.1.4

dotnet new gitignore

この時点で一度、起動するか確認。

dotnet run

Entity の定義

models ディレクトリをつくり、その中に Entity の定義をします。

hibernate と比較して、 Entity 自体に oneToMany といった relation を示す定義は書かないみたいです。

DbContext を継承した Context の設定用クラスが味噌っぽいです。

このクラスに Context 内で扱いたい Entity を記載します。 _context.Books.Where() のように扱えます。

        public DbSet<Book> Books { get; private set; }
        public DbSet<Author> Authors { get; private set; }
        public DbSet<Review> Reviews { get; private set; }
        public DbSet<Reviewer> Reviwers { get; private set; }
        public DbSet<BookAuthor> BookAuthors { get; private set; }

ManyToMany(N:N) の relation については中間テーブルが必要になるため、 OnModelCreating メソッドに定義を追加します。

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<BookAuthor>().HasKey(ba => new { ba.BookId, ba.AuthorId});
            modelBuilder.Entity<BookAuthor>()
                .HasOne(ba => ba.Author)
                .WithMany(a => a.BookAuthors)
                .HasForeignKey(ba => ba.AuthorId);
            modelBuilder.Entity<BookAuthor>()
                .HasOne(ba => ba.Book)
                .WithMany(b => b.BookAuthors)
                .HasForeignKey(ba => ba.BookId);
        }

Framework による migration

Spring の Active Profiles のように複数の設定ファイルを切り替えて設定情報を注入できるようです。

開発用の設定として、 SQLite の接続情報を記載します。

// appsettings.Development.json

    "ConnectionStrings": {
        "DefaultConnection": "Data source=sandbox.db",
        "MSSQLConnection": "Server=localhost,1433;Initial Catalog=Sandbox; User ID=sa; Password=P@ssw0rd!;"
    }

Startup.cs の ConfigureServices メソッドが DI コンテナへの追加を行います。

ここに DI に必要な設定として、 interface と使用する実装のペアを記載して使用するのですが、 今回は、 Datasource への接続情報を Context に登録するようなイメージで設定を記載します。

が、接続情報は外部の設定ファイルから注入したいので、 appsettings.Development.json の設定名の key で指定します。

// Startup.cs

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllers();
            services.AddDbContext<DataContext>(options =>
            {
              options.UseSqlite(Configuration.GetConnectionString("DefaultConnection"));
            });
        }

あとは、 migration を実行し、 Migrations フォルダが作成された後に、 update で DB に適用されます。

$ dotnet ef --version
Entity Framework Core .NET Command-line Tools
3.1.4

dotnet ef migrations add InitialCreate

# 削除する場合
# dotnet ef migrations list
# dotnet ef migrations remove

# DB に適用
dotnet ef database update

# 適用したものを削除
# dotnet ef database drop

migration を行わず、 DDL を生成することもできるようです。 ここで MSSQL むけの DDL を生成して、 dbdiagram.io で ER図を作成としました。

-- dotnet ef migrations script
-- CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
--     "MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
--     "ProductVersion" TEXT NOT NULL
-- );

CREATE TABLE "Authors" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Authors" PRIMARY KEY AUTOINCREMENT,
    "FirstName" TEXT NULL,
    "LastName" TEXT NULL
);

CREATE TABLE "Books" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Books" PRIMARY KEY AUTOINCREMENT,
    "Title" TEXT NULL,
    "Published" TEXT NULL
);

CREATE TABLE "Reviwers" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Reviwers" PRIMARY KEY AUTOINCREMENT,
    "FirstName" TEXT NULL,
    "LastName" TEXT NULL
);

CREATE TABLE "BookAuthors" (
    "BookId" INTEGER NOT NULL,
    "AuthorId" INTEGER NOT NULL,
    CONSTRAINT "PK_BookAuthors" PRIMARY KEY ("BookId", "AuthorId"),
    CONSTRAINT "FK_BookAuthors_Authors_AuthorId" FOREIGN KEY ("AuthorId") REFERENCES "Authors" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_BookAuthors_Books_BookId" FOREIGN KEY ("BookId") REFERENCES "Books" ("Id") ON DELETE CASCADE
);

CREATE TABLE "Reviews" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Reviews" PRIMARY KEY AUTOINCREMENT,
    "Headline" TEXT NULL,
    "ReviewText" TEXT NULL,
    "Rating" INTEGER NOT NULL,
    "ReviewerId" INTEGER NULL,
    "BookId" INTEGER NULL,
    CONSTRAINT "FK_Reviews_Books_BookId" FOREIGN KEY ("BookId") REFERENCES "Books" ("Id") ON DELETE RESTRICT,
    CONSTRAINT "FK_Reviews_Reviwers_ReviewerId" FOREIGN KEY ("ReviewerId") REFERENCES "Reviwers" ("Id") ON DELETE RESTRICT
);

CREATE INDEX "IX_BookAuthors_AuthorId" ON "BookAuthors" ("AuthorId");

CREATE INDEX "IX_Reviews_BookId" ON "Reviews" ("BookId");

CREATE INDEX "IX_Reviews_ReviewerId" ON "Reviews" ("ReviewerId");

-- INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
-- VALUES ('20210531222210_InitialCreate', '3.1.4');

作成された DB の確認

SQLite なので単一のファイルとして存在しています。 Mac には標準でクライアントが入っているので、繋いで確認します。

$ file sandbox.db
sandbox.db: SQLite 3.x database, last written using SQLite version 3028

$ sqlite3 sandbox.db 
SQLite version 3.32.3 2020-06-18 14:16:19

sqlite> .database
main: /Users/kiyotakeshi/gitdir/c-charp/c-charp-sandbox/sandbox/sandbox.db

sqlite> .table
Authors                Books                  Reviwers             
BookAuthors            Reviews                __EFMigrationsHistory

DB が作成されていることが確認できました。

普段の開発では任意の SQLite に対応したクライアントを使用するといいと思います。 vscode なら以下のものが使いやすかったです。

Name: SQLite
Id: alexcvzz.vscode-sqlite
Description: Explore and query SQLite databases.
Version: 0.12.0
Publisher: alexcvzz
VS Marketplace Link: https://marketplace.visualstudio.com/items?itemName=alexcvzz.vscode-sqlite

reverse proxy として nginx をコンテナ起動し、 Spring Boot Application と連携する

nginx を reverse proxy としてコンテナ起動する際に、 http://host.docker.internal:8081/ と設定している意味について検証したメモ。

アーキテクチャのイメージ

f:id:ponkan1219:20210228160950j:plain


localhost:8081 で起動している Spring Boot で作成したアプリケーションの前段に、 nginx を置いて、 localhost:80 でアプリケーションにアクセスできるようにします。

昔ながらの三層構造(Web,App,DB)だとこのような構成で動かすことが多いかと思います。

クラウド時代のデプロイとしては、

  • Web のレイヤーが AWS の ELB になり、ターゲットグループでアプリケーションの起動ポートを指定したり

  • Kubernetes の Service になったり

と複雑さが増しますが、今回はシンプルな構成としてみます。


設定する

nginx.conf を以下のように記載すると、実現できます。

events {
    worker_connections  16;
}
http {
    server {
        listen 80;
        server_name localhost;

        location / {

            proxy_pass http://host.docker.internal:8081/;
            proxy_redirect off;
        }
    }
}

ここまでは調べると出てくる情報だったのですが、 http://host.docker.internal:8081/ とはなんぞやという感想です。

Docker のドキュメントによると、

ホストの IP アドレスは変動します(あるいは、ネットワークへの接続がありません)。18.03 よりも前は、特定の DNS 名 host.docker.internal での接続を推奨していました。これはホスト上で内部の IP アドレスで名前解決します。これは開発用途であり、Docker Desktop forMac 外の本番環境では動作しません。

コンテナからホスト(今回だと Mac)の private ip を解決するもののようです。


host.docker.internal の動作を検証

上記の図となるような構成でコンテナを立ち上げます。

※参考リポジトリの docker-compose.yamlコメントアウトを解除

$ docker-compose up -d

host.docker.internal への疎通を確認します。

$ docker network inspect todo_default | grep "Gateway"

                    "Gateway": "172.19.0.1"

$ docker inspect reverse-proxy-nginx | egrep 'Gateway|IPAddress'

            "SecondaryIPAddresses": null,
            "Gateway": "",
            "IPAddress": "",
            "IPv6Gateway": "",
                    "Gateway": "172.19.0.1",
                    "IPAddress": "172.19.0.4",
                    "IPv6Gateway": "",

$ docker-compose exec nginx bash -c "apt-get update -y > /dev/null && apt install -y iputils-ping traceroute > /dev/null && ping host.docker.internal -c 2 && traceroute host.docker.internal"

# 結果の抜粋
PING host.docker.internal (192.168.65.2) 56(84) bytes of data.
64 bytes from 192.168.65.2 (192.168.65.2): icmp_seq=1 ttl=37 time=0.142 ms
64 bytes from 192.168.65.2 (192.168.65.2): icmp_seq=2 ttl=37 time=0.232 ms

--- host.docker.internal ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1ms
rtt min/avg/max/mdev = 0.142/0.187/0.232/0.045 ms
traceroute to host.docker.internal (192.168.65.2), 30 hops max, 60 byte packets
 1  172.19.0.1 (172.19.0.1)  0.625 ms  0.557 ms  0.534 ms

host.docker.internal192.168.65.2 に名前解決され、 Gateway を経由して到達しているようです。

Docker for Mac にて設定した、ネットワークのレンジであることが画像や、

f:id:ponkan1219:20210228173806p:plain

Mac のプロセスでも確認できました。

$ ps aux | grep docker | grep '192.168.65.2'

kiyotakeshi       1476   0.0  0.2  6200624 123032   ??  S     4:25PM   0:01.08 com.docker.vpnkit --ethernet fd:3 --diagnostics fd:4 --pcap fd:5 --vsock-path vms/0/connect --gateway-forwards /Users/kiyotakeshi/Library/Group Containers/group.com.docker/gateway_forwards.json --host-names host.docker.internal,docker.for.mac.host.internal,docker.for.mac.localhost --listen-backlog 32 --mtu 1500 --allowed-bind-addresses 0.0.0.0 --http /Users/kiyotakeshi/Library/Group Containers/group.com.docker/http_proxy.json --dhcp /Users/kiyotakeshi/Library/Group Containers/group.com.docker/dhcp.json --port-max-idle-time 300 --max-connections 2000 --gateway-ip 192.168.65.1 --host-ip 192.168.65.2 --lowest-ip 192.168.65.3 --highest-ip 192.168.65.254 --log-destination asl --gc-compact-interval 1800

ということで、 のような、ルーティングとなっているようです。


コンテナ間通信なら、 http://host.docker.internal は使わない

さて、ホストの側にアクセスしないで、コンテナ間通信する場合の、 reverse proxy の設定となると、 http://host.docker.internal を使うのは不適切です。

理由としては、以下のものが考えられます。

  • コンテナ名で名前解決して疎通することができるから

  • (意図してホスト側の private ip に疎通するならまだしも)、直接的なコンテナ間通信をしていないから

Spring Boot のアプリケーションも image 化してコンテナ起動した場合は、
Nginx とアプリケーションでコンテナ間通信をするので、以下の図のような関係性に変わります。

f:id:ponkan1219:20210228161009j:plain

その際の、 nginx.conf の設定はこちら。

events {
    worker_connections  16;
}
http {
    server {
        listen 80;
        server_name localhost;

        location / {
            # container name を指定
            proxy_pass http://todo:8081/;
            proxy_redirect off;
        }
    }
}

コンテナ名を指定するため、 http://host.docker.internal は使用しません。

docker-compose.yaml(参考リポジトリでは、 app.yaml というファイル名)

services:
  postgres:
    image: postgres:11.10
    container_name: todo-postgres
    ports:
      - 5432:5432
    volumes:
      - .docker/postgres:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
      POSTGRES_DB: todo
      POSTGRES_INITDB_ARGS: "--encoding=UTF-8"
    restart: always
  todo:
    image: todo # ビルドしておいた image を使用
    container_name: todo
    ports:
      - 8081:8081
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://todo-postgres:5432/todo
    depends_on:
      - postgres
  nginx:
    image: reverse-proxy-nginx
    build: ./reverse-proxy # Dockerfile がある任意の場所
    container_name: reverse-proxy-nginx
    ports:
      - 80:80
    depends_on:
      - todo

Dockerfile

FROM nginx:1.19
COPY nginx.conf /etc/nginx/nginx.conf

コンテナを起動後、 todo で名前解決できていることが確認できます。

docker-compose up -d

# app.yaml を使用する場合は、 `docker-compose -f app.yaml exec nginx ...`
$ docker-compose exec nginx bash -c "apt-get update -y > /dev/null && apt-get install dnsutils -y /dev/null && dig todo +short"

192.168.48.3

docker-compose で複数のコンテナを起動する際に、ネットワークモードの設定を明示的にしていないため、 bridge mode ですべてのコンテナが同一ネットワークに所属しています。

$ docker network ls | grep todo 

8af1c7f7ab3c   todo_default                       bridge    local

$ docker network inspect todo_default | jq -r '.[].Containers[] | [.Name, .IPv4Address]'

[
  "todo-postgres",
  "192.168.48.2/20"
]
[
  "reverse-proxy-nginx",
  "192.168.48.4/20"
]
[
  "todo",
  "192.168.48.3/20"
]

$ docker-compose exec nginx bash -c "dig todo-postgres +short"

192.168.48.2

そのため、 reverse-proxy の設定箇所は、コンテナ名の指定の方が適切となります。

proxy_pass http://todo:8081/;

Spring Boot で MongoDB をつかってみる

NoSQL 触ったことがなかったので、 Spring Boot で MongoDB を使用してみます。


まずは、ローカル開発は Docker でやるために、Mongo のコンテナを起動します。

というかこの点に関してハマった点をまとめるエントリーです。

まずは、root user および admin database をセットアップせずに起動し、
Application から疎通するために必要な流れをみていきます。

  • docker-compose.yaml
    • MongoDB と mongo-express を記載
    • 初期DBを指定
      • この DB にコレクション(RDBでいう Table のようなイメージ)を作る必要あり
version: '3.1'

services:
  mongo:
    container_name: spring-mongo
    image: mongo:4.2
    restart: always
    environment:
      MONGO_INITDB_DATABASE: spring-mongo
    ports:
      - 27017:27017
    volumes:
      - ./mongo:/data/db
      - ./mongo/init:/docker-entrypoint-initdb.d

  mongo-express:
    container_name: spring-mongo-express
    image: mongo-express
    restart: always
    ports:
      - 9080:8081 # コンテナの 8081 にマッピングしていればホスト側は任意
    depends_on:
      - mongo
spring:
  data:
    mongodb:
      # port: 27017 # if you set not default port

      host: localhost
      database: spring-mongo
      username: demo
      password: 1qazxsw2

host,database,username,password の記載は uri: mongodb://localhost/spring-mongo とすることも可能です。

ここで指定している username,password はどこで設定したかというと、 image: mongo:4.2 の ENTRYPOINT であるdocker-entrypoint.sh にて実行される .js か .sh ファイル にて流し込んでいます。

$ ls -1 mongo/init

01_import_init_data.json
01_import_init_data.sh
02_create_user.js
let user = {
  user: 'demo',
  pwd: '1qazxsw2',
  roles: [{
    role: 'readWrite',
    db: 'spring-mongo'
  }]
};
db.createUser(user);

これで、 docker-compose up -d をすると、

Application の接続先が準備できます。

MongoDB に繋いで確認してみます。 DB,Collection,Document が作成されています。

docker-compose exec  mongo bash

mongo

> show dbs
admin         0.000GB
config        0.000GB
local         0.000GB
spring-mongo  0.000GB

> use spring-mongo
switched to db spring-mongo

> show collections
customer

> db.customer.find()
{ "_id" : ObjectId("6024e6a39c82f64f379738ca"), "firstName" : "Mike", "lastName" : "Popcorn", "_class" : "com.kiyotakeshi.spring.mongo.Customer" }
{ "_id" : ObjectId("6024e6a39c82f64f379738cb"), "firstName" : "Sam", "lastName" : "Smith", "_class" : "com.kiyotakeshi.spring.mongo.Customer" }

これらを接続先として、 application.yaml で指定していたわけです。

あとは、アプリを起動すると...

./mvnw spring-boot:run

MongoDB につなぎこみ、検索できています。

※このアプリの実装はほぼ 参考 記載の Spring のガイドのままです

------------------------------
Customers found with findAll()
2021-02-14 13:30:26.807  INFO 6466 --- [           main] org.mongodb.driver.connection            : Opened connection [connectionId{localValue:3, serverValue:12}] to localhost:27017
Customer{id='6024e6a39c82f64f379738ca', firstName='Mike', lastName='Popcorn'}
Customer{id='6024e6a39c82f64f379738cb', firstName='Sam', lastName='Smith'}

--------------------------------
Customer found with findByFirstName('Alice'):
null

--------------------------------
Customers found with findByLastName('Smith'):
Customer{id='6024e6a39c82f64f379738cb', firstName='Sam', lastName='Smith'}
2021-02-14 13:30:26.848  INFO 6466 --- [extShutdownHook] org.mongodb.driver.connection            : Closed connection [connectionId{localValue:3, serverValue:12}] to localhost:27017 because the pool has been closed.

Process finished with exit code 0

root user および admin database をセットアップした場合

以下おまけ。

docker-compose に以下のような記載をしているものを見かけました。

# mongodb
      MONGO_INITDB_ROOT_USERNAME: root
      MONGO_INITDB_ROOT_PASSWORD: 1qazxsw2

# mongo-express
    environment:
      ME_CONFIG_MONGODB_ADMINUSERNAME: root
      ME_CONFIG_MONGODB_ADMINPASSWORD: 1qazxsw2

これらは、

をしている箇所で、この設定を有効にしている場合、 Application の接続時の設定も変更する必要がありました。

先ほどの設定で、そのままアプリを起動するとコネクション時に認証エラーに。

Caused by: org.springframework.data.mongodb.UncategorizedMongoDbException: Exception authenticating MongoCredential{mechanism=SCRAM-SHA-1, userName='demo', source='spring-mongo', password=<hidden>, mechanismProperties=<hidden>}; nested exception is com.mongodb.MongoSecurityException: Exception authenticating MongoCredential{mechanism=SCRAM-SHA-1, userName='demo', source='spring-mongo', password=<hidden>, mechanismProperties=<hidden>}

Caused by: com.mongodb.MongoSecurityException: Exception authenticating MongoCredential{mechanism=SCRAM-SHA-1, userName='demo', source='spring-mongo', password=<hidden>, mechanismProperties=<hidden>}

Caused by: com.mongodb.MongoCommandException: Command failed with error 18 (AuthenticationFailed): 'Authentication failed.' on server localhost:27017. The full response is {"ok": 0.0, "errmsg": "Authentication failed.", "code": 18, "codeName": "AuthenticationFailed"}

2021-02-14 13:42:18.968  INFO 6735 --- [           main] org.mongodb.driver.connection            : Closed connection [connectionId{localValue:4}] to localhost:27017 because there was a socket exception raised by this connection.

以下のように、 application.yaml に authentication-database と接続情報を記載する必要があります。

      host: localhost
      database: spring-mongo
      username: root # if you set "MONGO_INITDB_ROOT_USERNAME", change that username
      password: 1qazxsw2
      # if you set "MONGO_INITDB_ROOT_*", in docker-compose
      authentication-database: admin

      # host,database,username,password の記載を一行にまとめる場合
      # uri: mongodb://root:1qazxsw2@localhost/spring-mongo?authSource=admin

これで無事にコネクションできます。


@RequestBody をつけたら request body is missing のエラーになった

POST メソッドを受け付ける Controller に @RequestBody を付与した際に、タイトルの通りのエラーに遭遇したためメモ。

2021-01-20 03:05:42.522  WARN 2842 --- [nio-8081-exec-2] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: Required request body is missing: public org.springframework.http.ResponseEntity<com.kiyotakeshi.todo.entity.Todo> com.kiyotakeshi.todo.controller.TodoController.createTodo(com.kiyotakeshi.todo.entity.Todo)]

原因としては、 json ではない、あやまったリクエストを送っていたため。

f:id:ponkan1219:20210120032122p:plain

curl --location --request POST 'http://localhost:8081/todo/?activityName=test&color=black&category=test'

正しいリクエストは以下の形式。postman で作ったものを curl としてコピー。

f:id:ponkan1219:20210120032152p:plain

curl -i --location --request POST 'http://localhost:8081/todo/' \
--header 'Content-Type: application/json' \
--data-raw '{
    "activityName":"test",
    "color":"black",
    "category":"test"
}'

debugger で止めて、 Evaluate Expression すると、しっかりとオブジェクトに値が入っていることがわかる。

f:id:ponkan1219:20210120032208p:plain

以下がレスポンス、正常に作成された。

HTTP/1.1 201
Location: /todo/10012
Content-Type: application/json
Transfer-Encoding: chunked
Date: Tue, 19 Jan 2021 17:50:34 GMT

{"id":10012,"activityName":"test","color":"black","category":"test"}

ちなみに、レスポンスが json で返ってきているのは、Controller に付与している @RestController に、

@RestController
public class TodoController {
}

@ResponseBody が付与されているため。

※ちなみに @Controller (ステレオタイプアノテーション)がついてるから、 Component Scan され Bean化されてる

// org/springframework/web/bind/annotation/RestController.java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {
    (略)
}

そんな感じで、APIjson でリクエストを受けて、 json を返すものということがわかった。

参考にした stack overflow

flyway × postgres × jpa のid自動採番がうまくいかないときの対処法

entity を JPA により永続化する際に primary key の自動採番に苦戦したためメモ。

今回の検証内容のリポジトリ


JPA により DML,DDL を用意

以下のような、いたってシンプルな entity を用意する。

@Entity
public class Todo {

    @Id
    private Long id;

    private String activityName;

    private String color;

    private String category;
}

次にテストコードを用意し、JPA が発行した DDL を参考に、データソースを h2 から postgres に変更する。

@DataJpaTest
class TodoTests {

    @Autowired
    TestEntityManager em;

    @Test
    void mapping() {
        var todo1 = this.em.find(Todo.class, 1000L);
        assertThat(todo1.getActivityName()).isEqualTo("go to supermarket");
        assertThat(todo1.getCategory()).isEqualTo("housework");
        assertThat(todo1.getColor()).isEqualTo("white");
    }
}

※発行された DDL

Hibernate: create table todo (id bigint generated by default as identity, activity_name varchar(255), category varchar(255), color varchar(255), primary key (id))

なお、アプリケーション起動時に、flyway の sql-based-migrations を使用し、postgres に初期テーブル、データを作成する。

ちなみに、postgres コンテナ を docker-compose で起動する際に初期DBを作成する手順は 以前の投稿を参考までに。

DML の定義ファイルを用意。

※あとでハマる原因となる、 primary key である id の型が bigint(Long 型の entity の定義から自動的に設定されたもののようです)

-- V1.0.0__add_todo_table.sql
create table todo (id bigint not null, activity_name varchar(255), category varchar(255), color varchar(255), primary key (id));

DDLJPA に自動生成してもらうために、テストケースを作成。

@Test
void newTodo() {
    var todo = new Todo("new", "white", "test");
    this.em.persistAndFlush(todo);
}
Hibernate: insert into todo (id, activity_name, category, color) values (null, ?, ?, ?)

参考にし DDL の定義ファイルも用意。

-- V1.0.1__insert_todo_records.sql
insert into todo (activity_name, category, color, id) values ('go to supermarket', 'housework', 'white', 1000);
insert into todo (activity_name, category, color, id) values ('listen to music', 'hobby', 'white', 1001);
insert into todo (activity_name, category, color, id) values ('make a presentation', 'job', 'black', 1002);

idの自動採番ができずに怒られる

アプリを起動し、

docker-compose up -d

./mvn spring-boot:run

POST リクエストをすると、

curl 'http://localhost:8081/todo/' -i -X POST \
    -d 'activityName=test&color=black&category=test'

insert 時に id が null で怒られます。

Hibernate: insert into todo (activity_name, category, color) values (?, ?, ?)
2021-01-18 22:45:01.523  WARN 2262 --- [nio-8081-exec-3] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 0, SQLState: 23502
2021-01-18 22:45:01.523 ERROR 2262 --- [nio-8081-exec-3] o.h.engine.jdbc.spi.SqlExceptionHelper   : ERROR: null value in column "id" violates not-null constraint

org.postgresql.util.PSQLException: ERROR: null value in column "id" violates not-null constraint
  Detail: Failing row contains (null, test, test, black).

flyway の migration によりアプリ起動時に作成されたテーブルの定義を確認してみます。 postgres のコマンドをコンテナ内部に渡して、確認すると、

# `-d todo` で指定の todo は DB
# `\d todo` で todo table の定義を確認
docker-compose exec postgres bash -c 'psql -Upostgres -d todo -c "\d todo"'

V1.0.0__add_todo_table.sql で定義した通り、bigint です。

                           Table "public.todo"
    Column     |          Type          | Collation | Nullable | Default 
---------------+------------------------+-----------+----------+---------
 id            | bigint                 |           | not null | 
 activity_name | character varying(255) |           |          | 
 category      | character varying(255) |           |          | 
 color         | character varying(255) |           |          | 
Indexes:
    "todo_pkey" PRIMARY KEY, btree (id)

解決策

連番で採番し null を回避するには、 serial 型が使えそうです。

※serial 型のドキュメント

デフォルト値が連番を発生させる仕組みから割り当てられるようにしました。 また、NOT NULL 制約を適用することによって、null 値が明示的に挿入されないようにします

bigint を使用していたので、 bigserial を使用してみます。

-- V1.0.0__add_todo_table.sql
create table todo (id bigserial not null, activity_name varchar(255), category varchar(255), color varchar(255), primary key (id));

コンテナを破棄、再生成して、アプリを起動しテーブルの定義を確認してみます。

docker-compose down && docker-compose up -d

./mvn spring-boot:run

$ docker-compose exec postgres bash -c 'psql -Upostgres -d todo -c "\d todo"'

bigint で 自動採番される定義になっています。

                                       Table "public.todo"
    Column     |          Type          | Collation | Nullable |             Default              
---------------+------------------------+-----------+----------+----------------------------------
 id            | bigint                 |           | not null | nextval('todo_id_seq'::regclass)
 activity_name | character varying(255) |           |          | 
 category      | character varying(255) |           |          | 
 color         | character varying(255) |           |          | 
Indexes:
    "todo_pkey" PRIMARY KEY, btree (id)

todo_id_seq という設定についても確認すると、

docker-compose exec postgres bash -c 'psql -Upostgres -d todo -c "\d"'

sequence という Type で、

                  List of relations
 Schema |         Name          |   Type   |  Owner   
--------+-----------------------+----------+----------
 public | flyway_schema_history | table    | postgres
 public | todo                  | table    | postgres
 public | todo_id_seq           | sequence | postgres
(3 rows)
docker-compose exec postgres bash -c 'psql -Upostgres -d todo -c "\d todo_id_seq"'

1からインクリメントしていくもののようです。

                        Sequence "public.todo_id_seq"
  Type  | Start | Minimum |       Maximum       | Increment | Cycles? | Cache 
--------+-------+---------+---------------------+-----------+---------+-------
 bigint |     1 |       1 | 9223372036854775807 |         1 | no      |     1
Owned by: public.todo.id

あとは、 entity の定義を変更します。

@Entity
public class Todo {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) // 自動インクリメントで一意の値を生成
    private Long id;

    private String activityName;

    private String color;

    private String category;
}

POST リクエストをすると、

curl 'http://localhost:8081/todo/' -i -X POST \
    -d 'activityName=test&color=black&category=test'

curl 'http://localhost:8081/todo/' -i -X POST \
    -d 'activityName=test2&color=white&category=test'

1番から採番されて、entity が永続化できています。

curl -s http://localhost:8081/todo/ | jq -r '.[] | select(.id == 1 or .id == 2)'
{
  "id": 1,
  "activityName": "test",
  "color": "black",
  "category": "test"
}
{
  "id": 2,
  "activityName": "test2",
  "color": "white",
  "category": "test"
}

DB も確認してみる。

docker-compose exec postgres bash -c 'psql -Upostgres -d todo -c "select * from todo"'
  id  |    activity_name    | category  | color 
------+---------------------+-----------+-------
 1000 | go to supermarket   | housework | white
 1001 | listen to music     | hobby     | white
 1002 | make a presentation | job       | black
    1 | test                | test      | black
    2 | test2               | test      | white

もう一つのやり方

entity の定義に関して調べていると、自動採番の定義を @SequenceGenerator で設定するというやり方も見つけました。

@Entity
public class Todo {

    @Id
    @GeneratedValue(generator = "todo_id_gen")
    @SequenceGenerator(name = "todo_id_gen", sequenceName = "todo_id_seq", allocationSize = 1)
    private Long id;

    private String activityName;

    private String color;

    private String category;
}

sequence の定義も追加。

-- V1.0.0__add_todo_table.sql
create table todo (id bigint not null, activity_name varchar(255), category varchar(255), color varchar(255), primary key (id));
create sequence todo_id_seq start 10001;

relation の定義はどうなるかみてみましょう。(コンテナの disposability は便利)

docker-compose down && docker-compose up -d

./mvn spring-boot:run

docker-compose exec postgres bash -c 'psql -Upostgres -d todo -c "\d"'

sequence がつくられています。

                  List of relations
 Schema |         Name          |   Type   |  Owner   
--------+-----------------------+----------+----------
 public | flyway_schema_history | table    | postgres
 public | todo                  | table    | postgres
 public | todo_id_seq           | sequence | postgres
(3 rows)

詳しくみてみる。

docker-compose exec postgres bash -c 'psql -Upostgres -d todo -c "\d todo_id_seq"'

で POST リクエストをすると、

curl 'http://localhost:8081/todo/' -i -X POST \
    -d 'activityName=test&color=black&category=test'

curl 'http://localhost:8081/todo/' -i -X POST \
    -d 'activityName=test2&color=white&category=test'

10001番から採番されて、entity が永続化できています。

curl -s http://localhost:8081/todo/ | jq -r '.[] | select(.id == 10001 or .id == 10002)'
{
  "id": 10001,
  "activityName": "test",
  "color": "black",
  "category": "test"
}
{
  "id": 10002,
  "activityName": "test2",
  "color": "white",
  "category": "test"
}

DB も確認してみる。

docker-compose exec postgres bash -c 'psql -Upostgres -d todo -c "select * from todo"'
  id   |    activity_name    | category  | color 
-------+---------------------+-----------+-------
  1000 | go to supermarket   | housework | white
  1001 | listen to music     | hobby     | white
  1002 | make a presentation | job       | black
 10001 | test              | test    | black
 10002 | test2               | test    | white
(5 rows)

さらにもう一つのやり方

DBの設定として、 sequence の定義と id フィールドとの関連付けをするという意味では、以下のように設定することも可能。

-- V1.0.0__add_todo_table.sql
-- id は bigint として定義しつつ、sequence によるインクリメントを設定
create sequence todo_id_seq start 10001;
create table todo (id bigint not null DEFAULT nextval('todo_id_seq'), activity_name varchar(255), category varchar(255), color varchar(255), primary key (id));

DB側で設定しているので、 @GeneratedValue,@SequenceGenerator は不要。

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
docker-compose down && docker-compose up -d

./mvn spring-boot:run

docker-compose exec postgres bash -c 'psql -Upostgres -d todo -c "\d todo"'           
                                       Table "public.todo"
    Column     |          Type          | Collation | Nullable |             Default              
---------------+------------------------+-----------+----------+----------------------------------
 id            | bigint                 |           | not null | nextval('todo_id_seq'::regclass)
 activity_name | character varying(255) |           |          | 
 category      | character varying(255) |           |          | 
 color         | character varying(255) |           |          | 
Indexes:
    "todo_pkey" PRIMARY KEY, btree (id)
docker-compose exec postgres bash -c 'psql -Upostgres -d todo -c "\d todo_id_seq"'    
                        Sequence "public.todo_id_seq"
  Type  | Start | Minimum |       Maximum       | Increment | Cycles? | Cache 
--------+-------+---------+---------------------+-----------+---------+-------
 bigint | 10001 |       1 | 9223372036854775807 |         1 | no      |     1

POST したら想定通りに id が採番されている。

docker-compose exec postgres bash -c 'psql -Upostgres -d todo -c "select * from todo"'
  id   |    activity_name    | category  | color 
-------+---------------------+-----------+-------
  1000 | go to supermarket   | housework | white
  1001 | listen to music     | hobby     | white
  1002 | make a presentation | job       | black
 10001 | test                | tests     | black
(4 rows)

以上です。

postgres のコンテナ起動時の設定について

コンテナ起動時に初期DBを作成済みにする をモチベーションに environment の設定に関して理解を深めるために動作検証したのでメモ。

ドキュメントの設定値を参考に検証。

今回の検証内容のリポジトリ


TL;DR

  • ローカル開発なのでロールを気にしない
  • カスタムしたDBをコンテナ起動時に作成済みにしておきたい

のであれば、以下の docker-compose.yaml を作成し、

postgres:
  image: postgres:11.10
  container_name: postgres-playground
  ports:
    - 5432:5432
  environment:
    POSTGRES_USER: postgres # この設定は省略可能
    POSTGRES_PASSWORD: password
    POSTGRES_DB: custom

コンテナを起動すれば、カスタムしたDB(custom)を作成可能。

docker-compose up -d

docker-compose exec postgres bash -c 'psql -U postgres -c "\l"'
                                 List of databases
   Name    |  Owner   | Encoding |  Collate   |   Ctype    |   Access privileges
-----------+----------+----------+------------+------------+-----------------------
 custom    | postgres | UTF8     | en_US.utf8 | en_US.utf8 |
 postgres  | postgres | UTF8     | en_US.utf8 | en_US.utf8 |
 template0 | postgres | UTF8     | en_US.utf8 | en_US.utf8 | =c/postgres          +
           |          |          |            |            | postgres=CTc/postgres
 template1 | postgres | UTF8     | en_US.utf8 | en_US.utf8 | =c/postgres          +
           |          |          |            |            | postgres=CTc/postgres
(4 rows)

以下は検証。


まずはプレーンな状態

postgres:
  image: postgres:11.10
  container_name: postgres-playground
  ports:
    - 5432:5432
  environment:
    POSTGRES_USER: postgres
    POSTGRES_PASSWORD: password
    # POSTGRES_DB: test

起動してコンテナ内部に入って確認すると、

docker-compose up -d

docker-compose ps

docker-compose exec postgres bash

# 指定した role と同じ名前のDBにつなぎに行くため、 psql -U postgres -d postgres と同じ結果になる
psql -U postgres

\l

Owner が postgres で初期DBが作成されている。

                                 List of databases
   Name    |  Owner   | Encoding |  Collate   |   Ctype    |   Access privileges
-----------+----------+----------+------------+------------+-----------------------
 postgres  | postgres | UTF8     | en_US.utf8 | en_US.utf8 |
 template0 | postgres | UTF8     | en_US.utf8 | en_US.utf8 | =c/postgres          +
           |          |          |            |            | postgres=CTc/postgres
 template1 | postgres | UTF8     | en_US.utf8 | en_US.utf8 | =c/postgres          +
           |          |          |            |            | postgres=CTc/postgres

POSTGRES_DB を変更

POSTGRES_DB This optional environment variable can be used to define a different name for the default database that is created when the image is first started. If it is not specified, then the value of POSTGRES_USER will be used.

とのことで、デフォルトのDB名を変更できるようです。先ほどは未指定のため、POSTGRES_USER と同じ名前になってたようです。

postgres:
  image: postgres:11.10
  container_name: postgres-playground
  ports:
    - 5432:5432
  environment:
    POSTGRES_USER: postgres
    POSTGRES_PASSWORD: password
    POSTGRES_DB: test # この値を変更

コンテナ内部から postgres のコマンドを実行し、DBを確認すると、

# 先ほどのコンテナを破棄
docker-compose down

docker-compose up -d

docker-compose ps

docker-compose exec postgres bash

# シェルから postgres 内部のコマンドを実行
psql -Upostgres -c "\l"

test の名前でDBが作成されている。が、postgres のDBも存在している。 Owner の権限でも drop できないため、こちらは基本的には消せないものと思われる。

                                 List of databases
   Name    |  Owner   | Encoding |  Collate   |   Ctype    |   Access privileges
-----------+----------+----------+------------+------------+-----------------------
 postgres  | postgres | UTF8     | en_US.utf8 | en_US.utf8 |
 template0 | postgres | UTF8     | en_US.utf8 | en_US.utf8 | =c/postgres          +
           |          |          |            |            | postgres=CTc/postgres
 template1 | postgres | UTF8     | en_US.utf8 | en_US.utf8 | =c/postgres          +
           |          |          |            |            | postgres=CTc/postgres
 test      | postgres | UTF8     | en_US.utf8 | en_US.utf8 |

POSTGRES_USER を変更

This variable will create the specified user with superuser power and a database with the same name. If it is not specified, then the default user of postgres will be used.

スーパーユーザ権限を持つユーザと同じ名前のDBを作成する設定項目。

指定しない場合は、デフォルトユーザ postgres が使われる。

postgres:
  image: postgres:11.10
  container_name: postgres-playground
  ports:
    - 5432:5432
  environment:
    POSTGRES_USER: test
    POSTGRES_PASSWORD: password
    # POSTGRES_DB: test

コンテナに入るのもめんどくさいので、 postgres のコマンドをコンテナ内部に渡して結果を確認する。

docker-compose down

docker-compose up -d

docker-compose ps

docker-compose exec postgres bash -c 'psql -Upostgres -c "\l"'

デフォルトのユーザを変更しているため、ロールが無いためエラー。

psql: FATAL:  role "postgres" does not exist

変更したユーザのロールで確認すると、

docker-compose exec postgres bash -c 'psql -Utest -c "\l"'

Owner が test で指定したロールと同様のDBができている。

                             List of databases
   Name    | Owner | Encoding |  Collate   |   Ctype    | Access privileges
-----------+-------+----------+------------+------------+-------------------
 postgres  | test  | UTF8     | en_US.utf8 | en_US.utf8 |
 template0 | test  | UTF8     | en_US.utf8 | en_US.utf8 | =c/test          +
           |       |          |            |            | test=CTc/test
 template1 | test  | UTF8     | en_US.utf8 | en_US.utf8 | =c/test          +
           |       |          |            |            | test=CTc/test
 test      | test  | UTF8     | en_US.utf8 | en_US.utf8 |

POSTGRES_USER,POSTGRES_DB を変更

最後は、スーパーユーザ権限を持つユーザとデフォルトのDB名のどちらも指定。

postgres:
  image: postgres:11.10
  container_name: postgres-playground
  ports:
    - 5432:5432
  environment:
    POSTGRES_USER: test
    POSTGRES_PASSWORD: password
    POSTGRES_DB: employee

test ロールで変更したDBを指定すると、

docker-compose down && docker-compose up -d && docker-compose ps

docker-compose exec postgres bash -c 'psql -U test -d employee -c "\l'

以下のようにDBができています。

                             List of databases
   Name    | Owner | Encoding |  Collate   |   Ctype    | Access privileges
-----------+-------+----------+------------+------------+-------------------
 employee  | test  | UTF8     | en_US.utf8 | en_US.utf8 |
 postgres  | test  | UTF8     | en_US.utf8 | en_US.utf8 |
 template0 | test  | UTF8     | en_US.utf8 | en_US.utf8 | =c/test          +
           |       |          |            |            | test=CTc/test
 template1 | test  | UTF8     | en_US.utf8 | en_US.utf8 | =c/test          +
           |       |          |            |            | test=CTc/test

Application Context へのさまざまな Bean 登録の方法

様々な Bean 登録の方法を確認する。

今回の検証内容のリポジトリ


まずはベーシックな @ComponentScan により Application context に登録する方法。

public static void main(String[] args) {

    var context = new AnnotationConfigApplicationContext(ConfigurationComponentScan.class);
    var bean1 = context.getBean(Bean1.class);

    bean1.sayHello();
}
@ComponentScan
public class ConfigurationComponentScan {
}

ちなみに Spring Boot を使用した時は @SpringBootApplication に @ComponentScan が設定されている。

@SpringBootApplication
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}
@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
        @Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {

次は、Configuration class をつかって Bean登録をする。

public static void main(String[] args) {
    var context = new AnnotationConfigApplicationContext(AppConfig.class);
    var bean1 = context.getBean(Bean1.class);

    bean1.sayHello();
}
@Configuration
public class AppConfig {
    @Bean
    public Bean1 getBean1() {
        return new Bean1();
    }

    @Bean
    public Bean2 getBean2() {
        return new Bean2();
    }

    @Bean
    public Bean3 getBean3() {
        return new Bean3();
    }
}

この @Configuration に @Component が設定されているので、 Configuration class も DIコンテナにて管理される。

@Component
public @interface Configuration {

次は、 Application Context を作成時に、スキャンしたいコンポーネントのパッケージを指定するパターン。

public static void main(String[] args) {
    var context = new AnnotationConfigApplicationContext("com.kiyotakeshi.bean.creation.beans");

    var bean1 = context.getBean(Bean1.class);

    bean1.sayHello();
}

可変長引数をとるので、複数のパッケージを指定することが可能。

ただし String で受け取るので型によるチェックがないため、 typo に注意。

AnnotationConfigApplicationContext のコンストラクタに渡した引数を scan している。

public AnnotationConfigApplicationContext(String... basePackages) {
    this();
    scan(basePackages);
    refresh();
}

次は、 scan メソッドにパッケージを指定するパターン。

public static void main(String[] args) {
    AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();

    context.scan("com.kiyotakeshi.bean.creation.beans");
    context.refresh();

    Bean1 bean = context.getBean(Bean1.class);

    bean.sayHello();
}

次は、設定ファイルから読み取るパターン。

public static void main(String[] args) {
    var context = new ClassPathXmlApplicationContext("/beans.xml");
    var bean1 = context.getBean(Bean1.class);
    
    bean1.sayHello();
}

public static void main(String[] args) {
    String beansXmlLocationOnFilesystem = FileSystemXmlApplicationContextExample.class.getResource("/beans.xml").toExternalForm();

    var context = new FileSystemXmlApplicationContext(beansXmlLocationOnFilesystem);

    var bean1 = context.getBean(Bean1.class);

    bean1.sayHello();
}

xml に定義した設定で DI コンテナにBean登録できる。 xml の定義名のマッピングが必要(typo にも注意しなければならない)。

<!-- src/main/resources/beans.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
    http://www.springframework.org/schema/beans/spring-beans.xsd">

    <bean id="bean1" class="com.kiyotakeshi.bean.creation.beans.Bean1">
        <!-- Bean1 class の attribute である private Bean2 bean2; に対応 -->
        <property name="bean2" ref="bean2"/>
        <property name="bean3" ref="bean3"/>
    </bean>

    <bean id="bean2" class="com.kiyotakeshi.bean.creation.beans.Bean2"/>

    <bean id="bean3" class="com.kiyotakeshi.bean.creation.beans.Bean3"/>

</beans>