データ登録サービスの構築 その1
データを登録するためのサービスが必要ということがわかったので、ここのところはClojureでできそうか技術検証をしていた。 検証は先日出した記事で終えたので、いよいよ実装していく。
サービス名はdata-registryにしよう。
data-registryが必要な背景
下の記事でもふり返ったが、このあたりで定義していたmonolithサービスのAPIは、パラメータが多すぎて使いにくかった。
frasyrのvpa()に渡す個々のパラメータをAPIに露出させるのではなく、登録済みデータセットのversionIdを渡すほうが
、データの監査的にも良さそうという見通し。
そこで、データ登録を受け付け、登録済みデータセットのversionIdを返すサービス: data-registryが欲しいわけ。
data-registryの使われ方は下図のような感じ:
BFFからのデータ登録を受付け、monolithからの問い合わせに応じる
仕様を考える
いろいろ考えてみたのだが、data-registryには下記のようなリクエストを投げたい:
% curl /v0/catch-at-age \
--request POST \
--header 'Content-Type: application/json' \
--data '{
"metadata": {
"species": "マイワシ",
"stock": "太平洋系群",
"assessmentYear": 2026
},
"dataset": {
"valueUnit": "ton",
"dataSet": {
"0": {
"2025": 123,
"2026": 456
},
"1": {
"2025": 234,
"2026": 567
},
"2": {
"2025": 345,
"2026": 678
},
"3": {
"2025": 456,
"2026": 789
},
"4": {
"2025": 789,
"2026": 890
},
"5+": {
"2025": 890,
"2026": 901
}
}
}
}'
↑ポイントは、metadataを持っていることと、dataSetのキーに5+(5歳以上)があること1。
期待するレスポンスは下記:
{"version":"019f8bff-a5b8-7014-bfc8-41561eaac24c"}
↑もちろん、これが返るまでにデータベースへのデータ登録がある。
実装手順
data-registryの機能を実現するエッセンスは別記事で既に検証済みなので、さっそく実装手順を考えてみる。
- API契約を書く
- ルーティングの動作確認
- db セットアップ
- リポジトリ・ハンドラの実装
datasetVersion生成ロジックの設計- リファクタリング
- エラーハンドリングの追加
こんなところか。
やってみる
API契約を書く
年齢別漁獲量データを登録するためのエンドポイントを作ってみる。 OpenAPI形式で下記のように書いた:
---
openapi: 3.2.0
info:
title: Data Registry API
description: |
Immutable data service for stock assessment workflow.
Registers and versions biological datasets and parameter sets.
Returns version identifiers that other services reference for reproducibility and immutability.
version: 0.1.0
contact:
name: dev+yoshimoto-samonji@rindrics.com
license:
name: MIT
responses:
200:
content:
application/json:
schema:
summary: Return version of catch-at-age dataset
type: object
properties:
version:
type: string
example:
version: 019f8bff-a5b8-7014-bfc8-41561eaac24c
paths:
/v0/catch-at-age:
post:
operationId: catch-at-age-POST
summary: Register catch-at-age dataset
description: Register a catch-at-age dataset with metadata
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- metadata
- dataset
properties:
metadata:
type: object
required:
- species
- stock
- assessmentYear
properties:
species:
type: string
example: "マイワシ"
description: Name of target species
stock:
type: string
example: "太平洋系群"
description: Stock or population identifier
assessmentYear:
type: integer
example: 2026
description: Year of stock assessment
notes:
type: string
description: Additional notes or comments
dataset:
type: object
required:
- valueUnit
- dataSet
properties:
valueUnit:
type: string
enum: ["kg", "ton"]
description: Unit of catch weight
dataSet:
type: object
description: "Mapping of age_class:{year:catch_weight}"
additionalProperties:
type: object
description: "Mapping of year:catch_weight"
additionalProperties:
description: Catch weight
type: number
minimum: 0
responses:
200:
content:
application/json:
schema:
summary: Return version of catch-at-age dataset
type: object
properties:
version:
type: string
example:
version: 019f8bff-a5b8-7014-bfc8-41561eaac24c
↑レスポンスは、まずは正常系だけ書いたところ。
ルーティングの動作確認
APIスキーマを変えたので、前回の記事で書いたschema.cljを微修正する:
(eval `(def ~sym-name ~schema))))
- (defn validate-favorite-fish-request
- "Validate favorite-fish request, throw if invalid"
+ (defn validate-catch-at-age-request
+ "Validate catch-at-age request, throw if invalid"
[data]
- (let [schema (get schemas :favorite-fish-request)]
+ (let [schema (get schemas :catch-at-age-request)]
(if-let [error (m/explain schema data)]
- (throw (ex-info "Invalid favorite-fish request" error))
+ (throw (ex-info "Invalid catch-at-age request" error))
data)))
↑エンドポイント名を変えただけだが。
続いて、ルーターのコードも対応して修正する:
(eval `(def ~sym-name ~schema))))
- (defn favorite-fish-handler
- "POST /v0/favorite-fish"
+ (defn catch-at-age-handler
+ "POST /v0/catch-at-age"
[req]
(try
(log/info "Request body:" (:body req))
(let [body (:body req)
- validated-body (schema/validate-favorite-fish-request body)
- n (get-in validated-body [:fish :name])
- sn (get-in validated-body [:fish :scientific_name])
- response {:message (format "Your favorite fish is %s (%s), right?" n sn)}
- validated-response (schema/validate-response :favorite-fish-200 response)]
+ validated-body (schema/validate-catch-at-age-request body)
+ species (get-in validated-body [:metadata :species])
+ stock (get-in validated-body [:metadata :stock])
+ year (get-in validated-body [:metadata :assessmentYear])
+ response {:message (format "Registered %s (%s) for year %d" species stock year)}
+ validated-response (schema/validate-response :catch-at-age-200 response)]
{:status 200 :body validated-response})
(catch Exception e
- (log/error "Error in favorite-fish-handler" {:error (.getMessage e)})
+ (log/error "Error in catch-at-age-handler" {:error (.getMessage e)})
{:status 500 :body {:error (.getMessage e)}})))
(defn wrap-json-with-keywords [handler]
(wrap-json-body handler {:key-fn keyword, :charset "utf-8"}))
(def routes
- [["/v0/favorite-fish"
+ [["/v0/catch-at-age"
{:post {:summary "Register favorite fish"
- :parameters {:body schema/favorite-fish-request}
- :responses {200 {:body schema/favorite-fish-200}}
- :handler favorite-fish-handler}}]])
+ :parameters {:body schema/catch-at-age-request}
+ :responses {200 {:body schema/catch-at-age-200}}
+ :handler catch-at-age-handler}}]])
ここから、Malliスキーマの生成やルーティングの動作確認をしたいので、こちらの記事で使っていたdocker-compose.ymlを再利用しつつ、下記のようなMakefileを用意した:
.PHONY: run generate
LOG_LEVEL ?= DEBUG
COMPOSE_PROJECT_NAME ?= yoshimoto-samonji
DOCKER_NETWORK := $(COMPOSE_PROJECT_NAME)_yoshimoto-samonji
generate:
mkdir -p resources
clj scripts/generate-from-openapi.clj
run:
DATA_REGISTRY_DEV_PORT=$(DATA_REGISTRY_DEV_PORT) \
LOG_LEVEL=$(LOG_LEVEL) \
docker compose -f ../../docker-compose.yml -p $(COMPOSE_PROJECT_NAME) run --rm -p $(DATA_REGISTRY_DEV_PORT):$(DATA_REGISTRY_DEV_PORT) repl clj -M -m data-registry.core
さっそくOpenAPIからMalliスキーマを生成してみる:
% make generate
mkdir -p resources
clj scripts/generate-from-openapi.clj
WARNING: Implicit use of clojure.main with options is deprecated, use -M scripts/generate-from-openapi.clj
Found request schema: :catch-at-age-request
Found response schema: :catch-at-age-200
Generated resources/schemas.edn
{:catch-at-age-request [:map [:metadata [:map [:species :string] [:stock :string] [:assessment_year :int] [:notes :string]]] [:dataset [:map [:valueUnit :string] [:dataSet [:map]]]]], :catch-at-age-200 [:map [:detail :string]]}
↑一応できたように見える。
動作確認のためにAPIサーバを起動する:
% make run
DATA_REGISTRY_DEV_PORT=31505 \
LOG_LEVEL=DEBUG \
docker compose -f ../../docker-compose.yml -p yoshimoto-samonji run --rm -p 31505:31505 repl clj -M -m data-registry.core
WARN[0000] No services to build
[+] 1/1t 1/11
✔ Container yoshimoto-samonji-db-1 Running 0.0s
WARN[0000] No services to build
Container yoshimoto-samonji-repl-run-a5b67937303c Creating
Container yoshimoto-samonji-repl-run-a5b67937303c Created
2026-07-23 05:04:32.338:INFO::main: Logging initialized @873ms to org.eclipse.jetty.util.log.StdErrLog
2026-07-23 05:04:34.225:INFO:oejs.Server:main: jetty-9.4.51.v20230217; built: 2023-02-17T08:19:37.309Z; git: b45c405e4544384de066f814ed42ae3dceacdd49; jvm 25.0.3+9-LTS
2026-07-23 05:04:34.248:INFO:oejs.AbstractConnector:main: Started ServerConnector@79ac50fe{HTTP/1.1, (http/1.1)}{0.0.0.0:31505}
↑リッスンできた。
リクエストを送ってみる:
% curl -X POST localhost:31505/v0/catch-at-age \
-d '{"metadata": {
"species": "マイワシ",
"stock": "太平洋系群",
"assessmentYear": 2026},
"dataset": {
"valueUnit": "ton",
"dataSet": {
"0": {"2025": 123, "2026": 456},
"1": {"2025": 234, "2026": 567}
}
}
}' \
-H "Content-Type: application/json" && echo
{"error":"Invalid catch-at-age request"}
%
↑おや。
API schemaに足したrequiredフィールドをパースできていなかった:
#!/usr/bin/env clj
(require '[clj-yaml.core :as yaml])
(defn json-schema->malli
"Convert JSON Schema into Malli schema"
[schema]
(let [type (get schema :type)]
(cond
(= type "object")
(let [properties (get schema :properties {})
+ required (set (get schema :required []))
fields (map (fn [[prop-name prop-schema]]
- [(keyword prop-name) (json-schema->malli prop-schema)])
+ (let [kw (keyword prop-name)
+ is-required (contains? required (name kw))]
+ (if is-required
+ [kw (json-schema->malli prop-schema)]
+ [kw {:optional true} (json-schema->malli prop-schema)])))
properties)]
properties)]
(into [:map] fields))
(= type "string") :string
(= type "integer") :int
(= type "number") :number
(= type "boolean") :boolean
(= type "array")
(let [items (get schema :items)]
(if items
[:vector (json-schema->malli items)]
:vector))
:else :any)))
(defn extract-schemas [openapi]
(let [paths (get openapi :paths)
schemas (atom {})]
(doseq [[path path-item] paths]
(doseq [[_method operation] path-item]
(when (map? operation)
; request schema
(when-let [req-body (get operation :requestBody)]
(when-let [json-schema (get-in req-body [:content :application/json :schema])]
(let [schema-name (keyword (str (second (re-find #"/([^/]+)$" (str path))) "-request"))]
(println "Found request schema:" schema-name)
(swap! schemas assoc schema-name (json-schema->malli json-schema)))))
; response schema
(when-let [responses (get operation :responses)]
(doseq [[status response] responses]
(when-let [json-schema (get-in response [:content :application/json :schema])]
(let [status-str (or status "200")
schema-name (keyword (str (second (re-find #"/([^/]+)$" (str path))) "-" status-str))]
(println "Found response schema:" schema-name)
(swap! schemas assoc schema-name (json-schema->malli json-schema)))))))))
@schemas))
(defn -main [& _args]
(let [openapi (yaml/parse-string (slurp "schema/openapi.yaml"))
schemas (extract-schemas openapi)
edn-output (with-out-str (pr schemas))]
(spit "resources/schemas.edn" edn-output)
(println "Generated resources/schemas.edn")
(println schemas)))
(-main)
↑requiredでないフィールドには{:optional: true}が設定されるようにした。
もういちどmake generateしてからサーバーを起動したら、今度は成功した:
% curl -X POST localhost:31505/v0/catch-at-age \
-d '{"metadata": {
"species": "マイワシ",
"stock": "太平洋系群",
"assessmentYear": 2026},
"dataset": {
"valueUnit": "ton",
"dataSet": {
"0": {"2025": 123, "2026": 456},
"1": {"2025": 234, "2026": 567}
}
}
}' \
-H "Content-Type: application/json" && echo
{"message":"Registered マイワシ (太平洋系群) for year 2026"}
バリデーションが利いているかを確認したいので、invalidなリクエストを投げてみる:
% curl -XPOST localhost:31505/v0/catch-at-age \
-d '{"fish": {
"name": "マイワシ",
"scientific_name": "Sardinops melanostictus"
}
}' \
-H "Content-Type: application/json" && echo
{"error":"Invalid catch-at-age request"}
↑想定通り、バリデーション失敗を確認できたのでOK!
データベースのセットアップ
次はデータをデータベースに登録できるようにする。
前掲のdocker-compose.ymlとこちらのMakefileを使ってmake docker-upする:
% make docker-up
MONOLITH_IMAGE=yoshimotosamonji-monolith:latest \
DATA_REGISTRY_IMAGE=yoshimotosamonji-data-registry:latest \
VALIDATE_RESPONSE= \
docker compose up -d --no-build monolith data-registry db repl
[+] up 4/4
✔ Container yoshimoto-samonji-db-1 Running 0.0s
✔ Container yoshimoto-samonji-monolith-1 Running 0.0s
✔ Container yoshimoto-samonji-repl-1 Running 0.0s
✔ Container yoshimoto-samonji-data-registry-1 Recreated
↑データベースコンテナが立った(検証しながらやってるので他のも立ってる)。
マイグレーション
データベース操作の検証では手動でセットアップをしたが、今後の開発効率を考えてマイグレーションを仕組み化しておきたい。 マイグレーションツールにはコンテナ版のFlywayを使うことにして、下記のようなマイグレーションスクリプトを作った:
CREATE TABLE IF NOT EXISTS data_versions (
id SERIAL PRIMARY KEY,
version_id VARCHAR(64) UNIQUE NOT NULL, -- SHA-256 content hash
data_type VARCHAR(50) NOT NULL, -- catch_at_age, weight_at_age, maturity_at_age, parameter_set
metadata JSONB NOT NULL, -- {species, stock, assessment_year, ...}
data JSONB NOT NULL, -- The actual normalized data
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Indexes for common queries
CREATE INDEX idx_data_versions_version_id ON data_versions(version_id);
CREATE INDEX idx_data_versions_data_type ON data_versions(data_type);
CREATE INDEX idx_data_versions_created_at ON data_versions(created_at);
↑プライマリキーにはレスポンスのversionIdに使うversion_idではなく、自動インクリメントのidを使うことにした。
マイグレーション用のmakeターゲットを追加する:
+ migrate-data-registry:
+ docker run --rm \
+ --network $(DOCKER_NETWORK) \
+ -v $$(pwd)/services/data-registry/resources/migrations:/flyway/sql \
+ flyway/flyway:latest \
+ -url=jdbc:postgresql://$(POSTGRES_HOST):$(POSTGRES_PORT)/$(POSTGRES_DB) \
+ -user=$(POSTGRES_USER) \
+ -password=$(POSTGRES_PASSWORD) \
+ -baselineOnMigrate=true \
+ migrate
+
+ migrate: migrate-data-registry
マイグレーションしてみる:
% make migrate
docker run --rm \
--network yoshimoto-samonji_yoshimoto-samonji \
-v $(pwd)/services/data-registry/resources/migrations:/flyway/sql \
flyway/flyway:latest \
-url=jdbc:postgresql://db:5432/yoshimoto_samonji \
-user=yoshimoto \
-password=samonji \
-baselineOnMigrate=true \
migrate
WARNING: Storing migrations in 'sql' is not recommended and default scanning of this location may be deprecated in a future release
Flyway OSS Edition 13.0.0 by Redgate
See release notes here: https://help.red-gate.com/help/flyway-cli13/help_0.aspx?topic=release-notes-and-older-versions/release-notes-for-flyway-engine
Database: ******** (PostgreSQL 18.4)
Schema history table "public"."flyway_schema_history" does not exist yet
Successfully validated 1 migration (execution time 00:00.013s)
All configured schemas are empty; a baseline marker will not be added to Flyway's schema history table. A baseline or migration script with a lower version than the baseline version may execute if available. Check the Schemas parameter if this is not intended. See https://help.red-gate.com/help/flyway-cli13/help_0.aspx?topic=baseline-on-migrate for more info
Creating Schema History table "public"."flyway_schema_history" ...
Current version of schema "public": << Empty Schema >>
Migrating schema "public" to version "001 - init"
Successfully applied 1 migration to schema "public", now at version v001 (execution time 00:00.012s)
%
↑マイグレーションできた。
DBの状況を確認したいのでデータベースビューアを導入する。 コンテナ版adminerを使うことにした:
+ db-viewer:
+ image: adminer:latest
+ ports:
+ - "${DB_VIEWER_PORT:-8080}:8080"
+ depends_on:
+ - db
+ networks:
+ - yoshimoto-samonji
+ environment:
+ ADMINER_DEFAULT_SERVER: db
makeターゲットにもdb-viewerを追加しておく:
MONOLITH_IMAGE=$(MONOLITH_LOCAL_IMAGE):latest \
DATA_REGISTRY_IMAGE=$(DATA_REGISTRY_LOCAL_IMAGE):latest \
VALIDATE_RESPONSE=$(VALIDATE_RESPONSE) \
- $(DOCKER_COMPOSE) up -d --no-build monolith data-registry db repl
+ $(DOCKER_COMPOSE) up -d --no-build monolith data-registry db repl db-viewer
再度make docker-upする:
% make docker-up
MONOLITH_IMAGE=yoshimotosamonji-monolith:latest \
DATA_REGISTRY_IMAGE=yoshimotosamonji-data-registry:latest \
VALIDATE_RESPONSE= \
docker compose up -d --no-build monolith data-registry db repl db-viewer
[+] up 5/5
✔ Container yoshimoto-samonji-db-1 Running 0.0s
✔ Container yoshimoto-samonji-monolith-1 Running 0.0s
✔ Container yoshimoto-samonji-data-registry-1 Running 0.0s
✔ Container yoshimoto-samonji-repl-1 Running 0.0s
✔ Container yoshimoto-samonji-db-viewer-1 Created
↑db-viewerが起動した。
adminerの画面を見てみる:
マイグレーションによってテーブル
data_versionsができている
リポジトリ・ハンドラの実装
リポジトリを下記のように実装する:
(ns data-registry.repository
(:require [next.jdbc :as jdbc]
[next.jdbc.connection :as connection]
[cheshire.core :as json]
[clojure.tools.logging :as log])
(:import [com.zaxxer.hikari HikariDataSource]))
(def ^:private pool (atom nil))
(defn init-pool []
(if-let [db-url (System/getenv "DATABASE_URL")]
(reset! pool
(connection/->pool HikariDataSource
{:jdbcUrl db-url
:maximumPoolSize 5
:connectionTimeout 30000
:maxLifetime 600000}))
(log/warn "DATABASE_URL not set, skipping pool initialization")))
(defn get-pool []
(or @pool
(do (log/warn "Pool not initialized, initializing now")
(init-pool)
@pool)))
(.addShutdownHook (Runtime/getRuntime)
(Thread. ^{:name "db-pool-shutdown"}
#(when-let [p @pool]
(log/info "Closing database pool")
(.close p))))
(defn insert-catch-at-age
"Insert catch-at-age dataset into database"
[version-id metadata dataset]
(try
(let [pool (get-pool)
result (jdbc/execute! pool
["INSERT INTO data_versions (version_id, data_type, metadata, data) VALUES (?, ?, ?, ?)"
version-id
"catch_at_age"
metadata
dataset])]
(log/info "Inserted catch-at-age data with version_id:" version-id)
result)
(catch Exception e
(log/error "Failed to insert catch-at-age data:" (.getMessage e))
(throw e))))
必要な依存も下記のように追加する:
+ com.github.seancorfield/next.jdbc
+ {:mvn/version "1.3.894"}
+
+ org.postgresql/postgresql
+ {:mvn/version "42.7.3"}
+ cheshire/cheshire
+ {:mvn/version "5.13.0"}
+
+ com.zaxxer/HikariCP
+ {:mvn/version "5.1.0"}
+
+ ch.qos.logback/logback-classic
+ {:mvn/version "1.5.3"}}
}
coreは下記のようにコネクションプールを確保するようにする:
(ns data-registry.core
(:require [ring.adapter.jetty :as jetty]
- [data-registry.routes :as routes]))
+ [data-registry.routes :as routes]
+ [data-registry.repository :as repository]))
(defn start-server []
+. (repository/init-pool)
(let [port (Integer/parseInt (or (System/getenv "CONTAINER_PORT") "3000"))]
(jetty/run-jetty routes/app-router {:port port})))
(defn -main [& args]
(start-server))
ルータは下記のように変更する:
(defn catch-at-age-handler
"POST /v0/catch-at-age"
[req]
(try
(log/info "Request body:" (:body req))
(let [body (:body req)
validated-body (schema/validate-catch-at-age-request body)
- species (get-in validated-body [:metadata :species])
- stock (get-in validated-body [:metadata :stock])
- year (get-in validated-body [:metadata :assessmentYear])
- response {:message (format "Registered %s (%s) for year %d" species stock year)}
+ metadata (:metadata validated-body)
+ dataset (:dataset validated-body)
+ version-id (str (System/currentTimeMillis))
+ _ (repository/insert-catch-at-age version-id metadata dataset)
+ response {:detail (format "Registered %s (%s)"
+ (:species metadata)
+ (:stock metadata))}
validated-response (schema/validate-response :catch-at-age-200 response)]
{:status 200 :body validated-response})
(catch Exception e
(log/error "Error in catch-at-age-handler" {:error (.getMessage e)})
{:status 500 :body {:error (.getMessage e)}})))
↑ひとまず現時点では、リポジトリを直接使うことにする。
これまで通り、make upでサーバーを立ち上げ、リクエストを投げる:
% curl -X POST localhost:31505/v0/catch-at-age \
-d '{"metadata": {
"species": "マイワシ",
"stock": "太平洋系群",
"assessmentYear": 2026},
"dataset": {
"valueUnit": "ton",
"dataSet": {
"0": {"2025":123, "2026": 456},
"1": {"2025":234, "2026": 567}
}
}
}' \
-H "Content-Type: application/json" && echo
{"error":"ERROR: column \"metadata\" is of type jsonb but expression is oftype hstore\n Hint: Youwill need to rewrite or cast the expression.\n Position: 83"}
↑おや。
JSONにシリアライズするのを忘れていた:
(defn insert-catch-at-age
"Insert catch-at-age dataset into database"
[version-id metadata dataset]
(try
(let [pool (get-pool)
+ metadata-json (json/generate-string metadata)
+ dataset-json (json/generate-string dataset)
result (jdbc/execute! pool
- ["INSERT INTO data_versions (version_id, data_type, metadata, data) VALUES (?, ?, ?, ?)"
+ ["INSERT INTO data_versions (version_id, data_type, metadata, data) VALUES (?, ?, ?::jsonb, ?::jsonb)"
version-id
"catch_at_age"
- metadata
- dataset])]
+ metadata-json
+ dataset-json])]
(log/info "Inserted catch-at-age data with version_id:" version-id)
result)
(catch Exception e
(log/error "Failed to insert catch-at-age data:" (.getMessage e))
(throw e))))
再度リクエスト:
% curl -X POST localhost:31505/v0/catch-at-age \
-d '{"metadata": {
"species": "マイワシ",
"stock": "太平洋系群",
"assessmentYear": 2026},
"dataset": {
"valueUnit": "ton",
"dataSet": {
"0": {"2025":123, "2026": 456},
"1": {"2025":234, "2026": 567}
}
}
}' \
-H "Content-Type: application/json" && echo
{"detail":"Registered マイワシ (太平洋系群)"}
↑今度は保存できた。
何度か叩いてみてからデータベースの状態を見てみると、無事データが登録されていることを確認できた:
JSONB型はデータを目視できる点も楽
ここまでで一段落だが、現時点の実装ではversionIdがただの現在時刻になってしまっている。
versionIdは結果に追跡性をもたせるために重要な値なので、次はここを直していく。
ちょっと力尽き気味なので今回はここまで。
本記事でやったことと残作業↓:
- API契約を書く
- ルーティングの動作確認
- db セットアップ
- リポジトリ・ハンドラの実装
- datasetVersion生成ロジックの設計
- リファクタリング
- エラーハンドリングの追加
所感
- 一人で手探りでやってると観点漏れ(セキュリティ、リソース管理、データキャパシティ設計など)が起こりがち
- このプロジェクトを始めてから、OSSコミュニティへの感謝の念がさらに強まっている。ありがとうございます。
-
高齢魚は個体数が少ない傾向にあるので、資源評価ではある年齢以上をひとくくりの階級にすることが多い。「5+」は「5歳以上」の意味 ↩︎