Closed16

GolangとSveltekitでWebアプリケーションを作る

Kanahiro IguchiKanahiro Iguchi

サーバーはGolangで書き、イケてるらしいGinを使う、薄そうで良い感じ。

チュートリアル的にやるだけなら、とりあえず動くように書いていっても良いのだけど、趣味アプリをそのまま作りたいので、コンテナ開発、ホットリロードなど、多少まともな開発環境にしておく。

airというのを使うとGolangでもホットリロードが使えるようだ。
開発環境用にはairを使うコンテナイメージ、ビルド用には実行ファイルだけを持つイメージに、それぞれ構成。

Kanahiro IguchiKanahiro Iguchi

REST-APIで書こうか、そしたらOpenAPIが自動生成されたら嬉しいなぁ、何かあるかな…
と調べると、あんまり良い感じじゃないらしい。あってもOpenAPIからサーバーコードを自動生成するやつ。勝手な趣味では、OpenAPI->コードはあんまり…。

調べると、もうGraphQLの時代だよね〜みたいな言説を見かけたので、せっかくなのでGraphQLで書く。gqlgenというのがデファクトっぽい。

Kanahiro IguchiKanahiro Iguchi

https://qiita.com/hiroyky/items/4d7764172e73ff54f18b

go get github.com/99designs/gqlgen
go run github.com/99designs/gqlgen init
# server.goやgraphディレクトリが自動生成される
go mod tidy
go run ./server.go

Ginの環境は先に構築してあったのだが、Ginが統合されたコードが生成されたので、こちらを使う。

Kanahiro IguchiKanahiro Iguchi
graph/schema.graphqls
# GraphQL schema example
#
# https://gqlgen.com/getting-started/

type Todo {
    id: ID!
    text: String!
    done: Boolean!
    user: User!
}

type User {
    id: ID!
    name: String!
    email: String
}

type Query {
    users: [User!]!
    todos: [Todo!]!
}

input NewTodo {
    text: String!
    userId: String!
}

type Mutation {
    createTodo(input: NewTodo!): Todo!
    createUser(name: String!, email: String): User!
}

自動生成されるGraphQLのスキーマ定義。こいつを修正して、go run gqlgenとするとコードが生成される。なるほどPrismaみたいでわかりやすい。

Kanahiro IguchiKanahiro Iguchi

いくつかのコードが生成されるが、REST-APIにおけるrouter的なコードは*.resolvers.goになる。単純には、こいつを修正してやればレスポンスのロジックを実装出来る。

func (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) {
	return []*model.User{
		{
			ID:   "1",
			Name: "user1",
		},
		{
			ID:   "2",
			Name: "user2",
		},
	}, nil
}

なるほど〜、とっつきやすい。

resolver.goにはインターフェースだけが定義されている。DIに使う模様。あとでDBに接続するときにいじってみる。

Kanahiro IguchiKanahiro Iguchi

DBとリゾルバーの繋ぎは、調べると大体(たぶん)DDDのエッセンスを含んだServiceみたいなのがあるのだけど、正直あそこまで抽象レイヤーを重ねる必要性があんまりわかってない。単にDBを操作する関数群があればよいのでは?そのうち学ぶ機会があるでしょう…。

そんなわけでdocker composeでPostgreSQLを用意したうえで、sqlxを使ってDB層を実装

infra/db.go
package infra

import (
	"fmt"
	"log"
	"os"

	"github.com/jmoiron/sqlx"
	_ "github.com/lib/pq"
)

var schema = `
CREATE TABLE IF NOT EXISTS myuser (
		id serial PRIMARY KEY,
		name text NOT NULL,
		email text
);

CREATE TABLE IF NOT EXISTS todo (
		id serial PRIMARY KEY,
		text text NOT NULL,
		done boolean NOT NULL,
		user_id integer REFERENCES myuser(id)
)`

var DbInstance *sqlx.DB

func Db() *sqlx.DB {
	if DbInstance != nil {
		return DbInstance
	}

	dsn := fmt.Sprintf(
		"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Tokyo",
		os.Getenv("POSTGRES_HOST"),
		os.Getenv("POSTGRES_USER"),
		os.Getenv("POSTGRES_PASSWORD"),
		os.Getenv("POSTGRES_DB"),
		os.Getenv("POSTGRES_PORT"),
	)
	DbInstance, err := sqlx.Connect("postgres", dsn)

	DbInstance.MustExec(schema)

	if err != nil {
		log.Fatal("failed to init database: ", err)
	}

	return DbInstance
}

DI・マイグレーションどうすんのかは今は考えない。

Kanahiro IguchiKanahiro Iguchi
service/user.go
package service

import (
	"gin/graph/model"
	"gin/infra"
)

func GetUsers() ([]*model.User, error) {
	db := infra.Db()
	rows, _ := db.Queryx("SELECT * FROM myuser")

	var users []*model.User
	// db-user to model-user
	for rows.Next() {
		var user model.User
		rows.StructScan(&user)
		users = append(users, &user)
	}

	return users, nil
}

func CreateUser(name string, email *string) (*model.User, error) {
	db := infra.Db()
	tx := db.MustBegin()
	tx.MustExec("INSERT INTO myuser (name, email) VALUES ($1, $2)", name, email)
	tx.Commit()

	return &model.User{
		Name:  name,
		Email: email,
	}, nil
}

Node.jsではPrismaがお気に入りなのだけど、今回はORMは使わないでやってみる。
大きな理由はないが、ORMを使うと、GraphQLのスキーマ定義のほかにORMの定義が必要になる気がして、筋が良くない気がするため(GraphQLのスキーマ定義からDBのモデルまで作るサムシングが直感的にはベスト。一般的にどうなのかは知らない)。

Kanahiro IguchiKanahiro Iguchi
schema.resolvers.go
import (
	"context"
	"fmt"
	"gin/graph/model"
	"gin/service"
)

// 中略

// CreateUser is the resolver for the createUser field.
func (r *mutationResolver) CreateUser(ctx context.Context, name string, email *string) (*model.User, error) {
	user, err := service.CreateUser(name, email)
	return user, err
}

// Users is the resolver for the users field.
func (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) {
	users, err := service.GetUsers()
	return users, err
}

リゾルバーにDB操作の実装を追加する。

Kanahiro IguchiKanahiro Iguchi

GraphQLの色々なことを調べて、せっかくSveltekitと組み合わせるのだから、以下の構成の方が実践的で面白いのでは、と思い至る。

SveltekitのサーバーコードでGraphQLサーバーを実装し、すなわちBFFとする。GolangでService群を実装する…。こうすると、SSRできるし、Sveltekit自体はエッジサーバーに乗せられて色々うれしい

Kanahiro IguchiKanahiro Iguchi

ということでGinではREST-APIのみを実装する。
簡単なTODOのCRUDを書いてみる。

controller、すなわちreq/resはmain.goに直書き。
model, serviceはちょっと真面目にちゃんとpackageとして書いている。といっても大したコードではない…。

※DB層も書き換えているが割愛。

model/todo.gp
package model

type Todo struct {
	ID   int    `json:"id" db:"id"`
	Text string `json:"text" db:"text"`
	Done bool   `json:"done" db:"done"`
}
service
package service

import (
	"gin/model"

	"github.com/jmoiron/sqlx"
)

type TodoService struct {
	db sqlx.DB
}

func NewTodoService(db sqlx.DB) *TodoService {
	return &TodoService{db: db}
}

func (s TodoService) GetTodoList() []model.Todo {
	todoList := []model.Todo{}

	rows, err := s.db.Queryx("SELECT * FROM todo")
	if err != nil {
		panic(err)
	}

	for rows.Next() {
		todo := model.Todo{}
		err := rows.StructScan(&todo)
		if err != nil {
			panic(err)
		}
		todoList = append(todoList, todo)
	}

	return todoList
}

func (s TodoService) CreateTodo(text string, done bool) model.Todo {
	row, err := s.db.NamedQuery(`INSERT INTO todo (text,done) VALUES (:text,:done) RETURNING id, text, done`,
		map[string]interface{}{
			"text": text,
			"done": done,
		})

	if err != nil {
		panic(err)
	}

	newTodo := model.Todo{}
	row.Next()
	err = row.StructScan(&newTodo)
	if err != nil {
		panic(err)
	}

	return newTodo
}

func (s TodoService) UpdateTodoByID(id int, text string, done bool) model.Todo {
	row, err := s.db.NamedQuery(`UPDATE todo SET text = :text, done = :done WHERE id = :id RETURNING id, text, done`,
		map[string]interface{}{
			"id":   id,
			"text": text,
			"done": done,
		})

	if err != nil {
		panic(err)
	}

	todo := model.Todo{}
	row.Next()
	err = row.StructScan(&todo)
	if err != nil {
		panic(err)
	}

	return todo
}

func (s TodoService) DeleteTodoByID(id int) bool {
	_, err := s.db.NamedQuery(`DELETE FROM todo WHERE id = :id`,
		map[string]interface{}{
			"id": id,
		})

	if err != nil {
		panic(err)
	}

	return true
}

TodoServiceという構造体にdbをDIしている。
クラスではなく構造体なので(?)、コンストラクタという概念はないっぽい(クラスと構造体の違いの理解が浅い)。ファクトリメソッドを実装して、そこでDBを注入出来るようにするしかない?

あとは大したことはしていない。sqlxは楽ちん。よっぽど使いやすいORMがない限り、そこで頑張りすぎるよりこれくらいSQL書いちゃった方がいい気がした。

Kanahiro IguchiKanahiro Iguchi

ここからはSveltekitでのGraphQLの話。
まずSveltekitが新しく、そこで動くGraphQLサーバーはgraphql-yoga一択のようだった。

https://the-guild.dev/graphql/yoga-server/docs/integrations/integration-with-sveltekit

fetch周りでバグがありそうだった。

https://github.com/dotansimha/graphql-yoga/issues/3091

迂回策はあるが、この辺は何がどうなっているのかよくわからない。

GraphQLクライアントにはurqlを選択。

https://formidable.com/open-source/urql/

こちらもSveltekitのintegrationがあるため。

Kanahiro IguchiKanahiro Iguchi

GraphQLサーバーの実装。
SSRでもCSRでも使えるよう、Sveltekitに外部からも利用出来るエンドポイントを生やす方針(/api/graphql)。

src/routes/api/graphql/+server.ts
import { createSchema, createYoga } from 'graphql-yoga';
import type { RequestEvent, RequestHandler } from '@sveltejs/kit';

const yogaApp = createYoga<RequestEvent>({
	schema: createSchema({
		typeDefs: `
			type Query {
				todoList: [Todo!]!
			}
			type Mutation {
				postTodo (text: String!): Todo!
				updateTodo (id: ID!, text: String!, done: Boolean!): Todo!
				deleteTodo (id: ID!): Boolean!
			}
			type Todo {
				id: ID!
				text: String!
				done: Boolean!
			}
		`,
		resolvers: {
			Query: {
				todoList: async () => {
					const res = await fetch('http://app:3000/api/todo/');
					const val = await res.json();
					return val;
				}
			},
			Mutation: {
				postTodo: async (parent: unknown, args: { text: string }) => {
					const res = await fetch('http://app:3000/api/todo/', {
						method: 'POST',
						headers: {
							'Content-Type': 'application/json'
						},
						body: JSON.stringify({ text: args.text, done: false })
					});
					const val = await res.json();
					return val;
				},
				updateTodo: async (parent: unknown, args: { id: string; text: string; done: boolean }) => {
					const res = await fetch(`http://app:3000/api/todo/${args.id}`, {
						method: 'PUT',
						headers: {
							'Content-Type': 'application/json'
						},
						body: JSON.stringify({ text: args.text, done: args.done })
					});
					const val = await res.json();
					return val;
				},
				deleteTodo: async (parent: unknown, args: { id: string }) => {
					const res = await fetch(`http://app:3000/api/todo/${args.id}`, {
						method: 'DELETE'
					});
					const val = (await res.json()) as boolean;
					return val;
				}
			}
		}
	}),
	// Needed to be defined explicitly because our endpoint lives at a different path other than `/graphql`
	graphqlEndpoint: '/api/graphql',

	// Needed to let Yoga use sveltekit's Response object
	fetchAPI: { Response }
}) satisfies RequestHandler;

export { yogaApp as GET, yogaApp as POST };

GETはGraphQLのGUI。
取り立てて難しい点はない。他のサーバー実装を知らないけど書きやすいのではないだろうか。

Kanahiro IguchiKanahiro Iguchi

これでSveltekitにGraphQLサーバーが実装された。
あとはSSRでこのサーバーへQuery・ブラウザからMutationする実装を書いていく。

まずはQuery

src/routes/+page.server.ts
import type { PageServerLoad } from './$types';
import { Client, fetchExchange, gql } from '@urql/core';

const client = new Client({
	url: 'http://front:5173/api/graphql',
	exchanges: [fetchExchange]
});

export const load: PageServerLoad = async ({ depends, setHeaders }) => {
	const result = await client
		.query(
			gql`
				query {
					todoList {
						id
						text
						done
					}
				}
			`,
			{}
		)
		.toPromise();

	depends('app:todolist');
	return { todoList: result.data.todoList };
};

+page.server.tsでload関数をexportすると、+page.svelteではdataとしてデータを受け取れる。これらはサーバー内で実行され、ブラウザには「レンダリング済み」のHTMLが降ってくる(SSR)。

Kanahiro IguchiKanahiro Iguchi

データを受け取ってレンダリングする側の実装。

+page.svelte
<script lang="ts">
	import { invalidate, invalidateAll } from '$app/navigation';
	import { Client, cacheExchange, fetchExchange, gql, mutationStore } from '@urql/svelte';

	const client = new Client({
		url: 'http://localhost:5173/api/graphql',
		exchanges: [cacheExchange, fetchExchange]
	});

	type Todo = {
		id: string;
		text: string;
		done: boolean;
	};

	let newTodo: Omit<Todo, 'id'> = {
		text: '',
		done: false
	};

	export let data: { todoList: Todo[] };

	const updateTodo = (id: string, text: string, done: boolean) => {
		mutationStore({
			client,
			query: gql`
        mutation {
          updateTodo (id: "${id}", text: "${text}", done: ${done ? 'true' : 'false'}) {
            id
            text
            done
          }
        }
      `
		});
		invalidate('app:todolist');
	};

	const deleteTodo = async (id: string) => {
		mutationStore({
			client,
			query: gql`
        mutation {
          deleteTodo (id: "${id}")
        }
      `
		});
		invalidate('app:todolist');
	};

	const createTodo = async (newTodo: Omit<Todo, 'id'>) => {
		mutationStore({
			client,
			query: gql`
        mutation {
          postTodo (text: "${newTodo.text}") {
            id
            text
            done
          }
        }
      `
		});
		invalidate('app:todolist');
		newTodo.text = '';
	};
</script>

<ul>
	<!-- show todolist-->
	{#each data.todoList as todo}
		<li>
			<span>{todo.id}</span>
			<input
				type="text"
				class={`${todo.done ? 'line-through' : ''}`}
				value={todo.text}
				on:change={(val) => updateTodo(todo.id, val.target.value, todo.done)}
			/>
			<input
				type="checkbox"
				name={todo.id}
				bind:checked={todo.done}
				on:change={(val) => {
					updateTodo(todo.id, todo.text, val.target.checked);
				}}
			/>
			<button on:click={() => deleteTodo(todo.id)}> delete </button>
		</li>
	{/each}
	<li>
		<span>new</span>
		<input type="text" bind:value={newTodo.text} />
		<button on:click={() => createTodo(newTodo)}>create</button>
	</li>
</ul>

SSRされた画面から、データのMutationを実行。その場合は再度データを送信してもらわなければならない(なのでdepends/invalidate)。

全体的にNext.jsより直感的な動きだと思う。

Kanahiro IguchiKanahiro Iguchi

まとめ

  • Golang/Ginの入門にはなったかな…。正直Golangのおいしいところとかはまだ試せてないと思う(goroutineとか)。単にフレームワークに乗っかっているだけ。でも静的型付けはやっぱり良い。運用面では、シングルバイナリにビルドされるのは非常に大きいメリットだと思う(Node.jsはまだしもPythonはこの辺かなり辛い)。
  • BFFにGraphQLはアリ。一方で、負荷など考えても一般向けに公開するようなモノではない気がする(そういうのはRESTなんじゃないかな…)。
  • TODOのCRUD一個くらいだとGraphQLはtoo much。また、GraphQLはフロントエンドエンジニアが書くものだと思った(ここが「バックエンドが書くもの」みたいな分業が起きると、厳しそう)。
  • SveltekitのSSR周りは直感的で使いやすい。もちろん多少は覚えることはあるけど学習コスト小さい。
このスクラップは2023/11/12にクローズされました