GolangとSveltekitでWebアプリケーションを作る
ここにコードを追加していく
サーバーはGolangで書き、イケてるらしいGinを使う、薄そうで良い感じ。
チュートリアル的にやるだけなら、とりあえず動くように書いていっても良いのだけど、趣味アプリをそのまま作りたいので、コンテナ開発、ホットリロードなど、多少まともな開発環境にしておく。
airというのを使うとGolangでもホットリロードが使えるようだ。
開発環境用にはairを使うコンテナイメージ、ビルド用には実行ファイルだけを持つイメージに、それぞれ構成。
REST-APIで書こうか、そしたらOpenAPIが自動生成されたら嬉しいなぁ、何かあるかな…
と調べると、あんまり良い感じじゃないらしい。あってもOpenAPIからサーバーコードを自動生成するやつ。勝手な趣味では、OpenAPI->コードはあんまり…。
調べると、もうGraphQLの時代だよね〜みたいな言説を見かけたので、せっかくなのでGraphQLで書く。gqlgenというのがデファクトっぽい。
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が統合されたコードが生成されたので、こちらを使う。
# 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みたいでわかりやすい。
いくつかのコードが生成されるが、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に接続するときにいじってみる。
DBとリゾルバーの繋ぎは、調べると大体(たぶん)DDDのエッセンスを含んだServiceみたいなのがあるのだけど、正直あそこまで抽象レイヤーを重ねる必要性があんまりわかってない。単にDBを操作する関数群があればよいのでは?そのうち学ぶ機会があるでしょう…。
そんなわけでdocker composeでPostgreSQLを用意したうえで、sqlxを使ってDB層を実装
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・マイグレーションどうすんのかは今は考えない。
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のモデルまで作るサムシングが直感的にはベスト。一般的にどうなのかは知らない)。
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操作の実装を追加する。
GraphQLの色々なことを調べて、せっかくSveltekitと組み合わせるのだから、以下の構成の方が実践的で面白いのでは、と思い至る。
SveltekitのサーバーコードでGraphQLサーバーを実装し、すなわちBFFとする。GolangでService群を実装する…。こうすると、SSRできるし、Sveltekit自体はエッジサーバーに乗せられて色々うれしい
ということでGinではREST-APIのみを実装する。
簡単なTODOのCRUDを書いてみる。
controller、すなわちreq/resはmain.goに直書き。
model, serviceはちょっと真面目にちゃんとpackageとして書いている。といっても大したコードではない…。
※DB層も書き換えているが割愛。
package model
type Todo struct {
ID int `json:"id" db:"id"`
Text string `json:"text" db:"text"`
Done bool `json:"done" db:"done"`
}
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書いちゃった方がいい気がした。
ここからはSveltekitでのGraphQLの話。
まずSveltekitが新しく、そこで動くGraphQLサーバーはgraphql-yoga
一択のようだった。
fetch周りでバグがありそうだった。
迂回策はあるが、この辺は何がどうなっているのかよくわからない。
GraphQLクライアントにはurql
を選択。
こちらもSveltekitのintegrationがあるため。
GraphQLサーバーの実装。
SSRでもCSRでも使えるよう、Sveltekitに外部からも利用出来るエンドポイントを生やす方針(/api/graphql)。
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。
取り立てて難しい点はない。他のサーバー実装を知らないけど書きやすいのではないだろうか。
これでSveltekitにGraphQLサーバーが実装された。
あとはSSRでこのサーバーへQuery・ブラウザからMutationする実装を書いていく。
まずはQuery
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)。
データを受け取ってレンダリングする側の実装。
<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より直感的な動きだと思う。
まとめ
- Golang/Ginの入門にはなったかな…。正直Golangのおいしいところとかはまだ試せてないと思う(goroutineとか)。単にフレームワークに乗っかっているだけ。でも静的型付けはやっぱり良い。運用面では、シングルバイナリにビルドされるのは非常に大きいメリットだと思う(Node.jsはまだしもPythonはこの辺かなり辛い)。
- BFFにGraphQLはアリ。一方で、負荷など考えても一般向けに公開するようなモノではない気がする(そういうのはRESTなんじゃないかな…)。
- TODOのCRUD一個くらいだとGraphQLはtoo much。また、GraphQLはフロントエンドエンジニアが書くものだと思った(ここが「バックエンドが書くもの」みたいな分業が起きると、厳しそう)。
- SveltekitのSSR周りは直感的で使いやすい。もちろん多少は覚えることはあるけど学習コスト小さい。