Works by

Ren's blog

@rennnosuke_rk 技術ブログです

【Golang】net/httpのPath Parameterパース

net/httphandleFunc() handle() に登録できるパスは Path Parameter を認識しない。例えば、他の Web フレームワークのように、下記 /products/:id エンドポイント中の :id を変数として取得することができない。

// :idを変数としてパースできない
http.handleFunc("/products/:id", f)

Path Parameter を取得するためには、Path Parameter 抜きのエンドポイントへのリクエストを一旦ハンドルし、そのハンドラの中で Path Parameter をパースする必要がある。

func main() {
    http.handleFunc("/products/", handleProducts)
    http.ListenAndServe(":8000", nil)
}

func handleProducts(r *http.Request, w http.ResponseWriter) {
    sub := strings.TrimPrefix(r.URL.Path, "/products")
    _, id := filepath.Split(sub)
    if id != "" {
        // :idを使う...
    }
}

/products/:id リクエストへのハンドラを設定する際には、 /products ではなく /products/ へハンドラを設定する。 /products リクエストハンドラは /products/:id リクエストを受け付けられないため。ただし /products/hoge /products/hoge/fuga などの適当なエンドポイントも受け付けてしまうので、404 を返すなど適宜処理する。

備考

Handle() HandleFunc() に登録できるパスパターンは、 net/http 中の構造体 ServeMux のルールに従う。

パターンは、「/ favicon.ico」のような固定されたルート化されたパス、または「/ images /」のようなルート化されたサブツリーに名前を付けます(末尾のスラッシュに注意してください)。長いパターンは短いパターンよりも優先されるため、「/ images /」と「/ images / thumbnails /」の両方にハンドラーが登録されている場合、「/ images / thumbnails /」で始まるパスと前者のハンドラーが呼び出されます。 「/ images /」サブツリー内の他のパスのリクエストを受け取ります。

参考文献

【Golang】Golangの並列実行時の競合状態検出

-race オプション

Go バイナリ実行時、 -race オプションを指定することで競合状態のテストを実施することができる。具体的には

  • go -race run
  • go -race build

のようにコード実行時、バイナリビルド時に指定できる。
ただし実行可能な環境は linux/amd64freebsd/amd64darwin/amd64windows/amd64 のみ。

Example

Golang の map の非スレッドセーフ性と排他制御の記事で掲載した、map に対する並行アクセスを実行する。

main.go

package main

func main() {

    kvs := NewKeyValueStore()

    for i := 0; i < 10; i++ {
        go func(kvs *KeyValueStore) {
            kvs.set("key", "value")
            kvs.get("key")
        }(kvs)
    }

}

type KeyValueStore struct {
    m map[string]string
}

func NewKeyValueStore() *KeyValueStore {
    return &KeyValueStore{m: make(map[string]string)}
}

func (s *KeyValueStore) set(k, v string) {
    s.m[k] = v
}

func (s *KeyValueStore) get(k string) (string, bool) {
    v, ok := s.m[k]
    return v, ok
}

上記コードは map がスレッドセーフで無いために、値書き込み時のデータの競合状態が発生する。このコードを -race オプションとともに実行すると、競合状態が発生しうることに対する警告が吐き出される。

実行結果

$ go run -race main.go
==================
WARNING: DATA RACE
Write at 0x00c000088000 by goroutine 7:
  runtime.mapassign_faststr()
      /usr/local/go/src/runtime/map_faststr.go:202 +0x0
  main.main.func1()
      /Users/rennnosuke/go/src/github.com/rennnosuke/rens-blog-codes/20200323/not_concurrent_safe/main.go:25 +0x71

Previous write at 0x00c000088000 by goroutine 6:
  runtime.mapassign_faststr()
      /usr/local/go/src/runtime/map_faststr.go:202 +0x0
  main.main.func1()
      /Users/rennnosuke/go/src/github.com/rennnosuke/rens-blog-codes/20200323/not_concurrent_safe/main.go:25 +0x71

Goroutine 7 (running) created at:
  main.main()
      /Users/rennnosuke/go/src/github.com/rennnosuke/rens-blog-codes/20200323/not_concurrent_safe/main.go:8 +0x9c

Goroutine 6 (finished) created at:
  main.main()
      /Users/rennnosuke/go/src/github.com/rennnosuke/rens-blog-codes/20200323/not_concurrent_safe/main.go:8 +0x9c
==================
==================
WARNING: DATA RACE
Write at 0x00c00008c088 by goroutine 7:
  main.main.func1()
      /Users/rennnosuke/go/src/github.com/rennnosuke/rens-blog-codes/20200323/not_concurrent_safe/main.go:25 +0x86

Previous write at 0x00c00008c088 by goroutine 6:
  main.main.func1()
      /Users/rennnosuke/go/src/github.com/rennnosuke/rens-blog-codes/20200323/not_concurrent_safe/main.go:25 +0x86

Goroutine 7 (running) created at:
  main.main()
      /Users/rennnosuke/go/src/github.com/rennnosuke/rens-blog-codes/20200323/not_concurrent_safe/main.go:8 +0x9c

Goroutine 6 (finished) created at:
  main.main()
      /Users/rennnosuke/go/src/github.com/rennnosuke/rens-blog-codes/20200323/not_concurrent_safe/main.go:8 +0x9c
==================
Found 2 data race(s)
exit status 66

競合が発生しうる場合、上記のように競合が発生するコード上の箇所が表示される。競合検出は実行された処理のみに対して実施され、実行されない処理に競合の可能性があっても検出はされない。

競合状態にならない場合、特に出力はなく実行は終了する。

main.go

package main

import (
    "fmt"
    "sync"
)

func main() {

    kvs := NewConcurrentKeyValueStore()

    for i := 0; i < 10; i++ {
        go func(kvs *ConcurrentKeyValueStore) {
            kvs.set("key", "value")
            kvs.get("key")
        }(kvs)
    }

}

type ConcurrentKeyValueStore struct {
    m  map[string]string
    mu sync.RWMutex
}

func NewConcurrentKeyValueStore() *ConcurrentKeyValueStore {
    return &ConcurrentKeyValueStore{m: make(map[string]string)}
}

func (s *ConcurrentKeyValueStore) set(k, v string) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.m[k] = v
}

func (s *ConcurrentKeyValueStore) get(k string) (string, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    v, ok := s.m[k]
    return v, ok
}

実行結果

$ go run -race main.go // 何も出力されない

パッケージインストール時の競合検出

コード実行・ビルド時だけでなく、外部パッケージをインストールするときにも競合検出を実施することができる。

  • go get -race [package]
  • go install -race [package]

参考文献

【Golang】Golang : `runtime` パッケージを使用したモニタリング

Golang のメモリ使用量やゴルーチン数など、実行時ランタイムに関係するステータスを観測するには、 runtime パッケージを使用する。

Memory

var m runtime.MemStats
runtime.ReadMemStats(&m)

// ヒープ上に割り当てられたオブジェクト累積メモリ量
fmt.Printf("MAlloc : %v\n", humanize.Bytes(m.Mallocs))
// ヒープ上から開放されたオブジェクト数
fmt.Printf("Frees : %v\n", m.Frees)

// ヒープ上に割り当てられたオブジェクトメモリ量
fmt.Printf("Alloc : %v\n", humanize.Bytes(m.Alloc))
fmt.Printf("HeapAlloc : %v\n", humanize.Bytes(m.HeapAlloc))
// ヒープ上に割り当てられたオブジェクトメモリ量。ただし開放されたオブジェクト分も含む
fmt.Printf("TotalAlloc : %v\n", humanize.Bytes(m.TotalAlloc))

// OSから割り当てられたプロセスの総メモリ量
// ヒープ + スタック + その他
fmt.Printf("Sys : %v\n", humanize.Bytes(m.Sys))

// ポインタのルックアップ数
fmt.Printf("Lookups : %v\n", m.Lookups)

// 到達可能、あるいはGCによって解放されていないヒープオブジェクトメモリ量
fmt.Printf("HeapAlloc : %v\n", humanize.Bytes(m.HeapAlloc))
// 未使用ヒープ領域メモリ量
fmt.Printf("HeapIdle : %v\n", humanize.Bytes(m.HeapIdle))
// 使用中ヒープ領域メモリ量
fmt.Printf("HeapInuse : %v\n", humanize.Bytes(m.HeapInuse))
// OSに返却される物理メモリ量
fmt.Printf("HeapReleased : %v\n", humanize.Bytes(m.HeapReleased))
// ヒープに割り当てられたオブジェクト量
fmt.Printf("HeapObjects : %v\n", m.HeapObjects)

// 使用中スタック領域メモリ量
fmt.Printf("StackInuse : %v\n", humanize.Bytes(m.StackInuse))
// OSから割り当てられたスタック領域メモリ量
fmt.Printf("StackSys : %v\n", humanize.Bytes(m.StackSys))

// 割り当てられたmspan構造体バイト数
fmt.Printf("MSpanInuse : %v\n", humanize.Bytes(m.MSpanInuse))
// OSから取得したmspan構造体バイト数
fmt.Printf("MSpanSys : %v\n", humanize.Bytes(m.MSpanSys))

// 割り当てられたmcache構造体バイト数
fmt.Printf("MCacheInuse : %v\n", humanize.Bytes(m.MCacheInuse))
// OSから取得したmcache構造体バイト数
fmt.Printf("MCacheSys : %v\n", humanize.Bytes(m.MCacheSys))

MAlloc : 271 B
Frees : 4
Alloc : 179 kB
HeapAlloc : 179 kB
TotalAlloc : 179 kB
Sys : 70 MB
Lookups : 0
HeapAlloc : 179 kB
HeapIdle : 66 MB
HeapInuse : 418 kB
HeapReleased : 66 MB
HeapObjects : 267
StackInuse : 229 kB
StackSys : 229 kB
MSpanInuse : 6.1 kB
MSpanSys : 16 kB
MCacheInuse : 21 kB
MCacheSys : 33 kB

Goroutine 数

var m runtime.MemStats
runtime.ReadMemStats(&m)

// 3 goroutine
go func() { time.Sleep(time.Second * 10) }()
go func() { time.Sleep(time.Second * 10) }()
go func() { time.Sleep(time.Second * 10) }()

// 呼び出し時存在するゴルーチン数(main + 3)
fmt.Printf("Goroutine : %v\n", runtime.NumGoroutine()) // n-GoroutineProfile : 4

ゴルーチン別メモリプロファイル

ゴルーチン毎のスタックトレースを uint32 配列で返す。

var m runtime.MemStats
runtime.ReadMemStats(&m)

sr := []runtime.StackRecord{
    {},
    {},
    {},
    {},
}
n, ok := runtime.GoroutineProfile(sr)
fmt.Printf("n-GoroutineProfile : %d\n", n)
if ok {
    for i, p := range sr {
        fmt.Printf("GoroutineProfile-%d: %v\n", i, p.Stack0)
    }
}
// GoroutineProfile-0: [17609957 17607648 16957534 17123361 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
// GoroutineProfile-1: [17610480 17123361 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
// GoroutineProfile-2: [17610560 17123361 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
// GoroutineProfile-3: [17610640 17123361 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]

参考文献

【Golang】Golang の map の非スレッドセーフ性と排他制御

Golang の map はスレッドセーフでない

Golang の map はスレッドセーフ、もといゴルーチンセーフではない。そのため、複数のゴルーチンからの同時アクセスによって整合性が保たれない状態になることがある。

例えば、以下のように map に対して複数のゴルーチンからの書き込みが発生すると、たまに非同期書き込み失敗のエラーが発生する。

package main

func main() {

    kvs := NewKeyValueStore()

    for i := 0; i < 10; i++ {
        go func(kvs *KeyValueStore) {
            kvs.set("key", "value")
            kvs.get("key")
        }(kvs)
    }

}

type KeyValueStore struct {
    m map[string]string
}

func NewKeyValueStore() *KeyValueStore {
    return &KeyValueStore{m: make(map[string]string)}
}

func (s *KeyValueStore) set(k, v string) {
    s.m[k] = v
}

func (s *KeyValueStore) get(k string) (string, bool) {
    v, ok := s.m[k]
    return v, ok
}

実行結果(たまに起こる)

fatal error: concurrent map writes

map がなぜデフォルトで非スレッドセーフになっているかというと、単純にパフォーマンス上不利であるためと思われる。この仕様のため、複数御ルーチンから map アクセスを実施するには明示的に排他制御を行う必要がある。1

map の排他制御

Go で排他制御を実現するには、sync.Mutex を使用する。

package main

import (
    "fmt"
    "sync"
)

func main() {

    kvs := NewConcurrentKeyValueStore()

    for i := 0; i < 10; i++ {
        go func(kvs *ConcurrentKeyValueStore) {
            kvs.set("key", "value")
            kvs.get("key")
        }(kvs)
    }

}

type ConcurrentKeyValueStore struct {
    m  map[string]string
    mu sync.RWMutex
}

func NewConcurrentKeyValueStore() *ConcurrentKeyValueStore {
    return &ConcurrentKeyValueStore{m: make(map[string]string)}
}

func (s *ConcurrentKeyValueStore) set(k, v string) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.m[k] = v
}

func (s *ConcurrentKeyValueStore) get(k string) (string, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    v, ok := s.m[k]
    return v, ok
}

上記のコードでは sync.RWMutex を使用している。sync.RWMutexsync.Mutex が提供する Lock()/Unlock() のほか、 RLock()/RUnlock() を提供する。

  • Lock()/Unlock() は占有ロック。ロック時に対象への書き込み、読み込みを禁止する。
  • RLock()/RUnlock() は共有ロック。ロック時に対象への書き込みのみ禁止し、読み込みは禁止しない。

読み込み処理の場合、他ゴルーチンからの読み込み処理も考慮して RLock を使用していくと良い。

Go の競合検出

Go ではコードの実行・ビルド時、あるいはパッケージインストール時に -race オプションを指定することで、実行中の競合検出を行うことができる。

Golang の並列実行時の競合状態検出

参考文献


  1. 例えば java も同様の理由で java.util.HashMap などは非スレッドセーフ。ただし初期の頃の動的配列などはそうではなく、同期的な java.util.Vector のみが提供されていた。これも後に非同期的な java.util.List 具象型( ArrayList など)に置き換えられた。同期的動的配列は Collections.synchronizedList(new ArrayList<>()) でラップする

【Golang】goimportsでプロジェクト配下のimport文すべてをパッケージ種類別に整形する

TL;DR

下記コマンドで、カレントディレクトリ配下のすべての .go ファイルの import 文を整形してくれる。

$ find . -print | grep --regex '.*\.go' | xargs goimports -w -local "github.com/your/package"

goimports-w オプションでファイルを直接書き換える。
また -local <string> は指定した string を prefix にもつパッケージの整形済み import 文の上部に、他の整形済み import 文が置かれる。
すなわち、string が prefix の import 文のかたまりが最後にくるようになる。

よって、import 文整形順は 標準パッケージ -> Publicパッケージ -> 自パッケージ の順となる。

あとは、find . -print | grep --regex '.*\.go' で引っ掛けた .go ファイル全てに上記 import 整形を適用する。

example

before

import (
    "fmt"
    "github.com/rennnosuke/gih/domain/model/entity"
    "github.com/rennnosuke/gih/domain/service/git/issue"
    "github.com/urfave/cli/v2"
    "regexp"
    "strconv"
    "unicode/utf8"
)

after

import (
    // 標準パッケージ
    "fmt"
    "regexp"
    "strconv"
    "unicode/utf8"

    // Publicパッケージ
    "github.com/urfave/cli/v2"

    // 自パッケージ
    "github.com/rennnosuke/gih/domain/model/entity"
    "github.com/rennnosuke/gih/domain/service/git/issue"
)

参考 : goimports

Go は標準でソースの import 文を整形するツール goimports を提供している。
goimports を Go ソースファイルに実行することで、ソースをコンパイルするのに必要なパッケージの import 文を自動挿入したり、インデントを追加したり、不要な import 文を削除してくれる。

before

package main

func main() {
    s1 := "Hello"
    s2 := "goimports."
    s := strings.join(s1, s2, ",")
    fmt.Println(s)
}
$ goimports -w main.go

after

package main

import (
    "fmt"
    "strings"
)

func main() {
    s1 := "Hello"
    s2 := "goimports."
    s := strings.Join(s1, s2, ",")
    fmt.Println(s)
}

参考文献

goimports - GoDoc

【Golang】Context

Golang における Context とは

大事なことは全部 Document に書いてある。おしまい。

context - The Go Programming Language

・・・と言ってしまうと元も子もないので、自分なりに整理してみる。

Go の Context を要約すると、

Web アプリケーションで横断的に使用するリクエストスコープ変数や処理を取り回す役割を持つもの

といえる。例えば、

が Context 内で管理される。

Go の net/http ライブラリによって起動する Web サーバーは、ひとつのリクエストにひとつのゴルーチンを割り当てて処理を開始する。リクエストを受けて実行される処理の中で、ゴルーチンは更に増えていく。Context はそのように増えていくゴルーチン間で容易に値や処理を共有できる仕組みとして提供される。

Context=文脈といった意味だが、それこそアプリケーションの文脈では「あるスコープ内で横断的に共有されるモノ」といった意味合いで使用される。Go の Context も「1 リクエスト内で共有されるデータ・処理」といった意味合いで扱われている気がする。

Context の中身

Context は以下のインタフェースによって定義されている。

type Context interface {
    Done() <-chan struct{}

    Err() error

    Deadline() (deadline time.Time, ok bool)

    Value(key interface{}) interface{}
}

Done()

キャンセルかタイムアウトを検知できるチャネルを返す。このチャネルは Context に対してキャンセルが通知されたか、タイムアウトが通知されたタイミングで閉じる。キャンセルは後述する WithCancel() WithDeadline() WithTimeout() から返る関数を呼び出すと通知できる。タイムアウトは後述する WithDeadline() WithTimeout() 関数で Context に設定できる。

Context を使う側は、この Done() から返るチャネルを経由してリクエストの状態を知ることになる。

Err()

Context がキャンセルされた理由を含む error 値が返る。
キャンセルされない間、 Err()nil を返す。

Deadline()

Context がタイムアウトになるまでの時間と、Deadline が設定されているかどうかを返す。

Value(key interface{})

Context が保持する、 keyに紐づいた値を返す。
Value() が返すべき値の管理はそれぞれの具象 Context 構造体にて定義される。


Context 単体だけではタイムアウトやキャンセルの通知はできない。Context は上記インタフェースの具象値だけでなく、後述する context パッケージ内関数と組み合わせて使う。

Context のルール

  • 保持する変数はリクエストスコープである Value() で習得できる値は、リクエストスコープ内で完結する必要がある。すなわち、複数リクエストから参照可能な値であってはならない。

  • リクエストスコープはゴルーチン安全である リクエストスコープの値は、複数ゴルーチンから同時に参照されても安全、すなわちゴルーチンセーフ(?)である必要がある。

Context の仕組み

Context Tree

Context を使う場合、単一の Context だけをずっと使い回すわけではない。Context を使うアプリ内では、最初に生成した Context から次々と子 Context を派生させ、一つの Context Tree を築いていく。

context パッケージには、 Context から子コンテキストを派生する関数が用意されている。

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

func WithValue(parent Context, key, val interface{}) Context

これらの関数は引数の Context parent から子 Context を派生させる。また同時に、リクエストがキャンセルされたことを通知するための関数も返す。このキャンセル関数を呼び出すと、生成された Context とその 子 Context にキャンセルが通知される。

キャンセルが Context に通知されると、Context が持つ Done() チャネルが閉じ、 Err() が error 値を返すようになる。返す error 値は関数生成元の関数による。

「子コンテキストの派生」「キャンセルの通知関数の生成」、そして子コンテキストへの性質の付与を同時に行う理由は、アプリケーションの境界によって渡す値や伝播するイベントを制御しやすくするため。例えば、データアクセス層へ渡す Context を WithTimeout() で派生した子 Context にすることで、キャンセルイベントの伝播をデータアクセス層に閉じることができる。親 Context へはイベントは伝播しないため、上位層の Context について考えなくても良くなる。

Context を仕組みを助ける関数たち

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

引数に取る Context から新しい子 Context を生成・返戻し、加えてキャンセル関数を返す。 キャンセル関数は呼びだすと ctx とその子 Context すべてにキャンセルが通知され、各 Context の Done() チャネルが閉じ、 Err() で error 値が得られるようになる。

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

基本的には WithCancel と同じ。異なる点は、指定した d の時刻を経過したかどうかが Deadline() から得られるようになるところ。

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

基本的には WithDeadline と同じ。異なる点は、Deadline を現在時刻からの経過時間で指定する点( WithDeadline(parent, time.Now().Add(timeout)) )。

func WithValue(parent Context, key, val interface{}) Context

生成する子 Context に値を設定する関数。子 Context は新たなリクエストスコープ値を持つことになる。

Context のつかいかた

実際に Context を使ってみた。下記コードの全体はGithub 上に置いてある

Context の生成

下記コードでは、 Product 構造体配列を永続化層から取得して、標準出力へ書き出す処理を行っている。

func main() {
    r := repository.ProductRepositoryImpl{}
    s := service.ProductService{Repo: &r}

    ctx := context.Background()

    prods, err := s.GetProducts(ctx)
    if err != nil {
        panic(err)
    }

    fmt.Println(prods)
}
type Product struct {
    Name  string
    Price int
}

Context の生成は context.Background() で行う。生成した Context をアプリ内で呼び出す関数に渡していく。必要であれば、 context パッケージ上の関数で子 Context に派生したり、タイムアウトを設けたり、新たなリクエストスコープ値を作ったりする。

type ProductRepositoryImpl struct{}

// ProductService::GetProducts()が呼び出す、
// ProductRepository::GetProducts()関数の実装
func (r *ProductRepositoryImpl) GetProducts(ctx context.Context) ([]entity.Product, error) {

    c := make(chan []entity.Product, 1)

    childCtx, cancel := context.WithTimeout(ctx, time.Second*5)
    defer cancel()

    go func(ctx context.Context) { c <- r.FindProducts(ctx) }(childCtx)

    select {
    case <-childCtx.Done():
        return nil, errors.New("query canceled")
    case prods := <-c:
        return prods, nil
    }

}

上記関数では、引数で受け取った Context からタイムアウトつき子 Context を派生して使用している。この Context から得られる Done() チャネルを監視することで、キャンセルあるいはタイムアウトしたかどうかを検知することができる。

Context を運用する上で気をつけること

1. フレームワーク独自の Context との共用を避ける

フレームワークが独自に実装している Context でも標準パッケージ context とほぼ同様のことができるが、そのような Context で Wrap したりすると、フレームワークに強く依存した実装になってしまう。

2. Value は不変とする

Context が保持するリクエストスコープ値は、複数ゴルーチンから参照されても安全でなければならない

3.Context を他の構造体フィールドにしない

2. より Context の保持する値はリクエストスコープで完結させたほうがよい。Context を別の構造体のフィールドとすると、その構造体の扱い次第ではアプリケーションスコープになりえるため、特に深刻な理由がなければ構造体フィールド化を避ける。

RootContext はひとつ

リクエストスコープ変数・シグナルを管理する機構が 2 つになるため、Context Tree が 2 つ以上になる状況は厳しい。

Context は nil で渡さない

関数で渡される Context は非 nil とする。どうしても渡す Context がない時のために、そのことを明示する Context を生成する context.Todo() があるので、それを利用する。

参考文献

【Golang】APIMATIC で Postman Collection を OpenAPI Specification に変換する

PostmanはOpenAPIと互換性があり、OpenAPI Specification(以下OAS)に沿って書かれたAPIドキュメントはPostmanへimportできる。一方で、PostmanからOpenAPIへのexportはサポートされていない。これはPostmanへのFeature requestとしても挙がっており、そこそこ支持を得ている様子ではあるものの、3年前に提案されてからも未だに導入されていない。

Postmanを使ってAPIクライアントとのコミュニケーションを取りたいが、ドキュメントとして見た場合一部痒いところに手が届かない部分がある(BodyのDescription/Schemaを記述できないなど)。そのため、Postmanで記述したAPI仕様をOASに落として、管理は煩雑になるが仕様をより詳細に載せたverを作成できないかと考えた。

そこで、3rd partyから変換可能なものがないか探してみた。

Postman Collection -> OAS 変換可能な3rd partyのツール/サービス

stoplightio/api-spec-converter

Spotlight.ioでも使用されているライブラリ。Postman Collection v1のみ対応。
JSライブラリのため、変換用のコードをJSで記述する必要がある。ただ実際に変換を検証したところ、v1形式のPostman Collectionを変換しようとしたが、うまく変換できなかった。。。

また、3年前ほど前から保守がストップしている。そのため今回は使用を見送り。

APIMATIC

APIMATIC社が提供するSaaS。Postman Collection v2の変換に対応している。

アカウント登録が必要だが、結果から言うとドキュメント変換がきれいに実行できた。今回はこのツールを使用してみる。

APIMATICで Postman Collection を OASに変換する

はじめに、Postman上に定義されたAPI仕様をexportする。
exoprtしたいCollectionの三点リーダをクリックし、出現したリストの Export をクリックする。

f:id:rennnosukesann:20210102165220p:plain


f:id:rennnosukesann:20210102165247p:plain

するとexportするファイルのバージョンを選択できるので、ここではv2.1を選択。

f:id:rennnosukesann:20210102165305p:plain

これで、 Postman Collection と呼ばれる json ファイル形式のAPIドキュメントが出力される。 このファイルはPostman間の定義や設定共有に使用されるものだが、今回はその中のAPI定義部分を抽出し、OASとして変換する。

次に、 APIMANICのWebページを開き、 Sign Up for free をクリック。

f:id:rennnosukesann:20210102165311p:plain

新規アカウント登録画面に遷移するので、必要な情報を入力し、 Sign Up をクリック。

f:id:rennnosukesann:20210102165317p:plain

しばらくすると登録したメールアドレスにconfirmメールが来るので、メール内のconfirmボタンをクリックし、 Click here をクリックする。

f:id:rennnosukesann:20210102165322p:plain

APIMATICダッシュボード画面に遷移する。 Transform API をクリック。

f:id:rennnosukesann:20210102165325p:plain

先程exportした Postman Collection jsonファイルを選択し、 好きな形式を選択してexportできる。今回はOAS3.0を選択。Convertボタンをクリック。

f:id:rennnosukesann:20210102165328p:plain

最後にうまく変換できたかどうかが表示されるので、確認した上でProceedボタンを押して変換後ファイルをダウンロード。

f:id:rennnosukesann:20210102165335p:plain

完了。

以下のようなOAS3.0形式ファイルが出力される。

openapi: 3.0.0
info:
  title: test
  contact: {}
  version: '1.0'
servers:
- url: http://hoge.api.com
  variables: {}
paths:
  /test:
    get:
      tags:
      - Misc
      summary: http://hoge.api.com/test
      description: http://hoge.api.com/test
      operationId: http://hoge.api.com/test
      parameters: []
      responses:
        200:
          description: ''
          headers: {}
      deprecated: false
tags:
- name: Misc
  description: ''

これで、 OpenAPI形式への変換が完了した。

これでOASに合わせて記述を追加したり、OAS対応のサービスでおしゃれにレンダリングしたりできる。

参考文献