replace redis with eventstore

This commit is contained in:
Peter Tillemans 2023-09-03 13:45:51 +02:00
parent 3669ea6eb7
commit 2262b2680f
10 changed files with 267 additions and 155 deletions

1
.gitginore Normal file
View file

@ -0,0 +1 @@
.#*

3
.gitignore vendored
View file

@ -1 +1,4 @@
tmp tmp
todo*.txt
\#*
\.#*

View file

@ -1,16 +1,29 @@
# Todo App with GO and HTMX # Todo App with GO and HTMX
## Plan ## Plan day 1
- [x] create Go webserver - [x] create Go webserver
- [x] create landing page - [x] create landing page
- [x] import HTMX code - [x] import HTMX code
- [x] display todo list - [x] display todo list
- [ ] add tailwind CSS - [x] add tailwind CSS
- [ ] show todo list - [x] show todo list
- [ ] let todos be marked completed - [x] let todos be marked completed
- [ ] remove todos - [x] remove todos
- [ ] add new todo with form - [x] add new todo with form
- [ ] persist todos in redis - [x] persist todos in redis
- [ ] spruce up - [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
View 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
View 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
View 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})
}

View 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
View file

@ -1,141 +1,23 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"github.com/redis/go-redis/v9"
"log" "log"
"encoding/json"
"net/http" "net/http"
"html/template"
"strconv"
) )
type todo struct { import "snamellit.com/play/todo/internal/webserver"
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
}
func main() { func main() {
fmt.Println("Starting web server on port 8080") fmt.Println("Starting web server on port 8080")
fs := http.FileServer(http.Dir("./static")) fs := http.FileServer(http.Dir("./static"))
http.Handle("/static/", http.StripPrefix("/static/", fs)) http.Handle("/static/", http.StripPrefix("/static/", fs))
http.HandleFunc("/", IndexHandler) http.HandleFunc("/", webserver.IndexHandler)
http.HandleFunc("/todos", TodosHandler) http.HandleFunc("/todos", webserver.TodosHandler)
togglePath := "/toggle-todo/" togglePath := "/toggle-todo/"
http.Handle(togglePath, http.StripPrefix(togglePath, http.HandlerFunc(ToggleTodoHandler))) http.Handle(togglePath, http.StripPrefix(togglePath, http.HandlerFunc(webserver.ToggleTodoHandler)))
todoPath := "/todo/" 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)) log.Fatal(http.ListenAndServe(":8080", nil))
} }

View file

@ -534,10 +534,6 @@ video {
--tw-backdrop-sepia: ; --tw-backdrop-sepia: ;
} }
.block {
display: block;
}
.flex { .flex {
display: flex; display: flex;
} }
@ -554,10 +550,6 @@ video {
grid-template-columns: repeat(1, minmax(0, 1fr)); grid-template-columns: repeat(1, minmax(0, 1fr));
} }
.flex-row {
flex-direction: row;
}
.gap-2 { .gap-2 {
gap: 0.5rem; gap: 0.5rem;
} }
@ -568,24 +560,10 @@ video {
margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse))); margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
} }
.rounded {
border-radius: 0.25rem;
}
.rounded-xl { .rounded-xl {
border-radius: 0.75rem; 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 { .bg-red-500 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(239 68 68 / var(--tw-bg-opacity)); 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)); 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 { .p-4 {
padding: 1rem; padding: 1rem;
} }

View file

@ -2,10 +2,10 @@
{{range $index, $task := . }} {{range $index, $task := . }}
<li id={{$task.Id}} class="p-4 rounded-xl bg-slate-100"> <li id={{$task.Id}} class="p-4 rounded-xl bg-slate-100">
<input type="checkbox" {{if $task.Completed}}checked{{end}} <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}} {{$task.Title}}
<button class="bg-red-500" <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> </li>
{{end}} {{end}}
<li> <li>