Simple minds think alike

より多くの可能性を

PDFファイル構造の概要

PDFファイルの構造を知りたくてAdobe社から公開されているPDF 1.7の仕様書を少し読んだのですが、ボリュームが多く、英語で書かれていて理解が進みづらいと感じていました。

日本語の本とかないのかなと探していたらO'REILLYから「PDF構造解説」という書籍が2012年に出版されていていて、248ページという適度なボリュームで良さそうだと思って読んでみました。

また、この本の特徴として、PDFコンテンツのオブジェクト間の繋がりを有向グラフで表現していて、PDFの表示の仕組みに関しても理解が進みやすくなっているように工夫されている点が良いなと感じました。

この書籍の主に3章(ファイル構造)を読んで把握できた点をまとめてみようかと思います。

環境

  • macOS Catalina 10.15.7
  • pdftk 2.02

PDFファイルの構造

PDFファイルはテキストデータとバイナリデータの両方を含むことができます。

組み込みのフォントや画像などのデータはバイナリデータですが、PDFファイルの構造や文章はテキストデータになっているので、テキストエディタで開くことでざっくり内容を確認することができます。

PDFファイルを開くと以下の4つの部分から構成されていることが分かります。

  • ヘッダー
  • ボディ
  • クロスリファレンステーブル
  • トレイラー

f:id:moritamorie:20211024004420p:plain

ヘッダー

最初の行では準拠するPDF仕様のバージョン(ここでは1.0)を指定します。

%PDF-1.0

ボディ

ボディ部分は一連のオブジェクト(ページ、注釈、画像、署名など)で構成され、PDFビューワーで表示されるテキストや画像などのコンテンツはボディ内のオブジェクトとして指定します。

それぞれのオブジェクトの1行目で「オブジェクト番号世代番号objキーワード」を指定します。最後の行で「endobjキーワード」を指定し、その間がオブジェクトのコンテンツになります。以下のオブジェクトでは <</Kids [2 0 R] /Count 1 /Type /Pages >> という辞書(後述)がコンテンツです。

1 0 obj
<<
/Kids [2 0 R]
/Count 1
/Type /Pages
>>
endobj

クロスリファレンステーブル

クロスリファレンステーブルでは、ボディ内の各オブジェクト毎にファイルの先頭からのバイトオフセットを保持しています。

この情報によってランダムアクセスが可能になるため、PDFビューワーはファイルの一番上から順番に処理しなくても、任意のページを高速に表示することができます。

xrefキーワードから始まり、次の行(0 7)でテーブル内のエントリ(0から始まる7つのエントリがある)を表します。 その後に続く3列の項目では、各オブジェクトの バイトオフセット世代番号最後の1文字で使用・未使用(fが未使用、nが使用する)を表します。

xref
0 7
0000000000 65535 f 
0000000015 00000 n 
(以下、省略)

※最初のエントリ(0000000000 65535 f)はスペシャルエントリを表しており、使用しません。次のエントリからオブジェクト1、オブジェクト2…とそれぞれのバイトオフセットを保持します。

トレイラー

トレイラーは、クロスリファレンステーブルへのバイトオフセットを保持しており、直接アクセス可能にすることで高速にPDFファイルを開くための役割を果たしています。

trailerキーワードから始まり、次にトレーラー辞書が続きます。

トレーラー辞書は、以下2つのエントリが必須です。

  • /Size エントリ(クロスリファレンステーブルのエントリ数)
  • /Rootエントリ(ルート要素であるドキュメントカタログのオブジェクト番号)

その後、startxrefキーワードが続き、次の行でクロスリファレンステーブルのバイトオフセット、最後行を表す%%EOFが記述されます。

trailer
<< /Size 7
   /Root 5 0 R
>>
startxref
612
%%EOF

PDFファイルの基本要素

ここからはボディやトレイラーといったPDFファイルの一部ではなく、PDFファイル全体を通して使われる基本要素に関してまとめてみます。

単一の要素

整数や文字列といったそれ単体で表現される要素です。 整数や実数、文字列、名前、ブーリアン値、nullの5つがあります。

整数や実数

例: 43, 1.4142

文字列

丸括弧で囲まれると文字列になります。
例: (Lucy in the Sky)

名前

"/"から始まります。項目の識別子で、辞書のキーや様々な用途に用いられます。
例: /Kids/Count/Size

ブーリアン

trueキーワード または falseキーワード

null

nullキーワード指定されます。

複合的な要素

配列や辞書、ストリームの3つがあり、要素を複合的に使用します。

配列

ブランケットで囲まれると配列になります。 整数や文字列、間接参照といった要素以外にも配列や辞書も含めることができます。
例: [2 0 R 1 0 R]
上記は、1つ目の要素が 間接参照2 0 R、2つ目の要素が間接参照 1 0 R、という2つの要素から構成される長さ2の配列。

辞書

不等号を2つ続けたもの( << , >> )で囲うと辞書になります。名前から他の値に対応づけられます。配列や辞書も値にできます。

例:

<<
/Kids [2 0 R]
/Count 1
/Type /Pages
>>

上記は、/Kids が間接参照2 0 Rを含む配列、 /Countが整数1、/Typeが名前Pages に対応づけられた辞書。

ストリーム

ストリームは、フォントや画像、図形などを格納するものです。
streamendstreamの間に画像のバイナリデータなどの実データを指定し、辞書とセットで記述されます。
辞書には、データ長や圧縮方式といったストリームに関するメタデータを格納します。

3 0 obj
<<
 /Length 2817
>>
stream
(実データ)
endstream
endobj

参照する要素

関節参照

他のオブジェクトのリンクを作成します。
例: 1 0 R (オブジェクト番号1、世代番号0のオブジェクトへのリンクを表します。)

テキストエディタとpdftkを使ってPDFファイルを作る

ここまでのPDFファイルの構造、基本要素の内容を把握した段階で、大部分はテキストデータで記述できることが分かったので、簡単なPDFファイルをテキストエディタで作ってみました。

ただ、クロスリファレンステーブル内、トレーラー内のバイトオフセットを自分で算出して記載するのは手間だったので pdftk というコマンドラインツールを補完的に使いました。

pdftkのインストール

pdftk のインストール方法を調べたところ Homebrewを使った方法は動かなくなっているようだったので以下のパッケージファイルをダウンロードしてインストールしました。(Stackoverflow参照)

https://www.pdflabs.com/tools/pdftk-the-pdf-toolkit/pdftk_server-2.02-mac_osx-10.11-setup.pkg

テキストエディタでPDF作成

以下のグラフような構造のPDFファイルを作ってみます。

f:id:moritamorie:20211024100512p:plain

%PDF-1.0

1 0 obj 
<< /Type /Pages
   /Count 1
   /Kids [2 0 R] 
>>
endobj

2 0 obj 
<< /Type /Page
   /MediaBox [0 0 650 840]
   /Resources 3 0 R
   /Parent 1 0 R
   /Contents [4 0 R]
>>
endobj

3 0 obj 
<< /Font
     << /DF
          << /Type /Font
             /BaseFont /Helvetica
             /Subtype /Type1 >>
     >>
>>
endobj

4 0 obj
<< >>
stream
1. 0. 0. 1. 10. 800. cm
BT
  /DF 30. Tf
  (Hello, PDF!) Tj
ET 
endstream
endobj

5 0 obj
<< /Type /Catalog
   /Pages 1 0 R
>>
endobj
xref

0 6
trailer
<< /Size 6
   /Root 5 0 R
>>
startxref
0
%%EOF

PDFビューアーで開くと以下のような表示になります。 f:id:moritamorie:20211024102013p:plain

sample.pdfの内容

トレーラー

トレーラー辞書の/Rootには間接参照5 0 Rが対応づけられており、ルート要素であるドキュメントカタログのオブジェクト番号は5であることが分かります。

trailer
<< /Size 6
   /Root 5 0 R
>>
ドキュメントカタログ

ページツリーのオブジェクトの参照( 1 0 R )が対応づけられています。

5 0 obj
<< /Type /Catalog
   /Pages 1 0 R
>>
endobj
ページツリー(Pages)

オブジェクト番号2であるのページだけから構成されるページツリーです。

1 0 obj 
<< /Type /Pages
   /Count 1
   /Kids [2 0 R] 
>>
endobj
ページ(Page)

用紙サイズ650x840、リソースオブジェクトの参照(3 0 R )、親ページツリーの参照( 1 0 R )、コンテンツの参照( 4 0 R )が指定されています。

2 0 obj 
<< /Type /Page
   /MediaBox [0 0 650 840]
   /Resources 3 0 R
   /Parent 1 0 R
   /Contents [4 0 R]
>>
endobj
フォントリソース

名前が /DF、BaseFontが Helvetica のフォントリソースを指定しています。

3 0 obj 
<< /Font
     << /DF
          << /Type /Font
             /BaseFont /Helvetica
             /Subtype /Type1 >>
     >>
>>
endobj
コンテンツ(Hello, PDF!)

streamキーワードの次の行で、座標を右に10、上に800移動しています。また、BT(Begin Text)とET(End Text)の間で、30ポイントのフォント/DFを選択し、テキスト文字列(Hello, PDF!)を描画しています。

4 0 obj
<< >>
stream
1. 0. 0. 1. 10. 800. cm
BT
  /DF 30. Tf
  (Hello, PDF!) Tj
ET 
endstream
endobj

pdftkを使って補完

このままでもPDFファイルとして開くことはできるのですが、バイトオフセットが入っておらず任意のページにランダムアクセス可能なPDFになっていないのでpdftkを使って補完してみます。(といっても、作成したsample.pdfのページ数1はなので体感できるような表示速度の向上はありません。)

以下を実行しました。

$ pdftk sample.pdf output converted.pdf

出力されたPDFファイルをテキストエディタで開くと以下のようになり、クロスリファレンステーブルが作成され各オブジェクトのバイトオフセット、startxrefの箇所にクロスリファレンステーブルへのバイトオフセット等の情報が入っていることが確認できます。

感想

膨大な英語のドキュメントを読んでいると辛くなってくるので、日本語で書いてある資料は貴重だと思いました。

ただこの本だけでPDF仕様全体を把握できないので入門的な位置付けで捉えておいて、都度SDKのドキュメントを読んだり、仕様書を読んだりするのが良さそうです。

参考資料

【Golang】UniPDFでPDFファイルにデジタル署名する

PDFファイルにデジタル署名ができるGo言語のライブラリ( UniPDF )を使ってみました。

今年(2021年)4月頃に従量課金プランにフリー層ができたとブログ記事に掲載があり、月100件は無料で使える状態になったようです。

以下の公式のブログ記事を参考にサンプルコードを試してみました。 unidoc.io

UniPDF の署名機能を使うと、具体的には以下のようなことができるようです。 (参照)

  1. RSAキーペアによるドキュメントへの署名
  2. PKCS#12ファイルを使用したドキュメントへの署名
  3. 外部の署名生成サービスを利用して文書に署名し、空白の署名欄に署名を付加する
  4. SoftHSMとCrypto11パッケージを使ったPKCS#11サービスによる文書の署名
  5. PDFの電子署名の検証

今回は 「1. RSAキーペアによるドキュメントへの署名」のExampleコードを実行し、内容を確認してみようと思います。

結果

既存のPDFファイルに対してExampleコードを実行したところ、以下のような署名付きのPDFファイルが出力されました。

ページ右下に背景が黄色の署名フィールドが追加され、署名パネルで追加した署名の内容を確認できました。

f:id:moritamorie:20211010232009g:plain

環境

  • Go v1.16.2
  • UniPDF v3.29.0

事前準備

実行するには事前にAPIキーを発行して、環境変数 UNIDOC_LICENSE_API_KEY を設定する必要があります。 Uni Cloudのsignup画面から登録し、以下のAPIキーの画面からAPIキーを発行します。

f:id:moritamorie:20211010233047p:plain

コードの確認

サンプルコードがやっていることをコードスニペットに分解して見ていこうと思います。

①パッケージの読み込み

unidoc/pdf/core, unidoc/pdf/modelは基本的なPDFタイプとドキュメントモデルを提供してくれているようです。 (詳細はUniPDF v2のPress Releaseを参照)

import (
    "crypto/rand"
    "crypto/rsa"
    "crypto/x509"
    "crypto/x509/pkix"
    "fmt"
    "log"
    "math/big"
    "os"
    "time"

    "github.com/unidoc/unipdf/v3/annotator"
    "github.com/unidoc/unipdf/v3/common/license"
    "github.com/unidoc/unipdf/v3/core"
    "github.com/unidoc/unipdf/v3/model"
    "github.com/unidoc/unipdf/v3/model/sighandler"
)

②ライセンスAPIキー(環境変数)の読み込み

先ほど設定した UNIDOC_LICENSE_API_KEY環境変数から読み込みます。

func init() {
    err := license.SetMeteredKey(os.Getenv(`UNIDOC_LICENSE_API_KEY`))
    if err != nil {
        panic(err)
    }
}

RSAキーペアとX.509証明書

generateKeys()を実行し、RSAキーペアとX.509証明書を生成します。generateKeys()の中身は折りたたんで掲載しておきます。

   priv, cert, err := generateKeys()
    if err != nil {
        log.Fatal("Fail: %v\n", err)
    }

④PDFリーダーと署名の生成

PDFリーダー、署名を付加するためのアペンダーを作成します。また、③で作成した秘密鍵と証明書を元に署名ハンドラを作成し、署名ハンドラを元にデジタル署名を作成します。最後にデジタル署名を初期化します。

   file, err := os.Open(inputPath)
    if err != nil {
        log.Fatal("Fail: %v\n", err)
    }
    defer file.Close()

    reader, err := model.NewPdfReader(file)
    if err != nil {
        log.Fatal("Fail: %v\n", err)
    }

    appender, err := model.NewPdfAppender(reader)
    if err != nil {
        log.Fatal("Fail: %v\n", err)
    }

    handler, err := sighandler.NewAdobePKCS7Detached(priv, cert)
    if err != nil {
        log.Fatal("Fail: %v\n", err)
    }

    signature := model.NewPdfSignature(handler)
    signature.SetName("Test Self Signed PDF")
    signature.SetReason("TestSelfSignedPDF")
    signature.SetDate(now, "")

    if err := signature.Initialize(); err != nil {
        log.Fatal("Fail: %v\n", err)
    }

⑤署名の挿入

最後にPDFファイルにデジタル署名を行い、アノテーションを挿入し、ファイルを出力します。

   opts := annotator.NewSignatureFieldOpts()
    opts.BorderSize = 1
    opts.FontSize = 10
    opts.Rect = []float64{475, 25, 590, 80}
    opts.FillColor = model.NewPdfColorDeviceRGB(255, 255, 0)
    opts.TextColor = model.NewPdfColorDeviceRGB(0, 0, 200)

    field, err := annotator.NewSignatureField(
        signature,
        []*annotator.SignatureLine{
            annotator.NewSignatureLine("Name", "Takashi Morita"),
            annotator.NewSignatureLine("Date", "2021.10.10"),
            annotator.NewSignatureLine("Reason", "Looks good to me"),
            annotator.NewSignatureLine("Location", "Tokyo"),
        },
        opts,
    )
    field.T = core.MakeString("Self signed PDF")

    if err = appender.Sign(1, field); err != nil {
        log.Fatal("Fail: %v\n", err)
    }

    err = appender.WriteToFile(outputPath)
    if err != nil {
        log.Fatal("Fail: %v\n", err)
    }

コード全体

最後にコード全体を掲載しておきます。

サンプルコードの実行

$ go run main.go sample.pdf signed.pdf

出力結果の確認

出力されたPDFファイルをテキストエディタで開いたところ署名オブジェクトは以下のようになっていました。

14 0 obj
<<   /Type /Sig
     /Filter /Adobe.PPKLite
     /SubFilter /adbe.pkcs7.detached
     /Contents (省略)
     /Name (Test Self Signed PDF)
     /Reason (TestSelfSignedPDF)
     /M (D:20211010231753+09'00')
     /ByteRange [0 3457 19843 740]
>>

PDF 1.7の仕様書のDigital Signatureの章を参考にすると以下の値が入るようです。ByteRangeに関しては仕様書読んでもよくわからなかった。。

キー タイプ
Type name (オプション)Sigが入る
Filter name (必須) この署名を検証する際に使用する優先的な署名ハンドラの名前。適合するリーダーは、指定されたサブフィルターフォーマットをサポートしている限り、署名を検証する際に別のハンドラーで代用することができる。署名ハンドラーの例としては、Adobe.PPKLITEEntrust.PPKEFCICI.SignItVerisign.PPKVSがある。
SubFilter name (オプション) 署名辞書の署名値とキー情報のエンコーディングを記述した名前。適合するリーダーは、このフォーマットをサポートする任意のハンドラーを使用して、署名を検証することができる。
Contents byte string (必須) 署名の値。ByteRangeが存在する場合、値はバイトレンジダイジェストの値を表す16進文字列。
M date (オプション)署名の時間。署名ハンドラによって、検証されない通常のコンピュータの時刻か検証可能な方法で安全なタイムサーバから生成された時刻になる。
ByteRange array (署名フィールドの一部であるすべての署名、および権限辞書のUR3エントリから参照される使用権署名に必要) ダイジェスト計算のための正確なバイト範囲を記述する整数のペア(開始バイトオフセット、バイト単位の長さ)の配列。

また、最近のAdobeプロダクトのバージョン(11.x以降)では、RSAだと adbe.pkcs7.detachedETSI.CAdES.detached がSubFilterとして適合しているようでした。

感想

思ったより簡単に既存のPDFにデジタル署名することができました。RSAやX.509証明書を作る部分はGo言語の標準パッケージ(crypto/rsa, crypto/x509)に任せることができるのが理由なのかもと思いました。

また、長期署名である PAdES に関しても将来的にサポートを予定しているようなので、UniPDFの今後のアップデートに期待したいです。

関連記事

PDFファイルの内容の確認の仕方は以下の記事にまとめていますので、よろしければご参照ください。

simple-minds-think-alike.moritamorie.com

参考資料

【Github actions】DependabotのPull RequestでSecretsが参照できずワークフローがFailになった場合の対処

Dependabot のPull Request(以下PR)が作られた際に開始したGithub Actionsワークフローが Secrets を参照できずに失敗していたので原因を調べてみました。

f:id:moritamorie:20210317022417p:plain

2021/3/1から適用になった以下のUpdateが影響していて、 Dependabot から実行される Github Actionsワークフローは読み取りだけが可能な GITHUB_TOKEN のみ使うことができ、いかなる Secrets も使えなくなるという変更が原因でした。

github.blog

なので、例えばpushイベントトリガーで実行されるワークフローの中で Secrets として追加しておいたPersonal access tokensを使って、取得したカバレッジのサマリをコメントで追加したり、自動でラベルを追加するといった書き込み(write)権限が必要な場合は、ワークフローが落ちる状況になっていました。

結論

以下のように

  • dependabotから実行されされた場合はpull_request_targetトリガーの時のみ実行
  • dependabot以外から実行された場合は pull_request_targetトリガー以外の時に実行

するようにしました。

on: 
  push:
  pull_request_target:
  workflow_dispatch:

name: Test
jobs:
  test:
    runs-on: ubuntu-latest
    if: (github.event_name == 'pull_request_target' && github.actor == 'dependabot[bot]') || (github.event_name != 'pull_request_target' && github.actor != 'dependabot[bot]') 
    steps:
      - uses: actions/checkout@v2
        if: (github.event_name == 'pull_request_target' && github.actor == 'dependabot[bot]')
        with:
          ref: ${{ github.event.pull_request.head.sha }}
          fetch-depth: 0
      - uses: actions/checkout@v2
        if: (github.event_name != 'pull_request_target' && github.actor != 'dependabot[bot]')
        with:
          fetch-depth: 0
      - name: SonarCloud Scan
        uses: SonarSource/sonarcloud-github-action@master
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

actions/checkoutのオプションに ref: ${{ github.event.pull_request.head.sha }} がないと、 mainブランチをチェックアウトしてしまうようなので pull_request_targetの場合は同時にこのオプションを付けるようにすると良いようです。(参照

このように対応することに至った具体的な経緯を共有していきます。

失敗していた原因

実行結果の詳細を見るとSet the SONAR_TOKEN env variable.というエラーが発生しており、 Secrets に設定していた値が取れていないことが確認できました。

f:id:moritamorie:20210317025933p:plain

以下のリポジトリで、同様の状況を再現してみましたので、よろしければ参考にしてみてください。

Bump axios from 0.19.1 to 0.21.1 by dependabot · Pull Request #1 · moritamori/dependabot-testing · GitHub

対応方針の検討

主に以下の3つのドキュメント・Github issueを参照しました。

これらの情報から大きく分けて3通りの対応方法があることが分かりました。

また、①と②のどちらを適用するかは場合によって異なります。いずれの場合もwrite権限が付与され Secrets にアクセスできるようになる、という点は変わりません。

  • PR自体を更新する必要がある場合
    • => ②workflow_run
  • コメントやラベル等を追加しPRを構成するだけで更新しない場合
    • => ①pull_request_target

pull_request_target トリガーを使う

2021/2/19に公開されたGithubブログの記事 によると、write(書き込み)権限があるトークンが必要な場合、以下の2020/12/15に公開されたGithubブログの記事に書いているリスクを把握したうえで pull_request_target を使ってください、という記述があったのでリスクに関する記述を読んでみました。

リスクに関する記述1: 適用用途

pull_request_target トリガーに関して以下の記述がありました。

The reason to introduce the pull_request_target trigger was to enable workflows to label PRs (e.g. needs review) or to comment on the PR. The intent is to use the trigger for PRs that do not require dangerous processing, say building or running the content of the PR.

pull_request_targetトリガを導入した理由は、ワークフローでPRにラベルを付けたり(例:needs review)、PRにコメントを付けたりできるようにするためでした。このトリガーは、PRの内容を構築したり実行したりするような危険な処理を必要としないPRに使用することを意図しています。

今回遭遇したエラーは、この用途に該当するGithubワークフローで発生したものでした。

リスクに関する記述2: 注意点

pull_request_target トリガーの中でどのような処理を実行しても良いかを考えるうえで、以下の記述が参考になりました。

a workflow triggered on pull_request_target still has the read/write repository token in memory that is potentially available to any running program.

If the workflow uses actions/checkout and does not pass the optional parameter persist-credentials as false, it makes it even worse.

The default for the parameter is true. It means that in any subsequent steps any running code can simply read the stored repository token from the disk.

日本語にすると

pull_request_target でトリガーされたワークフローでは、メモリ内に読み取り/書き込み可能なリポジトリトークンが残っており、実行中のプログラムから利用できる可能性があります。

ワークフローが actions/checkout を使用していて、オプションのパラメータ persist-credentials を false にしていない場合は、さらに悪い状況になります。このパラメータのデフォルトはtrueです。

これは、後続のステップで、実行中のコードがディスクから保存されたリポジトリトークンを単純に読み取れることを意味します。リポジトリへの書き込みアクセスやシークレットが必要ない場合は、pull_request トリガーを使用してください。

という感じかと思います。

workflow_run を使う

リスクに関する記述1: 適用用途

workflow_run トリガーに関して以下の記述がありました。

Together with the pull_request_target, a new trigger workflow_run was introduced to enable scenarios that require building the untrusted code and also need write permissions to update the PR with e.g. code coverage results or other test results.

pull_request_target と共に、新しいトリガ workflow_run が導入され、信頼できないコードの構築を必要とするシナリオや、コードカバレッジの結果やその他のテスト結果などで PR を更新するための書き込み権限を必要とするシナリオを可能にしました。

Dependabot によって実行される Github actions ワークフローの中でPRを更新するようなケースでは workflow_run を使うと良さそうです。

リスクに関する記述2: 注意点

workflow_run トリガーの中でどのような処理を実行しても良いかを考えるうえで、以下の記述が参考になりました。

To do this in a secure manner, the untrusted code must be handled via the pull_request trigger so that it is isolated in an unprivileged environment.

これを安全に行うためには、信頼できないコードは、権限のない環境で隔離されるように、pull_requestトリガを介して処理されなければなりません。

③Dependabotを諦めてRenovateに乗り換える

対応が煩雑なので Renovate への置き換える方もでてきているようです、合わせて検討すると良いかもしれません。

github.com

対応したコード

用途に適した対応が①pull_request_target トリガーを使うだったので、Github issueコメント記載されていた対応コードのサンプルを参考に対応してみました。

Dependabot cant read secrets anymore · Issue #3253 · dependabot/dependabot-core · GitHub

ワークフローが正常に動くようになることを確認した検証用リポジトリも共有しておきます。 github.com

private registriesを使う場合

2021/3/15に以下のリリースがあり、GitHub Packagesやnpm等のプライベートレジストリを使ってパッケージの更新を行っている場合はよりセキュアに Secrets を使うことができるようになったので、こちらの方法と併せて対応すると良さそうです。

github.blog

Dependabot secretsの検証

もしかすると、 private registriesで使用可能な以下の DependabotSecrets を設定することで、Github actions ワークフローから参照できるようにならないかな、と思って試してみたのですがうまくいきませんでした。dependabot.ymlの中からしか参照できないようです。

f:id:moritamorie:20210317125542p:plain

Github actions ワークフローで行っている Secrets を使った処理を Dependabot の機能に移せる場合に、 Dependabot のprivate registriesの使用を検討すると良さそうです。

検証した際のGithubリポジトリを以下に記載しておきます。

Bump axios from 0.19.1 to 0.21.1 by dependabot · Pull Request #1 · moritamori/dependabot-testing3 · GitHub

参考資料

DockerイメージをAmazon ECRパブリックレジストリで公開してみた

2020年12月にリリースが発表されたAmazon ECRのパブリックレジストリを試したくて、 cawsay (牛(cow)に喋らせる(say)というジョークコマンド) を実行するだけのDockerファイルをビルドしてイメージを公開してみました。手順を共有してみたいと思います。

f:id:moritamorie:20210314155207p:plain

以下がリリースされた際の公式のブログ記事です。 aws.amazon.com

モチベーション

コンテナイメージからLambda functionを作れるようになったこともあり、AWSリージョン内からのECRの利用用途が広がっているので、パブリックなDockerイメージにも適用範囲を広げられそうということで試してみました。(参照)

なお、任意のAWSリージョン内からのPullは無料とのことで、ECS, EKSやLambdaなどAWSのプラットフォームから公開されているDockerイメージを使う場合には良さそう。 aws.amazon.com

また、Docker Hubのように6時間の間に100回までしかDocker pullできないなどの利用制限が無いところや、ECSやEKSでプライベートなECRリポジトリを使っている場合DockerイメージをAWSに集約できるのも運用面で嬉しい点です。

前提

リポジトリを作成

ecr-publicのサブコマンド create-repositoryを使って新しいリポジトリ moritamorie-cawsay を作ります。リージョンに ap-northeast-1を指定すると失敗したのでus-east-1に作りました。(2021年3月時点での実行結果)

$ aws ecr-public create-repository --repository-name \ 
                 moritamorie-cawsay --region ap-northeast-1

Could not connect to the endpoint URL: 
          "https://api.ecr-public.ap-northeast-1.amazonaws.com/"
$ aws ecr-public create-repository --repository-name \
                 moritamorie-cawsay --region us-east-1
{
    "repository": {
        "repositoryArn": "arn:aws:ecr-public::434137281992:repository/moritamorie-cawsay",
        "registryId": "434137281992",
        "repositoryName": "moritamorie-cawsay",
        "repositoryUri": "public.ecr.aws/w2q8j1y6/moritamorie-cawsay",
        "createdAt": "2021-03-14T17:07:57.039000+09:00"
    },
    "catalogData": {}
}

Dockerfileを作る

Dockerホストの任意のディレクトリ(ここで は cowsay としました)に、以下のDockerfileを作ってみます。

FROM debian:buster

RUN apt-get update && apt-get install -y cowsay

プッシュコマンドを確認

AWS consoleにログインして、Amazon ECRのところから作成したリポジトリを選択すると「プッシュコマンドの表示」というボタンがあるので押して、コマンドを確認しておきます。 f:id:moritamorie:20210314145915p:plain

プッシュコマンドを実行

表示されたプッシュコマンドを順次実行していきます。

ログイン

AWS CLI で認証トークンを取得し、レジストリに対して Docker クライアントを認証します。

$ aws ecr-public get-login-password --region us-east-1 | \
  docker login --username AWS --password-stdin public.ecr.aw

Dockerビルド

Dockerファイルをビルドして、イメージを生成します。

$ docker build -t cawsay .

タグ付け

作成したECRのパブリックリポジトリにイメージをプッシュできるように、イメージにタグをつけます。

$ docker tag moritamorie-cawsay:latest public.ecr.aws/w2q8j1y6/moritamorie-cawsay:latest

プッシュする

新しく作成した AWS リポジトリにこのイメージをプッシュします。

$ docker push public.ecr.aws/w2q8j1y6/moritamorie-cawsay:latest

※プッシュした後に、ログイン状態をそのままにしておく認証情報がなくてハマることがあるようなので、 プッシュ後docker logout することをお勧めします。(参照

Docker runでcawsayを実行

公開リポジトリにプッシュしたイメージを使って、cawsay 実行して"Moo"と言わせてみます。

$ docker run -it public.ecr.aws/w2q8j1y6/moritamorie-cawsay:latest \
  /usr/games/cowsay "Moo"

f:id:moritamorie:20210314151827p:plain

非常に有用な?イメージを公開できた気がします。

費用

参考までに、2021年3月時点での費用の一例を載せておきます。

費用はストレージとデータ転送の2つに分かれています。 以下の費用は、米国東部 (バージニア北部)[us-east-1]リージョンにおける費用です。

  • ストレージ
  • 月間の無料ストレージ
    • 50 GB
  • 月間の無料ストレージ以上の利用

    • GB/月あたり 0.10USD
  • データ転送

    • AWS アカウントを使用しない場合
      • 500GBまで無料
    • AWS アカウントを使用する場合
      • AWS 以外のリージョン
        • 5 TB/月まで無料
        • 5TB/月を超えるデータは、0.09 USD/GB
      • 任意の AWS リージョンへの任意の量のデータ
        • 無料

詳細は公式の以下のページ参照してみてください。料金の例を元に詳細が記載されています。

aws.amazon.com

ECR Publicのコマンド

以下のドキュメントにまとまっています。

awscli.amazonaws.com

Amazon ECR Public Gallery

作成したリポジトリAmazon ECR Public Galleryから参照可能です。

gallery.ecr.aws

参考資料

【Golang】共有された変数を平行的に安全するには(単一のゴルーチンに閉じ込める、相互排他)

プログラミング言語Go」オンライン読書会で学んだ第9章「共有された変数による平行性」に関して共有したいと思います。

並行的に安全(concurrency-safe)とは

プログラミング言語Go」の一部を引用すると

二つ以上のゴルーチンから同期を加えることなく呼び出された場合であっても正しく動作を続けるのであれば、その関数は並行的に安全(concurrency-safe)です。

という記載があります。

例えば

  • 月予算
  • 週予算
  • 今月中に使った金額

という3つの変数を扱うデータ構造を実装することを考えてみます。

このデータ構造は

  • 月予算 >= 週予算
  • 今月中に使った金額 <= 月予算
  • 今月中に使った金額が月予算に達していれば、週予算も達している

という条件を満たす必要があり、複数のゴルーチンでこのデータ構造に操作を加えてもこれらの条件が必ず満たせれば並行的に安全と言えます。

実装

以下のような実装にしたとすると、これは並行的に安全ではありません。

type CostManager struct {
    MonthlyBudget uint
    WeeklyBudget  uint
    Spent         uint
}

// 週予算と月予算を設定
func (cm *CostManager) SetBudget(weeklyBudget uint, monthlyBudget uint) error {
    if weeklyBudget > monthlyBudget {
        return errors.New("週予算が月予算を超えています")
    }
    if cm.Spent > monthlyBudget {
        return errors.New("既に月予算より多い金額を使っています")
    }
    cm.WeeklyBudget = weeklyBudget // ①
    cm.MonthlyBudget = monthlyBudget
    return nil
}

// 使った金額を設定
func (cm *CostManager) SetSpent(spent uint) error {
    if cm.MonthlyBudget < spent {
        return errors.New("使った金額が既に月予算を超えています")
    }

    cm.Spent = spent
    return nil
}

// 使った金額が月予算に達している
func (cm *CostManager) IsReachedToMonthlyBudget() bool {
    return cm.MonthlyBudget <= cm.Spent
}

// 使った金額が週予算に達している
func (cm *CostManager) IsReachedToWeeklyBudget() bool {
    return cm.WeeklyBudget <= cm.Spent
}

並行的に安全ではない理由

複数のゴルーチンから同期を加えることなく操作した場合、変数が必要な条件を満たさなくなるので、並行的に安全ではありません。

1つのゴルーチンが SetBudget(週予算と月予算を設定)を実行した時にプログラム中の①の時点まで実行した時に、他のゴルーチンが予算に達したかをチェックする関数を実行した時に必要な機能を満たさなくなります。

具体的には、最初の状態が

  • 週予算: 100
  • 月予算: 300
  • 今月中に使った金額: 300

の時に別々のゴルーチンから

  • SetBudget(400, 1000) (週予算: 400、月予算: 1,000を設定)を実行し、プログラム中の①の時点(週予算を設定)まで実行
  • 予算に達したかチェックする関数を実行

すると以下の図の状態になり、「今月中に使った金額が月予算に達していれば、週予算も達している」という要件を満たしていません。

f:id:moritamorie:20210312233329p:plain

対処方法

この問題に対処する方法を2つあります。

  • 変数を単一のゴルーチンに閉じ込める
  • 相互排他(mutual exclusion)の高度な不変式 (invariant) を維持する

変数を単一のゴルーチンに閉じ込める

1つ目の方法は、以下のように変数を特定のゴルーチンからしかアクセスさせなくすることで、安全にするというシンプルな方法です。

func SomeFunc() {
    // 月予算: 1,000、週予算: 100、今月使った金額: 0で初期化
    cm := CostManager{
        MonthlyBudget: 1000,
        WeeklyBudget:  100,
        Spent:         0,
    }
    // 変数 cm はこのゴルーチン内からしかアクセスさせない。
}

func main() {
    go SomeFunc()
}

また、類似の方法として、単一のゴルーチンの代わりに、パイプラインの中にアクセスする変数を閉じ込めるという方法に関しても言及されていました。パイプラインは以下のGo blogの記事で紹介されている手法です。

相互排他(mutual exclusion)の高度な不変式 (invariant) を維持する

もう1つの方法は、相互排他を用いて不変式を維持する方法です。

不変式とは

不変式とは、常に真である条件または関係です。

上記のデータ構造が持つ特有の不変式としては

  • 月予算 >= 週予算
  • 今月中に使った金額 <= 月予算
  • 今月中に使った金額が月予算に達していれば、週予算も達している

があります。

相互排他とは

不変式を維持する方法として、相互排他( sync.Mutex: ミューテックス )が提供されています。

プログラミング言語Goの一部を引用すると

ミューテックスの目的は、共有された変数のある種の不変式がプログラム実行中の重要な時点で維持されるのを保証することです。

という記載があります。

ミューテックスロックの使い方と効果

ミューテックスのロックを使うことによって、以下のように不変式を維持することができます。

var mu sync.Mutex

func SomeFunc() {
    # ①不変式が維持された状態
    mu.Lock()
    # ②一時的に不変式が破られた状態
    mu.Unlock()
    # ③不変式が維持された状態
}

複数のゴルーチンからデータ構造にアクセスできなくなり、ロックする時・ロックを解除した時に不変式が維持された状態を保証することができます。

月予算・週予算サンプルの例

最初の週予算と月予算のプログラムにミューテックスを導入するには、それぞれの関数の最初に

mu.Lock()
defer mu.Unlock()

を追加します。既にロックを獲得しているゴルーチンが存在する場合、他のゴルーチンはロックの解除待ちになります。

var mu sync.Mutex

// 週予算と月予算を設定
func (cm *CostManager) SetBudget(weeklyBudget uint, monthlyBudget uint) error {
    mu.Lock()
    defer mu.Unlock()
    if weeklyBudget > monthlyBudget {
        return errors.New("週予算が月予算を超えています")
    }
    if cm.Spent > monthlyBudget {
        return errors.New("既に月予算より多い金額を使っています")
    }
    cm.WeeklyBudget = weeklyBudget
    cm.MonthlyBudget = monthlyBudget
    return nil
}

// 使った金額を設定
func (cm *CostManager) SetSpent(spent uint) error {
    mu.Lock()
    defer mu.Unlock()
    if cm.MonthlyBudget < spent {
        return errors.New("使った金額が既に月予算を超えています")
    }

    cm.Spent = spent
    return nil
}

// 使った金額が月予算に達している
func (cm *CostManager) IsReachedToMonthlyBudget() bool {
    mu.Lock()
    defer mu.Unlock()
    return cm.MonthlyBudget <= cm.Spent
}

// 使った金額が週予算に達している
func (cm *CostManager) IsReachedToWeeklyBudget() bool {
    mu.Lock()
    defer mu.Unlock()
    return cm.WeeklyBudget <= cm.Spent
}

ミューテーションロックが再入可能(re-retrant)ではない

再入可能(re-retrant)とは以下のようにmu.Lock()、mu.Unlock()入れ子の状態にできることを指しますが、実行時にエラー(fatal error: all goroutines are asleep - deadlock)になります。

mu.Lock()
// 処理①
mu.Lock()
// 処理②
mu.Unlock()
// 処理③
mu.Unlock()

プログラミング言語Goの一部を引用すると

再入可能なミューテックスは他のゴルーチンが共有された変数へアクセスしないことを保証するでしょうが、それらの変数の追加の不変式を保護することはできません。

という記載があり、このような背景から明示的に再入できない仕様にしているものと考えられます。

参考資料

【Golang】apitest でEchoを使ったREST APIのテストを書く

apitestとは

GoのAPIテスティング用のライブラリの1つです。 github.com

apitestは

という特徴があり、個人的に好きでAPIのテスト書くのに使っています。

難点としては

  • websocket のテストは書けない(2021年3月時点)
    • その場合 httpexpect 等他のライブラリが候補にあがりそう。
  • サードパーティのライブラリのため都度バージョンアップが必要
    • 標準パッケージだけで賄いたい場合はnet/http/httptest使うのが良さそう

というところかと思います。

apitest の紹介のためにサンプルコードを書いてみました。どのようにテストを書けるのか紹介していきたいと思います。

サンプルコード

作ったサンプルはEchoで作った以下のREST APIを持つアプリケーションです

  • 書籍一覧 (GetIndex - パス: /books、メソッド: GET)
  • 書籍詳細 (GetDetail - パス: /books/1、メソッド: GET)
  • 書籍登録 (Post - パス: /books/、メソッド: POST)
  • 書籍登録 (Put - パス: /books/1、メソッド: PUT)

このEchoのサンプルは、apitest の以下のexamplesを元に作りました。

コードのリンク

コードの全ては紹介しきれないので、この記事中にはAPIの実装コード・テストコードの一部を載せてます。コード全体をみたい方はリンクを載せておきますので、よろしければご参照ください。

書籍一覧 (GetIndex)

一覧のAPIの実装コード・テストコードです。

実装コード

bh.bookRepo.FindAll()で書籍の一覧を取得し

  • エラーが発生していればクライアントにBadRequestを返す
  • エラーがなければステータス: OK(200)と取得した一覧を返す

という実装です。

func (bh *BookHandler) GetIndex(c echo.Context) error {
    bks, err := bh.bookRepo.FindAll()

    if err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    rl := &resultLists{Books: bks}
    return c.JSON(http.StatusOK, rl)
}
テストコード

bh.bookRepo.FindAll()で取得する書籍一覧をスタブにしています。 apitestを使ったテストコードでは、パス/booksにGETでHTTPリクエストを送った時に

  • スタブと同じ内容のResponse bodyが返っているか
  • ステータス: OK(200)が返っているか

をテストしています。

type BookRepoStub struct{}

func (u *BookRepoStub) FindAll() ([]model.Book, error) {
    bks := []model.Book{}
    t, _ := time.Parse("2006-01-02", "2021-01-01")

    bk1 := model.Book{Title: "Go言語の本", Author: "誰か"}
    bk1.ID = 1
    bk1.CreatedAt = t
    bk1.UpdatedAt = t
    bks = append(bks, bk1)

    b2 := model.Book{Title: "Go言語の本2", Author: "誰か2"}
    b2.ID = 2
    b2.CreatedAt = t
    b2.UpdatedAt = t
    bks = append(bks, b2)

    return bks, nil
}

func TestGetIndex(t *testing.T) {
    e := echo.New()
    brs := &BookRepoStub{}
    h := NewBookHandler(brs)
    e.GET("/books", h.GetIndex)

    apitest.New().
        Handler(e).
        Get("/books").
        Expect(t).
        Body(`
          {
              "Books": [
                  {
                      "ID": 1,
                      "CreatedAt": "2021-01-01T00:00:00Z",
                      "UpdatedAt": "2021-01-01T00:00:00Z",
                      "DeletedAt": null,
                      "Title": "Go言語の本",
                      "Author": "誰か"
                  },
                  {
                      "ID": 2,
                      "CreatedAt": "2021-01-01T00:00:00Z",
                      "UpdatedAt": "2021-01-01T00:00:00Z",
                      "DeletedAt": null,
                      "Title": "Go言語の本2",
                      "Author": "誰か2"
                  }
              ]
          }
      `).
        Status(http.StatusOK).
        End()
}

Handler()には、http.Handlerのインタフェース実装を渡します。

書籍詳細 (GetDetail)

詳細情報のAPIの実装コード・テストコードです。

実装コード

bh.bookRepo.FindByID()で書籍の詳細情報を取得し

  • エラーが発生していればクライアントにBadRequestを返す
  • エラーがなければステータス: OK(200)と取得した詳細情報を返す

という実装です。

func (bh *BookHandler) GetDetail(c echo.Context) error {
    id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
    b, err := bh.bookRepo.FindByID(id)

    if err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    return c.JSON(http.StatusOK, b)
}
テストコード

bh.bookRepo.FindByID()で取得する書籍詳細をスタブにしています。 apitestを使ったテストコードでは、パス/books/1にGETでHTTPリクエストを送った時に

  • スタブと同じ内容のResponse bodyが返っているか
  • ステータス: OK(200)が返っているか

をテストしています。

type BookRepoStub struct{}

func (u *BookRepoStub) FindByID(id uint64) (model.Book, error) {
    t, _ := time.Parse("2006-01-02", "2021-01-01")
    b := model.Book{Title: "Go言語の本", Author: "誰か"}
    b.ID = 1
    b.CreatedAt = t
    b.UpdatedAt = t
    return b, nil
}

func TestGetDetail(t *testing.T) {
    e := echo.New()
    brs := &BookRepoStub{}
    h := NewBookHandler(brs)
    e.GET("/books/:id", h.GetDetail)

    apitest.New().
        Handler(e).
        Get("/books/1").
        Expect(t).
        Body(`
          {
              "ID": 1,
              "CreatedAt": "2021-01-01T00:00:00Z",
              "UpdatedAt": "2021-01-01T00:00:00Z",
              "DeletedAt": null,
              "Title": "Go言語の本",
              "Author": "誰か"
          }
      `).
        Status(http.StatusOK).
        End()
}

書籍登録 (Post)

登録のAPIの実装コード・テストコードです。

実装コード

実装では

  • HTTPボディで送られてくる"title"(書籍名), "author"(書籍著者)を取得
  • title, authorの両方共に値があるかどうかをバリデートする(空文字もNG)
  • バリデーションに引っかかれば、クライアントにBadRequestを返す
  • Create でエラーが発生していればクライアントにBadRequestを返す
  • エラーがなければOK(200)と取得した登録した書籍情報を返す

という実装です。

func (bh *BookHandler) Post(c echo.Context) error {
    t := c.FormValue("title")
    a := c.FormValue("author")
    b := model.Book{Title: t, Author: a}

    if err := validator.New().Struct(b); err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    if err := bh.bookRepo.Create(&b); err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    return c.JSON(http.StatusOK, b)
}
テストコード

bh.bookRepo.Create()の書籍登録をスタブにしています。やっていることは nil を返しているだけです。

apitestを使ったテストコードでは、パス/books/にPostでHTTPリクエスト(ボディ "title": "新規書籍名", "author": "新規著者")を送った時に

  • スタブと同じ内容のResponse bodyが返っているか
  • ステータス: OK(200)が返っているか

をテストしています。

また、登録処理ではバリデーションに失敗するケースもあるので、そのケースもテストを書いてみました。

type BookRepoStub struct{}

func (u *BookRepoStub) Create(b *model.Book) error {
    return nil
}

func TestPost(t *testing.T) {
    e := echo.New()
    brs := &BookRepoStub{}
    h := NewBookHandler(brs)
    e.POST("/books", h.Post)

    // 正常系
    apitest.New().
        Handler(e).
        Post("/books").
        FormData("title", "新規書籍名").
        FormData("author", "新規著者").
        Expect(t).
        Status(http.StatusOK).
        End()

    // 異常系
    apitest.New().
        Handler(e).
        Post("/books").
        FormData("title", "").
        FormData("author", "新規著者").
        Expect(t).
        Status(http.StatusBadRequest).
        End()
}

書籍更新 (Put)

更新のAPIの実装コード・テストコードです。

実装コード

実装では

  • HTTPボディで送られてくる"title"(書籍名), "author"(書籍著者)を取得
  • title, authorの両方共に値があるかどうかをバリデートする(空文字もNG)
  • バリデーションに引っかかれば、クライアントにBadRequestを返す
  • Save でエラーが発生していればクライアントにBadRequestを返す
  • エラーがなければOK(200)と取得した更新した書籍情報を返す

という実装です。

func (bh *BookHandler) Put(c echo.Context) error {
    id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
    b, _ := bh.bookRepo.FindByID(id)

    t := c.FormValue("title")
    a := c.FormValue("author")
    b = model.Book{Title: t, Author: a}

    if err := validator.New().Struct(b); err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    if err := bh.bookRepo.Save(&b); err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    return c.JSON(http.StatusOK, b)
}
テストコード

bh.bookRepo.Create()の書籍登録をスタブにしています。やっていることは nil を返しているだけです。

apitestを使ったテストコードでは、パス/books/にPostでHTTPリクエスト(ボディ "title": "新規書籍名", "author": "新規著者")を送った時に

  • スタブと同じ内容のResponse bodyが返っているか
  • ステータス: OK(200)が返っているか

をテストしています。

また、登録処理ではバリデーションに失敗するケースもあるので、そのケースもテストを書いてみました。

type BookRepoStub struct{}

func (u *BookRepoStub) Save(b *model.Book) error {
    return nil
}

func TestPut(t *testing.T) {
    e := echo.New()
    brs := &BookRepoStub{}
    h := NewBookHandler(brs)
    e.PUT("/books/:id", h.Put)

    // 正常系
    apitest.New().
        Handler(e).
        Put("/books/1").
        FormData("title", "更新後書籍名").
        FormData("author", "更新後著者").
        Expect(t).
        Status(http.StatusOK).
        End()

    // 異常系
    apitest.New().
        Handler(e).
        Put("/books/1").
        FormData("title", "").
        FormData("author", "更新後著者").
        Expect(t).
        Status(http.StatusBadRequest).
        End()
}

デバッグの仕方

Debugを使うとHTTPの内容がログ出力され分かりやすくなり、テストの修正に役立てます。

例えば、上記の TestPut のテストコードを以下のように変更し Fail するようにし、併せて Debug を追加します。

   // 正常系
    apitest.New().
        Debug(). ←追加
        Handler(e).
        Put("/books/"). ←ここを存在しないパスに変更
        FormData("title", "更新後書籍名").
        FormData("author", "更新後著者").
        Expect(t).
        Status(http.StatusOK).
        End()

テストを実行すると、以下のように

  • Http requestのinbound内容
  • 最終的なHttp Responseの内容

がコンソールに出力され、テストが Fail した原因を見つけやすくなります。

----------> inbound http request
PUT /books/ HTTP/1.1
Host: sut
Content-Type: application/x-www-form-urlencoded

author=%E6%9B%B4%E6%96%B0%E5%BE%8C%E8%91%97%E8%80%85&title=%E6%9B%B4%E6%96%B0%E5%BE%8C%E6%9B%B8%E7%B1%8D%E5%90%8D

<---------- final response
Running tool: /usr/local/go/bin/go test -timeout 30s -run ^TestPut$ github.com/moritamori/echo-testing/handler
HTTP/1.1 404 Not Found
Connection: close
Content-Type: application/json; charset=UTF-8

{"message":"Not Found"}

Duration: 173.685µs

--- FAIL: TestPut (0.00s)
    /home/takashi/go/src/github.com/moritamori/echo-testing/handler/assert.go:37: 
            Error Trace:    assert.go:37
                                        apitest.go:941
                                        apitest.go:792
                                        apitest.go:599
                                        book_test.go:153
            Error:          Not equal: 
                            expected: 200
                            actual  : 404
            Test:           TestPut
            Messages:       Status code 404 not equal to 200
FAIL
FAIL    github.com/moritamori/echo-testing/handler  0.092s
FAIL

Debugがないと以下のようなシンプルな出力内容で、200が返ることを期待しているが実際には404が返っていることが分かるくらいで情報が少ないです。

Running tool: /usr/local/go/bin/go test -timeout 30s -run ^TestPut$ github.com/moritamori/echo-testing/handler

--- FAIL: TestPut (0.00s)
    /home/takashi/go/src/github.com/moritamori/echo-testing/handler/assert.go:37: 
            Error Trace:    assert.go:37
                                        apitest.go:941
                                        apitest.go:792
                                        apitest.go:599
                                        book_test.go:152
            Error:          Not equal: 
                            expected: 200
                            actual  : 404
            Test:           TestPut
            Messages:       Status code 404 not equal to 200
FAIL
FAIL    github.com/moritamori/echo-testing/handler  0.077s
FAIL

便利な機能

JSON path

APIから返ってきたJSON全体をテストするサンプルを書きましたが、JSON pathを使うと部分的にテストする際に便利です。

カスタムマッチャ

レスポンスを検証する対象としてBodyCokkieHeader等がありますが、独自のマッチャを作って検証することもできます。

インタセプター

HTTPリクエストを送る前にインタセプターを挟んで、http.Request を元に送る内容を加工できます。

参考記事

上記のAPIのサンプルは各エントリポイントでGormを使ったDBアクセスをしています。以下の記事では go-sqlmock を使ったGorm アプリケーションのテスト方法も書いてますので、もしご興味があれば参考にしてみてください!

simple-minds-think-alike.hatenablog.com

サンプルコード

github.com

参考資料

mysqldump: Error: 'Access denied; you need (at least one of) the PROCESS privilege(s) for this operation' when trying to dump tablespaces が発生した時の対処

先日、 mysqldumpAWS RDSインスタンス(aurora)に繋いでデータをdumpしようとしたら、見たことがないErrorが発生していたので調べました。

mysqldump: Error: 'Access denied; you need (at least one of) the PROCESS privilege(s) for this operation' when trying to dump tablespaces

原因

調べてみると、MySQLバージョンのアップグレードの影響で tablespace の情報にアクセスするには PROCESS 権限が必要になったことが原因であるということがわかりました。

dev.mysql.com

対応方法

tablespaceの情報は不要だったので、MySQL公式のページに書いてあった通り--no-tablespacesを付けてmysqldumpを実行したら、エラーが発生しなくなりました。

他の方法としては、dumpするユーザにPROCESS権限をつけて対処することもでき、tablespace を含めてdumpしたい場合には権限をつけると良さそうです。

エラー発生の状況

dumpの方法

以下のようにしてdumpしていました。

$ mysqldump -u [DBユーザー] -p[DBパスワード] -h [DBホスト] [データベース名] > backup.sql

エンジン

以下のMySQL 5.6互換のAuroraでエラーが発生していました。エンジンは 5.6.mysql_aurora.1.23.1docs.aws.amazon.com

参考資料