Goで試しにOCPI APIを自動生成から実装してみた
Go言語を用いてOCPI(Open Charge Point Interface)のAPIを実装することがありました。
その経験から考える、スキーマファーストの考え方と、その実装例を共有します。
OCPIとは
OCPIは、電気自動車(EV)と充電インフラ間のやり取りを標準化するプロトコルであり、
CPO と eMSP の間の通信を定義しています。
※OCPIがなんなのかは、今回共有したい内容とはほぼ関係ありません。
※CPO(Charging Point Operator): EV充電ステーションの設置・運用・管理を行う事業者
※eMSP(e-Mobility Service Provider): EVドライバーに充電サービスを提供する事業者
発端
- GoのAPIサーバーがあり、先日そこにOCPIプロトコルに準拠したAPIを追加しました。
- そのときはスキーマファーストではなく、実装ベースでAPIを開発しました。
- Goは静的言語なので追いやすい反面、細かいコードを書くことが多い。
なぜやるか
- Goは自動生成ツールが充実していると思っている。
- スキーマを最初に作成することで、認識合わせやフロントエンドとの連携をスムーズに行える
- 自動生成により、手作業によるコーディングミスを減らし、開発効率を向上させたい
使用技術とツール
- Go言語: シンプルで高性能なAPI開発を可能にするプログラミング言語。
- oapi-codegen: OpenAPI仕様からGoのコードを自動生成するツール。
- GORM: GoのORMライブラリで、データベース操作を容易にします。
- Docker: 開発環境の構築とデプロイを簡素化するコンテナ技術。
OpenAPIのツール選定
OpenAPIスキーマファイルから自動生成するライブラリは
OpenAPIのコード自動生成ライブラリにはopenapi-generator
やoapi-codegen
がありますが、
軽く調べた所 oapi-codegen の方が細かい指定ができそうなのでそちらを使うことにした。
package構成検討
MVCっぽく、お試しなので適当にシンプルに以下のような構成にしたいと思います。
- package models: gormの構造体やそれに関連する処理
- package controllers: 後述する自動生成された ServerInterface を実装する
- package ocpi: スキーマファイルから自動生成された構造体等
- package lib: お便利関数をぶち込む
実装のステップ
0. 初期設定
go install golang.org/dl/go1.21.6@latest
go1.21.6 download
go1.21.6 mod init github.com/myname/ocpi-api-generation
go1.21.6 mod tidy
1. OpenAPIスキーマファイルの用意
まずはOpenAPIスキーマファイルを使い、/locations
エンドポイントの実装を自動生成します。
よって、OCPI仕様に基づいてOpenAPIスキーマを作成しました。
これにより、APIのエンドポイントやデータモデルを明確に定義できます。
ocpi-api-generation/ocpi/openapi-spec.yaml
に配置することにします。
※OCPI 2.2.1-d2 とかを参考に自前で用意してもらう必要があると思います。
※一応検索すると転がってますが、所々間違っていそう。
2. oapi-codegenでコードの自動生成する
oapi-codegenを使用して、作成したOpenAPIスキーマからGoのコードを自動生成しました。
これにより、手作業による実装ミスを減らし、効率的に開発を進めることができます。
実装の方針に対する意見の対立も減らせます(私にもみんなにも拘りは有ります。尊重してます)
go1.21.6 install github.com/deepmap/oapi-codegen/v2/cmd/oapi-codegen@latest
go1.21.6 mod tidy
読みやすいように種類別に自動生成する。
oapi-codegen -generate gin -package ocpi ocpi/openapi-spec.yaml > ocpi/gin.gen.go
oapi-codegen -generate types -package ocpi ocpi/openapi-spec.yaml > ocpi/types.gen.go
oapi-codegen -generate spec -package ocpi ocpi/openapi-spec.yaml > ocpi/spec.gen.go
生成物を見ると、
ocpi-api-generation/ocpi/types.gen.go
: OpenAPIスキーマファイルのschemas等から生成される構造体
ocpi-api-generation/ocpi/gin.gen.go
: どうやらここの interface を満たす実装をすれば良さそうです
3. データベースと接続
GORMを用いて、OCPIのデータモデルに対応するデータベースのテーブルを設計・作成しました。
以下のような .env
ファイルを作成し、データベース接続情報を設定します。
# DBコンテナ名
DB_HOST=database
# init時のdatabase
DB_DATABASE=ocpi
# DB情報
DB_USER=postgres
DB_PASSWORD=koikeya
DB_PORT=5432
# app コンテナ
APP_CONTAINER_NAME=app
APP_PORT=8080
package main
import (
"flag"
"fmt"
"log"
"os"
"github.com/myname/ocpi-api-generation/controllers"
"github.com/myname/ocpi-api-generation/models"
)
func main() {
appPort := flag.String("port", os.Getenv("APP_PORT"), "Port for test HTTP server")
flag.Parse()
// database
host := os.Getenv("DB_HOST")
user := os.Getenv("DB_USER")
password := os.Getenv("DB_PASSWORD")
dbname := os.Getenv("DB_DATABASE")
port := os.Getenv("DB_PORT")
if err := models.InitDB(host, user, password, dbname, port, true); err != nil {
fmt.Fprintf(os.Stderr, "Error InitDB\n: %s", err)
}
defer models.CloseDB()
// Create an instance of our handler which satisfies the generated interface
ocpiApi := controllers.NewOcpiApi()
s := controllers.NewGinOcpiServer(ocpiApi, *appPort)
// And we serve HTTP until the world ends.
log.Fatal(s.ListenAndServe())
}
package models
import (
"fmt"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"github.com/myname/ocpi-api-generation/lib"
)
var db *gorm.DB
// Init initializes database
func InitDB(host, user, password, dbname, port string, autoMigrate bool) error {
var err error
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Tokyo", host, user, password, dbname, port)
db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
return fmt.Errorf("failed to Open: %w", err)
}
if autoMigrate {
if err := AutoMigrate(); err != nil {
return fmt.Errorf("failed to Open: %w", err)
}
}
return nil
}
func AutoMigrate() error {
return db.AutoMigrate(Location{}, LocationOpeningTimesRegularHour{}, Evse{}, Connector{})
}
// GetDB returns database connection
func DB() *gorm.DB {
return db
}
// Close closes database
func CloseDB() {
sqldb, _ := db.DB()
sqldb.Close()
}
package models
import (
"fmt"
"time"
"github.com/myname/ocpi-api-generation/ocpi"
)
func (r *Location) ToOcpi() (d ocpi.Location) {
ocpiEvses := make([]ocpi.EVSE, len(r.Evses))
for i, evse := range r.Evses {
ocpiEvses[i] = evse.ToOcpi()
}
ocpiRegularHours := make([]ocpi.RegularHours, len(r.OpeningTimesRegularHours))
for i, ocpiRegularHour := range r.OpeningTimesRegularHours {
ocpiRegularHours[i] = ocpi.RegularHours{
Weekday: ocpiRegularHour.Weekday,
PeriodBegin: ocpiRegularHour.PeriodBegin,
PeriodEnd: ocpiRegularHour.PeriodEnd,
}
}
return ocpi.Location{
CountryCode: r.CountryCode,
PartyId: r.PartyID,
Id: r.ID,
Publish: r.Publish,
Name: r.Name,
Address: r.Address,
City: r.City,
Country: r.Country,
Coordinates: ocpi.GeoLocation{Latitude: r.CoordinatesLatitude, Longitude: r.CoordinatesLongitude},
Evses: &ocpiEvses,
TimeZone: r.TimeZone,
OpeningTimes: &ocpi.Hours{
Twentyfourseven: r.OpeningTimesTwentyfourseven,
RegularHours: &ocpiRegularHours,
},
LastUpdated: r.LastUpdated.UTC(),
}
}
func (r *Evse) ToOcpi() (d ocpi.EVSE) {
ocpiConnectors := make([]ocpi.Connector, len(r.Connectors))
for i, connector := range r.Connectors {
ocpiConnectors[i] = connector.ToOcpi()
}
return ocpi.EVSE{
Uid: r.ID,
EvseId: r.EvseID,
Status: ocpi.Status(r.Status),
Connectors: ocpiConnectors,
LastUpdated: r.LastUpdated.UTC(),
}
}
func (r *Connector) ToOcpi() (d ocpi.Connector) {
return ocpi.Connector{
Id: r.ID,
Standard: ocpi.ConnectorType(r.Standard),
Format: ocpi.ConnectorFormat(r.Format),
PowerType: ocpi.PowerType(r.PowerType),
MaxVoltage: r.MaxVoltage,
MaxAmperage: r.MaxAmperage,
LastUpdated: r.LastUpdated.UTC(),
}
}
func FindLocations(dateFrom, dateTo *time.Time, offset, limit *int) ([]*Location, error) {
var rows []*Location
query := DB().Where("").Preload("OpeningTimesRegularHours").Preload("Evses.Connectors").Order("created_at ASC")
if dateFrom != nil {
// Only return Locations that have last_updated after or equal to this Date/Time (inclusive).
query = query.Where("last_updated >= ?", dateFrom)
}
if dateTo != nil {
// Only return Locations that have last_updated up to this Date/Time, but not including (exclusive).
query = query.Where("last_updated < ?", dateTo)
}
if offset != nil {
query = query.Offset(*offset)
}
if limit != nil {
query = query.Limit(*limit)
}
result := query.Find(&rows)
if result.Error != nil {
return nil, fmt.Errorf("failed to Find: %w", result.Error)
}
return rows, nil
}
※modelsの構造体が自作か自動生成で ocpi-api-generation/models/scheme.go
に存在するとします。
4. APIエンドポイントの実装
自動生成されたコードを基に、必要なビジネスロジックを実装しました。
これには、リクエストのバリデーションやレスポンスの整形などが含まれます。
package controllers
import (
"fmt"
"net"
"net/http"
"os"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
middleware "github.com/oapi-codegen/gin-middleware"
"github.com/hoge/ocpi-api-generation/ocpi"
)
var s *http.Server
type OcpiApi struct {
}
func NewOcpiApi() *OcpiApi {
return &OcpiApi{}
}
func NewGinOcpiServer(ocpiApi *OcpiApi, port string) *http.Server {
swagger, err := ocpi.GetSwagger()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading swagger spec\n: %s", err)
os.Exit(1)
}
// Clear out the servers array in the swagger spec, that skips validating
// that server names match. We don't know how this thing will be run.
swagger.Servers = nil
// This is how you set up a basic gin router
r := gin.Default()
// CORS
corsConfig := cors.DefaultConfig()
corsConfig.AllowOrigins = []string{"http://localhost:8001"}
r.Use(cors.New(corsConfig))
// BaseURL
baseURL := "/ocpi/ocpi-api-generation/2.2"
// /openapi-spec.yaml endpoint
r.GET(baseURL+"/openapi-spec.yaml", func(c *gin.Context) { c.File("ocpi/openapi-spec.yaml") })
// We now register our ocpiApi above as the handler for the interface
ocpi.RegisterHandlersWithOptions(r, ocpiApi, ocpi.GinServerOptions{BaseURL: baseURL})
// Use our validation middleware to check all requests against the
// OpenAPI schema.
r.Use(middleware.OapiRequestValidator(swagger))
s = &http.Server{
Handler: r,
Addr: net.JoinHostPort("0.0.0.0", port),
}
return s
}
func CloseGinOcpiServer() {
s.Close()
}
package controllers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/myname/ocpi-api-generation/lib"
"github.com/myname/ocpi-api-generation/models"
"github.com/myname/ocpi-api-generation/ocpi"
)
// GetLocations implements all the handlers in the ServerInterface
func (o *OcpiApi) GetLocations(c *gin.Context, params ocpi.GetLocationsParams) {
rows, err := models.FindLocations(params.DateFrom, params.DateTo, params.Offset, params.Limit)
if err != nil {
c.JSON(http.StatusOK, ocpi.LocationsResponse{
Data: nil,
StatusCode: 3000,
StatusMessage: lib.Ptr("Server errors"),
Timestamp: time.Now(),
})
return
}
dests := make([]ocpi.Location, len(rows))
for i, row := range rows {
dests[i] = row.ToOcpi()
}
c.JSON(http.StatusOK, ocpi.LocationsResponse{
Data: &dests,
StatusCode: 1000,
StatusMessage: lib.Ptr("Success"),
Timestamp: time.Now(),
})
}
5. 起動準備
FROM golang:1.21.6-alpine3.19
RUN apk update && apk upgrade && \
apk add --no-cache bash git openssh
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o main .
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
restart: always
container_name: ${APP_CONTAINER_NAME}
env_file:
- .env
tty: true
depends_on:
db:
condition: service_healthy
ports:
- ${APP_PORT}:8080
command: ./main
db:
image: postgres:13.3
restart: always
container_name: ${DB_HOST}
environment:
- POSTGRES_DB=${DB_DATABASE}
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- db-store:/var/lib/postgresql/data
ports:
- ${DB_PORT}:5432
healthcheck:
test: "pg_isready -U postgres -d ocpi || exit 1"
swagger-ui:
image: swaggerapi/swagger-ui
restart: always
container_name: "swagger-ui"
ports:
- "8001:8080"
environment:
SWAGGER_JSON_URL: http://localhost:${APP_PORT}/ocpi/ocpi-api-generation/2.2/openapi-spec.yaml
volumes:
db-store:
6. 起動/確認
docker compose up --build
http://localhost:8001/ にアクセスしてswagger-uiを立ち上げる
成果物
ocpi-api-generation/
├── .env
├── Dockerfile
├── controllers/
│ ├── api.go
│ └── locations.go
├── docker-compose.yml
├── go.mod
├── go.sum
├── lib/
│ └── util.go
├── main.go
├── models/
│ ├── gorm.go
│ ├── locations.go
│ └── scheme.go
└── ocpi/
├── gin.gen.go
├── openapi-spec.yaml
├── spec.gen.go
└── types.gen.go
まとめ
- 自動生成を活用して実装量を減らせる。開発が楽になります。
- レビュアーの負担も軽減されます。自動生成物は基本レビューする必要ないと思っています。
- 今回は使ってないですが、データベースのモデルも自動生成する方法があります。
Discussion