こんにちは。KOUKIです。
TodoリストアプリケーションをJavaScriptとGo言語で実装しています。
今回は、検索機能を実装しましょう。
前回
作るもの
UIに検索ボックスを追加して、検索文字列に部分一致したTodoだけ表示させます。
ワークスペース
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
touch api/delivery/search.go $ tree . ├── api │ ├── Makefile │ ├── delivery │ │ ├── allget.go │ │ ├── delete.go │ │ ├── statusupdate.go │ │ ├── search.go │ │ └── store.go │ ├── domain │ │ └── todo.go │ ├── go.mod │ ├── main.go │ ├── repository │ │ ├── todo_map.go │ │ └── todo_map_test.go │ └── usecase │ └── todo_usecase.go └── ui ├── index.html ├── script.js └── style.css |
バックエンド
まずは、バックエンド側に検索処理を実装します。
ドメイン
ドメインにSearchを追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
// domain/todo.go package domain ... type TodoUsecase interface { AllGet() ([]Todo, error) StatusUpdate(id int) error Store(todo Todo) error Delete(id int) error Search(key string) ([]Todo, error) // 追加 } type TodoRepository interface { AllGet() ([]Todo, error) StatusUpdate(id int) error Store(todo Todo) error Delete(id int) error Search(key string) ([]Todo, error) // 追加 } |
レポジトリ
レポジトリに、Searchメソッドを追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
// repository/todo_map.go package repository import ( "errors" "fmt" "strings" "sync" "todo/domain" ) ... func (t *todoRepository) Delete(id int) error {...} func (t *todoRepository) Search(key string) ([]domain.Todo, error) { var todos []domain.Todo t.m.Range(func(_ interface{}, value interface{}) bool { todo := value.(domain.Todo) NORESULT := -1 searchResult := strings.Index(todo.Text, key) if searchResult != NORESULT { todos = append( todos, todo, ) } return true }) return todos, nil } |
stringsパッケージのIndexは、検索文字列に対して文字列が部分一致で合致しているかチェックします。そして、合致しない場合は、-1が返されます。
テストコードも実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
// repository/todo_map_test.go package repository import ( "testing" "todo/domain" "github.com/stretchr/testify/require" ) func TestSyncMapRepository(t *testing.T) { ... } func TestSyncMapRepositorySearch(t *testing.T) { dbRepo := NewSyncMapTodoRepository() testData1 := domain.Todo{ ID: 1, Text: "First Post", Completed: false, } testData2 := domain.Todo{ ID: 2, Text: "こんにちは。読者のみなさん", Completed: false, } dbRepo.Store(testData1) dbRepo.Store(testData2) t.Run("Search Todo Test", func(t *testing.T) { // Test1 - Firstで検索 todos, _ := dbRepo.Search("First") // 空でないこと require.NotEmpty(t, todos) // 取得結果が1件 require.Equal(t, len(todos), 1) // 取得したタスクがID1 require.Equal(t, todos[0].ID, 1) // Test2 - こんにちはで検索 todos, _ = dbRepo.Search("こんにちは。") // 空でないこと require.NotEmpty(t, todos) // 取得結果が1件 require.Equal(t, len(todos), 1) // 取得したタスクがID2 require.Equal(t, todos[0].ID, 2) // Test3 - NORESULTで検索 todos, _ = dbRepo.Search("NORESULT") // 空であること require.Empty(t, todos) }) } |
テストを実行しましょう。
1 2 3 4 5 |
$ go test -v ./... === RUN TestSyncMapRepositorySearch === RUN TestSyncMapRepositorySearch/Search_Todo_Test --- PASS: TestSyncMapRepositorySearch (0.00s) --- PASS: TestSyncMapRepositorySearch/Search_Todo_Test (0.00s) |
テストがパスしました。
デリバリー
デリバリーを実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
// delivery/search.go package delivery import ( "todo/domain" "github.com/gofiber/fiber/v2" ) // Handlerを定義する type todoSearchHandler struct { todoUseCase domain.TodoUsecase } func NewTodoSearchHandler(c *fiber.App, th domain.TodoUsecase) { handler := &todoSearchHandler{ todoUseCase: th, } c.Post("/todo/search", handler.Search) } func (h *todoSearchHandler) Search(c *fiber.Ctx) error { todo := new(domain.Todo) err := c.BodyParser(todo) if err != nil { c.Status(400) return c.JSON(fiber.Map{ "message": "Unexpected Request. To check text", }) } // UseCaseのSearchを呼びだす todos, err := h.todoUseCase.Search(todo.Text) if err != nil { c.Status(500) return c.JSON(fiber.Map{ "message": "Internal Server Error", }) } return c.JSON(todos) } |
ユースケース
次は、ユースケースを実装します。
1 2 3 4 5 6 7 8 9 10 11 |
// usecase/todo_usecase.go package usecase ... func (t *todoUsecase) Search(key string) ([]domain.Todo, error) { todos, err := t.todoRepo.Search(key) if err != nil { return nil, err } return todos, nil } |
フレームワーク&ドライバー
main関数からSearch機能を呼びだします。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// main.go func main() { ... delivery.NewTodoAllGetHandler(c, tu) delivery.NewTodoDeleteHandler(c, tu) delivery.NewTodoStatusUpdateHandler(c, tu) delivery.NewTodoStoreHandler(c, tu) delivery.NewTodoSearchHandler(c, tu) // 追加 c.Listen(":80") } |
動作確認
Talend API Testerで動作確認をしてみましょう。
- POST:http://localhost/todo/store
- POST:http://localhost/todo/search
まずは、「http://localhost/todo/store」のエントリポイントで、データを登録をします。
次のテストデータを登録してください。
1 2 3 4 5 6 7 8 9 10 |
// 1つ目 { "id": 1, "text": "First Todo" } // 2つ目 { "id": 2, "text": "こんにちは。読者のみなさん" } |
データが登録できたら、このキーワードで検索します。
1 2 3 |
{ "text": "こんにちは" } |


検索できましたね。完璧です。
クリーンアーキテクチャを意識して実装すると機能追加がかなり楽です。
UI
検索時の挙動を以下にまとめます。
1. 検索時にTodoリストを作り直す
2. 検索文字に部分一致したものを表示する
3. 検索結果が0件の場合はリストに何も表示しない
4. 空文字を検索することでTodoを全件取得する -> 検索リセット
検索窓
まずは、検索窓をつけましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="stylesheet" href="style.css" /> <title>Todo List</title> </head> <body> <h1>todos</h1> <!-- 追加 --> <form id="search-form"> <input type="text" class="input" id="search" placeholder="Search..." autocomplete="off" style="margin-bottom: 30px; background-color: skyblue;"> </form> <form id="form"> <input type="text" class="input" id="input" placeholder="Enter your todo" autocomplete="off"> <ul class="todos" id="todos"></ul> </form> <small>Left click to toggle completed. <br> Right click to delete todo</small> <script src="script.js"></script> </body> |
検索用のform要素を追加しました。

要素の取得
次に、先ほどHTMLに設置したsearch要素を取得します。
1 2 3 |
// 要素を取得 const searchForm = document.getElementById('search-form') const search = document.getElementById('search') |
Search
Fetch APIを使って、サーバー側に実装したsearchエントリポイントへリクエスト送信できるようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
async function deleteTodo(id){...} // Todoを検索 async function searchTodo(searchKey){ const response = await fetch(baseURL + 'todo/search',{ method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({text: searchKey}), }) const todos = await response.json() if(todos) { todos.forEach(todo => addTodo(todo)) } } |
Submitイベントの登録
最後にSubmitイベントを登録します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// Search時に発火する searchForm.addEventListener('submit', (e) => { // デフォルトの動きをキャンセル e.preventDefault() // Todoリストをクリアする while(todosUL.firstChild) { // ulの子要素(li)を全て削除 todosUL.removeChild(todosUL.firstChild) } const searchKey = search.value if (search) { searchTodo(searchKey) } else { // 入力値がない場合は全件取得する(検索リセット) getAllTodo() } }) |
これで完成です。
次回
次回は、TodoリストをDocker化し、MySQLを導入します。
関連記事
こちらの記事も人気です!
JavaScriptまとめ
Go言語まとめ
JSソースコード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 |
const form = document.getElementById('form') const input = document.getElementById('input') const todosUL = document.getElementById('todos') // 要素を取得 const searchForm = document.getElementById('search-form') const search = document.getElementById('search') const baseURL = 'http://localhost/' let todos getAllTodo() async function getAllTodo() { const response = await fetch(baseURL + 'todos') const todos = await response.json() if(todos) { todos.forEach(todo => addTodo(todo)) } } async function store(todo){ await fetch(baseURL + 'todo/store',{ method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(todo), }) } async function statusUpdate(id){ await fetch(baseURL + 'todo/statusupdate',{ method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({id: id}), }) } async function deleteTodo(id){ await fetch(baseURL + 'todo/delete',{ method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({id: id}), }) } // Todoを検索 async function searchTodo(searchKey){ const response = await fetch(baseURL + 'todo/search',{ method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({text: searchKey}), }) const todos = await response.json() if(todos) { todos.forEach(todo => addTodo(todo)) } } // Todo入力時に発火する form.addEventListener('submit', (e) => { // デフォルトの動きをキャンセル e.preventDefault() // Todoを作成 addTodo() }) // Search時に発火する searchForm.addEventListener('submit', (e) => { // デフォルトの動きをキャンセル e.preventDefault() // Todoリストをクリアする while(todosUL.firstChild) { // ulの子要素(li)を全て削除 todosUL.removeChild(todosUL.firstChild) } const searchKey = search.value if (search) { searchTodo(searchKey) } else { // 入力値がない場合は全件取得する(検索リセット) getAllTodo() } }) function addTodo(todo) { let todoText = input.value let id = Math.floor( Math.random() * (10000 + 1 - 1) ) + 1 const todoData = { id: id, text: todoText } if(todo) { id = todo.id todoText = todo.text } if (!todo && todoText) { store(todoData) } if(todoText) { const todoEl = document.createElement('li') if(todo && todo.completed) { todoEl.classList.add('completed') } todoEl.innerText = todoText todoEl.addEventListener('click', () => { todoEl.classList.toggle('completed') statusUpdate(id) }) todoEl.addEventListener('contextmenu', (e) => { e.preventDefault() todoEl.remove() deleteTodo(id) }) todosUL.appendChild(todoEl) input.value = '' } } |
コメントを残す
コメントを投稿するにはログインしてください。