replace redis with eventstore
This commit is contained in:
parent
3669ea6eb7
commit
2262b2680f
10 changed files with 267 additions and 155 deletions
1
.gitginore
Normal file
1
.gitginore
Normal file
|
@ -0,0 +1 @@
|
|||
.#*
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1 +1,4 @@
|
|||
tmp
|
||||
todo*.txt
|
||||
\#*
|
||||
\.#*
|
||||
|
|
29
README.md
29
README.md
|
@ -1,16 +1,29 @@
|
|||
# Todo App with GO and HTMX
|
||||
|
||||
## Plan
|
||||
## Plan day 1
|
||||
|
||||
- [x] create Go webserver
|
||||
- [x] create landing page
|
||||
- [x] import HTMX code
|
||||
- [x] display todo list
|
||||
- [ ] add tailwind CSS
|
||||
- [ ] show todo list
|
||||
- [ ] let todos be marked completed
|
||||
- [ ] remove todos
|
||||
- [ ] add new todo with form
|
||||
- [ ] persist todos in redis
|
||||
- [ ] spruce up
|
||||
- [x] add tailwind CSS
|
||||
- [x] show todo list
|
||||
- [x] let todos be marked completed
|
||||
- [x] remove todos
|
||||
- [x] add new todo with form
|
||||
- [x] persist todos in redis
|
||||
- [x] spruce up
|
||||
|
||||
## Plan day 2
|
||||
|
||||
make it use only battery included features,
|
||||
|
||||
i.e. replace redis with an event driven approach
|
||||
|
||||
- [x] modularize app
|
||||
- [x] replace redis with json stream
|
||||
- [x] use hashmap for fast individual access
|
||||
- [ ] cache data to avoid reading each time
|
||||
- [ ] create event store
|
||||
- [ ] make events
|
||||
- [ ] implement event store
|
||||
|
|
8
internal/model.go
Normal file
8
internal/model.go
Normal file
|
@ -0,0 +1,8 @@
|
|||
package model
|
||||
|
||||
type Todo struct {
|
||||
Id int
|
||||
Title string
|
||||
Completed bool
|
||||
}
|
||||
var MaxId int = 0
|
10
internal/model/model.go
Normal file
10
internal/model/model.go
Normal file
|
@ -0,0 +1,10 @@
|
|||
package model
|
||||
|
||||
type Todo struct {
|
||||
Id int
|
||||
Title string
|
||||
Completed bool
|
||||
}
|
||||
|
||||
|
||||
type Todos map[int]Todo
|
132
internal/persist/persist.go
Normal file
132
internal/persist/persist.go
Normal file
|
@ -0,0 +1,132 @@
|
|||
package persist
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"io"
|
||||
)
|
||||
|
||||
import "snamellit.com/play/todo/internal/model"
|
||||
|
||||
var EMPTY_TODO = model.Todo{Id: 0, Title: "", Completed: false}
|
||||
|
||||
var maxId int = 0
|
||||
|
||||
var todos model.Todos
|
||||
|
||||
type eventType string
|
||||
const (
|
||||
ADDED eventType = "ADDED"
|
||||
DELETED eventType = "DELETED"
|
||||
CHANGED eventType = "CHANGED"
|
||||
)
|
||||
|
||||
type event struct {
|
||||
Kind eventType
|
||||
Todo model.Todo
|
||||
}
|
||||
|
||||
func readTodos() (model.Todos, error) {
|
||||
f, err := os.OpenFile("todo_events.txt", os.O_RDONLY|os.O_CREATE, 0644)
|
||||
defer f.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(f)
|
||||
|
||||
todos = make(model.Todos)
|
||||
for {
|
||||
var e event
|
||||
err = dec.Decode(&e)
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
fmt.Println("Read event", e)
|
||||
id := e.Todo.Id
|
||||
if id > maxId {
|
||||
maxId = id
|
||||
}
|
||||
applyEvent(e.Kind, e.Todo)
|
||||
}
|
||||
return todos, nil
|
||||
}
|
||||
|
||||
func applyEvent(kind eventType, todo model.Todo) error {
|
||||
id := todo.Id
|
||||
switch t := kind; t {
|
||||
case ADDED:
|
||||
fmt.Println("apply add ", todo)
|
||||
todos[id] = todo
|
||||
case CHANGED:
|
||||
fmt.Println("apply change ", todo)
|
||||
todos[id] = todo
|
||||
case DELETED:
|
||||
fmt.Println("apply delete ", todo)
|
||||
delete(todos, id)
|
||||
default:
|
||||
return fmt.Errorf("Unknown event type %s", t)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addEvent(kind eventType, todo model.Todo) error {
|
||||
err := applyEvent(kind, todo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.OpenFile("todo_events.txt", os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
|
||||
defer f.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("Write todo", todo)
|
||||
enc := json.NewEncoder(f)
|
||||
e := event{Kind: kind, Todo: todo}
|
||||
fmt.Println("Write event", e)
|
||||
err = enc.Encode(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetTodos() (model.Todos, error) {
|
||||
if len(todos) == 0 {
|
||||
return readTodos()
|
||||
}
|
||||
return todos, nil
|
||||
}
|
||||
|
||||
|
||||
func GetTodoById(id int) (model.Todo, error) {
|
||||
todo, present := todos[id]
|
||||
if !present {
|
||||
return EMPTY_TODO, fmt.Errorf("Todo with id %d not found", id)
|
||||
}
|
||||
return todo, nil
|
||||
}
|
||||
|
||||
func SetTodo(todo model.Todo) error {
|
||||
fmt.Println("SetTodo", todo)
|
||||
todos[todo.Id] = todo
|
||||
return addEvent(CHANGED, todo)
|
||||
}
|
||||
|
||||
func AddTodo(title string) error {
|
||||
fmt.Println("AddTodo", title)
|
||||
maxId++;
|
||||
todo := model.Todo{Id: maxId, Title: title, Completed: false}
|
||||
return addEvent(ADDED, todo)
|
||||
}
|
||||
|
||||
func DeleteTodoById(id int) error {
|
||||
delete(todos, id)
|
||||
return addEvent(DELETED, model.Todo{Id: id})
|
||||
}
|
||||
|
75
internal/webserver/webserver.go
Normal file
75
internal/webserver/webserver.go
Normal file
|
@ -0,0 +1,75 @@
|
|||
package webserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"snamellit.com/play/todo/internal/persist"
|
||||
)
|
||||
|
||||
|
||||
func IndexHandler(w http.ResponseWriter, r *http.Request) {
|
||||
t, _ := template.ParseFiles("templates/index.html")
|
||||
t.Execute(w, nil)
|
||||
}
|
||||
|
||||
|
||||
func renderTodos(w http.ResponseWriter) {
|
||||
fmt.Println("renderTodos")
|
||||
todos,err := persist.GetTodos()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, "Error: %s", err)
|
||||
}
|
||||
t, _ := template.ParseFiles("templates/todos.html")
|
||||
t.Execute(w, todos)
|
||||
}
|
||||
|
||||
func TodosHandler(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Println("TodosHandler")
|
||||
if r.Method == "POST" {
|
||||
r.ParseForm()
|
||||
todoTitle := r.Form.Get("title")
|
||||
persist.AddTodo(todoTitle)
|
||||
}
|
||||
renderTodos(w)
|
||||
}
|
||||
|
||||
func ToggleTodoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Println("ToggleTodoHandler")
|
||||
todoId, err := strconv.Atoi(r.URL.Path)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, "Error: %s", err)
|
||||
}
|
||||
fmt.Println("Toggle Todo Id: %d", todoId)
|
||||
todo, err := persist.GetTodoById(todoId)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, "Error: %s", err)
|
||||
}
|
||||
todo.Completed = !todo.Completed
|
||||
err = persist.SetTodo(todo)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, "Error: %s", err)
|
||||
}
|
||||
renderTodos(w)
|
||||
}
|
||||
|
||||
func TodoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "DELETE" {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
fmt.Fprintf(w, "Method %s not allowed", r.Method)
|
||||
return
|
||||
}
|
||||
todoId, err := strconv.Atoi(r.URL.Path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println("Todo Id: %d", todoId)
|
||||
persist.DeleteTodoById(todoId)
|
||||
TodosHandler(w, r)
|
||||
return
|
||||
}
|
128
main.go
128
main.go
|
@ -1,141 +1,23 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"log"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"html/template"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type todo struct {
|
||||
Id int
|
||||
Title string
|
||||
Completed bool
|
||||
}
|
||||
var maxId int = 0
|
||||
|
||||
var ctx = context.Background()
|
||||
var rdb = redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
Password: "", // no password set
|
||||
DB: 0, // use default DB
|
||||
})
|
||||
|
||||
func GetTodos() []todo {
|
||||
var cursor uint64 = 0
|
||||
todosJson, cursor, err := rdb.Scan(ctx, cursor, "todo:*", 20).Result()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
var todos []todo = make([]todo, len(todosJson))
|
||||
for idx, todoJson := range todosJson {
|
||||
todoJson := rdb.Get(ctx, todoJson).Val()
|
||||
fmt.Println("Todo: %s", todoJson)
|
||||
err = json.Unmarshal([]byte(todoJson), &todos[idx])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if todos[idx].Id > maxId {
|
||||
maxId = todos[idx].Id
|
||||
}
|
||||
}
|
||||
return todos
|
||||
}
|
||||
|
||||
func GetTodoById(id int) todo {
|
||||
todoJson, err := rdb.Get(ctx, fmt.Sprintf("todo:%d", id)).Result()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
var todo todo
|
||||
err = json.Unmarshal([]byte(todoJson), &todo)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return todo
|
||||
}
|
||||
|
||||
func SetTodo(todo todo) {
|
||||
todoJson, err := json.Marshal(todo)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
key := fmt.Sprintf("todo:%d", todo.Id)
|
||||
err = rdb.Set(ctx, key, string(todoJson), 0).Err()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println("Set todo: %s", todoJson)
|
||||
}
|
||||
|
||||
func DeleteTodoById(id int) {
|
||||
err := rdb.Del(ctx, fmt.Sprintf("todo:%d", id)).Err()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func IndexHandler(w http.ResponseWriter, r *http.Request) {
|
||||
t, _ := template.ParseFiles("templates/index.html")
|
||||
t.Execute(w, nil)
|
||||
}
|
||||
|
||||
func TodosHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" {
|
||||
r.ParseForm()
|
||||
todoTitle := r.Form.Get("title")
|
||||
SetTodo(todo{Id: maxId + 1, Title: todoTitle, Completed: false})
|
||||
}
|
||||
todos := GetTodos()
|
||||
t, _ := template.ParseFiles("templates/todos.html")
|
||||
t.Execute(w, todos)
|
||||
}
|
||||
|
||||
func ToggleTodoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
todoId, err := strconv.Atoi(r.URL.Path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println("Todo Id: %d", todoId)
|
||||
todo := GetTodoById(todoId)
|
||||
todo.Completed = !todo.Completed
|
||||
SetTodo(todo)
|
||||
TodosHandler(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
func TodoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "DELETE" {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
fmt.Fprintf(w, "Method %s not allowed", r.Method)
|
||||
return
|
||||
}
|
||||
todoId, err := strconv.Atoi(r.URL.Path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println("Todo Id: %d", todoId)
|
||||
DeleteTodoById(todoId)
|
||||
TodosHandler(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
import "snamellit.com/play/todo/internal/webserver"
|
||||
|
||||
func main() {
|
||||
fmt.Println("Starting web server on port 8080")
|
||||
fs := http.FileServer(http.Dir("./static"))
|
||||
http.Handle("/static/", http.StripPrefix("/static/", fs))
|
||||
http.HandleFunc("/", IndexHandler)
|
||||
http.HandleFunc("/todos", TodosHandler)
|
||||
http.HandleFunc("/", webserver.IndexHandler)
|
||||
http.HandleFunc("/todos", webserver.TodosHandler)
|
||||
togglePath := "/toggle-todo/"
|
||||
http.Handle(togglePath, http.StripPrefix(togglePath, http.HandlerFunc(ToggleTodoHandler)))
|
||||
http.Handle(togglePath, http.StripPrefix(togglePath, http.HandlerFunc(webserver.ToggleTodoHandler)))
|
||||
todoPath := "/todo/"
|
||||
http.Handle(todoPath, http.StripPrefix(todoPath, http.HandlerFunc(TodoHandler)))
|
||||
http.Handle(todoPath, http.StripPrefix(todoPath, http.HandlerFunc(webserver.TodoHandler)))
|
||||
|
||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||
}
|
||||
|
|
|
@ -534,10 +534,6 @@ video {
|
|||
--tw-backdrop-sepia: ;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
@ -554,10 +550,6 @@ video {
|
|||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
@ -568,24 +560,10 @@ video {
|
|||
margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
|
||||
}
|
||||
|
||||
.rounded {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.rounded-xl {
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.bg-slate-100 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(241 245 249 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-slate-200 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(226 232 240 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-red-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(239 68 68 / var(--tw-bg-opacity));
|
||||
|
@ -596,6 +574,16 @@ video {
|
|||
background-color: rgb(14 165 233 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-slate-100 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(241 245 249 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-slate-200 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(226 232 240 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.p-4 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
|
|
@ -2,10 +2,10 @@
|
|||
{{range $index, $task := . }}
|
||||
<li id={{$task.Id}} class="p-4 rounded-xl bg-slate-100">
|
||||
<input type="checkbox" {{if $task.Completed}}checked{{end}}
|
||||
hx-post="/toggle-todo/{{$task.Id}}" hx-swap="outerHTML" hx-target="#todos">
|
||||
hx-post="/toggle-todo/{{$index}}" hx-swap="outerHTML" hx-target="#todos">
|
||||
{{$task.Title}}
|
||||
<button class="bg-red-500"
|
||||
hx-delete="/todo/{{$task.Id}}" hx-swap="outerHTML" hx-target="#todos">X</button>
|
||||
hx-delete="/todo/{{$index}}" hx-swap="outerHTML" hx-target="#todos">X</button>
|
||||
</li>
|
||||
{{end}}
|
||||
<li>
|
||||
|
|
Loading…
Reference in a new issue