2018年の振り返りと2019年の抱負
はじめに
2018年のアウトプットをまとめた上で、2019年何していくのかをいくつかまとめていきます。
2018年の振り返り
アウトプット
ツール・ライブラリ
去年は業務で書いたコードからいろいろ切り出して、公開したものが多かった。特に、自動生成や静的解析周りのツールを多く作った年になった。
favalid
FE向けバリデータ。去年作ったものの中で一番好き。
紹介記事はこちら。 akito0107.hatenablog.com
JSer.infoでも触れて頂きました。
agreed-typed
ほぼ内部向けツール。TypeScriptのASTをいじったり型遊びゲームやったりといろいろ楽しかった。
紹介記事はこちら。
generr
Go用error自動生成ツール。自分で言うのもなんだけど便利です。
紹介記事はこちら
errwrp
Go用error静的解析ツール
紹介記事はこちら
発表・その他
Japan Container Days v18.12
僕のパートのネタとしては、
これを中心に話しました。
社内ブログ
お仕事ではGoのAPIサーバ書いているので、そのアーキとかの話。
社内Go研修
プログラミング言語Goの翻訳者である柴田さんを講師に迎えての研修を受講し、無事に修了することができました 作業レポジトリはこちら。問題数が多く、大分苦しんだけどなんとかやり遂げられてよかった。
GitHub - akito0107/gopl: Programming Language Go
草
薄いけど、こんな感じ。
前述のGo研修のおかげで休日でもコンスタントにコードが書く習慣ができたので、毎日は無理だったけれども、一昨年に比べたら大分コードを書いたと思う。
読んだ本(主に技術系)
- ベタープログラマ
- テストから見えてくるグーグルのソフトウェア開発
テスト駆動開発 | コンピュータ・一般書,プログラミング・開発,開発技法 | Ohmsha
- プログラミング言語Go (研修)
- Clean Architecture
https://www.amazon.co.jp/dp/4048930656/
- カンバン ソフトウェア開発の変革
2019年の抱負
技術周り
言語は昨年に引き続きGoとTypeScriptに絞ってやっていきたいと思う。去年は外の発表が少なかったので、どちらの言語についてもカンファレンスで発表をしたい。 Goに関しては、analysis系の静的解析についてか、自動生成周りの知見をまとめて発表していきたい。TypeScriptは作ったツールの話とかになる気がする。
昨年はツール・ライブラリを作ることが多かったが、多分今年もそうなると思う。仕事としては引き続き現場でソフトウェア開発をすることになるのだけれども、実際の現場の課題を解決するようなツールを作るのは楽しかった。これは今年も続けていきたいと思う。
現状で作りたいと思っているツールや、作り途中でまだ公開していないツールがいくつかある。ここらへんをいい感じにまとめて、また記事をかけると良いなあと思っている。昨年はアドカレに便乗して12月に多めに記事を書いたけれども、正直とても辛かったので、コンスタントに記事を書いていきたい。
その他
それ以外に、副業を積極的に受けていこうかなと思っている。去年は声がかかったけど、タイミングや条件面での折り合いが悪く、仕事を受けることができなかった案件が結構多かった。個人事業主の申請なんかも視野にいれてチャレンジしてみようかなと思う。
まとめ
去年やったことと、今年やろうと思っていることをまとめた。
引き続き頑張っていくぞ。
Annotating errorsのlinter
はじめに
Go言語の話です。
過去の記事でも触れたDave Cheney先生の記事や、各種のerror handling best practiceでも触れらているように、Goのerror
は、そのままreturnせず、errors.Wrap
などでannotateすると良いとされています。
ですが、人間(というより僕)の心は弱いもので、つい手癖でそのままreturn err
してしまいます。これをいちいちソースコードレビューでチェックするのは辛いので、静的に検査できるツールを作りました。
またgoのanalysisに関する記事を読んで、せっかくなのでanaysis
にも対応させました。
今回の記事ではツールの紹介と実装の紹介をします。
作ったツールはこちら
Annotate error
簡単に言うと、下記のような状況のときにはerrors.Wrap
などを用いてどういう状況なのかを付与すると、debugなどで有利だよという方針です。
func OpenFile(fpath string) error { _, err := os.Open(fpath) if err != nil { return err } return nil }
このコードを違うパッケージのコードから呼び出します。
func Caller() { err := somepackage.OpenFile("./notexistsfile.txt") if err != nil { log.Fatal(err) } // 以下略 }
このとき、(もちろんOpenFileに渡される引数のファイルは存在しないものとして)、エラーとして出力される文字列は、下記のようになります。
2018/12/25 18:29:59 open ./notexistsfile.txt: no such file or directory
このerror
の情報は、os.Open
が生成したものであり、このエラーが発生した箇所、OpenFile
の位置情報が抜け落ちてしまっています。どこで関数を読んだかが分からなくなってしまい、デバッグの際に苦労することになります。
そこで、推奨されるのがerrors.Wrapf()
などを用いて、エラー時のコンテキストを付与していく手法です。
OpenFileを修正して、errors.Wrap()
を使います。
func OpenFile(fpath string) error { _, err := os.Open(fpath) if err != nil { return errors.Wrap(err, "os.Open failed at OpenFile") } return nil }
この状態で呼び出すと、メッセージが以下のようになります。
2018/12/25 18:38:43 os.Open failed at OpenFile(): open ./notexistsfile.txt: no such file or directory exit status 1
すると、Wrap
で渡した情報がerrorに付与され、より情報を詳細にすることができました。(また、ここでは触れませんが、log.Fatalf("%+v", err)
で表示されることにより、stacktraceの情報も得ることができます)
どういった情報を付与するかはそれぞれのユースケースによるかとは思いますが、僕の場合は、関数名とその際の簡単な状況を付与しています。
レイヤを複数にまたがるようなerror handlingはWrap
するのが基本的な作法となっているかと思います。
mustwrap
しかし、正しくerrorがWrap
されて返されているかをチェックする方法は、(僕が調べたところ)存在せず、僕たちのプロジェクトでもソースコードレビューで対応するといった状況でした。
これは完全に言い訳なのですが、editorやIDEのsnippetに
if err != nil { return err }
が登録されており、ついついその機能を使ってしまうため、ソースコードレビューで度々指摘され、静的検査でなんとかならないかなあと思っていました。
そこで、ASTをパースして上記の状況を検査するツールを作りました。
使ってみる
$ go get -u github.com/akito0107/errwrp/cmd/mustwrap-standalone
でインストールできます。
上記のファイルを検査にかけます。(--path
オプションは必須です)
$ mustwrap-standalone --path ./
すると、以下の画像のような出力になります。
error
がWrap
されずにreturnされている箇所を指摘し、強調表示してくれます。
該当の箇所をerrors.Wrap()
を用いて修正すると、エラーは出力されなくなります。
ちなみに、fmt.Errorf(), errors.New(), errors.Wrapf()
等の関数を用いても同様にエラーは出力されなくなります。
実装の紹介
実装の紹介をします。
下のコードはこのファイルからの抜粋です。
func Parse(r io.Reader, fname string) ([]*ParsedAST, *token.FileSet, error) { src, err := ioutil.ReadAll(r) // fileから読み込み if err != nil { return nil, nil, errors.Wrap(err, "parser: ioutil/ReadAll") } fset := token.NewFileSet() f, err := parser.ParseFile(fset, "", string(src), parser.ParseComments) // ASTを取得 if err != nil { return nil, nil, errors.Wrap(err, "parser: parser/ParseFile") } decls := parse(fname, f) return decls, fset, nil } func parse(fname string, f *ast.File) []*ParsedAST { var decls []*ParsedAST ast.Inspect(f, func(n ast.Node) bool { switch x := n.(type) { case *ast.FuncDecl: if x.Type.Results == nil { return true } resList := x.Type.Results.List for i := 0; i < len(resList); i++ { // FuncDeclのResultsの型を見て、errorがあれば結果セットに追加 if !containsErrorType(resList[i].Type) { continue } if isNamedReturn(resList[i]) { // NamedReturnについては未対応 logger.Warningf("named return currently not supported: %v", x.Name) continue } p := &ParsedAST{ ErrOrd: i, AST: x, FileName: fname, } decls = append(decls, p) } return true default: return true } return true }) return decls }
最初に、チェック対象のファイルを読み込み、parser.ParseFile
を使ってASTを取得します。
取得したASTをast.Inspect
し、*ast.FuncDecl
(関数定義のAST)を抽出します。ast.FuncDeclのDocumentを見るとわかりやすいのですが、FuncDeclは下記のような定義になってる構造体です。
type FuncDecl struct { Doc *CommentGroup // associated documentation; or nil Recv *FieldList // receiver (methods); or nil (functions) Name *Ident // function/method name Type *FuncType // function signature: parameters, results, and position of "func" keyword Body *BlockStmt // function body; or nil for external (non-Go) function }
error
のreturnがある関数だけを抜き出そうとした場合、FuncDecl.Type
(FuncType)のResults
プロパティをチェックすれば可能です。
ここで抽出したast.FuncDecl
と、後々にline noなどの情報を得るため、token.FileSet
を固めてreturn
します。
集められたast.FuncDecl
は、次のCheck
関数に渡されます。ここからの抜粋です。
type Result struct { Position token.Position Pos token.Pos Fname string } func Check(aset *ParsedAST, fset *token.FileSet) ([]*Result, error) { res := check(aset, fset) return res, nil } func check(aset *ParsedAST, fset *token.FileSet) []*Result { var res []*Result ast.Inspect(aset.AST, func(n ast.Node) bool { switch x := n.(type) { case *ast.ReturnStmt: // may be return directly via function call if len(x.Results) < len(aset.AST.Type.Results.List) { // おそらくreturn func()のパターン、wrapすべき。 res = append(res, &Result{ Fname: aset.FileName, Position: fset.Position(x.Pos()), Pos: x.Pos(), }) return true } expr := x.Results[aset.ErrOrd] if isNilError(expr) { // return nilのパターン、無視する return true } if isUsingPkgErrors(expr) { // return errors.Wrapなどのパターン、無視する return true } if mayUseOriginalError(expr) { // custom type errorのパターン、無視する。 return true } if isUsingFmtError(expr) { // fmt.Errorfなどを使っているパターン、無視する。 return true } // それ以外の場合はreportする res = append(res, &Result{ Fname: aset.FileName, Position: fset.Position(x.Pos()), Pos: x.Pos(), }) default: return true } return true }) return res }
*ast.FuncDecl
をさらにInspect
し、return
文のASTである*ast.ReturnStmt
の中身を読み、err
をそのままreturn
している箇所をResult
の構造体に格納していきます。
この実装だと、
err := func() err = errors.Wrap(err, "....") return err
のように、errors.Wrap
とreturn
が別のstatementで行われた場合チェックできないといったような問題点もありますが、現実ではあまりないユースケースな気もしているので、現在はこういった実装になっています。
Result
構造体のPosition
プロパティは、token.FileSet
のPosition関数にASTのPos
を渡すことによって得られるASTの位置情報です。
token.Positionによって定義され、LineNoや、filenameを得ることができます。
(後述しますが、analysis対応のため、Posの情報もそのままResultに詰めています。)
あとはResultをいい感じに色をつけてprintするだけです。
analysisへの対応
冒頭でも紹介しましたが、この記事を読んで、analysisパッケージの存在を知りました。
せっかくなので、このツールをanalysisのinterfaceに対応させようと思います。
必要な情報はすべてResult
に入っているので、それをpass.Reportf
に渡してあげるだけで完結します。
var Analyzer = &analysis.Analyzer{ Name: "mustwrap", Doc: "check for just returning error without errors.Wrap", RunDespiteErrors: false, Run: run, } func run(pass *analysis.Pass) (interface{}, error) { var decls []*ParsedAST for _, f := range pass.Files { decls = append(decls, parse("", f)...) } var res []*Result for _, d := range decls { res = append(res, check(d, pass.Fset)...) } for _, r := range res { pass.Reportf(r.Pos, "should be use errors.Wrap() or errors.Wrapf()") } return nil, nil }
analysis.Analyzer
構造体を宣言し、Name
, Doc
といった必要なプロパティを設定します。
run
は、pass *analysis.Pass
の構造体を引数に取る関数で、チェックした結果をReport
したい場合は、token.Pos
と、メッセージを渡せばそれだけあとはanalyzerがやってくれます。
mainは下記のように実装します。
package main import ( "github.com/akito0107/errwrp" "golang.org/x/tools/go/analysis/singlechecker" ) func main() { singlechecker.Main(errwrp.Analyzer) }
singlechecker.Main
にわたすだけの非常に簡単なAPIです。
このanalysis対応版は、mustwrap
というコマンドで公開しています。
$ go get -u github.com/akito0107/errwrp/cmd/mustwrap
インストールしたら実行してみます。standalone版はpathをoptionに渡していましたが、analysis版はpackageのpathを引数に渡します。
$ mustwrap github.com/akito0107/path/to/targetpkg /Users/akito/go/src/github.com/akito0107/path/to/targetpkg/openfile.go:11:3: should be use errors.Wrap() or errors.Wrapf()
github.com/akito0107/path/to/targetpkg
というのがチェック対象のpackageだと思ってください。
このようにチェックした結果を出力してくれることが確認できました。
(み、見た目が。。。ここはなんとかしていきたいですね。。。)
まとめ
annotate errorの説明と、その静的解析ツールの紹介をしました。実は今回作ったツールはまだ実プロダクトで使えてはいないので、正直どこまで役に立つかはわかっていません。。。軽く使ってみた感想だと、チェックツールがうるさすぎるので、もうちょっとルールを工夫したいと思ってはいます。なにかいいアイデア等ありましたら共有していただきたいと思っています!
analysis
パッケージについては、情報がまだあまりなく、手探りの状態でしたが、上記のブログや、公式を読むとなんとか実装することができました。
まだAnalyzerの依存関係や、その有用性などの理解が全くできていないので、引き続き学習していきたいと思います。
ありがとうございました!
- この記事は、リクルートエンジニアアドベントカレンダーその223日目の記事です。
TypeScriptのcompiler APIをいじる
はじめに
これを作っているときに、もともとのagreedとswaggerの型の帳尻を合わせるために、TypeScriptのASTをいじっていろいろやりました。 そのときに調べたことなどをメモ代わりにまとめます。この内容は調べたこと+僕独自の解釈が入っている可能性があり、正確ではないかもしれませんが、ご了承ください。
TypeScript Compiler API
何ができるようになるか
TypeScript Compilerを使うことにより、TypeScriptのASTの解析や編集などを行うことができます。linterや、静的解析、自動生成系のツールを作る際によく使う機能です。 例えば前述のagreed-typedではTypeScriptの型定義やコメントをASTを解析することにより抜き出し、そこからOpenAPI2.0(Swagger)を生成しています。 実際のアプリケーションコードを書く際には全く触れることはない(また、触れるべきではありません)機能ですが、用途を絞って活用することにより、利便性の高いツール群を実装することができます。
サンプル実装
TypeScriptのCompilerAPIを使って、アプリケーションコードの中からassert
を削除するツールを書いてみようと思います。
以降unassertと呼びますが、unassertやassertionについては過去の記事を参照していただくとわかりやすいと思います。
実装にあたって、ここに公式のdocumentがありますが、そこまで詳細なdocumentationではなく、ここにある情報だけで何かを実装するのは少し難しいかなと思っています。このAPIをいじる際には、GithubのTypeScriptのIssueを探しながら行うと良いかもしれません。
作るものとしては、TypeScriptのソースを読み込み、unassertify
した上で別のファイル(もしくは標準出力)にTypeScriptのファイルを書き出すような実装にしようと思います。TypeScript Compiler APIでは本来の機能ではTypeScriptからJavaScriptにtranspileしますが、今回transpileまでは行いません。
unassertifyの実装
早速コードの方を見ていこうと思います。今回のコードは下記に上がっています。src/trans.ts
を参照してください。
他にもやり方はあるのですが、今回は最もシンプルに、ts.createSourceFile
,ts.transform
を使うやり方を見ていきたいと思います。
import * as ts from "typescript"; // fileを文字列として読み込む const src = fs.readFileSync(path.resolve(/*path to file*/), { encoding: "utf-8" }); const sourceFile = ts.createSourceFile("", src, ts.ScriptTarget.ES2015); const result = ts.transform(sourceFile, [removeImport/*ここにtransformerを入れる*/] ); result.dispose(); const printer = ts.createPrinter(); console.log(printer.printFile(result.transformed[0] as ts.SourceFile));
fs.readFileSync
でファイルを文字列として読み込み、その文字列をts.createSourceFile
に渡しています。ts.createSourceFile
の第1引数はファイル名の文字列が入るのですが、今回は空で大丈夫です。
createSourceFile
の返り値は、Node
などの情報が格納されたASTとなっています。
ts.createPrinter()
でこのAST(SourceFile)をprint再びTypeScript
のソースに復元することができます。
ts.transform
の第1引数にSourceFile
, 第2引数にASTを変換するTransformerFactory
を渡すことができます。
このコードがTransoformerFactoryの関数のサンプル実装です。
TransformerFactorの型定義は下記のようになっています。
type TransformerFactory<T extends Node> = (context: TransformationContext) => Transformer<T>; type Transformer<T extends Node> = (node: T) => T;
本質的には、Node
を受け取り、Node
を返す関数を生成する関数です。
const removeImport = <T extends ts.Node>(context: ts.TransformationContext) => ( rootNode: T ) => { function visit(node: ts.Node): ts.Node { node = ts.visitEachChild(node, visit, context); if (!ts.isImportDeclaration(node)) { return node; } const importDecl: ts.ImportDeclaration = node; if ((importDecl.moduleSpecifier as any).text === "assert") { return null; } return node; } return ts.visitNode(rootNode, visit); };
この関数はassert
をimport
している行を削除するtransformerです。
Nodeを削除したい場合はnull
をreturn
すると削除することができます。ただし、生成したASTがinvalidな状態だと、思わぬところで実行時例外が発生する可能性があるので、注意が必要です。
このtransformerは、NodeがImportDeclaration
かどうかをチェックしています。
if (!ts.isImportDeclaration(node)) { return node; }
この部分がちょうどそれに当たります。TypeScriptは、すべてのNodeにkind
の数値が割り当てられており、どの種類のNode
なのかを判別することができます。このあたりで実装されています。
kind
の数値を覚えていればどのNodeなのかの判別は効きますが、流石につらすぎるので、src/compiler/utilities.ts
で定義されているような関数を用いてNodeの種類を判別することをおすすめします。
また、どの行がなんのASTに変換されるのかを意識するのも非常に難しいと思います。ts-ast-viewerなどのツールを使いながら、目的の文や式がなんのASTに当たるのかをチェックしながら実装するのをおすすめします。
この状態でテスト用に下記のファイルを用意します。
import * as assert from "assert"; console.log("hello");
ts-node
や、transpile
などを用いて、このファイルpathを引数に与えつつ、実装したコマンドを実行してください。
$ node lib/index.js --path {path to example file} console.log("hello"); # importが消えて入れば成功
ちなみに、削除する場合はnullを返せばよいですが、Nodeを書き換えたい・置き換えたい場合は、引数で与えられるNodeの値を書き換えることにより可能になります。
例えば、assert
をpower-assert
のimportに書き換えたい場合は下記のように実現できます(かなり強引ですが...)。
const swapImport = <T extends ts.Node>(context: ts.TransformationContext) => ( rootNode: T ) => { function visit(node: ts.Node): ts.Node { node = ts.visitEachChild(node, visit, context); if (!ts.isImportDeclaration(node)) { return node; } const importDecl: ts.ImportDeclaration = node; if ((importDecl.moduleSpecifier as any).text === "assert") { (importDecl.moduleSpecifier as any).text = "power-assert"; // ここでmoduleSpecifierのStringLiteralを書き換える。 return node; } return node; } return ts.visitNode(rootNode, visit); };
引き続き、assert
のcall
部分を削除します。transfomerは下記のようになります。
const removeAssertExpression = <T extends ts.Node>( context: ts.TransformationContext ) => (rootNode: T) => { function visit(node: ts.Node): ts.Node { node = ts.visitEachChild(node, visit, context); if (!ts.isExpressionStatement(node)) { return node; } if (!ts.isCallExpression(node.expression)) { return node; } const call: ts.CallExpression = node.expression; if ((call.expression as any).escapedTxt === "assert") { return null; } if (!ts.isPropertyAccessExpression(call.expression)) { return node; } const propAccess: ts.PropertyAccessExpression = call.expression; if ((propAccess.expression as any).escapedText === "assert") { return null; } return node; } return ts.visitNode(rootNode, visit); };
assertをcallする場合、assert(value)
のように直接callするパターンか、assert.deepEqual(...)
のようにexportsされた他の関数を使うパターンがあります。TypeScriptのAST的には、ExpressionStatement
のexpression
のプロパティが、CallExpression
であり、CallExpression
がIdentifier
なのか、PropertyAccessExpression
なのかで区別することができます。
このあたりの細かい話は、実際にast-viewerを使ってASTを確認しながら書いていくとわかりやすいと思います。
下記のようなコードを用意して、書いたtransformerをチェックしてみましょう。
import * as assert from "assert"; const obj1 = { a: { b: 1 } }; const obj2 = { a: { b: 2 } }; assert(true); assert.deepEqual(obj1, obj2);
下記のように出力されるはずです。
const obj1 = { a: { b: 1 } }; const obj2 = { a: { b: 2 } };
非常に簡単ステップでしたが、unassertify
を実現することができました。
まとめ
TypeScriptのCompilerAPIを用いて、ASTの変換・編集を行う方法について書きました。 僕自身もまだ良くわかってない状態ですがTypeScript周りの自動生成ツールを作ろうかと思っていて、その折に触れた技術です。 TypeScriptは、compiler pluginを差し込む口がBabelなどと比べるとかなり制限されており、そのあたりでも差分を感じています。
以上です、ありがとうございました!
wasmでGoのAST Viewerを作った話
はじめに
Goの1.11からwasm
向けのバイナリを吐けるようになりました。
wasm
上でパーサを実行し、GoのASTをビジュアライズするものを作ったので、その紹介をします。
作ったものはこちら。
AST Viewer
Goに限らない話かもしれないですが、コード自動生成や、静的解析系のツールを実装しようとする場合、ASTを扱うことがよくあると思います。
ASTを直感的に扱えるようになるためには相当な慣れが必要になり、はじめのうちは、コードがどういったASTにパースされるのかを把握するために、逐一print
するなどして確認していました。
この作業の効率を上げるために、ASTのvisualizeをするツールをせっかくなので自分で作ってみることにしました。
というわけで、作ったものがこちらです。
editor部分はmonaco editor、ASTをパースする部分はGoのwasmで実装されています。 server sideでは何もしておらず、静的ファイルを返すだけの実装となっているので、hostingさえしてしまえば、browser上で実行することができます。
実行方法
wasm
やjs
はすべてstatikで固めているので、
$ go get -u github.com/akito0107/astviewer/cmd/astviewer
で手元にインストールすることができます。手元でビルドしたい場合は、
Go, dep, Node.js, yarnを入れた上で、
$ git clone https://github.com/akito0107/astviewer/cmd/astviewer $ cd ${clone-repo} $ make
とうつとbin
配下にバイナリが生成されると思います。
コマンドを実行すると、サーバが起動します。
$ astviewer
デフォルトでは8080
でlistenしますが、-p
オプションでportを任意に設定できます。
ブラウザにアクセスすると、添付の画像のような画面にアクセスできると思います。
wasmの実装
wasm部分の実装を簡単に見ていきます。Goのwasmについてはここを参照してください。
これがwasmの実装になっています。
// +build js,wasm package main import ( "bytes" "fmt" "go/ast" "go/parser" "go/token" "log" "syscall/js" ) // 中略 func main() { gosrc := js.Global().Get("document").Call("getElementById", "gosrc") src := gosrc.Get("value").String() goout := js.Global().Get("document").Call("getElementById", "goout") var buf bytes.Buffer var depth int fset := token.NewFileSet() f, err := parser.ParseFile(fset, "", src, parser.ParseComments) if err != nil { fmt.Printf("%+v\n", err) return } ast.Inspect(f, func(n ast.Node) bool { if n != nil { depth-- } else { depth++ return true } lineno := fset.Position(n.Pos()).Line view := &ASTView{ LineNo: lineno, } switch x := n.(type) { case *ast.ArrayType: view.Label = "ArrayType" view.Value = fmt.Sprintf("Len: %s, Elt: %#v", x.Len, x.Elt) case *ast.AssignStmt: view.Label = "AssignStmt" view.Value = fmt.Sprintf("Lhs: %#v, Tok: %d, Rhs: %#v", x.Lhs, x.Tok, x.Rhs) /* ~中略~ */ default: view.Label = "Unknown" } if _, err := fmt.Fprintf(&buf, "<span class=\"astline lineno%d\">%*s%s</span>\n", view.LineNo, depth*2, "", view); err != nil { log.Printf("%+v", err) return false } return true }) goout.Set("innerHTML", buf.String()) }
syscall/js
をimport
することにより、js.Global
などの関数が使えるようになります。
下記のように、直感的にDOMの値を取得および操作することができるようになります。
gosrc := js.Global().Get("document").Call("getElementById", "gosrc") src := gosrc.Get("value").String() // 中略 goout := js.Global().Get("document").Call("getElementById", "goout") // 中略 goout.Set("innerHTML", buf.String())
Examplesは少ないですが、公式のdocも充実しているので、参照してみてください。
ASTをparseする際のtipsとして、token.FileSet
を用いることにより、Nodeの行数を取得することができるようになります。
これにより、ASTをbufferに書き出す際に、<span class="lineno{行数}">
のclassを付与し、editorのcursorの行数の背景色をリアルタイムに変えています。
fset := token.NewFileSet() // ParseFileの第1引数に渡す f, err := parser.ParseFile(fset, "", src, parser.ParseComments) ast.Inspect(f, func(n ast.Node) bool { // ast.NodeのPos()をPosition()に渡すとASTのPositionを手に入れられる lineno := fset.Position(n.Pos()).Line // 以下略
まとめ
サーバ側のプロセスでparseを行わないでも、browser上のwasm
でGoのパーサを実行することにより、ASTをvisualizeすることができました。
自分自身もASTをパースして静的解析や自動生成のツールを書くことが多いので、便利になるようなツールを作ってみました。
今後はよりわかりやすい見た目にしていきたいと思います!
Goのカスタムエラーとその自動生成について
はじめに
この記事はGo2アドベントカレンダー14日目の記事です。
GoのErrorハンドリングについては、これまでにも様々なパターンが発表されてきました。 今回は独自定義のエラーについて、これまでのパターンをまとめた上で、その実現をeasyにするgenerrというツールを作ったので、その紹介をします。
Goのカスタムエラー
Goのエラーハンドリングの中で、カスタムなerror
型を用いるパターンは比較的広く知られているかと思います。
その中からいくつかのパターンを紹介します。
sentinel errorsパターン
Goのerror
はinterfaceとして定義されており、Error() string
のメソッドを実装さえしていれば、error
として扱うことができます。
標準パッケージで提供されているerrorsやfmt.Errorfを用いて簡単にエラーを表現することができます。
しかし、error
を場合によってハンドリングしたい場合、string
の中身をパースして、何が起きたかを判断せねばならず、人間にとっては易しいのですが、プログラミングでは扱いにくい形式だと思います。
その問題に対応するため、標準パッケージのsqlなどで採用されている方式として、予め固定された値を提供するパターンが知られています(Dave Cheney先生により、sentinel errorsと命名されています)。
前述のsqlパッケージで提供されているerror
のうちの一つ、ErrNoRows
は以下のように定義されています。
var ErrNoRows = errors.New("sql: no rows in result set")
実際にErrNoRows
をハンドリングしたい場合、 返されたerror
を==
で比較することができます。
以下はQueryRowのExampleからの引用です。
id := 123 var username string var created time.Time err := db.QueryRowContext(ctx, "SELECT username, created_at FROM users WHERE id=?", id).Scan(&username, &created) switch { case err == sql.ErrNoRows: // ここでerrの種類をチェック log.Printf("No user with id %d", id) case err != nil: log.Fatal(err) default: fmt.Printf("Username is %s, account created on %s\n", username, created) }
このようにsentinel errorsを用いることで、エラーハンドリングのソースコードの見通しを大きく改善することができます。
error typesパターン
上記のパターンは見通しを改善するという意味では成功していますが、エラーに発生時の情報を付与することができず、エラーの情報量としてはやや不十分となる場合があります。
前述のExampleからの引用でもsql.ErrNoRows
時のlog
で出力されていますが、sql.ErrNowRows
が発生したとき、どういったuserのidが渡されたのかをerr
の中に格納したい場合があります。
そういった場合は自分でError() string
を実装した型を定義し、カスタムなerror
を作ります。上記のケースだと、例えば以下のようにエラーを定義できます。
type UserNotFound struct { Id int } func (e *UserNotFound) Error() string { return fmt.Sprintf("userid: %d is not found", e.Id) }
このUserNotFound
型を用いて、上記のExampleを書き換えてみます。
func GetUesr(id int) (User, error) { var username string var created time.Time err := db.QueryRowContext(ctx, "SELECT username, created_at FROM users WHERE id=?", id).Scan(&username, &created) switch { case err == sql.ErrNoRows: return nil, &UserNotFound{Id: id} // Idの情報を渡せる case err != nil: return nil, err default: return &User{username, created}, nil } }
先程のExampleをGetUser
という関数に書き換え、sql.ErrNoRows
のときに、よりエラーの文脈を表すUserNotFound
というエラーに翻訳し、呼び出し元にreturn
するというコードに修正しました。UserNotFound
を作る際にId
を渡すことができるようになり、エラーの情報量を豊かにすることができました。
呼び出し側を想定し、error handlingしてみます。type assertionを行うことにより、handlingを行います。
func GetUserCaller() { id := .... // 中略 u, err := GetUser(id) switch type.(err) { case nil: // 正常系の処理を続ける case *UserNotFound: log.Printf("user id %d is not exists", err.Id) // NotFoundのときの処理を行う default: // unknown error } }
上記の関数ではhandlingに意味はないですが、REST APIなどでは、errが*UserNotFound
だった場合は、404
を返し、default
に入ってきた場合は50x
系を返すというようなユースケースをイメージするとわかりやすいと思います。
Assert for behaviourパターン
error types
を用いることにより、errorをより文脈に沿った形に翻訳し、エラー時の情報をより豊かにすることができるようになりました。
ですが、上記のパターンでも問題となるケースがあります。
「GetUser
はUserNotFound
という型のエラーを返す」という知識を呼び出し元のGetUserCaller
が持っていなければならず、呼び出し元と呼び出され側の依存関係が強くなってしまうという点です。
この問題点は前述のBanzai CloudのError Handling Practiceのブログなどで触れられています。
解決策として、Errorの具体的な実装は公開せず、振る舞いのみを公開するというパターンがあります。以下のコードはBanzai CloudのError Handling Practiceからの引用(および一部改変)です。
上記のUserNotFound
の場合は、以下のようにinterface
を定義し、併せてerr
が定義したinterface
を満たしているかどうかをcheckする関数を定義します。
type userNotFound interface { UserNotFound() int64 //返り値はidを想定 } func IsUserNotFound(err error) (bool, int64) { if e, ok := err.(userNotFound); ok { return true, e.UserNotFound() } return false, 0 // 0はint64のゼロ値 }
ポイントとしては、checkする関数のみをpublicなAPIとして定義する点です。
呼び出され側(GetUser
)では、以下のようにすべてprivateなAPIとしてerrorを定義します。
type userNotFound struct { id int64 } func (e *userNotFound) Error() string { return fmt.Sprintf("user with ID %d not found", e.id) } func (e *userNotFound) UserNotFound() int64 { return e.id }
上記のコードを用いて、GetUserおよびGetUserCallerをリファクタします。
GetUserはrepository
パッケージ、GetUserCallerはmain
パッケージに定義されているものとします。
package repository func GetUesr(id int) (User, error) { var username string var created time.Time err := db.QueryRowContext(ctx, "SELECT username, created_at FROM users WHERE id=?", id).Scan(&username, &created) switch { case err == sql.ErrNoRows: return nil, &userNotFound{Id: id} // 同一packageのstructを使用する case err != nil: return nil, err default: return &User{username, created}, nil } }
package main func GetUserCaller() { id := .... // 中略 u, err := repository.GetUser(id) if ok, id := IsUserNotFound(err); ok { // メソッドがidを返すので、情報量は落ちない log.Printf("user id %d is not exists", id) // NotFoundのときの処理を行う } else if err != nil { // unknown error } // 正常系の処理を続ける }
error typesを用いたときと同様の情報量を保ちつつ、依存を緩めることに成功しました。 このパターンは特に複数のパッケージにまたがるような規模の大きい開発のときに特に有効だと感じています。
generrについて
ここからがこの記事の本題です。
assert for behaviour
のパターンは柔軟かつ有効だと思うのですが、 やや記述量が多い とも感じていました。
errors.New()
でエラーを定義していたときから比べると、interface
を定義し、checkの関数を定義し、errorの実装を定義しというように、大分手数が増えたなあと思います。
そこで、最初のinterface
さえ定義すれば、あとはgo generate
で他のものをすべて自動生成してくれるツール、generrを作りました。
使い方
実際の使い方を見ていきたいと思います。
install
go getしてください。
$ go get github.com/akito0107/generr/cmd/generr
describe interface
任意のパッケージで、interfaceを定義します。
type userNotFound interface { UserNotFound() (id int64) }
ややトリッキーなのですが、値をreturnさせたい場合、必ずnamedな値にしてください。 準備はこれだけです。
generate!
同じレベルのディレクトリで、
$ generr -t userNotFound
と打ってください。-t
オプションで渡すのは、interface
の型名です。
すると、userNotFond_check.go
というコードが生成されます。中身は、下記のようになっています。
func IsUserNotFound(err error) (bool, int64) { var id int64 if e, ok := err.(userNotFound); ok { id = e.UserNotFound() return true, id } return false, id }
interfaceをcheckし、情報を取り出すための関数が生成されています。
generate struct!
次に、実装を生成します。
以下のようなディレクトリ構成だと仮定します。
. ├── error.go # userNotFoundのinterface定義ファイル ├── implpkg │ └── # ここに実装を吐き出す └── userNotFound_check.go
下記のコマンドを打ってください。
$ generr -t userNotFound -i -it userNotFound -o implpkg
オプションの解説をします。
-i
は実装を吐き出すオプションです。 -it
は吐き出す実装の型名です。 -o
は吐き出すディレクトリです。
-o
に前述した例だと、repository
などを指定するイメージです。
下記のファイルが userNotFound_impl.go
として生成されます。
// Code generated by "generr"; DO NOT EDIT. package implpkg import "fmt" type userNotFound struct { Id int64 } func (e *userNotFound) UserNotFound() int64 { return e.Id } func (e *userNotFound) Error() string { return fmt.Sprintf("userNotFound Id: %v", e.Id) }
error + userNotFound interfaceの実装が吐かれました。
ちなみに、-it
と-o
は任意です。指定しなかった場合、interface定義ディレクトリと同じディレクトリに生成され、構造体の型名はUserNotFound
(interface名の先頭を大文字にしたもの)になります。
ユースケースとして、パッケージ分離が必要なく、custom error typesをeasyに生成したいときなどはこのケースで十分だと思います。なお、-u
オプションで、上記の生成物(チェック関数と実装)を一つのファイルにまとめることもできます。同様に、custom error typesをeasyに使いたいときに有効です。
with go generate
僕がこのツールを使うときにはgo generate
と一緒に使うことが多いです。
先程の例だと、
//go:generate generr -t userNotFound -i -it userNotFound -o implpkg type userNotFound interface { UserNotFound() (id int64) }
として定義し、
$ go generate
で同様の結果が得られます。
また、通常のerror typesパターンとして使いたいときなどは、
//go:generate generr -t userNotFound -i -u type userNotFound interface { UserNotFound() (id int64) }
として定義して、
$ go generate
と実行すると、以下のようなファイルがuserNotFound_impl.go
として生成されます。
// Code generated by "generr"; DO NOT EDIT. package generrdemo import "fmt" func IsUserNotFound(err error) (bool, int64) { var id int64 if e, ok := err.(userNotFound); ok { id = e.UserNotFound() return true, id } return false, id } type UserNotFound struct { Id int64 } func (e *UserNotFound) UserNotFound() int64 { return e.Id } func (e *UserNotFound) Error() string { return fmt.Sprintf("userNotFound Id: %v", e.Id) }
UserNotFound
とIsUserNotFound
が公開されており、assert for behaviour
パターンからするとアンバランスですが、ユースケースによってはこれで十分な場合もあるかと思います。
また、-m
オプションでError() string
で返すメッセージのカスタマイズが可能となっています。
まとめ
Goにおけるカスタムなerrorを使ったパターンを紹介しました。 その上で、その実現をeasyにするツール、generrの紹介をしました。
バグなどがありましたら教えていただけると幸いです。
ありがとうございました!
agreed-typedの紹介
はじめに
agreed-typedというツールを現在開発中なので、その紹介をします。
自分は普段バックエンドを書いているエンジニアで、フロントエンドエンジニア向けのツールであるagreedをメインで活用することはないのですが、APIのドキュメンテーションや、バックエンドとフロントエンドのAPIの整合性をとるという観点から、agreedを補完するためのツールとしてagreed-typedを開発しています。 その背景と、agreed-typedで実現したことを紹介していきます。
以下のサンプルコードはすべてGithubにupしてあります。
agreedとはなにか
agreed-typedの説明をする前に、agreedの説明をします。
agreedの紹介
agreedはリクルートテクノロジーズがOSSとして公開している、Consumer Driven ContractおよびJSON Mock Serverのツールです。agreedの詳しい紹介については、この記事をみていただきたいのですが、簡単にまとめると、APIの仕様定義をバックエンド側(API提供側)が決めるのではなく、フロントエンド(API使用側)が主導する Consumer Driven Contract を達成するためのツールで、フロントエンドエンジニアがまずjsやjson、yamlなどでAPI定義を記述し、バックエンド側がそれを実装するといった開発フローを実現します。
実際の開発フローを以下に記載していきます。
1. agreed fileの記述
フロントエンドエンジニアが、画面の描画に必要なAPIをjson(5), yaml, jsなどで記述する。(これをagreed fileといいます)
例えば、 GET /user/:id
にリクエストを行い、レスポンスとして、 {message: "hello ${id}"}
(この id
はURLの :id
に相当する) といったjsonを期待する場合、以下のように書きます。
// save as agreed.js module.exports = [ { request: { path: '/user/:id', method: 'GET', }, response: { body: { message: 'hello {:id}' } } } }
これはjs
でagreed fileを記述する場合ですが、普段js
を書いているエンジニアならば比較的直感的に書ける形式かと思います。これをPull Requestなどのコミュニケーションパスを用いて、バックエンドエンジニア側に伝えます。バックエンドエンジニアはこれを用いて、このAPI定義の通りのリクエストレスポンスになるように実装し、フロントエンドエンジニアは、バックエンドから提供されるAPIがこの通りのレスポンスを返してくれるものと想定して機能・画面の実装を開始します。
2. フロントエンド開発のためのMock Server
フロントエンドエンジニアは、記載したagreed fileを用いて、JSON Mock Serverを立ち上げることができます。 npmなどを用いて、agreedをインストールします。
$ npm install -g agreed
agreedをinstallすると、agreed-serverというコマンドがインストールされます。 --path
optionで、記述したagreed fileのpathを渡すと、JSONのMock Serverが立ち上がります。
$ agreed-server --path ./agreed.js --port 3010
試しにcurl
でリクエストを行います。
$ curl -XGET localhost:3000/user/123 {"message":"hello 123"}
期待通りにURLのパラメータで渡したidがセットされたresponseが返って来ました。 フロントエンドエンジニアは、APIの実装完了を待たずして、画面や機能を開発することができます。
3. バックエンド開発のためのAPI Checker
JSON Mock Server以外にも、バックエンドのために期待通りのレスポンスが返せているかをチェックする機能があります。
agreed
をインストールすると、agreed-server
に加え、agreed-client
というコマンドがインストールされます。このagreed-client
を使うと、実装したAPIがagreed fileの定義に沿っているかをチェックすることができます。
今回はデモ用にexpress
を用いて以下のような簡単なAPIサーバを用意します。
const express = require('express'); const app = express(); app.get('/user/:id', (req, res) => { res.json({message: 'hello'}) }); app.listen(3010, () => { console.log('listen'); });
このサーバはGET /user/:id
のpathにリクエストを飛ばすと、固定で{message: "hello"}
というレスポンスを返します。つまり、agreedで定義されたAPIとは異なる挙動をします。
このAPIに対して、agreed-client
でチェックをします。
$ agreed-client --path ./agreed.js --port 3010 --host localhost ✗ fail! GET /user/:id body: { message: 'hello' } message mismatch value, agreed value is hello {:id}, but actual value is hello agreed: hello {:id} actual: hello
agreed
で定義された挙動と異なるので、failしました。このように、APIが正しく実装されてるのかのcheckingを行うことができます。
API側を修正します。app.get
の中身を修正すれば良さそうです。
app.get('/user/:id', (req, res) => { - res.json({message: 'hello'}) + res.json({message: `hello ${req.param.id}`}) });
再びagreed-client
でチェックします。
$ agreed-client --path ./agreed.js --port 3010 --host localhost ✔ pass! GET /user/:id
今度はpassしました。APIが正しく実装されたことがわかります。
このように、agreedは 1. フロントエンドエンジニアフレンドリーなI/Fにより、フロントエンド主導のAPI開発を実現する。 2. Mock Serverによりフロントエンドとバックエンドの開発を非同期的に行うことが可能になる。 3. API Clientにより、APIのE2Eテストを可能にする。
といった効果をもたらします。
agreedのユースケース
上記で紹介したのは最もシンプルなケースでした。ここでは実際の開発現場での使われ方を少し紹介します。
正常系・異常系の出し分け
極端に単純なAPIでない限り、APIにはエラーのケースがあります。agreed
はこれらの表現も可能です。
先程作ったGET /user/:id
のAPIに拡張して、異常系を追加しようと思います。
module.exports = [ { request: { path: '/user/:id', method: 'GET', }, response: { body: { message: 'hello {:id}' } } }, + { + request: { + path: '/user/9999', + method: 'GET', + }, + response: { + status: 404, + body: { + message: 'user not found' + } + } + } ]
agreedはrequest
/ response
が対になったオブジェクトの配列として定義されます。
今回は、上記のAPIに加え、GET /user/9999
にアクセスしたときに、404
になるようなAPIとして定義します。リクエストのpath, methodは最初に定義したAPIと同一ですが、存在しないユーザidにアクセスするパターンを想定しています。
mock serverを立ち上げ、curlで動作をチェックします。
$ agreed-server --path ./agreed.js --port 3010
$ curl -XGET localhost:3010/user/9999 {"message":"user not found"} # not foundのケース $ curl -XGET localhost:3010/user/123 {"message":"hello 123"} # 正常系のケース
URLに与えるidの値により、responseが動的に出し分けられることが確認できます。agreedでは、path
やrequest bodyの値によるresponseの出し分けが可能となっています。
agreedの運用上の課題
agreedはフロントエンド・バックエンドの初期開発には非常に効果的なのですが、運用を続けていくにつれ、いくつかの課題が見られました。以下で紹介します。
agreedの記述力の問題
上記の異常系の出し分けのパターンで見たように、agreedは同一path, 同一メソッドのAPI定義を書くことも許容できます。したがって、以下のようなパターンの記述も可能です。
module.exports = [ { request: { path: '/user/', method: 'POST', body: { email: '{:email}', name: '{:name}' } }, response: { status: 201 body: { id: 1, message: 'created!' } } }, { request: { path: '/user/', method: 'POST', body: { email: '{:email}', name: '{:name}', phoneNumber: '012-345-678' // <- 必須パラメータ??? } }, response: { status: 201, body: { id: 1, message: 'created!' } } } ]
このAPI定義では、同一pathに対してPOST
を送っているAPIの定義になります。1つ目の定義ではphoneNumber
というプロパティがありませんが、2つ目の定義には存在しています。この場合、phoneNumber
というプロパティをどう解釈すればよいのか、ここからだと判断する方法がありません。実は必須パラメータで、1つ目の定義は書き忘れただけなのか、任意のパラメータなのかといった解釈の余地があり、これだけでAPIを実装することはできません。
このように、agreedは柔軟な記述能力を持っている反面、strictなAPI定義ができないという弱点を持っています。
API documentationの二重管理
上記の記述能力の問題にも関わってくるのですが、agreed
単体でAPI定義が完結せず、結局、別個に詳細なAPIのドキュメントが必要になってくるという問題もあります。
APIの定義をする上で、必須や非必須を表現するだけではなく、このプロパティが許容する値は何なのか、値が数値の場合、最大値は何で最小値は何なのかなどを明確にしなければ、バックエンドの実装は非常に困難です。 その表現がagreed単体では不可能で、結局別のメディアにより詳細なAPI定義を行い、agreedとそのドキュメントをそれぞれ管理しなければならないという問題が発生してしまいました。 詳細なAPIドキュメントとagreedの整合性を担保する手段はなく、手動による二重管理が発生し、そのコストが無視できないレベルになってきてしまいました。
agreed-typed
上記のような問題を解決するため、agreedにtypescriptによる静的な型づけを行い、agreedファイル自体のメンテナンス性を向上させ、さらに、必須・非必須などのAPIドキュメントとしての表現力を上げるためのツールとして、agreed-typedを開発しました。
APIへの型定義の追加
従来agreedはjs, json, yamlなどのフォーマットで記述していたのですが、ここにtypescript
でも書けるように、agreed本体に機能を追加しました。agreedはrequire-hook
を用いて、require
時に様々なフォーマットをコンパイルし、データとして読み込むという実装がされています。そこにtypescript
のhookを加え、typescriptのファイルをrequire
する際にon the flyでtranspileするようにしました。この回収により、agreedがtypescriptで記述できるようになりました。
agreed-typedは、APIDef
という型をexport
していて、この型を用いてユーザはagreedを定義します。例として、上記のexampleをagreed-typed
を用いて型定義していきたいと思います。
まずは、npmのprojectをinitします。
$ mkdir agreed-examples && cd agreed-examples $ npm init -y $ npm install agreed agreed-typed
基本的には作成したagreed-examplesのディレクトリの中で作業するものとします。
typescriptのagreedファイルを作成します。
$ vim agreed.ts
以下のように記載してください。
// save as agreed.ts import { APIDef, GET, Capture, Success200, ResponseDef, convert } from "agreed-typed"; // 必ずexportすること! export type UserGetAPI = APIDef< GET, // HTTP Method ["user", Capture<":id">], // request path => /user/:id {}, // request header {}, // request query undefined, // request body {}, // response header ResponseDef<Success200, { message: string }> // response >; const api: Array<UserGetAPI> = [ { request: { path: ["user", ":id"], method: "GET", body: undefined, }, response: { status: 200, body: { message: "hello {:id}" } } } ] module.exports = convert(...api);
保存したら、このファイルを読み込んでagreed-serverを起動します。
$ agreed-server --path agreed.ts --port 3010
通常のagreedと同じように、curl
で期待通りのresponseが返ってくるはずです。
$ curl -XGET localhost:3010/user/123 {"message":"hello 123"}
コードの解説を行います。
import { APIDef, GET, Capture, Success200, ResponseDef, convert } from "agreed-typed";
agreed-typedをライブラリとして読み込み、必要な型をimportしてください。VSCodeなどを使っていれば補完が効くと思います。
export type UserGetAPI = APIDef< GET, // HTTP Method ["user", Capture<":id">], // request path => /user/:id {}, // request header {}, // request query undefined, // request body {}, // response header ResponseDef<Success200, { message: string }> // response >;
ここがagreed-typed
のAPIの型定義の本体です。APIDef
は型パラメータを7つとる筋肉質な実装になっています。HTTP VerbのGET
も型として提供しています(もちろんPOST
, PUT
, PATCH
, DELETE
なども定義してあります。)
なお、この型定義は必ずexport
してください!
各パラメータはどの型に当たるのかはソースコード中のコメントを参照してください。また、レポジトリのテストコードなども参考になるかと思います。
ちなみに、GET
メソッドでbody
をundefined以外で指定すると、型チェックがfailするので注意してください(そういうように型を作っています)。
2つ目の型パラメータ、path
を表している部分について解説します。ここだけほかの型と気色が違います。このpathはtuple
として定義してあり、イメージとしては、pathの/
で区切られた階層がtuple
の要素に対応しています。
Path Parameter (:id
など)を用いるときは、Capture
という型を定義してあるので、そちらを用いて、Capture
の型パラメータにpath parameterの名前を入れるようにしてください。これに呼応し、agreedのpathの定義自体もtuple
を用いて定義する形に変更になっています。(ちなみに、最後のconvert
関数はこのtuple
をもとのpathの形に変換するだけの関数です)
最後の型パラメータ、ResponseDef
について解説します。ResponseDef
は2つ型パラメータをとります。1つ目はResponseのステータスコードを表す型で、これも200 - 4xx番台まですべて定義してあります。2つ目は、responseのbodyを表す型定義です。これは既存のものと同様に自由に定義できます。なぜここだけこういった形で定義しているのかというと異常系とセットで定義するときに利便性が高いからです。
このことを説明するために、上記のagreed-typedのファイルに、id=9999のときの異常系を追加してみます。
import { Capture, Success200, ResponseDef, - convert + convert, + Error404 } from "agreed-typed"; type UserGetAPI = APIDef< {}, // request query undefined, // request body {}, // response header - ResponseDef<Success200, { message: string }> // response + | ResponseDef<Success200, { message: string }> + | ResponseDef<Error404, { message: "user not found" }> // response >; const api: Array<UserGetAPI> = [ { request: { path: ["user", ":id"], method: "GET", body: undefined }, response: { status: 200, body: { message: "hello {:id}" } } + }, + { + request: { + path: ["user", "9999"], + method: "GET", + body: undefined + }, + response: { + status: 404, + body: { message: "user not found" } + } } ];
ResponseDef
をunion type
として定義することにより、正常系・異常系双方のパターンを型で表現できるようになります。
また、ステータスコードとresponse bodyを別個で表現するよりも、同じ型として表現したほうが対応関係が明確になりやすいので、このようなAPIにしてあります。
型チェックを試す
せっかく型を定義したので、いくつか型チェックを試してみます。以下はVSCodeの画像を貼ってあります。エラーの文言で型チェックが落ちていることがわかると思います。
pathの型チェックを調べる
user
として定義しているpathにusers
と入れてみます。
pathのパラメータはtuple
として定義してあるので、pathも型として表現されています。なので、事前に定義した型以外の値は入れられません。
Capture
は唯一例外で、agreedのURLパラメータを表現するために導入されている特別な型です。以下のように任意の文字列をアサインできます。
また、Capture
の第2型引数で任意の型を渡せば、その型をアサインすることができます。
未定義のresponse
わかり易い例だと、未定義のstatus codeを無理やり定義しようとすると、当然エラーになります。
status codeが200|404
しか定義されていない状態で、201
を定義しようとこうなります。
404のときのエラーメッセージはuser not found
と定義されているのですが、これ以外のメッセージを入れようとしてもエラーとなります。
これはuser not found
が文字列ではなく、型として定義されているからです。この機能により、特に異常系の表現力が豊かになっていると感じます。
swagger generator
agreed-typed
はAPIに型定義を提供するだけではなく、その型定義をパースし、Swagger(OpenAPI 2.0)の定義ファイルを出力するコマンドラインツールも提供しています。
先程のディレクトリで、
$ npx agreed-typed gen-swagger --path agreed.ts
とコマンドを実行すると、同じディレクトリにschema.json
というファイルが生成されます。このファイルはOpenAPI2.0に準拠したjson schemaで、ここからAPI Documentationを生成することができます。
中身をすべてコピーし、swagger editorに貼り付けてみてください。
この画像のようなAPI Documentationが生成されるはずです。
さて、これではやや味気ないので、API Documentationにdescription, summaryを追加してみましょう。
先程のagreed.ts
に戻り、下記のように修正してください。
+/** + * @summary User Greeting API + * @description Userに挨拶を返すAPI + * Errorパターン + * - user not found + * - `id`が見つからなかった場合 + */ export type UserGetAPI = APIDef< GET, // HTTP Method ["user", Capture<":id">], // request path => /user/:id {}, // request header // 以下略
APIDefの上に、コメントで@description
と@summary
を追加してください。@description
はmarkdownが使えます。ここでErrorのパターンなどを明記しておくとわかりやすいですね。
修正が完了したら、先ほどと同じコマンドをうち、schema.json
を更新した上で、内容をコピーしてswagger editorに貼り付けてください。すると、下記の画像のようにdocumentationに説明がついていることが確認できると思います。
agreed本来の機能であるJSON Mock ServerとAPI Client Checking toolの役割はそのままに、型定義による堅牢性と、リッチなAPI Documentationが実現できるようになりました。
以上でagreed-typed
の基本機能の説明は終了です。
agreed-typed in action
ここから先は思いつく限りagreed-typed
の応用的な使い方を書いていきます。
POST / PATCHで型の使い回し
下記のようなPOSTのAPIを定義します。
import { APIDef, Success201, ResponseDef, Error400, POST } from "agreed-typed"; /** * @summary User Createg API * @description User作成API * Errorパターン * - user already exists * - emailが重複している場合 * - password too week * - passwordが弱すぎる場合 */ export type UserPOSTAPI = APIDef< POST, // HTTP Method ["user"], // request path => /user/:id {}, // request header {}, // request query { email: string; name: string; password: string; }, {}, // response header | ResponseDef<Success201, { id: number }> | ResponseDef<Error400, { message: errorMessages }> // response >; /** * @description error messages * 同じstatus codeのエラーmessageはunion typeで定義すると便利 */ type errorMessages = "password too week" | "user already exists";
このとき、request bodyは
{ email: string; name: string; password: string; }
この部分に当たるのですが、POST
の場合はすべて必須パラメータとなります。しかし、PATCH
を定義しようとしたときに、部分更新を可能にしようとすると、全て非必須の以下のような型を定義し、request bodyにしたいと感じると思います。
{ email?: string; name?: string; password?: string; }
この場合、同じプロパティで非必須の型を定義するのではなく、typescriptのPartialが使えます。すなわち、下記のように書くことができます。
// ベースとなる型を定義 type User = { email: string; name: string; password: string; }; // 中略 export type UserPOSTAPI = APIDef< POST, // HTTP Method ["user"], // request path => /user/:id {}, // request header {}, // request query User, // ここで使う {}, // response header | ResponseDef<Success201, { id: number }> | ResponseDef<Error400, { message: errorMessages }> // response >; // 中略 export type UserPatchAPI = APIDef< PATCH, ["user", Capture<":id">], {}, {}, Partial<User>, // Partial型を使う {}, | ResponseDef<Success201, { id: number }> | ResponseDef<Error400, { message: errorMessages }> // response >;
この状態で、swaggerを生成してみましょう。Postの方のrequest bodyは下のようになっているはずです。*
は必須(required)を表しており、POSTの場合はすべてのパラメータが必須であることが明示されています。
一方、PATCHの方は、
requiredのパラメータが1つもないことが確認できるかと思います。このように、一度型を定義すれば、Partial
などを駆使することにより、様々な場所で再利用が可能になります。
typescript-json-schemaの機能を使う
agreed-typedは内部でtypescript-json-schemaを使っています。typescript-json-schema
はtypescriptの型定義からjson schemaに変換してくれる非常に便利なツールで、さらに、型に対しコメントを付与すると、それもjson schemaに変換してくれます。(ちなみに、APIに対する@summary / @descriptionのドキュメント変換機能はこの機能を用いておらず独自実装しています)
この機能を応用することで、さらに詳細にdocumentを詳細にすることができます。
先程のUserの型定義にage: number
のプロパティを加えた上で、思いつく限りのコメントを追加していきます。
/** * @description userの型定義 */ type User = { /** * @description email * @pattern ^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$ */ email: string; /** * @description 氏名 */ name: string; /** * @description パスワード(半角英字と半角数字それぞれ1文字以上含む8文字以上20文字以下の文字列) * @pattern ^(?=.*?[a-z])(?=.*?\d)[a-z\d]{8,20}$ */ password: string; /** * @description 年齢 * @TJS-type integer * @minimum 0 * @maximum 100 */ age: number; };
この型定義を加え、swaggerを生成すると、この画像のようになります。
ここまで来ると、ほぼ完璧なAPI documentationと呼べると思います。コメントを充足させることで、さらにリッチなAPI documentationが完成するだけではなく、swagger-codegenを用いて、このschemaからAPI Server / Clientのコードを自動生成したときに、validationルールなどもセットで生成されます。 手動でやや面倒ですが、このagreed.tsをマスターとし、そこからdocumentation / server / clientを自動生成することができれば、メンテナンスのコストは大幅に削減できます。
まとめ
だいぶうちわ向けの解説となってしまいましたが、agreed-typed
の解説は以上となります。
agreedに型を付け、さらにその情報からAPI documentationを生成するというツールがagreed-typedです。typescriptの型システムは非常に協力で、pathやhttp methodを含んだAPIそのものの定義を型として表現するのに十分な能力を持っていると気づき、このツールを開発しました。
なお、agreed-typedは現在私達のプロジェクトで試験的に導入したばかりで、まだ成功するかどうかわかりませんし、日々発生するバグをこまめに修正している最中です。 また、内部でtypescriptのunstableなcompiler APIなどを使用している関係もあり、ここで解説した内容が変更される可能性も十分ありますのでご注意ください。