diff --git a/framework/h/cache.go b/framework/h/cache.go index 0c02056..6209388 100644 --- a/framework/h/cache.go +++ b/framework/h/cache.go @@ -7,17 +7,30 @@ import ( type CachedNode struct { cb func() *Element + isByKey bool + byKeyCache map[any]*Entry mutex sync.Mutex expiration time.Time html string } +type Entry struct { + expiration time.Time + html string +} + type GetElementFunc func() *Element type GetElementFuncT[T any] func(T) *Element type GetElementFuncT2[T any, T2 any] func(T, T2) *Element type GetElementFuncT3[T any, T2 any, T3 any] func(T, T2, T3) *Element type GetElementFuncT4[T any, T2 any, T3 any, T4 any] func(T, T2, T3, T4) *Element +type GetElementFuncWithKey[K comparable] func() (K, GetElementFunc) +type GetElementFuncTWithKey[K comparable, T any] func(T) (K, GetElementFunc) +type GetElementFuncT2WithKey[K comparable, T any, T2 any] func(T, T2) (K, GetElementFunc) +type GetElementFuncT3WithKey[K comparable, T any, T2 any, T3 any] func(T, T2, T3) (K, GetElementFunc) +type GetElementFuncT4WithKey[K comparable, T any, T2 any, T3 any, T4 any] func(T, T2, T3, T4) (K, GetElementFunc) + func Cached(duration time.Duration, cb GetElementFunc) func() *Element { element := &Element{ tag: CachedNodeTag, @@ -32,6 +45,127 @@ func Cached(duration time.Duration, cb GetElementFunc) func() *Element { } } +func CachedPerKey[K comparable](duration time.Duration, cb GetElementFuncWithKey[K]) func() *Element { + element := &Element{ + tag: CachedNodeTag, + meta: &CachedNode{ + isByKey: true, + cb: nil, + html: "", + expiration: time.Now().Add(duration), + }, + } + return func() *Element { + key, componentFunc := cb() + return &Element{ + tag: CachedNodeByKeyEntry, + meta: &ByKeyEntry{ + key: key, + parent: element, + cb: componentFunc, + }, + } + } +} + +type ByKeyEntry struct { + key any + cb func() *Element + parent *Element +} + +func CachedPerKeyT[K comparable, T any](duration time.Duration, cb GetElementFuncTWithKey[K, T]) func(T) *Element { + element := &Element{ + tag: CachedNodeTag, + meta: &CachedNode{ + isByKey: true, + cb: nil, + html: "", + expiration: time.Now().Add(duration), + }, + } + return func(data T) *Element { + key, componentFunc := cb(data) + return &Element{ + tag: CachedNodeByKeyEntry, + meta: &ByKeyEntry{ + key: key, + parent: element, + cb: componentFunc, + }, + } + } +} + +func CachedPerKeyT2[K comparable, T any, T2 any](duration time.Duration, cb GetElementFuncT2WithKey[K, T, T2]) func(T, T2) *Element { + element := &Element{ + tag: CachedNodeTag, + meta: &CachedNode{ + isByKey: true, + cb: nil, + html: "", + expiration: time.Now().Add(duration), + }, + } + return func(data T, data2 T2) *Element { + key, componentFunc := cb(data, data2) + return &Element{ + tag: CachedNodeByKeyEntry, + meta: &ByKeyEntry{ + key: key, + parent: element, + cb: componentFunc, + }, + } + } +} + +func CachedPerKeyT3[K comparable, T any, T2 any, T3 any](duration time.Duration, cb GetElementFuncT3WithKey[K, T, T2, T3]) func(T, T2, T3) *Element { + element := &Element{ + tag: CachedNodeTag, + meta: &CachedNode{ + isByKey: true, + cb: nil, + html: "", + expiration: time.Now().Add(duration), + }, + } + return func(data T, data2 T2, data3 T3) *Element { + key, componentFunc := cb(data, data2, data3) + return &Element{ + tag: CachedNodeByKeyEntry, + meta: &ByKeyEntry{ + key: key, + parent: element, + cb: componentFunc, + }, + } + } +} + +func CachedPerKeyT4[K comparable, T any, T2 any, T3 any, T4 any](duration time.Duration, cb GetElementFuncT4WithKey[K, T, T2, T3, T4]) func(T, T2, T3, T4) *Element { + element := &Element{ + tag: CachedNodeTag, + meta: &CachedNode{ + isByKey: true, + cb: nil, + html: "", + expiration: time.Now().Add(duration), + }, + } + return func(data T, data2 T2, data3 T3, data4 T4) *Element { + key, componentFunc := cb(data, data2, data3, data4) + return &Element{ + tag: CachedNodeByKeyEntry, + meta: &ByKeyEntry{ + key: key, + parent: element, + cb: componentFunc, + }, + } + } +} + func CachedT[T any](duration time.Duration, cb GetElementFuncT[T]) func(T) *Element { element := &Element{ tag: CachedNodeTag, @@ -102,16 +236,57 @@ func (c *CachedNode) ClearCache() { } func (c *CachedNode) Render(ctx *RenderContext) { - c.mutex.Lock() - if c.expiration.Before(time.Now()) { - c.html = "" + if c.isByKey == true { + panic("CachedPerKey should not be rendered directly") + } else { + c.mutex.Lock() + defer c.mutex.Unlock() + if c.expiration.Before(time.Now()) { + c.html = "" + } + + if c.html != "" { + ctx.builder.WriteString(c.html) + } else { + c.html = Render(c.cb()) + ctx.builder.WriteString(c.html) + } + } +} + +func (c *ByKeyEntry) Render(ctx *RenderContext) { + key := c.key + parentMeta := c.parent.meta.(*CachedNode) + + if parentMeta.byKeyCache == nil { + parentMeta.byKeyCache = make(map[any]*Entry) } - if c.html != "" { - ctx.builder.WriteString(c.html) - } else { - c.html = Render(c.cb()) - ctx.builder.WriteString(c.html) + entry := parentMeta.byKeyCache[key] + + var setAndWrite = func() { + html := Render(c.cb()) + parentMeta.byKeyCache[key] = &Entry{ + expiration: parentMeta.expiration, + html: html, + } + ctx.builder.WriteString(html) } - c.mutex.Unlock() + + // exists in cache but expired + if entry != nil && entry.expiration.Before(time.Now()) { + delete(parentMeta.byKeyCache, key) + setAndWrite() + return + } + + // not in cache + if entry == nil { + setAndWrite() + return + } + + // exists in cache and not expired + ctx.builder.WriteString(entry.html) + } diff --git a/framework/h/render_test.go b/framework/h/render_test.go index 926c485..b9ef912 100644 --- a/framework/h/render_test.go +++ b/framework/h/render_test.go @@ -183,6 +183,190 @@ func TestCacheMultiple(t *testing.T) { assert.Equal(t, 1, count) } +func TestCacheByKey(t *testing.T) { + t.Parallel() + renderCount := 0 + callCount := 0 + cachedItem := CachedPerKey(time.Hour, func() (any, GetElementFunc) { + key := "key" + if callCount == 3 { + key = "key2" + } + if callCount == 4 { + key = "key" + } + callCount++ + return key, func() *Element { + renderCount++ + return Div(Text("hello")) + } + }) + + Render(Div( + cachedItem(), + cachedItem(), + cachedItem(), + cachedItem(), + cachedItem(), + )) + + assert.Equal(t, 5, callCount) + assert.Equal(t, 2, renderCount) +} + +func TestCacheByKeyT(t *testing.T) { + t.Parallel() + renderCount := 0 + cachedItem := CachedPerKeyT(time.Hour, func(key string) (any, GetElementFunc) { + return key, func() *Element { + renderCount++ + return Div(Text("hello")) + } + }) + + Render(Div( + cachedItem("one"), + cachedItem("one"), + cachedItem("two"), + cachedItem("two"), + cachedItem("three"), + )) + + assert.Equal(t, 3, renderCount) +} + +func TestCacheByKeyT4(t *testing.T) { + t.Parallel() + renderCount := 0 + cachedItem := CachedPerKeyT4(time.Hour, func(arg1 string, arg2 string, arg3 string, arg4 string) (any, GetElementFunc) { + return arg1, func() *Element { + renderCount++ + return Div(Text("hello")) + } + }) + + Render(Div( + cachedItem("one", uuid.NewString(), uuid.NewString(), uuid.NewString()), + cachedItem("one", uuid.NewString(), uuid.NewString(), uuid.NewString()), + cachedItem("two", uuid.NewString(), uuid.NewString(), uuid.NewString()), + cachedItem("two", uuid.NewString(), uuid.NewString(), uuid.NewString()), + cachedItem("three", uuid.NewString(), uuid.NewString(), uuid.NewString()), + )) + + assert.Equal(t, 3, renderCount) +} + +func TestCacheByKeyT3(t *testing.T) { + t.Parallel() + renderCount := 0 + cachedItem := CachedPerKeyT3(time.Hour, func(arg1 string, arg2 string, arg3 string) (any, GetElementFunc) { + return arg1, func() *Element { + renderCount++ + return Div(Text("hello")) + } + }) + + Render(Div( + cachedItem("one", uuid.NewString(), uuid.NewString()), + cachedItem("one", uuid.NewString(), uuid.NewString()), + cachedItem("two", uuid.NewString(), uuid.NewString()), + cachedItem("two", uuid.NewString(), uuid.NewString()), + cachedItem("three", uuid.NewString(), uuid.NewString()), + )) + + assert.Equal(t, 3, renderCount) +} + +func TestCacheByKeyT2(t *testing.T) { + t.Parallel() + renderCount := 0 + cachedItem := CachedPerKeyT2(time.Hour, func(arg1 string, arg2 string) (any, GetElementFunc) { + return arg1, func() *Element { + renderCount++ + return Div(Text("hello")) + } + }) + + Render(Div( + cachedItem("one", uuid.NewString()), + cachedItem("one", uuid.NewString()), + cachedItem("two", uuid.NewString()), + cachedItem("two", uuid.NewString()), + cachedItem("three", uuid.NewString()), + )) + + assert.Equal(t, 3, renderCount) +} + +func TestCacheByKeyConcurrent(t *testing.T) { + t.Parallel() + renderCount := 0 + callCount := 0 + cachedItem := CachedPerKey(time.Hour, func() (any, GetElementFunc) { + key := "key" + if callCount == 3 { + key = "key2" + } + if callCount == 4 { + key = "key" + } + callCount++ + return key, func() *Element { + renderCount++ + return Div(Text("hello")) + } + }) + + wg := sync.WaitGroup{} + for i := 0; i < 5; i++ { + wg.Add(1) + go func() { + defer wg.Done() + Render(Div( + cachedItem(), + )) + }() + } + + wg.Wait() + + assert.Equal(t, 5, callCount) + assert.Equal(t, 2, renderCount) +} + +func TestCacheByKeyT1_2(t *testing.T) { + t.Parallel() + renderCount := 0 + cachedItem := CachedPerKeyT(time.Hour, func(key string) (any, GetElementFunc) { + return key, func() *Element { + renderCount++ + return Pf(key) + } + }) + + assert.Equal(t, "

one

", Render(cachedItem("one"))) + assert.Equal(t, "

two

", Render(cachedItem("two"))) + assert.Equal(t, "

two

", Render(cachedItem("two"))) + assert.Equal(t, 2, renderCount) +} + +func TestCacheByKeyT1Expired(t *testing.T) { + t.Parallel() + renderCount := 0 + cachedItem := CachedPerKeyT(time.Millisecond, func(key string) (any, GetElementFunc) { + return key, func() *Element { + renderCount++ + return Pf(key) + } + }) + + assert.Equal(t, "

one

", Render(cachedItem("one"))) + assert.Equal(t, "

two

", Render(cachedItem("two"))) + time.Sleep(time.Millisecond * 2) + assert.Equal(t, "

two

", Render(cachedItem("two"))) + assert.Equal(t, 3, renderCount) +} + func BenchmarkMailToStatic(b *testing.B) { b.ReportAllocs() ctx := RenderContext{ diff --git a/framework/h/renderer.go b/framework/h/renderer.go index 79877fd..f29c4db 100644 --- a/framework/h/renderer.go +++ b/framework/h/renderer.go @@ -10,7 +10,8 @@ import ( type CustomElement = string var ( - CachedNodeTag CustomElement = "htmgo_cache_node" + CachedNodeTag CustomElement = "htmgo_cache_node" + CachedNodeByKeyEntry CustomElement = "htmgo_cached_node_by_key_entry" ) /* @@ -57,6 +58,12 @@ func (node *Element) Render(context *RenderContext) { return } + if node.tag == CachedNodeByKeyEntry { + meta := node.meta.(*ByKeyEntry) + meta.Render(context) + return + } + if node.tag != "" { context.builder.WriteString("<") context.builder.WriteString(node.tag) diff --git a/htmgo-site/md/docs/5_performance/1_caching.md b/htmgo-site/md/docs/5_performance/1_caching_globally.md similarity index 72% rename from htmgo-site/md/docs/5_performance/1_caching.md rename to htmgo-site/md/docs/5_performance/1_caching_globally.md index 7ab156a..8ba3258 100644 --- a/htmgo-site/md/docs/5_performance/1_caching.md +++ b/htmgo-site/md/docs/5_performance/1_caching_globally.md @@ -1,4 +1,4 @@ -**Caching Components** +**Caching Components Globally** You may want to cache components to improve performance. This is especially useful for components that are expensive to render or make external requests for data. @@ -17,6 +17,9 @@ h.CachedT3(duration time.Duration, cb GetElementFunc) // Four arguments passed to the component h.CachedT4(duration time.Duration, cb GetElementFunc) ``` +For caching components per user, see [Caching Components Per User](#performance-caching-per-user). + +
The `duration` parameter is the time the component should be cached for. The `cb` parameter is a function that returns the component. @@ -44,25 +47,10 @@ func IndexPage(ctx *h.RequestContext) *h.Page { } ``` -**Real Example:** -I want to make a navbar that renders how many github stars my repository has. I don't want to make a request to the GitHub API everytime someone visits my page, so I will cache the component for 15 minutes. -```go -var CachedGithubStars = h.CachedT(time.Minute*15, func(t *h.RequestContext) *h.Element { - return GithubStars(t) -}) - -func GithubStars(ctx *h.RequestContext) *h.Element { - stars := http.Get("https://api.github.com/repos/maddalax/htmgo/stargazers") - return h.Div( - h.Text(stars), - ) -} -``` - **Note:** We are using CachedT because the component takes one argument, the RequestContext. If the component takes more arguments, use CachedT2, CachedT3, etc. -**Important**: -1. The cached value is stored globally in memory, so it is shared across all requests. Do not store request-specific data in a cached component. Only cache components that you are OK with all users seeing the same data. +**Important Note When Using CachedT and NOT CachedPerKeyT:** +1. When using h.CachedT(T2, T3, etc) and not **CachedPerKey**, The cached value is stored globally in memory, so it is shared across all requests. Do not store request-specific data in a cached component. Only cache components that you are OK with all users seeing the same data. 2. The arguments passed into cached component **DO NOT** affect the cache key. You will get the same cached component regardless of the arguments passed in. This is different from what you may be used to from something like React useMemo. 3. Ensure the declaration of the cached component is **outside the function** that uses it. This is to prevent the component from being redeclared on each request. diff --git a/htmgo-site/md/docs/5_performance/1_caching_per_user.md b/htmgo-site/md/docs/5_performance/1_caching_per_user.md new file mode 100644 index 0000000..5052fef --- /dev/null +++ b/htmgo-site/md/docs/5_performance/1_caching_per_user.md @@ -0,0 +1,80 @@ +**Caching Components Per User** + +If you need to cache a component per user, you can use the `CachedPerKey` functions. +These functions allow you to cache a component by a specific key. This key can be any string that uniquely identifies the user. + +Note: I'm using the term 'user' to simply mean a unique identifier. This could be a user ID, session ID, or any other unique identifier. + +To cache a component by unique identifier / key in htmgo, we offer: + +```go +// No arguments passed to the component, the component can be cached by a specific key +h.CachedPerKey(duration time.Duration, cb GetElementFuncWithKey) +// One argument passed to the component, the component can be cached by a specific key +h.CachedPerKeyT1(duration time.Duration, cb GetElementFuncWithKey) +// Two argument passed to the component, the component can be cached by a specific key +h.CachedPerKeyT2(duration time.Duration, cb GetElementFuncWithKey) +// Three arguments passed to the component, the component can be cached by a specific key +h.CachedPerKeyT3(duration time.Duration, cb GetElementFuncWithKey) +// Four arguments passed to the component, the component can be cached by a specific key +h.CachedPerKeyT4(duration time.Duration, cb GetElementFuncWithKey) + +``` + +The `duration` parameter is the time the component should be cached for. The `cb` parameter is a function that returns the component and the key. + +When a request is made for a cached component, the component is rendered and stored in memory. Subsequent requests for the same component with the same key within the cache duration will return the cached component instead of rendering it again. + +**Usage:** + +```go +var CachedUserDocuments = h.CachedPerKeyT(time.Minute*15, func(ctx *h.RequestContext) (string, h.GetElementFunc) { + userId := getUserIdFromSession(ctx) + return userId, func() *h.Element { + return UserDocuments(ctx) + } +}) + +func UserDocuments(ctx *h.RequestContext) *h.Element { + docService := NewDocumentService(ctx) + // Expensive call + docs := docService.getDocuments() + return h.Div( + h.Class("grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"), + h.List(docs, func(doc Document, index int) *h.Element { + return h.Div( + h.Class("p-4 bg-white border border-gray-200 rounded-md"), + h.H3(doc.Title), + h.P(doc.Description), + ) + }), + ) +} + +func MyPage(ctx *h.RequestContext) *h.Page { + + // Note this is not a real way to create a context, just an example + user1 := &h.RequestContext{ + Session: "user_1_session", + } + + user2 := &h.RequestContext{ + Session: "user_2_session", + } + + // Different users will get different cached components + return h.NewPage( + CachedUserDocuments(user1), + CachedUserDocuments(user2), + ) +} + +``` + +**Note:** We are using CachedPerKeyT because the component takes one argument, the RequestContext. +If the component takes more arguments, use CachedPerKeyT2, CachedPerKeyT3, etc. + +**Important** +1. The cached value is stored globally in memory by key, it is shared across all requests. Ensure if you are storing request-specific data in a cached component, you are using a unique key for each user. +2. The arguments passed into cached component **DO NOT** affect the cache key. The only thing that affects the cache key is the key returned by the `GetElementFuncWithKey` function. +3. Ensure the declaration of the cached component is **outside the function** that uses it. This is to prevent the component from being redeclared on each request.