API Design

はじめに

改めて自分の中でAPI設計について整理するために、Googleが公開しているAPI設計ガイドを読みました。
cloud.google.com
基本的なリソースやパス設計の考え方はRails Wayに馴染みのあるものですが、一部自分の新たな気付きになったものを中心にピックアップして、自分用にまとめました。
なお、この記事内の記載している例については、ガイド内で紹介されている内容を引用しています。
また、RESTに限らずgRPCなどのケースであっても適用可能なガイドになっています。

用語

コレクション
リソースに対し、コレクションは同じタイプのサブリソースのリストを含む特殊なリソース。

標準メソッド
GoogleAPI設計では、リソースに対する操作は基本的に、List/Get/Create/Update/Delete を提供します。
多数のリソース+少数のメソッドでAPI設計をするのがベターとされています。

カスタムメソッド
標準メソッドだけでは表現が困難な操作があります。
例えば、「削除操作の取り消し」や「インスタンスの再起動」などがあります。
標準メソッドの動詞(List|Get|Create|Update|Delete)だけでは表現できない操作を対象に定義されます。

API設計について

標準メソッドの場合

List

List を使用する場合は、コレクション名+0個以上のパラメータを使用します。
また、List操作はHTTP GETメソッドを使用する必要があり、コレクション名はパスにマッピングする(本文に含めない)ことが推奨されます。
例)

  • GET shelves (コレクション名: shelves)
  • GET shelves/shelf1/books (コレクション名: books)

Listを使用するケースとしては、検索が代表的です。
ただし、簡易的な検索(フィルタリングなど)ではなく、範囲の広い検索においては、カスタムメソッドsearchを使用することが推奨されます。
また、レスポンス本文には任意のメタデータを含めることが推奨されます。
例えば、ページングを行うための page_sizenext_token を含めます。

Get

Get を使用する場合は、リソース名+0個以上のパラメータを使用します。
また、HTTP GETメソッドを使用する必要がありリソース名はパスにマッピングすることが推奨されます。
例)

  • GET shelves/shelf1 (リソース名: shelf1)
  • GET shelves/shelf1/books/book2 (リソース名: book2)

返却されるリソースは本文全体にマッピングされます。
=> Getのレスポンス専用の型を用意してならず、リソース全体を本文として返します。
CreateやUpdateでも同様です。

Create

Create を使用する場合は、親リソース名+リソース+0個以上のパラメータを使用します。
また、HTTP POSTメソッドを使用する必要があり、リクエストメッセージには親リソースID parent を含めることが推奨されます。
例)

{
  "parent": "shelves/shelf1",
  "book": {
    "name": "book name"
  }
}

リソースに関連しないその他リクエストメッセージは、すべてクエリパラメータにマッピングされるべきです。
Getの場合と同様、作成したリソースは、レスポンス本文全体にマッピングされるべきです。

Update

Update を使用する場合は、リソース名+0個以上のパラメータを使用します。
リソース名や親リソース名を除き、変更可能なリソースのプロパティは基本的に更新可能にする必要があります。
また、リソース名の変更や親の移動を行う場合は、カスタムメソッドを使用することが推奨されます。

レスポンスに関しては、更新されたリソース自体であることが必須となります。
また、部分的な更新を行えるように、FiledMaskでPATCHを使用する必要もあります。

例)
PATCH shelves/shelf1/books/book2

{
  "book": {
    "name": "updated book name"
  },
  "update_mask": "book.name"
}

リソースに関連しない残りのメッセージフィールドは、すべてクエリパラメータにマッピングされるべきです。

Delete

Delete を使用する場合は、リソース名+0個以上のパラメータを使用します。
また、HTTP DELETEメソッドを使用する必要があり、リソース名はパスにマッピングすることが推奨されます。
残りのメッセージフィールドは、すべてクエリパラメータにマッピングされるべきです。
例) DELETE shelves/shelf1/books/book2

Delete は、指定したリソースを即時削除するか、削除をスケジュールします。
即時削除の場合、google.protobuf.Empty(空の本文)を返すことが推奨される。
削除済みとしてマークするだけの場合、更新されたリソースを返すことが推奨される。

カスタムメソッドの場合

カスタムメソッドを使用する場合、基本的には柔軟性を持つ(bodyを使用できる) HTTP POST メソッドが推奨されます。
一方、PATCH の使用は推奨されません。
また、カスタムメソッドが関連するリソースやコレクションのリソース名は、パスにマッピングされることが推奨されます。
カスタムメソッドが有効な代表的なケースは以下のように紹介されています。

メソッド名 動詞 HTTP Method
キャンセル :cancel POST
batchGet :batchGet GET
移動 :move POST
検索 :search GET
削除の取り消し :undelete POST

(削除の取り消しについて、削除済みのリソースの推奨保持期間は30日。)

GETを使用するケースでは、冪等であり副作用がないことが前提となります。

カスタム動詞を使用する場合は、動詞とリソース名を / ではなく : で区切ります(/を含まないことで、任意のパスで対応できるため)。
例) shelves/shelf1:undelete

カスタム動詞は接尾辞として付与することが必須です。

命名について

リソースID

リソース指向APIでは、リソースは名前付きエンティティであり、リソース名はその識別子となります。
各リソースには、一意性を表すためのリソース名が必要であり、以下で構成されます。

  1. リソース自体のID
  2. すべての親リソースのID
  3. そのAPIサービス名

例)

  • "//library.googleapis.com/shelves/shelf1/books/book2" (完全なリソース名)
  • "shelves/shelf1/books/book2" (相対的なリソース名)

コレクションIDの設計

コレクション名の設計については、以下を満たすように設計します。

  1. 簡素で明確な英単語を使用する
  2. キャメルケースで使用する
  3. コレクションIDでは、非常に一般的な用語は使用しないか、修飾して使用する(values -> rowValues)

以下の単語は、修飾して使用することが推奨されます。

  • elements
  • entries
  • instances
  • items
  • objects
  • resources
  • types
  • values

リソース定義

リソースを定義する場合、最初のフィールドはリソース名の文字列フィールドにする必要があり、フィールド名にはnameという用語を用います。
これは、nameが非常に広い意味を持つ用語であり、開発者によって解釈が異なる可能性が高いため、予約語として使用します(その他のフィールド名では、titleなどより明確な用語を使用することが強いられる)。

一般的な設計パターン

空レスポンス

Deleteでは、ソフト削除を除いて基本的にgoogle.protobuf.Empty(空の本文)を返す必要がある。
一方、ソフト削除の場合は状態の変更がわかるように、更新後のリソースを返す必要がある。

区間表現

半開区間を使用する必要がある。
また、命名には start_xxxend_xxx といった規則を用いる。
例) [start_date, end_date) / [start_time, end_time)

型は Timestamp

長時間実行オペレーション

実行に長時間かかるかかるAPIメソッドは、長時間実行オペレーションのリソースをクライアントに返すように設計できます。
Operationインターフェースを使用し、長時間実行APIごとにインターフェースが独自に定義されないようにする必要があります。

サブコレクションのList

shelfとサブコレクションであるbookに対し、すべてのshelfに属するbookを検索する(親のshelfに対する制約がない)場合、以下のようなREST APIを使用します。 例) GET https://library.googleapis.com/v1/shelves/-/books?filter=xxx
親コレクションに対してワイルドカードとして-を使用します。

サブコレクションからの一意なリソース取得

取得したいbookリソースが、親であるshelfリソースのIDを知らなくても取得できるようにしておくと便利です。
その場合、以下のようなREST APIを使用できます。
例) GET https://library.googleapis.com/v1/shelves/-/books/{id}

サブコレクションのListと同様、ワイルドカードとして-を使用します。

並び替え順序

リクエストに order_by を含め、SQL構文に従いカンマ区切りで指定します。
foo, bar desc

リクエストの重複

基本的に冪等なAPIが好まれますが、簡単には冪等にできないケースもあります(リソースの作成やデータベーストランザクションなど)。
このような場合、リクエストにUUIDなど一意な値を持たせることが推奨されます。
サーバーはその値を用いて重複を検知し、リクエストが1度だけ実行されるようにします。

列挙型のデフォルト値

列挙型エントリは0から開始することが必須となります。
明示的に指定されていない場合、このエントリが使用されることが推奨されます。
一般的なデフォルト動作がない場合、列挙型の0ENUM_TYPE_UNSPECIFIEDという名前にすることが推奨されます。
また、これが使用された場合は、INVALID_ARGUMENTで拒否することが推奨されます。

部分レスポンス

レスポンスメッセージ内の特定のサブセットのみが必要なケースでは、暗黙的なシステムクエリパラメータ$fieldを使用します。
例) GET https://library.googleapis.com/v1/shelves?$fields=name

これは、FieldMaskのJSON表現として使用できます。

シングルトンリソース

例えば、ユーザーリソースに対してユーザー固有の設定はシングルトンリソースとして存在します。
シングルトンリソースは、標準のCreate/Deleteが省略されることが必須です。
親が作成または削除されると、暗黙的に作成、削除されます。
リソースにアクセスするためには、Get/Updateかカスタムメソッドを用いてアクセスしなければなりません。

データ保持

誤ってデータを削除してしまうことは多々あり、ビジネス的ダメージを軽減するため、データの削除ポリシーとして一定期間データを保持することが推奨されます。
データ保持の推奨期間は以下のように記載されています。

Docker Engine APIでサンドボックス環境を試してみる

概要

CI自体の開発や、教育支援システム開発などでは、何度も同じ環境でコードを実行し、再現性のある結果を出力する必要があります。
CIの例はイメージしやすいと思いますが、教育支援システムにプログラムの自動採点機能を設けるとすると、その採点ロジックは実際にコードを実行し、期待する結果が出力できているかを照合する処理を実装します。
このような機能は、サンドボックス用の Docker や Kubernetes に対して、サーバーサイドアプリケーションからコンテナ作成依頼をする形で実現されると思います。
自分自身、そういったユースケースを考えることが多かったため、興味の範疇で上記を実現するためのスニペットを書いてみました。

試すこと

Docker SDK を使用して、以下を試してみたいと思います。

  1. コンテナを作成
  2. コンテナでのコマンド実行
  3. 非同期での標準出力の拾い上げ

準備物

  1. コンテナ内で実行するサンプルコード
  2. コンテナを作成、実行、標準出力を取得するためのクライアント実装

まずは 1 から。
次のコードを使用して、30秒間、毎秒ログを吐きます。

package main

import (
    "context"
    "log"
    "os"
    "time"
)

func main() {
    l := log.New(os.Stdout, "echo: ", 0)
    ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
    defer cancel()

    l.Println("start")

    for {
        select {
        case <-ctx.Done():
            l.Println("finish")
            return
        default:
            l.Println("hello")
            time.Sleep(time.Second)
        }
    }
}

このコードをビルドし、Docker Image (echo:latest) を作ります。

次に、2 のクライアントコードを示します。
Docker SDK を使用して、「コンテナ作成 -> コンテナの開始 -> Exec -> コンテナの標準出力をコピーして標準出力に表示」を行います。

package main

import (
    "bytes"
    "context"
    "fmt"
    "os"
    "time"

    "github.com/docker/docker/api/types"
    "github.com/docker/docker/api/types/container"
    "github.com/docker/docker/client"
    "github.com/docker/docker/pkg/stdcopy"
    "github.com/google/uuid"
)

type runConfig struct {
    Image string
    Tag   string
    Cmd   []string
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
    defer cancel()

    c := runConfig{
        Image: "echo",
        Tag:   "latest",
        Cmd:   []string{"/echo"},
    }
    if err := run(ctx, c); err != nil {
        fmt.Println(err)
        os.Exit(1)
        return
    }
}

func run(ctx context.Context, runCfg runConfig) error {
    cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
    if err != nil {
        return fmt.Errorf("new client error: %w", err)
    }

    u, err := uuid.NewUUID()
    if err != nil {
        return fmt.Errorf("new uuid error: %w", err)
    }

    name := runCfg.Image + "_" + u.String()
    containerCfg := &container.Config{
        Image: fmt.Sprintf("%s:%s", runCfg.Image, runCfg.Tag),
        Tty:   true,
    }
    resp, err := cli.ContainerCreate(ctx, containerCfg, nil, nil, nil, name)
    if err != nil {
        return fmt.Errorf("create container error: %w", err)
    }
    defer func() {
        cli.ContainerRemove(ctx, name, types.ContainerRemoveOptions{Force: true})
    }()

    containerID := resp.ID

    if err := cli.ContainerStart(ctx, containerID, types.ContainerStartOptions{}); err != nil {
        return fmt.Errorf("start container error: %w", err)
    }

    // wait for container starting
    for {
        resp, err := cli.ContainerInspect(ctx, containerID)
        if err != nil {
            return fmt.Errorf("inspect container error: %w", err)
        }
        if !resp.State.Running {
            if resp.State.Dead {
                return fmt.Errorf("container dead")
            }
            if e := resp.State.Error; e != "" {
                return fmt.Errorf("container error: %s", e)
            }
            if c := resp.State.ExitCode; c != 0 {
                return fmt.Errorf("container exited code: %d", c)
            }
            time.Sleep(500 * time.Millisecond)
            continue
        }
        break
    }

    execCfg := types.ExecConfig{
        AttachStderr: true,
        AttachStdout: true,
        Cmd:          runCfg.Cmd,
    }
    exec, err := cli.ContainerExecCreate(ctx, containerID, execCfg)
    if err != nil {
        return fmt.Errorf("create exec error: %w", err)
    }

    out, err := cli.ContainerExecAttach(ctx, exec.ID, types.ExecStartCheck{})
    if err != nil {
        return fmt.Errorf("attach exec error: %w", err)
    }

    stdo := new(bytes.Buffer)
    stde := new(bytes.Buffer)

    end := make(chan struct{}, 1)
    defer close(end)

    go func() {
        _, err := stdcopy.StdCopy(stdo, stde, out.Reader)
        if err != nil {
            panic(fmt.Errorf("copy error: %w", err))
        }
        end <- struct{}{}
    }()

    ticker := time.NewTicker(time.Second)
    defer ticker.Stop()
    for {
        select {
        case <-ticker.C:
            fmt.Println(stdo)
        case <-end:
            return nil
        }
    }
}

tickerの周期を短くすることで、よりリアルタイムに標準出力を拾えるようになります。

まとめ

教育支援システムのユースケースを考えるならば、プログラムコードをコンテナに埋め込んだり、プログラムコードから Docker Image を作成したりする処理も必要になります。
その場合は、cli.ImageBuild(...)cli.ImageCreate(...)を使用することでイメージを用意することもできるので、上記と組み合わせて実現できそうです。

CloudFrontでのアクセス制御付きコンテンツ配信

アクセス制限付きのコンテンツ配信

AWSではCloudFrontを使用してコンテンツ配信を行うケースは多いと思います。
CloudFrontでは、署名付きURL と 署名付きCookie を使用することで、権限を持つユーザーにのみコンテンツへのアクセス権限を与えることが可能です。
ユーザーに対しコンテンツのアクセスを制限する場合、コンテンツProxyを自分で用意したり、署名付きURLを発行したりするなどの選択肢があります。

例: オリジンで認証情報を検証し、コンテンツをプロキシする場合。
Authorization ヘッダーをオリジンに転送するように、CloudFront を設定します。

コンテンツをProxyする場合
コンテンツをProxyする場合

例: 署名付きURL を発行し、コンテンツ配信を CloudFront + S3 で実現する場合。

署名付きURLを使用する場合
署名付きURLを使用する場合

どちらの場合も、キャッシュ期間やURL有効期限を適切に決定する必要があります。
今回は後者を試してみます。

準備

以下を準備します。

  1. S3バケット
  2. CloudFront Key Management
  3. CloudFront Distribution

S3バケット

今回は、contents-deliveryという名前でバケットを作成しました。
パブリックアクセスはすべてブロックします。

パブリックアクセスブロック

署名に使用する鍵の生成

署名付きURLの生成に使用するキーペアを作成し、公開鍵をCloudFrontに登録します。
CloudFrontで使用する鍵の生成には、大きく2通りの選択肢があります。

  1. ルートユーザーとしてログインし IAMのセキュリティ認証情報 から生成する(非推奨)
  2. キーペアを作成し、公開鍵をCloudFront の Key Management に登録する(推奨)

署名付きURLについてのほとんどの記事では、1 で説明されているものが多く見受けられますが、公式では非推奨となっています。 docs.aws.amazon.com

今回は推奨方式で進めます。
キーペアを生成します。

$ openssl genrsa -out private_key.pem 2048
$ openssl rsa -pubout -in private_key.pem -out public_key.pem
$ pbcopy < public_key.pem

CloudFront の設定から、Key Management > Public keys > Add public key から鍵を登録します。
また、Key groups からグループを作成します(Distribution作成時に使用します)。

KeyGroup

これでキーペアの準備は完了です。

CloudFront Distribution

CloudFront の Distribution を作成します。
Origin Domain Name には、先程作成したS3バケットを指定します。
このとき、デフォルトで表示されているドメインに対して、s3-<region>のように、明示的にリージョンを指定することができます。
これにより、S3バケットと作成するDistributionのリージョン差異によって生じる反映遅延を短くすることができるため、即時反映が必要な場合は明示的に追記します。

また、Restrict Bucket Access を有効にし、Create a New Identity を選択すると、CloudFront の Origin Access Identity が生成されます。
これは、S3バケットなどのアクセス設定で、特定のIdentityにのみアクションを許可する(例えば、特定のCloudFront Distributionにのみ読み取りを許可する)ために使用されます。

Distribution設定1

この時点で、S3バケットバケットポリシーが更新され、以下のようになります。

{
    "Version": "2008-10-17",
    "Id": "PolicyForCloudFrontPrivateContent",
    "Statement": [
        {
            "Sid": "1",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity E1YQQEHMTH97R7"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::contents-delivery/*"
        }
    ]
}

Restrict Viewer Access を有効にし、先程作成したKey Groupを指定します。
この設定により、対象のオリジンに対して署名付きURLまたは署名付きCookieを使用していないリクエストが拒否されるようになります。

Distribution設定2

これでアクセス制限付きコンテンツ配信をするためのCloudFrontの設定ができました。

署名付きURLを生成する

コードは以下のようになります。

package main

import (
    "crypto/rsa"
    "crypto/x509"
    "encoding/pem"
    "fmt"
    "io/ioutil"
    "os"
    "time"

    "github.com/aws/aws-sdk-go/service/cloudfront/sign"
)

const (
    keyID  = "K2INMPKFCORO4O"
    rawURL = "https://d1f21o78fmc0sh.cloudfront.net/sample/sample.jpg"
    ttl    = 3 * time.Minute
)

func main() {
    if err := run(); err != nil {
        fmt.Println(err)
        os.Exit(1)
        return
    }
}

func run() error {
    bs, err := ioutil.ReadFile("./private_key.pem")
    if err != nil {
        return err
    }

    key, err := readRsaPrivateKey(bs)
    if err != nil {
        return err
    }
    key.Precompute()

    if err := key.Validate(); err != nil {
        return err
    }

    url, err := issueURL(key, rawURL)
    if err != nil {
        return err
    }

    fmt.Printf("signed url: %s\n", url)
    return nil
}

func issueURL(key *rsa.PrivateKey, url string) (string, error) {
    signer := sign.NewURLSigner(keyID, key)
    signedURL, err := signer.Sign(url, time.Now().Add(ttl))
    if err != nil {
        return "", fmt.Errorf("failed to sign url: %w", err)
    }
    return signedURL, nil
}

func readRsaPrivateKey(bs []byte) (*rsa.PrivateKey, error) {
    block, _ := pem.Decode(bs)
    if block == nil {
        return nil, fmt.Errorf("invalid private key data")
    }

    switch block.Type {
    case "RSA PRIVATE KEY":
        key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
        if err != nil {
            return nil, fmt.Errorf("failed to parse key: %w", err)
        }
        return key, nil
    case "PRIVATE KEY":
        ki, err := x509.ParsePKCS8PrivateKey(block.Bytes)
        if err != nil {
            return nil, fmt.Errorf("failed to parse key: %w", err)
        }
        key, ok := ki.(*rsa.PrivateKey)
        if !ok {
            return nil, fmt.Errorf("key is not rsa private key")
        }
        return key, nil
    default:
        return nil, fmt.Errorf("invalid private key type: %s", block.Type)
    }
}

実行すると署名付きURLが出力されます。

実際にアクセスしてみると、元のURLと署名付きURLで挙動の違いを確認できます。
画像や比較的容量の小さい音声ファイルなどは、署名付きURLを使用することでアクセス制限付きのコンテンツ配信を手軽に実装できそうです。

動画を配信する場合

HLS方式で動画を配信する場合、署名付きURLでは実現が困難になります。
画像や音声ファイルは単一のファイルに対して署名付きURLを発行することでアクセスを許可することができますが、HLSの場合はファイルが複数に分割されるため、複数ファイルに対する署名付きURLが必要になってしまうためです。
これに対して、署名付きCookieを使用することでアクセス制限付きHLS配信を行います。
有効期限付きCookieクライアントに返し、ファイル単位ではなくユーザー単位で対象全ファイルへのアクセスを許可できます。

サンプル動画の用意

適当なmp4ファイルを用意し、ffmpegでHLSに変換します。

$ ffmpeg -i contents/movie.mp4 -c:v copy -c:a copy -f hls -hls_time 5 -hls_playlist_type vod -hls_segment_filename "hls/movie%03d.ts" hls/movie.m3u8

複数のtsファイルとmovie.m3u8ファイルが作成されるので、すべてS3にアップロードします。
再生確認をしたい場合は、以下で確認できます。

$ ffplay hls/movie.m3u8

署名付きCookieの使用方法

追加のCloudFrontの設定はありません。
リクエストに乗せるCookie情報を取得します。

以下を参考にしました。 qiita.com

ポリシーを定義

{"Statement":[{"Resource":"https://d1f21o78fmc0sh.cloudfront.net/*","Condition":{"DateLessThan":{"AWS:EpochTime":1613823764}}}]}

base64

$ cat policy.json | openssl base64 | tr '+=/' '-_~'

署名されたbase64

$ cat policy.json | openssl sha1 -sign private_key.pem | openssl base64 | tr '+=/' '-_~'

これらの情報をリクエストヘッダーにセットすれば、HLSなどの複数ファイルを参照する形式のリクエストであっても、対象すべてのファイルにアクセスできるようになります。

署名付きCookieの動作確認

以下の手順で確認しました。

  1. ブラウザに上記のCookie情報をセット
  2. HLSで動画を再生するHTMLをS3にアップロード
  3. DistributionにHTML配信用のオリジンを追加し、/index.htmlアクセスに対して、2 でアップロードしたHTMLが参照されるように設定

ブラウザに設定するCookieは、以下のような値になります。

  • CloudFront-Policy=<base64>
  • CloudFront-Signature=<署名base64>
  • CloudFront-Key-Pair-Id=<キーペアID>

HTMLは以下を使用しました。

<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<video id="movie" controls>

<script>
  if(Hls.isSupported()) {
    const video = document.getElementById('movie');
    const hls = new Hls({
      xhrSetup: xhr => {
        xhr.withCredentials = true;
      }
    })
    hls.loadSource('https://d1f21o78fmc0sh.cloudfront.net/sample/movie.m3u8');
    hls.attachMedia(video);
 }
</script>

CloudFrontのマルチオリジン設定を行った後上記HTMLを参照すると、問題なく動画が再生できました。

HLS再生
HLS再生
動画ファイルには以下を利用させていただきました。
https://pixabay.com/ja/videos/%E6%B5%B7-%E6%97%A5%E6%B2%A1-%E3%83%93%E3%83%BC%E3%83%81-%E6%B5%B7%E5%B2%B8-62249/

ローカル環境から検証する場合は、必要に応じてS3バケットやCloudFrontのCORS設定を行ってください。

AWS認定試験の受験方法

はじめに

先日、awsの認定試験を受験してきました。
聞き馴染みのない ピアソンVUE というシステムを使用した受験で、正直当日まで不安が拭いきれなかったので、当日の流れを含めて共有できればと思います。
なお、今回受験したのはオフラインでの試験になります。

テストプロバイダー

awsの認定試験では、テストプロバイダーを2種類から選ぶことができます。
(テストプロバイダーとは、ベンダー(今回だとaws)の試験を管理、被験者に配信する組織になります。)

  1. ピアソンVUE (ナショナル・コンピュータ・システムズ・ジャパン社)
  2. PSI (PSI社)

2019年3月には、ナショナル・コンピュータ・システムズ・ジャパン社は OnVUE というソリューションの提供を開始し、オンライン上での試験も行えるようになったようです。

www.pearsonvue.co.jp

ピアソンVUE を使用する場合でも、PSI を使用する場合でも、オンラインかオフラインかを選ぶことができます。
実質4通りの受験方法があることになります。

サポートしている試験会場の数などから、ピアソンVUE がよく選択されるようです。
自分もその前情報を知っていたため、ピアソンVUE での受験を選択しました。

試験会場と当日の流れ

当日の流れについては、丁寧にまとめられた記事がありました。

PSI blog.trainocate.co.jp

ピアソンVUE blog.trainocate.co.jp

他にも、自分と同じ試験会場で過去に受験された方がいらっしゃったので、そちらの体験記も参考にさせていただきました。

qiita.com

試験会場

渋谷テストセンターを利用しました。
噂に聞いていたとおり、小学校の教室くらいの広さで、雑居ビルの中に入っています。
試験室は予備校の自習室に似ているかもしれません。
隣の席に衝立があり、コンピューターは小中学校で使用されているようなコンパクトなデスクトップでした。

当日の流れ

試験開始30分前に受付に到着し、ガイダンスに従い以下の順で準備をします。

  1. 同意書にサイン
    • 長い同意書に目を通し署名しました。
  2. 身分証明書を提示と顔の照合
    • 運転免許証 と クレジットカード を提示しました。
    • 1つは顔写真付きのものである必要があります。
    • その他、ファーストネーム、ラストネーム、住所などが予約登録情報と一致する必用があります。
    • 一瞬マスクを外して顔を照合し、本人確認が行われます。
  3. 検温
    • このご時世なので、検温も行います。
  4. 顔写真撮影
    • 受付で立ったまま、webカメラのようなカメラで撮影が行われます。
  5. 試験室に入室
    • ロッカーに荷物を入れ、身分証(運転免許証)とロッカーの鍵、メモ用のボードとペンのみを持って案内されます。
    • 試験監督が予め試験開始の準備をしてくれているため、名前と試験に間違いがないことを確認するとすぐに試験が始まります。
    • 開始時間30分前に入室し、ここまでで10分ほどしか経過していなかったのですが、予定時刻より早く試験を開始できました。
  6. 試験室から退室
    • 解答と見直しが終わると、最後にアンケートに答えて退出となります。
    • 画面が切り替わるのに時間がかかりますが、ログイン画面のような表示に切り替わるまで席を立たないようにします。
    • アンケートに回答後、合否が表示されます。詳細なスコアは5~6時間後にweb上で確認できるようになりました。
  7. 帰宅
    • ボードとペンを返却し、返却時刻の記述と署名を行います。

まとめ

時節柄オンラインでの受験を希望される方が多いと思いますが、オンラインでの受験は不安要素が多いように思います。
例えば、

  • セキュリティ要件を満たした環境かどうかが分からず、当日NGになる可能性がある
  • コンピューターやネットワークトラブルの際とても不安になるし、再試験になることもある

といった懸念は結構クリティカルだと思っています。
一方で、オフラインではそういったトラブルが起きにくいため、自分の立てたスケジュール通りに試験を完了できると思います。

とは言っても、オフラインでの感染リスクなどと上記リスクを天秤にかけて、当事者がどちらを採るかですね。

個人的には、オフラインでの試験は特に不満もなく、今後の受験でも利用しようと考えています。

クラウドプラクティショナーを受験しました

はじめに

awsを仕事で使い始めて2~3年ほどが経ちました。
はじめは EC2 上にRailsやflaskで実行したwebアプリケーションに対して、ALBでルーティングするようなシンプルなものでした。
2年目から Fargate や Lambda などを使うようになり、より一層サーバーレスや疎結合なマイクロサービス化を意識した設計を心がけるようになりました。

そんな中で、自分が資格を保持する利点とコスパを考えてみました。

  1. 業務委託やその他仕事で関わる中で、信頼を得やすい (または単価が上がりやすい)
  2. 発言に重みが出る (資格保有者であるバックグランドが、発言に尤もらしさを上乗せする)
  3. 普段awsを使用しているので、初心者が資格取得するよりかなり楽ができる (普段使っている技術について問われるので)

1 は憶測も含みますが、少なくともマイナスにはならないはずです。
特に、2 に関しては普段の業務でもメリットが大きいと思うので、受験しようと思い立ったという訳です。

認定資格の種類

2021年2月時点で、awsの認定資格は12種類ほどあります。
MLやセキュリティなどは専門分野として別で区分けされています。

それ以外の分野では、基礎コース > アソシエイト > プロフェッショナル とレベル分けがされています。

AWS 認定 – AWS クラウドコンピューティング認定プログラム | AWS aws.amazon.com

今回自分が受験したのは、基礎コースの「クラウドラクティショナー」になります。 目的の資格はソリューションアーキテクト アソシエイトなのですが、先にクラウドラクティショナーを受験した理由は以下になります。

  1. 試験に合格すると模擬試験が無料で受けられる
  2. 試験に合格した場合、経費精算できる状態にある
  3. 次回受験料が安くなる

いきなりアソシエイトを受験するのではなく、模擬試験を無料で受けつつリスクヘッジができる戦略で臨んだ、ということになります。 自信がある方は、アソシエイトレベルを一発で取るのが一番効率が良いかと思います。

↓公式が出している、クラウドラクティショナーの出題範囲について↓
https://d1.awsstatic.com/ja_JP/training-and-certification/docs-cloud-practitioner/AWS-Certified-Cloud-Practitioner_Exam-Guide.pdf

行った対策

勉強時間としては、休日1~2日分です。
内容としては、公式の例題とamazonにあった模擬問題集を購入して解きました。
本を読んでインプットするというよりは、実際に問題を解いて傾向をみました。

↓公式の例題↓
https://d1.awsstatic.com/ja_JP/training-and-certification/docs-cloud-practitioner/AWS-Certified-Cloud-Practitioner_Sample-Questions.pdf

Amazon.co.jp: AWS認定 クラウドプラクティショナー 模擬問題集 eBook: Yoshi Otobe: Kindleストア https://www.amazon.co.jp/gp/product/B081D4ZJ5G/ref=ppx_yo_dt_b_d_asin_title_o02?ie=UTF8&psc=1www.amazon.co.jp

結果

結果ですが、無事合格することができました。
スコアは893で、準備期間を考慮すると費用対効果の高い対策になったかなと思います。
目的としているのはアソシエイトなので、今度はアソシエイトレベルの対策を行っていきます。

まとめ

資格を取得する目的は様々あると思いますが、どうしても気が重くなってしまうのが常だと思います。
今回、試験の予約を先にしてしまうことで逃げ道をなくした結果、受験するに至りました。
背水の陣も良いですね。

Goでファイルアップロード機能を手早く作る

概要

Rails でよく採用していた fogCarrierWave の組み合わせに近いことができないかと考えていました。
Goを使用する場合でも、画像アップロード機能など、あるあるな機能は効率よく実装したいものです。
そこで、tusd を知ったので、簡単な例を試してみたいと思います。

tusd

tusd はファイルの resumable upload protocol を実現するプロトコルである tus を実装しているそうです。
プロトコルの詳細は公式に記載があります。

tus.io

tus 自体はプロトコルの話なので、言語による制約は受けず、Go以外でも利用可能です。
tusd としては、以下を利用できます。

  1. tusd バイナリ (ファイルアップローダー単体として起動)
  2. tusd を実装する機能

1 は、社内ツールなどで取り敢えずアップロードができれば良い場合など、特定のユースケースで利用する場合に良いですね。
2 での利用がメインだと思います。

Go での tusd 利用

example が用意されているので、試しに動作させてみます。

package main

import (
    "context"
    "fmt"
    "net/http"

    "github.com/tus/tusd/pkg/filestore"
    tusd "github.com/tus/tusd/pkg/handler"
)

const (
    basePath = "/files/"
)

func main() {
    if err := run(context.Background()); err != nil {
        panic(err)
    }
}

func run(ctx context.Context) error {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    store := filestore.FileStore{
        Path: "./uploads",
    }
    composer := tusd.NewStoreComposer()
    store.UseIn(composer)

    handler, err := tusd.NewHandler(tusd.Config{
        BasePath:              basePath,
        StoreComposer:         composer,
        NotifyCompleteUploads: true,
    })
    if err != nil {
        return fmt.Errorf("unable to create handler: %w", err)
    }

    go func() {
        for {
            select {
            case event := <-handler.CompleteUploads:
                fmt.Printf("[event] upload %s finished\n", event.Upload.ID)
            }
        }
    }()

    http.Handle(basePath, http.StripPrefix(basePath, handler))

    if err := http.ListenAndServe(":8080", nil); err != nil {
        return fmt.Errorf("unable to listen: %w", err)
    }

    return nil
}

tusd を使用する場合は、form-data とは異なりクライアント側でも対応が必要になります。
今回は簡易的に試すために、uppy を使用します。
接続先ホストを変更し、ブラウザで表示します。

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Uppy</title>
    <link href="https://releases.transloadit.com/uppy/v1.25.0/uppy.min.css" rel="stylesheet">
  </head>
  <body>
    <div id="drag-drop-area"></div>

    <script src="https://releases.transloadit.com/uppy/v1.25.0/uppy.min.js"></script>
    <script>
      var uppy = Uppy.Core()
        .use(Uppy.Dashboard, {
          inline: true,
          target: '#drag-drop-area'
        })
        .use(Uppy.Tus, {endpoint: 'http://localhost:8080/files/'})

      uppy.on('complete', (result) => {
        console.log('Upload complete! We’ve uploaded these files:', result.successful)
      })
    </script>
  </body>
</html>

tusアップロード前
tusアップロード前

Upload を押すと、tus を実装したサーバーにログが出ます。

$ go run main.go
[tusd] 2021/01/31 17:11:59 event="RequestIncoming" method="OPTIONS" path="5be3a8fbb73293bde6baa61581617555" requestId=""
[tusd] 2021/01/31 17:11:59 event="ResponseOutgoing" status="200" method="OPTIONS" path="5be3a8fbb73293bde6baa61581617555" requestId=""
[tusd] 2021/01/31 17:11:59 event="RequestIncoming" method="HEAD" path="5be3a8fbb73293bde6baa61581617555" requestId=""
[tusd] 2021/01/31 17:11:59 event="ResponseOutgoing" status="404" method="HEAD" path="5be3a8fbb73293bde6baa61581617555" error="upload not found" requestId=""
[tusd] 2021/01/31 17:11:59 event="RequestIncoming" method="OPTIONS" path="" requestId=""
[tusd] 2021/01/31 17:11:59 event="ResponseOutgoing" status="200" method="OPTIONS" path="" requestId=""
[tusd] 2021/01/31 17:11:59 event="RequestIncoming" method="POST" path="" requestId=""
[tusd] 2021/01/31 17:11:59 event="UploadCreated" id="3a9ec02c54b7d4d09ccb0fb492a44440" size="388214" url="http://localhost:8080/files/3a9ec02c54b7d4d09ccb0fb492a44440"
[tusd] 2021/01/31 17:11:59 event="ResponseOutgoing" status="201" method="POST" path="" requestId=""
[tusd] 2021/01/31 17:11:59 event="RequestIncoming" method="OPTIONS" path="3a9ec02c54b7d4d09ccb0fb492a44440" requestId=""
[tusd] 2021/01/31 17:11:59 event="ResponseOutgoing" status="200" method="OPTIONS" path="3a9ec02c54b7d4d09ccb0fb492a44440" requestId=""
[tusd] 2021/01/31 17:11:59 event="RequestIncoming" method="PATCH" path="3a9ec02c54b7d4d09ccb0fb492a44440" requestId=""
[tusd] 2021/01/31 17:11:59 event="ChunkWriteStart" id="3a9ec02c54b7d4d09ccb0fb492a44440" maxSize="388214" offset="0"
[tusd] 2021/01/31 17:11:59 event="ChunkWriteComplete" id="3a9ec02c54b7d4d09ccb0fb492a44440" bytesWritten="388214"
[tusd] 2021/01/31 17:11:59 event="ResponseOutgoing" status="204" method="PATCH" path="3a9ec02c54b7d4d09ccb0fb492a44440" requestId=""
[event] upload 3a9ec02c54b7d4d09ccb0fb492a44440 finished

uploads/ 以下にファイルとメタ情報が保存されていることが分かります。

S3に保存したい場合

保存先をS3に変更したい場合、composer を以下のように変更します。

func run(ctx context.Context) error {
    ...

    s3Cli := s3.New(session.Must(session.NewSession()))
    s3 := s3store.New(bucket, s3Cli)
    composer := tusd.NewStoreComposer()
    s3.UseIn(composer)

    handler, err := tusd.NewHandler(tusd.Config{
        BasePath:              basePath,
        StoreComposer:         composer,
        NotifyCompleteUploads: true,
    })

    ...
}

処理を中断するとどうなるか

tus は resumable upload protocol であると謳っているので、アップロード処理が再開可能であることを確かめてみたいと思います。
アップロード処理中に SIGINT を送信し、サーバーをダウンさせます。
アップロード処理に時間のかかる動画ファイルを使用します。

アップロード処理中にサーバーをダウンさせたときの状態
アップロード処理中にサーバーをダウンさせたときの状態
しばらくすると失敗になる
しばらくすると失敗になる
サーバーをダウンさせたときの状態

Retry を押すことで、途中の状態からアップロードを再開できることが確認できました。

↓サーバーのログ (途中でサーバーをダウンさせている)

$ go run main.go
[tusd] 2021/01/31 17:31:30 event="RequestIncoming" method="OPTIONS" path="3059ba671e9aadbd39dcaaf05ff4519a" requestId=""
[tusd] 2021/01/31 17:31:30 event="ResponseOutgoing" status="200" method="OPTIONS" path="3059ba671e9aadbd39dcaaf05ff4519a" requestId=""
[tusd] 2021/01/31 17:31:30 event="RequestIncoming" method="HEAD" path="3059ba671e9aadbd39dcaaf05ff4519a" requestId=""
[tusd] 2021/01/31 17:31:30 event="ResponseOutgoing" status="404" method="HEAD" path="3059ba671e9aadbd39dcaaf05ff4519a" error="upload not found" requestId=""
[tusd] 2021/01/31 17:31:30 event="RequestIncoming" method="OPTIONS" path="" requestId=""
[tusd] 2021/01/31 17:31:30 event="ResponseOutgoing" status="200" method="OPTIONS" path="" requestId=""
[tusd] 2021/01/31 17:31:30 event="RequestIncoming" method="POST" path="" requestId=""
[tusd] 2021/01/31 17:31:30 event="UploadCreated" id="7d0595e10816348687d72586d3b5c3f2" size="418400795" url="http://localhost:8080/files/7d0595e10816348687d72586d3b5c3f2"
[tusd] 2021/01/31 17:31:30 event="ResponseOutgoing" status="201" method="POST" path="" requestId=""
[tusd] 2021/01/31 17:31:30 event="RequestIncoming" method="OPTIONS" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:31:30 event="ResponseOutgoing" status="200" method="OPTIONS" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:31:30 event="RequestIncoming" method="PATCH" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:31:30 event="ChunkWriteStart" id="7d0595e10816348687d72586d3b5c3f2" maxSize="418400795" offset="0"
^Csignal: interrupt
$ go run main.go
[tusd] 2021/01/31 17:32:11 event="RequestIncoming" method="OPTIONS" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:32:11 event="ResponseOutgoing" status="200" method="OPTIONS" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:32:11 event="RequestIncoming" method="HEAD" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:32:11 event="ResponseOutgoing" status="200" method="HEAD" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:32:11 event="RequestIncoming" method="OPTIONS" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:32:11 event="ResponseOutgoing" status="200" method="OPTIONS" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:32:11 event="RequestIncoming" method="PATCH" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:32:11 event="ChunkWriteStart" id="7d0595e10816348687d72586d3b5c3f2" maxSize="67930651" offset="350470144"
[tusd] 2021/01/31 17:32:11 event="ChunkWriteComplete" id="7d0595e10816348687d72586d3b5c3f2" bytesWritten="67930651"
[tusd] 2021/01/31 17:32:11 event="ResponseOutgoing" status="204" method="PATCH" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[event] upload 7d0595e10816348687d72586d3b5c3f2 finished

アップロードしたファイルのメタ情報について

tusd のハンドラーには、config として以下の情報を渡すことができます。

  • PreUploadCreateCallback
  • PreFinishResponseCallback

例えば、以下のようにメタ情報を得ることができます。

   handler, err := tusd.NewHandler(tusd.Config{
        BasePath:              basePath,
        StoreComposer:         composer,
        NotifyCompleteUploads: true,
        PreUploadCreateCallback: func(hook tusd.HookEvent) error {
            fmt.Println("[pre upload] ", hook.Upload.MetaData)
            return nil
        },
        PreFinishResponseCallback: func(hook tusd.HookEvent) error {
            fmt.Println("[pre finish] ", hook.Upload.MetaData)
            return nil
        },
    })

サーバーのログ

$ go run main.go
[tusd] 2021/01/31 17:43:38 event="RequestIncoming" method="OPTIONS" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:43:38 event="ResponseOutgoing" status="200" method="OPTIONS" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:43:38 event="RequestIncoming" method="HEAD" path="7d0595e10816348687d72586d3b5c3f2" requestId=""
[tusd] 2021/01/31 17:43:38 event="ResponseOutgoing" status="404" method="HEAD" path="7d0595e10816348687d72586d3b5c3f2" error="upload not found" requestId=""
[tusd] 2021/01/31 17:43:38 event="RequestIncoming" method="OPTIONS" path="" requestId=""
[tusd] 2021/01/31 17:43:38 event="ResponseOutgoing" status="200" method="OPTIONS" path="" requestId=""
[tusd] 2021/01/31 17:43:38 event="RequestIncoming" method="POST" path="" requestId=""
[pre upload]  map[filename:movie.MOV filetype:video/quicktime name:movie.MOV relativePath:null type:video/quicktime]
[tusd] 2021/01/31 17:43:38 event="UploadCreated" id="94cfafbed332e69aca184b503cd346ce" size="418400795" url="http://localhost:8080/files/94cfafbed332e69aca184b503cd346ce"
[tusd] 2021/01/31 17:43:38 event="ResponseOutgoing" status="201" method="POST" path="" requestId=""
[tusd] 2021/01/31 17:43:38 event="RequestIncoming" method="OPTIONS" path="94cfafbed332e69aca184b503cd346ce" requestId=""
[tusd] 2021/01/31 17:43:38 event="ResponseOutgoing" status="200" method="OPTIONS" path="94cfafbed332e69aca184b503cd346ce" requestId=""
[tusd] 2021/01/31 17:43:38 event="RequestIncoming" method="PATCH" path="94cfafbed332e69aca184b503cd346ce" requestId=""
[tusd] 2021/01/31 17:43:38 event="ChunkWriteStart" id="94cfafbed332e69aca184b503cd346ce" maxSize="418400795" offset="0"
[tusd] 2021/01/31 17:43:39 event="ChunkWriteComplete" id="94cfafbed332e69aca184b503cd346ce" bytesWritten="418400795"
[pre finish]  map[filename:movie.MOV filetype:video/quicktime name:movie.MOV relativePath:null type:video/quicktime]
[tusd] 2021/01/31 17:43:39 event="ResponseOutgoing" status="204" method="PATCH" path="94cfafbed332e69aca184b503cd346ce" requestId=""
[event] upload 94cfafbed332e69aca184b503cd346ce finished

filename と filetype を取得できるので、PreUploadPreFinish でDBの情報を更新することで、保存したファイルの管理も実現できそうです。
また、画像のみのアップロードを受け付ける場合は、PreUpload で filetype をチェックすれば良さそうです。

ginなどで利用したい場合

tusd の NewHandlerの実装 を見ると、UnroutedHandler をラップする実装になっていることが分かります。
UnroutedHandler を参照し、使い慣れた web フレームワークで使用することもできそうです。

   ...

    r := gin.Default()
    r.Use(cors.New(cors.Config{
        AllowAllOrigins: true,
        AllowMethods:    []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"},
        AllowHeaders:    []string{"Tus-Resumable", "Upload-Length", "Upload-Offset", "Upload-Metadata", "Upload-Defer-Length", "Upload-Concat", "Content-Type", "Content-Length", "Origin"},
        ExposeHeaders:   []string{"Tus-Version", "Tus-Resumable", "Tus-Max-Size", "Tus-Extension", "Upload-Offset", "Upload-Metadata", "Upload-Defer-Length", "Upload-Concat", "Upload-Length", "Location"},
    }))
    r.HEAD("files/:id", gin.WrapF(handler.HeadFile))
    r.GET("files/:id", gin.WrapF(handler.GetFile))
    r.POST("files/", gin.WrapF(handler.PostFile))
    r.PATCH("files/:id", gin.WrapF(handler.PatchFile))

    if err := r.Run(":8080"); err != nil {
        return fmt.Errorf("unable to listen: %w", err)
    }

    ...

許可するヘッダーには、tus 特有のものが含まれるため、注意が必要です。

まとめ

tusd を使用したファイルアップロードについて手元で試してみました。
Retry までを考慮したファイルアップロード処理の実装は意外と手間だったりするので、こういった手段が選択できるようになると楽ができそうです。
クライアントでも対応が必要なため、ユーザーの画像アップロード処理などで使用するためには swift や kotlin でのクライアント実装が充実しているかどうか確認する必要があります。