koyebが個人的に熱い
herokuに替わるおもちゃが欲しかった今日この頃
(herokuがなくなってからしばらく彷徨い続けて、netlifyやrenderなど試して、ようやくkoyebに辿り着いた)
koyebはいいぞぉ〜
普段はAWSで遊んでいるけど、個人で何かWebアプリを作るなら可能な限り無料で作りたい
そう言う時に、koyebは最高のおもちゃ
koyeb について
koyebは以下のプラットフォームを提供します
- すべてのアプリの展開を管理するための使いやすいウェブインターフェース:無料でアカウントを作成してください
- 完全な Web アプリケーション、静的および動的サイト、API、バックグラウンド ワーカーなど、あらゆる種類のサービスをサポートします
- Docker コンテナを完全にサポートします
- Git 駆動型のデプロイメントにより、Ruby、Node.js、Java、Python、Clojure、Scala、Go、Rust、PHP、またはリポジトリ内の Dockerfile を使用してネイティブ コードをビルドおよびデプロイします
- グローバル CDN とゾーン間の強力な負荷分散を備えた高性能エッジ ネットワーク
- フルサービスのメッシュと検出により、数秒で安全なマイクロサービスを展開できます
- 高速で安全な MicroVM での透過的な展開
- ターミナルから直接リソースを管理し、自動化するための Koyeb CLI (コマンド ライン インターフェイス)
Koyeb をプログラムで使用するための使いやすい REST API。APIドキュメントをご覧ください。
引用元:
https://www.koyeb.com/docs
...つまり、最強じゃね?(語彙力皆無)
作るモノ
CRUDを備えたTODOアプリを作る
機能一覧
機能 | パス | メソッド |
---|---|---|
TODO一覧取得 | /todos | GET |
TODO作成 | /todos | POST |
TODO取得 | /todos/{id} | GET |
TODO更新 | /todos/{id} | PUT |
TODO削除 | /todos/{id} | DELETE |
プログラム準備・koyebサービス作成
注意
※このコードはサンプルコードにつき、HTTPリクエストされてきた値のバリデーションは行なっておりません。実務で使用する場合は値のバリデーションは必須要件のため、必ず実装するようにしてください。
プログラム準備
今回使用するコードのディレクトリ構成は以下の通り
tree .
.
├── Dockerfile
├── go.mod
├── go.sum
├── main.go
└── todo
└── todo.go
2 directories, 5 files
ソースコード
Dockerfile
golangのアプリケーションはDockerコンテナ上で動かすため、
Dockerfileを用意する。
今回は公式にあるコードを参考に作成する。
- FROM golang:1.19-alpine AS builder
+ FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY . .
RUN go mod download
- RUN go build -o ./example-golang ./main.go
+ RUN go build -o ./main ./main.go
FROM alpine:latest AS runner
WORKDIR /app
- COPY --from=builder /app/example-golang .
+ COPY --from=builder /app/main .
EXPOSE 8080
- ENTRYPOINT ["./example-golang"]
+ ENTRYPOINT ["./main"]
golangのバージョンとビルドして生成される実行ファイル名は変更した。
main.go, todo/todo.go
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"sync"
t "github.com/sokorahen-szk/sample-koyeb-todo-for-go/todo"
)
// NOTE: 簡略化のためメモリ上に保存する
var todos = t.NewTodoList()
var mu sync.Mutex
func main() {
port := os.Getenv("PORT")
if port == "" {
log.Fatal("PORT is not set")
}
server := http.Server{
Addr: fmt.Sprintf(":%s", port),
Handler: nil,
}
http.HandleFunc("GET /todos", getTodos)
http.HandleFunc("POST /todos", addTodo)
http.HandleFunc("GET /todos/{id}", getTodo)
http.HandleFunc("PUT /todos/{id}", updateTodo)
http.HandleFunc("DELETE /todos/{id}", deleteTodo)
log.Printf("Server is running on port: %s\n", port)
if err := server.ListenAndServe(); err != nil {
log.Fatalln(err)
}
}
// getTodos TODOの一覧を取得する
func getTodos(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
res := struct {
Todos []t.Todo `json:"todos"`
}{
Todos: todos.List(),
}
if err := json.NewEncoder(w).Encode(res); err != nil {
log.Println(err)
}
}
// addTodo TODOを追加する
func addTodo(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
req := struct {
Name string `json:"name"`
Description string `json:"description"`
}{}
b, err := io.ReadAll(r.Body)
r.Body.Close()
if err != nil {
log.Println(err)
}
if err := json.Unmarshal(b, &req); err != nil {
log.Println(err)
}
createdTodo := t.NewCreateTodoFactory(
req.Name,
req.Description,
)
if !todos.Add(createdTodo) {
w.WriteHeader(http.StatusTooManyRequests)
return
}
if err := json.NewEncoder(w).Encode(createdTodo); err != nil {
log.Println(err)
}
}
// deleteTodo TODOを削除する
func deleteTodo(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
id := r.PathValue("id")
if todos.Get(id) == nil {
w.WriteHeader(http.StatusNotFound)
}
todos = todos.Remove(id)
}
// getTodo TODOを取得する
func getTodo(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
id := r.PathValue("id")
todo := todos.Get(id)
if todo == nil {
w.WriteHeader(http.StatusNotFound)
}
if err := json.NewEncoder(w).Encode(todo); err != nil {
log.Println(err)
}
}
func updateTodo(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
req := struct {
Name string `json:"name"`
Description string `json:"description"`
IsDone bool `json:"is_done"`
}{}
b, err := io.ReadAll(r.Body)
r.Body.Close()
if err != nil {
log.Println(err)
}
if err := json.Unmarshal(b, &req); err != nil {
log.Println(err)
}
id := r.PathValue("id")
todo := todos.Get(id)
if todo == nil {
w.WriteHeader(http.StatusNotFound)
return
}
updatedTodo := t.NewTodo(todo.ID, req.Name, req.Description, req.IsDone)
if !todos.Update(updatedTodo) {
w.WriteHeader(http.StatusNotAcceptable)
return
}
if err := json.NewEncoder(w).Encode(updatedTodo); err != nil {
log.Println(err)
}
}
package todo
import "github.com/google/uuid"
type Todo struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
IsDone bool `json:"is_done"`
}
func NewTodo(id, name, description string, isDone bool) Todo {
return Todo{
ID: id,
Name: name,
Description: description,
IsDone: isDone,
}
}
func NewCreateTodoFactory(name, description string) Todo {
return NewTodo(uuid.NewString(), name, description, false)
}
type TodoList struct {
items []Todo
maxTodoLength int
}
type TodoListOption func(*TodoList)
func NewTodoList(options ...TodoListOption) *TodoList {
defaultTodoMaxLimit := 10
tl := &TodoList{
items: make([]Todo, 0, defaultTodoMaxLimit),
maxTodoLength: defaultTodoMaxLimit,
}
for _, opt := range options {
opt(tl)
}
return tl
}
func (tl *TodoList) Count() int {
return len(tl.items)
}
func (tl *TodoList) Add(todo Todo) bool {
if tl.Count() <= tl.maxTodoLength {
tl.items = append(tl.items, todo)
return true
}
return false
}
func (tl *TodoList) Remove(id string) *TodoList {
for idx, todo := range tl.items {
if todo.ID == id {
return NewTodoList(TodoListOption(func(t *TodoList) {
t.items = tl.items[:idx+copy(tl.items[idx:], tl.items[idx+1:])]
}))
}
}
return NewTodoList(TodoListOption(func(t *TodoList) {
t.items = tl.items
}))
}
func (tl *TodoList) Update(todo Todo) bool {
for idx, t := range tl.items {
if t.ID == todo.ID {
tl.items[idx] = todo
return true
}
}
return false
}
func (tl *TodoList) Get(id string) *Todo {
for _, todo := range tl.items {
if todo.ID == id {
return &todo
}
}
return nil
}
func (tl *TodoList) List() []Todo {
unfinishedTodos := make([]Todo, 0)
for _, todo := range tl.items {
// 完了済みはTODOリストから除外される
if todo.IsDone {
continue
}
unfinishedTodos = append(unfinishedTodos, todo)
}
return unfinishedTodos
}
go.mod
module github.com/sokorahen-szk/sample-koyeb-todo-for-go
go 1.23.2
require github.com/google/uuid v1.6.0 // indirect
koyebサービス作成
サービスタイプを選択
左メニューから「Create Service」をクリックし、「Web service」をクリックする。
今回はGithubのコードを使用してデプロイするため、「GitHub」をクリックする。
GitHubのリポジトリを選択
リポジトリがプライベートの場合、Koyebに権限を与える必要がある。
今回は公開リポジトリを使用しているため、下部の「Public GitHub repository」にリポジトリのURLを入力する。その後、「Import」ボタンをクリックする。
リソースとリージョンを選択
CPUは「CPU Eco」を選択し、FreeのCPUを選択する。
リージョンは「Washington, D.C」を選択する。
日本リージョンもあるが、Freeプランでは選択不可になっている。
選択できたら、「Next」をクリックする。
確認画面
ここでは特に設定が不要のため、そのまま下部の「Deploy」をクリックする。
上記の「Deploy」押下後はしばらく待つ
Build => Completed
Deployment => Healthy
BuildとDeploymentがグリーンで正常になればOK
動作確認
TODO作成
$ curl -X POST -H "Content-Type: application/json" \
> -d '{"name":"タスク1", "description":"これはサンプル"}' https://blind-mareah-laboratories-0165c3cb.koyeb.app/todos | jq
{
"id": "5e8bae42-ec36-44e7-8174-d08f4e16d9d4",
"name": "タスク1",
"description": "これはサンプル",
"is_done": false
}
TODO一覧取得
$ curl https://blind-mareah-laboratories-0165c3cb.koyeb.app/todos | jq
{
"todos": [
{
"id": "5e8bae42-ec36-44e7-8174-d08f4e16d9d4",
"name": "タスク1",
"description": "これはサンプル",
"is_done": false
},
{
"id": "50c4b8f2-dfbc-4f1a-9a95-42fb5e233997",
"name": "タスク2",
"description": "これはサンプル",
"is_done": false
}
]
}
TODO取得
$ curl https://blind-mareah-laboratories-0165c3cb.koyeb.app/todos/5e8bae42-ec36-44e7-8174-d08f4e16d9d4 | jq
{
"id": "5e8bae42-ec36-44e7-8174-d08f4e16d9d4",
"name": "タスク1",
"description": "これはサンプル",
"is_done": false
}
TODO更新
$ curl -X PUT -H "Content-Type: application/json" \
> -d '{"name":"タスク3", "description":"これはサンプル"}' https://blind-mareah-laboratories-0165c3cb.koyeb.app/todos/5e8bae42-ec36-44e7-8174-d08f4e16d9d4 | jq
{
"id": "5e8bae42-ec36-44e7-8174-d08f4e16d9d4",
"name": "タスク3",
"description": "これはサンプル",
"is_done": false
}
TODO削除
$ curl -X DELETE https://blind-mareah-laboratories-0165c3cb.koyeb.app/todos/5e8bae42-ec36-44e7-8174-d08f4e16d9d4
次回
次はRDBMS(Postgresql)を使ってTODOを永続化するようにアプリ改修した記事を出す