add caching per key

This commit is contained in:
maddalax 2024-09-28 11:27:07 -05:00
parent 8c07bd7219
commit 17bb55655e
5 changed files with 462 additions and 28 deletions

View file

@ -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)
}

View file

@ -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, "<p >one</p>", Render(cachedItem("one")))
assert.Equal(t, "<p >two</p>", Render(cachedItem("two")))
assert.Equal(t, "<p >two</p>", 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, "<p >one</p>", Render(cachedItem("one")))
assert.Equal(t, "<p >two</p>", Render(cachedItem("two")))
time.Sleep(time.Millisecond * 2)
assert.Equal(t, "<p >two</p>", Render(cachedItem("two")))
assert.Equal(t, 3, renderCount)
}
func BenchmarkMailToStatic(b *testing.B) {
b.ReportAllocs()
ctx := RenderContext{

View file

@ -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)

View file

@ -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).
<br>
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.

View file

@ -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.