asterisc

技術ネタ中心にその他雑多なこと

2018年の振り返りと2019年の抱負

はじめに

2018年のアウトプットをまとめた上で、2019年何していくのかをいくつかまとめていきます。

2018年の振り返り

アウトプット

ツール・ライブラリ

去年は業務で書いたコードからいろいろ切り出して、公開したものが多かった。特に、自動生成や静的解析周りのツールを多く作った年になった。

favalid

FE向けバリデータ。去年作ったものの中で一番好き。

github.com

紹介記事はこちら。 akito0107.hatenablog.com

JSer.infoでも触れて頂きました。

agreed-typed

ほぼ内部向けツール。TypeScriptのASTをいじったり型遊びゲームやったりといろいろ楽しかった。

github.com

紹介記事はこちら。

akito0107.hatenablog.com

generr

Go用error自動生成ツール。自分で言うのもなんだけど便利です。

github.com

紹介記事はこちら

akito0107.hatenablog.com

errwrp

Go用error静的解析ツール

github.com

紹介記事はこちら

akito0107.hatenablog.com

発表・その他

Japan Container Days v18.12

speakerdeck.com

僕のパートのネタとしては、

akito0107.hatenablog.com

これを中心に話しました。

社内ブログ

お仕事ではGoのAPIサーバ書いているので、そのアーキとかの話。

recruit-tech.co.jp

社内Go研修

プログラミング言語Goの翻訳者である柴田さんを講師に迎えての研修を受講し、無事に修了することができました 作業レポジトリはこちら。問題数が多く、大分苦しんだけどなんとかやり遂げられてよかった。

GitHub - akito0107/gopl: Programming Language Go

薄いけど、こんな感じ。 f:id:akito0107:20190111005940p:plain

前述のGo研修のおかげで休日でもコンスタントにコードが書く習慣ができたので、毎日は無理だったけれども、一昨年に比べたら大分コードを書いたと思う。

読んだ本(主に技術系)

O'Reilly Japan - ベタープログラマ

  • テストから見えてくるグーグルのソフトウェア開発

Amazon CAPTCHA

テスト駆動開発 | コンピュータ・一般書,プログラミング・開発,開発技法 | Ohmsha

Amazon CAPTCHA

  • Clean Architecture

https://www.amazon.co.jp/dp/4048930656/

  • カンバン ソフトウェア開発の変革

Amazon CAPTCHA

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にも対応させました。 今回の記事ではツールの紹介と実装の紹介をします。

作ったツールはこちら

github.com

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 ./

すると、以下の画像のような出力になります。

errorWrapされずに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.Wrapreturnが別のstatementで行われた場合チェックできないといったような問題点もありますが、現実ではあまりないユースケースな気もしているので、現在はこういった実装になっています。

Result構造体のPositionプロパティは、token.FileSetPosition関数に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の依存関係や、その有用性などの理解が全くできていないので、引き続き学習していきたいと思います。

ありがとうございました!

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については過去の記事を参照していただくとわかりやすいと思います。

akito0107.hatenablog.com

実装にあたって、ここに公式のdocumentがありますが、そこまで詳細なdocumentationではなく、ここにある情報だけで何かを実装するのは少し難しいかなと思っています。このAPIをいじる際には、GithubのTypeScriptのIssueを探しながら行うと良いかもしれません。

作るものとしては、TypeScriptのソースを読み込み、unassertifyした上で別のファイル(もしくは標準出力)にTypeScriptのファイルを書き出すような実装にしようと思います。TypeScript Compiler APIでは本来の機能ではTypeScriptからJavaScriptにtranspileしますが、今回transpileまでは行いません。

unassertifyの実装

早速コードの方を見ていこうと思います。今回のコードは下記に上がっています。src/trans.tsを参照してください。

github.com

他にもやり方はあるのですが、今回は最もシンプルに、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);
};

この関数はassertimportしている行を削除するtransformerです。 Nodeを削除したい場合はnullreturnすると削除することができます。ただし、生成した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の値を書き換えることにより可能になります。 例えば、assertpower-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);
};

引き続き、assertcall部分を削除します。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的には、ExpressionStatementexpressionのプロパティが、CallExpressionであり、CallExpressionIdentifierなのか、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をビジュアライズするものを作ったので、その紹介をします。 作ったものはこちら。

github.com

AST Viewer

Goに限らない話かもしれないですが、コード自動生成や、静的解析系のツールを実装しようとする場合、ASTを扱うことがよくあると思います。 ASTを直感的に扱えるようになるためには相当な慣れが必要になり、はじめのうちは、コードがどういったASTにパースされるのかを把握するために、逐一printするなどして確認していました。 この作業の効率を上げるために、ASTのvisualizeをするツールをせっかくなので自分で作ってみることにしました。

というわけで、作ったものがこちらです。

editor部分はmonaco editor、ASTをパースする部分はGoのwasmで実装されています。 server sideでは何もしておらず、静的ファイルを返すだけの実装となっているので、hostingさえしてしまえば、browser上で実行することができます。

実行方法

wasmjsはすべて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/jsimportすることにより、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というツールを作ったので、その紹介をします。

github.com

Goのカスタムエラー

Goのエラーハンドリングの中で、カスタムなerror型を用いるパターンは比較的広く知られているかと思います。 その中からいくつかのパターンを紹介します。

sentinel errorsパターン

Goのerrorinterfaceとして定義されており、Error() stringのメソッドを実装さえしていれば、errorとして扱うことができます。 標準パッケージで提供されているerrorsfmt.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をより文脈に沿った形に翻訳し、エラー時の情報をより豊かにすることができるようになりました。 ですが、上記のパターンでも問題となるケースがあります。 「GetUserUserNotFoundという型のエラーを返す」という知識を呼び出し元の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)
}

UserNotFoundIsUserNotFoundが公開されており、assert for behaviourパターンからするとアンバランスですが、ユースケースによってはこれで十分な場合もあるかと思います。

また、-mオプションでError() stringで返すメッセージのカスタマイズが可能となっています。

まとめ

Goにおけるカスタムなerrorを使ったパターンを紹介しました。 その上で、その実現をeasyにするツール、generrの紹介をしました。

バグなどがありましたら教えていただけると幸いです。

ありがとうございました!

agreed-typedの紹介

はじめに

agreed-typedというツールを現在開発中なので、その紹介をします。

github.com

自分は普段バックエンドを書いているエンジニアで、フロントエンドエンジニア向けのツールであるagreedをメインで活用することはないのですが、APIドキュメンテーションや、バックエンドとフロントエンドのAPIの整合性をとるという観点から、agreedを補完するためのツールとしてagreed-typedを開発しています。 その背景と、agreed-typedで実現したことを紹介していきます。

以下のサンプルコードはすべてGithubにupしてあります。

github.com

agreedとはなにか

agreed-typedの説明をする前に、agreedの説明をします。

agreedの紹介

agreedリクルートテクノロジーズがOSSとして公開している、Consumer Driven ContractおよびJSON Mock Serverのツールです。agreedの詳しい紹介については、この記事をみていただきたいのですが、簡単にまとめると、APIの仕様定義をバックエンド側(API提供側)が決めるのではなく、フロントエンド(API使用側)が主導する Consumer Driven Contract を達成するためのツールで、フロントエンドエンジニアがまずjsやjsonyamlなどでAPI定義を記述し、バックエンド側がそれを実装するといった開発フローを実現します。

実際の開発フローを以下に記載していきます。

1. agreed fileの記述

フロントエンドエンジニアが、画面の描画に必要なAPIjson(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というコマンドがインストールされます。 --pathoptionで、記述した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/:idAPIに拡張して、異常系を追加しようと思います。

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-typedAPIの型定義の本体です。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" }
+    }
   }
 ];

ResponseDefunion 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-typedAPIに型定義を提供するだけではなく、その型定義をパースし、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を追加してください。@descriptionmarkdownが使えます。ここで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などを使用している関係もあり、ここで解説した内容が変更される可能性も十分ありますのでご注意ください。