🚀

Goで試しにOCPI APIを自動生成から実装してみた

2024/12/26に公開

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-generatoroapi-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 ファイルを作成し、データベース接続情報を設定します。

ocpi-api-generation/.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
ocpi-api-generation/main.go
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())
}
ocpi-api-generation/models/gorm.go
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()
}
ocpi-api-generation/models/locations.go
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エンドポイントの実装

自動生成されたコードを基に、必要なビジネスロジックを実装しました。
これには、リクエストのバリデーションやレスポンスの整形などが含まれます。

ocpi-api-generation/controllers/api.go
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()
}
ocpi-api-generation/controllers/locations.go
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. 起動準備

Dockerfile
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 .
ocpi-api-generation/docker-compose.yml
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