AWS

SvelteとGoでWebアプリ開発環境を構築してみた

内山浩佑

こんにちは。技術同人誌の原稿の締切に間に合わせることができて、ホッとしているエンジニアのuchikoです。

今回は、プライベートの開発でちょくちょく使用している技術スタックの紹介です。Serverless Stackというサーバレスのフレームワークを使用して、Webアプリ開発の環境を構築します。

フロントエンドをSvelte、バックエンドはGoを用いて、フルスタックに開発を楽しみたい人にはおすすめです。

前提条件

  • デプロイ先のAWSアカウントが準備できていること
  • デプロイ用のIAMユーザーの作成とクレデンシャルが用意できていること

今回構築するWebアプリについて

今回は、クリックカウンターアプリをステップバイステップで開発します。基本機能として、ユーザーがボタンをクリックする度に、そのクリック回数を数え、表示するものとします。

本アプリケーションは次の技術要素から成り立っています。

  • フロントエンド: Svelteを使用して実装。クリック操作とクリックカウントの表示を担います。フロントエンドのソースコードは、S3バケットから配信されます
  • バックエンド(WebAPI): AWSのAPI Gateway、Lambda(Go言語実装)、およびDynamoDBを使用して実装します。

主に使用する技術は、以下のとおりです。

  • Serverless Stack : デプロイツール。AWS CDKをベースにしているツール。
  • Svelte : UIフレームワーク
  • TypeScript : フロントエンド実装とServerless Stackで使用するプログラミング言語
  • Go言語 : バックエンド実装で使用するプログラミング言語

具体的な処理の流れは以下のとおりです。

  1. ユーザーアクション: ユーザーがフロントエンドのボタンをクリックします。
  2. データ送信: Svelteアプリケーションは、ボタンクリックをトリガーとして、API Gateway経由でLambda関数を呼び出します。
  3. クリックカウントの処理: Lambda関数(Go言語)は、クリック情報をDynamoDBに保存し、現在のクリックカウントを更新します。
  4. カウント表示: 更新されたクリックカウントがフロントエンドにレスポンスとして返り、画面上の数値が更新されます。

AWS認証設定

AWSにデプロイするためのクレデンシャル情報を設定します。 ~/.aws/credentials で以下のように追記します。

[sst_book]
aws_access_key_id={Access Key}
aws_secret_access_key={Secret Access Key}

この後のステップでは、 sst_book プロファイルを使用することを想定して進めていきます。

Voltaのインストール

Voltaは、Node.jsをインストールするためのツールです。Voltaを使用すると、複数バージョンのNode.jsの管理ができるようになります。

以下のコマンドで、Voltaをインストールします。

$ curl https://get.volta.sh | bash

次に、環境変数を設定します。.bashrc や .zshrc などの設定ファイルに、以下の内容を追記します。

export VOLTA_HOME=$HOME/.volta
export PATH=$PATH:$VOLTA_HOME/bin

設定後は、シェルを再起動するか、以下のようなコマンドで再読み込みして反映させます。

$ source ~/.bashrc

voltaコマンドが正しく実行できるかを確認します。

$ volta --version
Volta 1.1.1

バージョンが表示されれば、Voltaのインストールが完了しています。

Node.jsのインストール

さきほどインストールしたVoltaを使用して、Node.jsをインストールします。
今回は、バージョン 18.18.2 をインストールします。

$ volta install node@18.18.2

以下のコマンドで、nodeが正常にインストールされていることを確認します。

$ node --version
v18.18.2

pnpmのインストール

pnpmは、npmより高速なパッケージ管理ツールです。

npmコマンドを使用して、インストールします。

$ npm install -g pnpm

以下のコマンドで、pnpmが正常にインストールされていることを確認します。

$ pnpm --version
8.9.0

Goのインストール

以下のページに行き、何かしらの方法でGoをインストールしてください。
https://go.dev/dl/

以下のコマンドは、Homebrewを使用してインストールする場合です。

$ brew install go

次の手順に入る前に、GOPATHの設定を確認します。
この後に実行するコマンドは、 $GOPATH/bin にパスが通っていることを前提としています。

export GOPATH=$HOME/go
export PATH=$GOPATH/bin:$PATH

goコマンドが使用できるようになったら、以下の手順で目的のバージョンをインストールします。今回は、 1.21.3 を使用します。

$ go install golang.org/dl/go1.21.3@latest
$ go1.21.3 download

次に、以下の設定を @<B>{.bashrc}や@<B>{.zshrc}などのファイルに追加します。

export PATH=$(go1.21.3 env GOROOT)/bin:$PATH

以下のコマンドで、目的のバージョンが正常にインストールされていることを確認します。

$ go version
go version go1.21.3 darwin/arm64

プロジェクトを新規作成

Serverless Stack(SST)のコマンドを使用して、プロジェクトを新規作成します。

$ pnpx create-sst@latest click_count

コマンドを実行すると、ディレクトリが作成されるので、初期化コマンドを実行します。

$ cd click_count
$ pnpm install

voltaコマンドを使用して、プロジェクトで使用するNodeのバージョンを設定します。

$ volta pin node@18.18.2

Svelteプロジェクトを新規作成

Svelteのコマンドを使用して、フロントエンド用のディレクトリを作成します。

$ pnpx create-svelte@latest packages/frontend

いくつか質問されるので、以下のように選択します。

質問回答
Which Svelte app template?Skeleton project
Add type checking with TypeScript?Yes, using TypeScript syntax
Select additional options (use arrow keys/space bar)(何も選択しない)

コマンドが実行されると、 packages/frontend ディレクトリが作成されます。
以下の手順で、Svelteプロジェクトを初期化します。

$ cd packages/frontend
$ pnpm install
$ volta pin node@18.18.2
$ cd ../..

以上で、フロントエンドの初期設定は完了です。
次のステップを行うために、プロジェクトのトップディレクトリに戻ってください。

バックエンドの初期設定

バックエンドの実装は、 packages/functions ディレクトリ内で行います。
プロジェクトを作成した際に、サンプルのバックエンド実装ファイルがあるので、これらは削除してしまいます。

$ rm -rf packages/functions/*

今回は、バックエンドをGoで実装するため、以下のコマンドで初期化を行います。

$ cd packages/functions
$ go mod init counter
$ cd ../../

コマンドを実行すると、go.modファイルが作成されます。go.modの初めの方に go 1.21.3 と記述されている部分がありますが、ここは go 1.21 という内容に変更します。

packages/functions ディレクトリに handlers というディレクトリを作成します。

$ mkdir packages/functions/handlers

初期実装として、簡単な処理を行うプログラムを作成します。packages/functions/handlers ディレクトリに hello.go という名前のファイルを作成し、以下のプログラムを記述します。

package main

import (
	"context"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
)

// 処理を行うハンドラー
func handler(ctx context.Context) (events.APIGatewayProxyResponse, error) {
	// API Gatewayのレスポンスとして、ステータスコード200と文字列を返す
	return events.APIGatewayProxyResponse{
		StatusCode: 200,
		Body:       "Hello, World!",
	}, nil
}

// main関数
func main() {
	// ハンドラーを登録して実行
	lambda.Start(handler)
}

次に、以下のコマンドを実行し、依存モジュールをインストールします。

$ cd packages/functions
$ go mod tidy
$ cd ../../

go.modファイルに、依存モジュールが追記されます。

以上で、Goの初期設定は完了です。

スタックの初期設定

スタックの初期設定を行います。ここでは、以下のようなコンポーネントが作成されるように設定します。

  • DynamoDBのテーブル
  • API Gatewayエンドポイント
  • フロントエンド用S3バケット
  • フロントエンド用CloudFrontディストリビューション

stacks/ ディレクトリに、ExampleStack.ts という名前のファイルを作成し、以下の内容を記述します。

import {Api, StackContext, Table, SvelteKitSite} from "sst/constructs";


export function ExampleStack({ stack }: StackContext) {
    // DynamoDB テーブルを作成
    const table = new Table(stack, "counter", {
        fields: {
            counter: "string",
        },
        primaryIndex: {partitionKey: "counter"}, // パーティションキーを設定
    });

    // API GatewayエンドポイントとLambdaを作成
    const api = new Api(stack, "api", {
        // 全Lambda関数の共通設定
        defaults: {
            function: {
                bind: [table], // DynamoDBテーブルをバインド
                runtime: "go", // ランタイムをGoに設定
            },
        },
        // APIエンドポイントの設定
        routes: {
            "GET /hello": "packages/functions/handlers/hello.go",
        },
    });

    // SvelteKitのデプロイ設定
    const site = new SvelteKitSite(stack, "SvelteSite", {
        path: "packages/frontend", // デプロイするディレクトリを指定
        environment: {
           PUBLIC_API_ENDPOINT: api.url, // APIエンドポイントを環境変数に設定
        },
    });

    // 値の出力
    stack.addOutputs({
        SiteUrl: site.url, // フロントエンドのURL
        ApiEndpoint: api.url, // APIエンドポイントのURL
    });
};

作成したファイルが読み込まれるようにするため、 sst.config.ts の内容を以下のように変更します。

import { SSTConfig } from "sst";
import {ExampleStack} from "./stacks/ExampleStack";

export default {
  config(_input) {
    return {
      name: "click-count",
      region: "ap-northeast-1", // us-east-1からap-northeast-1に変更
    };
  },
  stacks(app) {
    app.stack(ExampleStack); // ExampleStackに変更
  }
} satisfies SSTConfig;

以上で、スタックの設定が完了しました。いったんAWSにデプロイします。以下のコマンドで、デプロイを実行します。初期デプロイでは、数分の時間がかかります。

$ AWS_PROFILE=sst_book pnpm sst deploy --stage=prod

デプロイが完了すると、以下のように、APIエンドポイントURLとサイトURLが出力されます。

SiteUrl のURLをコピーし、ブラウザのURLにペーストして、アクセスすると、以下のような画面が表示されます。

また、ApiEndpoint のURLをコピーし、curlコマンドを利用して、バックエンドにデプロイされているAPIにアクセスすると、Hello, World! という文字列が返ってきます。

$ curl https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/hello
Hello, World!

以上で、スタックの初期設定は完了です。ここから、実際にクリックをカウントする処理を実装していきます。

DynamoDB接続処理のバックエンド実装

今回は、クリックカウント値をDynamoDBに保存します。バックエンドの実装として、DynamoDBに接続する処理を実装します。

DynamoDBに接続するために必要な情報は、環境変数から取得します。さきほどのスタックの設定で、DynamoDBとAPIがバインドされているため、環境変数で取得できるように調整されています。今回は、 SST_Table_tableName_counter という環境変数からテーブル名を参照しています。

packages/functions ディレクトリに、 db.go というファイルを作成し、以下のようなプログラムを記述します。

package counter

import (
	"os"
    
	"github.com/pkg/errors"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"

	"github.com/guregu/dynamo"
)

// 環境変数からテーブル名を取得。環境変数名はSSTで定義されている命名規則に従う
// https://docs.sst.dev/resource-binding#how-it-works
func tableName() string {
	return os.Getenv("SST_Table_tableName_counter")
}

// DynamoDBに接続する処理
func connect() dynamo.Table {
	sess := session.Must(session.NewSession())
	db := dynamo.New(sess, &aws.Config{Region: aws.String("ap-northeast-1")})
	return db.Table(tableName())
}

上記のプログラムは、AWSとDynamoDB関連のモジュールを使用しています。以下のコマンドを実行し、必要なモジュールをダウンロードします。

$ cd packages/functions
$ go mod tidy

以上が、DynamoDB接続処理の実装となります。この後、DynamoDBに接続して、値を取得したり更新したりする処理を実装していきます。

カウント取得APIの実装

DynamoDBから値を取得するAPIを実装します。

// 追記

// Clicks DynamoDBのデータ構造を表す構造体
type Clicks struct {
	Counter string `dynamo:"counter"` // パーティションキー
	Count   uint64 `dynamo:"count"`   // カウント値
}

// GetCount カウント値を取得する
func GetCount() (uint64, error) {
	// DynamoDBに接続
	table := connect()

	// パーティションキーが"clicks"のデータを取得
	var result Clicks
	err := table.Get("counter", "clicks").One(&result)
	if err != nil {
		if !errors.Is(err, dynamo.ErrNotFound) {
			return 0, err
		}
		result.Count = 0
	}

	// カウント値を返す
	return result.Count, nil
}

次に packages/functions ディレクトリに、handlers.goというファイルを作成します。このファイルでは、Lambdaハンドラーを実装します。今回は、API Gatewayからリクエストを受け取り、API Gateway用にレスポンスを返すLambdaハンドラーを実装します。

さきほど実装したカウント値を返す関数と連携して、Lambdaハンドラーを次のように実装します。

package counter

import (
	"context"
	"encoding/json"

	"github.com/aws/aws-lambda-go/events"
)

// Response レスポンスのBodyに入れるデータ構造を定義する
type Response struct {
	Count uint64 `json:"count"` // クリックカウント値
}

// HandlerGet クリックカウント値を返すLambdaハンドラ
func HandlerGet(ctx context.Context) (events.APIGatewayProxyResponse, error) {
	// クリックカウント値を取得
	count, err := GetCount()
	if err != nil {
		return events.APIGatewayProxyResponse{}, err
	}

	// クリックカウント値を含んだJSONレスポンス
	body, err := json.Marshal(Response{
		Count: count,
	})
	if err != nil {
		return events.APIGatewayProxyResponse{}, err
	}

	// API Gateway用のレスポンス
	return events.APIGatewayProxyResponse{
		StatusCode: 200,
		Body:       string(body),
	}, nil
}

実装したハンドラーを呼び出すエントリポイントを実装します。このエントリポイントは、スタックの設定で参照されるファイルとなります。packages/functions/handlers ディレクトリに、get.go というファイルを作成し、以下のようなプログラムを記述します。

package main

import (
	"counter"

	"github.com/aws/aws-lambda-go/lambda"
)

func main() {
	lambda.Start(counter.HandlerGet)
}

以上で、カウント取得APIの実装は完了です。

カウント更新APIの実装

DynamoDBのカウント値を更新する処理を実装します。

// UpdateCount カウント値を更新する
func UpdateCount() error {
	// DynamoDBに接続
	table := connect()

	// カウント値を取得
	count, err := GetCount()
	if err != nil {
		return err
	}

	// カウント値を更新
	err = table.Put(Clicks{Counter: "clicks", Count: count + 1}).Run()
	if err != nil {
		return err
	}

	return nil
}

さきほど実装したカウント値を更新する関数と連携して、Lambdaハンドラーを次のように実装します。

// HandlerUpdate クリックカウント値を更新するLambdaハンドラ
func HandlerUpdate(ctx context.Context) (events.APIGatewayProxyResponse, error) {
	// クリックカウント値を更新
	err := UpdateCount()
	if err != nil {
		return events.APIGatewayProxyResponse{}, err
	}

	// クリックカウント値を取得
	count, err := GetCount()
	if err != nil {
		return events.APIGatewayProxyResponse{}, err
	}

	// クリックカウント値を含んだJSONレスポンス
	body, err := json.Marshal(Response{
		Count: count,
	})
	if err != nil {
		return events.APIGatewayProxyResponse{}, err
	}

	// API Gateway用のレスポンス
	return events.APIGatewayProxyResponse{
		StatusCode: 200,
		Body:       string(body),
	}, nil
}

実装したハンドラーを呼び出すエントリポイントを実装します。このエントリポイントは、スタックの設定で参照されるファイルとなります。packages/functions/handlers ディレクトリに、update.go というファイルを作成し、以下のようなプログラムを記述します。

package main

import (
	"counter"

	"github.com/aws/aws-lambda-go/lambda"
)

func main() {
	lambda.Start(counter.HandlerUpdate)
}

以上で、カウント更新APIの実装は完了です。

API Gatewayの設定

さきほど実装したバックエンドの実装をAPI GatewayとLambdaで実行されるように設定します。 stacks/ExampleStack.ts のAPI設定に、さきほど追加した2つのエントリポイントを追加します。

routes: {
  "GET /hello": "packages/functions/handlers/hello.go",
  "GET /count": "packages/functions/handlers/get.go", // 追加: カウント取得API
  "POST /count": "packages/functions/handlers/update.go", // 追加: カウント更新API
},

設定を追加したら、以下のコマンドで、デプロイします。

$ AWS_PROFILE=sst_book pnpm sst deploy --stage=prod

デプロイが完了したら、APIのエンドポイントURLが出力されます。SiteUrlの方のURLを使用して、APIを実行してみます。

以下のように、curlコマンドでカウント取得APIを実行します。レスポンスのJSONが出力され、現在のカウント値が含まれていることが確認できます。

$ export ENDPOINT=https://xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com
$ curl ${ENDPOINT}/count
{"count":0}

以下のように、curlコマンドでカウント更新APIを実行します。レスポンスのJSONが出力され、更新されたカウント値が含まれていることが確認できます。

$  curl -XPOST ${ENDPOINT}/count
{"count":1}

以上で、API Gatewayの設定が完了しました。

API実行処理のフロントエンド実装

フロントエンドの実装を行っていきます。フロントエンドのソースコードは packages/frontend ディレクトリに配置されています。

さきほど実装したAPIを、フロントエンドから実行するための実装を行います。APIのエンドポイントはスタックの設定で連携されているため、環境変数 PUBLIC_API_ENDPOINT で参照できるようになっています。複数の関数で、APIのエンドポイントを参照するので、エンドポイントを参照するための関数を定義します。

packages/frontend/src/lib/index.ts というファイルに、API関連の実装を記述していきます。エンドポイントを参照するための関数は以下のように実装します。

import {PUBLIC_API_ENDPOINT} from "$env/static/public";

const endpoint = () => `${PUBLIC_API_ENDPOINT}/count`;

次に、APIを実行する関数を以下のように実装します。

// カウントAPIのレスポンスの型
type ResponseCount = {
    count: number;
};

// カウント取得APIの実行関数
export const getCount = async (): Promise<ResponseCount> => {
    // fetch関数を使ってAPIを実行
    const res = await fetch(endpoint(), {
        method: 'GET',
        mode: 'cors',
    });
    // レスポンスJSONを返す
    return res.json();
};

// カウント更新APIの実行関数
export const updateCount = async (): Promise<ResponseCount> => {
    // fetch関数を使ってカウント更新APIを実行
    const res = await fetch(endpoint(), {
        method: 'POST',
        mode: 'cors',
    });
    // レスポンスJSONを返す
    return res.json();
}

以上で、API実行処理の実装が完了しました。

UIのフロントエンド実装

Svelte/Sveltekitで用意されている機能を使用して、UIを実装します。

まず、ページが最初にロードされるときに、カウント取得APIを実行し、カウント値を取得する処理を実装します。

packages/frontend/src/routes ディレクトリに +page.ts という名前のファイルを作成し、以下のプログラムを記述します。

import type { PageLoad } from './$types';
import {getCount} from "$lib";

// load関数は、SvelteKitの機能により、コンポーネントがレンダリングされる前に実行される
export const load: PageLoad = async () => {
    // カウント取得APIでカウントを取得
    return await getCount();
};

コメントに書いてあるとおり、ページが読み込まれる際に、定義したload関数が実行されます。load関数の中では、カウント取得APIを呼び出すように実装しています。

次に、Svelteで用意されている機能を使用してUIを実装します。UIの実装は packages/frontend/src/routes/+page.svelte という名前のファイルに記述します。以下のような内容になります。

<script lang="ts">
    // このコンポーネントで実行される処理を記述する

    import type { PageData } from './$types';
    import {updateCount} from "$lib";

    // +page.tsで定義されたload関数の返り値がここに入る
    // クリックカウント値がこの中に含まれている(data.count)
    export let data: PageData;

    // ボタンがクリックされた場合の処理
    const onClick = async () => {
        // クリックカウント更新APIの実行
        const cnt = await updateCount();
        // クリックカウント値を更新
        data.count = cnt.count;
    };
</script>

<div class="App">
    {#if data.count}<p>You clicked me {data.count} times.</p>{/if}
    <button on:click={onClick}>Click Me!</button>
</div>

<style>
    .App {
        text-align: center;
    }
    p {
        margin-top: 0;
        font-size: 20px;
    }
    button {
        font-size: 48px;
    }
</style>

scriptタグ内では、そのページで実行されるスクリプトを記述します。今回は、以下のような処理を記述しています。

  • load関数で読み込まれたカウント値の参照と更新の処理
  • ボタンがクリックされたときのイベント処理
  • クリックカウント更新APIを実行して、カウント値を更新する処理

divタグ内では、UIの実装をHTMLで記述しています。

style タグ内では、デザインの実装をCSSで記述しています。

以上が、フロントエンドの実装となります。以下のコマンドでデプロイします。

$ AWS_PROFILE=sst_book pnpm sst deploy --stage=prod

SiteUrlのURLでブラウザからアクセスすると、以下のような画面が表示されます。

"Click Me!"ボタンをクリックすると、数字が増えていきます。

以上で、シンプルなカウンターアプリの実装が完了しました。

後片付け

今回デプロイしたリソースを削除します。以下のコマンドで、デプロイしたリソースを削除することができます。

$ AWS_PROFILE=sst_book pnpm sst remove --stage=prod

DynamoDBは、データが入っていると削除されないので、AWSコンソールで直接削除します。

まとめ

この記事では、Svelte、Go、そしてServerless Stackを組み合わせて、シンプルなアプリケーションの実装方法を解説しました。完成したアプリは基本的なものですが、これによりフルスタック開発を開始するための基盤が整ったと考えます。この記事がお役に立てば幸いです。

AUTHOR
uchiko
uchiko
記事URLをコピーしました