1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

koyebで作るgoのTODOアプリ

Last updated at Posted at 2025-01-02

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を用意する。
今回は公式にあるコードを参考に作成する。

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

main.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)
	}
}
todo/todo.go
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

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」をクリックする。
IMG1

上記の「Deploy」押下後はしばらく待つ

IMG2

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を永続化するようにアプリ改修した記事を出す

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?