add caching component support

This commit is contained in:
maddalax 2024-09-27 21:29:53 -05:00
parent 3ee6a35b73
commit 010ab1fdd6
9 changed files with 289 additions and 26 deletions

117
framework/h/cache.go Normal file
View file

@ -0,0 +1,117 @@
package h
import (
"sync"
"time"
)
type CachedNode struct {
cb func() *Element
mutex sync.Mutex
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
func Cached(duration time.Duration, cb GetElementFunc) func() *Element {
element := &Element{
tag: CachedNodeTag,
meta: &CachedNode{
cb: cb,
html: "",
expiration: time.Now().Add(duration),
},
}
return func() *Element {
return element
}
}
func CachedT[T any](duration time.Duration, cb GetElementFuncT[T]) func(T) *Element {
element := &Element{
tag: CachedNodeTag,
meta: &CachedNode{
html: "",
expiration: time.Now().Add(duration),
mutex: sync.Mutex{},
},
}
return func(data T) *Element {
element.meta.(*CachedNode).cb = func() *Element {
return cb(data)
}
return element
}
}
func CachedT2[T any, T2 any](duration time.Duration, cb GetElementFuncT2[T, T2]) func(T, T2) *Element {
element := &Element{
tag: CachedNodeTag,
meta: &CachedNode{
html: "",
expiration: time.Now().Add(duration),
},
}
return func(data T, data2 T2) *Element {
element.meta.(*CachedNode).cb = func() *Element {
return cb(data, data2)
}
return element
}
}
func CachedT3[T any, T2 any, T3 any](duration time.Duration, cb GetElementFuncT3[T, T2, T3]) func(T, T2, T3) *Element {
element := &Element{
tag: CachedNodeTag,
meta: &CachedNode{
html: "",
expiration: time.Now().Add(duration),
},
}
return func(data T, data2 T2, data3 T3) *Element {
element.meta.(*CachedNode).cb = func() *Element {
return cb(data, data2, data3)
}
return element
}
}
func CachedT4[T any, T2 any, T3 any, T4 any](duration time.Duration, cb GetElementFuncT4[T, T2, T3, T4]) func(T, T2, T3, T4) *Element {
element := &Element{
tag: CachedNodeTag,
meta: &CachedNode{
html: "",
expiration: time.Now().Add(duration),
},
}
return func(data T, data2 T2, data3 T3, data4 T4) *Element {
element.meta.(*CachedNode).cb = func() *Element {
return cb(data, data2, data3, data4)
}
return element
}
}
func (c *CachedNode) ClearCache() {
c.html = ""
}
func (c *CachedNode) Render(ctx *RenderContext) {
c.mutex.Lock()
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)
}
c.mutex.Unlock()
}

View file

@ -20,6 +20,14 @@ func ElementIf(condition bool, element *Element) *Element {
} }
} }
func IfElseE(condition bool, element *Element, element2 *Element) *Element {
if condition {
return element
} else {
return element2
}
}
func IfElse[T any](condition bool, node T, node2 T) T { func IfElse[T any](condition bool, node T, node2 T) T {
if condition { if condition {
return node return node

View file

@ -7,7 +7,9 @@ import (
"golang.org/x/net/html" "golang.org/x/net/html"
"sort" "sort"
"strings" "strings"
"sync"
"testing" "testing"
"time"
) )
// Sort attributes of a node by attribute name // Sort attributes of a node by attribute name
@ -28,7 +30,7 @@ func traverseAndSortAttributes(node *html.Node) {
} }
// Parse HTML, sort attributes, and render back to a string // Parse HTML, sort attributes, and render back to a string
func parseSortAndRenderHTML(input string) string { func sortHtmlAttributes(input string) string {
// Parse the HTML string into a node tree // Parse the HTML string into a node tree
doc, err := html.Parse(strings.NewReader(input)) doc, err := html.Parse(strings.NewReader(input))
if err != nil { if err != nil {
@ -73,8 +75,8 @@ func TestRender(t *testing.T) {
div.attributes["data-attr-1"] = "value" div.attributes["data-attr-1"] = "value"
expectedRaw := `<div data-attr-1="value" id="my-div" data-attr-2="value" data-attr-3="value" data-attr-4="value" hx-on::before-request="this.innerText = 'before request';" hx-on::after-request="this.innerText = 'after request';"><div >hello, world</div>hello, child</div>` expectedRaw := `<div data-attr-1="value" id="my-div" data-attr-2="value" data-attr-3="value" data-attr-4="value" hx-on::before-request="this.innerText = 'before request';" hx-on::after-request="this.innerText = 'after request';"><div >hello, world</div>hello, child</div>`
expected := parseSortAndRenderHTML(expectedRaw) expected := sortHtmlAttributes(expectedRaw)
result := parseSortAndRenderHTML(Render(div)) result := sortHtmlAttributes(Render(div))
assert.Equal(t, assert.Equal(t,
expected, expected,
@ -123,6 +125,64 @@ func TestTagSelfClosing(t *testing.T) {
)) ))
} }
func TestCached(t *testing.T) {
t.Parallel()
count := 0
page := Cached(time.Hour, func() *Element {
count++
return ComplexPage()
})
firstRender := sortHtmlAttributes(Render(page()))
secondRender := sortHtmlAttributes(Render(page()))
assert.Equal(t, firstRender, secondRender)
assert.Equal(t, 1, count)
assert.Equal(t, firstRender, sortHtmlAttributes(Render(ComplexPage())))
}
func TestCachedExpired(t *testing.T) {
t.Parallel()
count := 0
page := Cached(time.Millisecond*3, func() *Element {
count++
return ComplexPage()
})
firstRender := sortHtmlAttributes(Render(page()))
time.Sleep(time.Millisecond * 5)
secondRender := sortHtmlAttributes(Render(page()))
assert.Equal(t, firstRender, secondRender)
assert.Equal(t, 2, count)
}
func TestCacheMultiple(t *testing.T) {
t.Parallel()
count := 0
cachedItem := Cached(time.Hour, func() *Element {
count++
return Div(Text("hello"))
})
wg := sync.WaitGroup{}
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
Render(Div(
cachedItem(),
cachedItem(),
cachedItem(),
))
}()
}
wg.Wait()
assert.Equal(t, 1, count)
}
func BenchmarkMailToStatic(b *testing.B) { func BenchmarkMailToStatic(b *testing.B) {
b.ReportAllocs() b.ReportAllocs()
ctx := RenderContext{ ctx := RenderContext{
@ -146,19 +206,32 @@ func BenchmarkMailToDynamic(b *testing.B) {
} }
} }
func BenchmarkComplexPage(b *testing.B) { func BenchmarkCachedComplexPage(b *testing.B) {
b.Skip()
b.ReportAllocs() b.ReportAllocs()
ctx := RenderContext{ ctx := RenderContext{
builder: &strings.Builder{}, builder: &strings.Builder{},
} }
page := ComplexPage()
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
page.Render(&ctx) CachedComplexPage().Render(&ctx)
ctx.builder.Reset() ctx.builder.Reset()
} }
} }
func BenchmarkComplexPage(b *testing.B) {
b.ReportAllocs()
ctx := RenderContext{
builder: &strings.Builder{},
}
for i := 0; i < b.N; i++ {
ComplexPage().Render(&ctx)
ctx.builder.Reset()
}
}
var CachedComplexPage = Cached(time.Hour, func() *Element {
return ComplexPage()
})
func ComplexPage() *Element { func ComplexPage() *Element {
return Html( return Html(
Head( Head(

View file

@ -7,6 +7,12 @@ import (
"strings" "strings"
) )
type CustomElement = string
var (
CachedNodeTag CustomElement = "htmgo_cache_node"
)
/* /*
* *
void tags are tags that cannot have children void tags are tags that cannot have children
@ -45,6 +51,12 @@ func (ctx *RenderContext) AddScript(funcName string, body string) {
func (node *Element) Render(context *RenderContext) { func (node *Element) Render(context *RenderContext) {
// some elements may not have a tag, such as a Fragment // some elements may not have a tag, such as a Fragment
if node.tag == CachedNodeTag {
meta := node.meta.(*CachedNode)
meta.Render(context)
return
}
if node.tag != "" { if node.tag != "" {
context.builder.WriteString("<") context.builder.WriteString("<")
context.builder.WriteString(node.tag) context.builder.WriteString(node.tag)

View file

@ -12,6 +12,7 @@ type PartialFunc = func(ctx *RequestContext) *Partial
type Element struct { type Element struct {
tag string tag string
attributes map[string]string attributes map[string]string
meta any
children []Ren children []Ren
} }

View file

@ -0,0 +1,51 @@
**Caching Components**
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.
To cache a component in htmgo, we offer:
```go
// No arguments passed to the component
h.Cached(duration time.Duration, cb GetElementFunc)
// One argument passed to the component
h.CachedT(duration time.Duration, cb GetElementFunc)
// Two arguments passed to the component
h.CachedT2(duration time.Duration, cb GetElementFunc)
// Three arguments passed to the component
h.CachedT3(duration time.Duration, cb GetElementFunc)
// Four arguments passed to the component
h.CachedT4(duration time.Duration, cb GetElementFunc)
```
The `duration` parameter is the time the component should be cached for. The `cb` parameter is a function that returns the component.
When a request is made for a cached component, the component is rendered and stored in memory. Subsequent requests for the same component within the cache duration will return the cached component instead of rendering it again.
**Usage:**
```go
func ExpensiveComponent(ctx *h.RequestContext) *h.Element {
// Some expensive call
data := http.Get("https://api.example.com/data")
return h.Div(
h.Text(data),
)
}
var CachedComponent = h.CachedT(5*time.Minute, func(ctx *h.RequestContext) *h.Element {
return ExpensiveComponent(ctx)
})
func IndexPage(ctx *h.RequestContext) *h.Page {
return h.NewPage(
CachedComponent(ctx),
)
}
```
**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.
2. 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

@ -8,7 +8,7 @@ import (
func IndexPage(ctx *h.RequestContext) *h.Page { func IndexPage(ctx *h.RequestContext) *h.Page {
return h.NewPage( return h.NewPage(
base.RootPage(ctx, h.Div( base.RootPage(ctx, h.Div(
h.Class("flex items-center justify-center "), h.Class("flex items-center justify-center"),
h.Div( h.Div(
h.Class("w-full px-4 flex flex-col prose md:max-w-3xl mt-6 mx-auto"), h.Class("w-full px-4 flex flex-col prose md:max-w-3xl mt-6 mx-auto"),
h.Div( h.Div(

View file

@ -1,9 +1,8 @@
package partials package partials
import ( import (
"fmt"
"github.com/maddalax/htmgo/framework/h" "github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/service"
"htmgo-site/internal/cache"
"htmgo-site/internal/httpjson" "htmgo-site/internal/httpjson"
"time" "time"
) )
@ -13,6 +12,15 @@ type NavItem struct {
Url string Url string
} }
var navItems = []NavItem{
{Name: "Docs", Url: "/docs"},
{Name: "Examples", Url: "/examples"},
}
var CachedStar = h.CachedT(time.Minute*15, func(t *h.RequestContext) *h.Element {
return Star(t)
})
func ToggleNavbar(ctx *h.RequestContext) *h.Partial { func ToggleNavbar(ctx *h.RequestContext) *h.Partial {
return h.SwapManyPartial( return h.SwapManyPartial(
ctx, ctx,
@ -20,25 +28,19 @@ func ToggleNavbar(ctx *h.RequestContext) *h.Partial {
) )
} }
var navItems = []NavItem{
{Name: "Docs", Url: "/docs"},
{Name: "Examples", Url: "/examples"},
}
func Star(ctx *h.RequestContext) *h.Element { func Star(ctx *h.RequestContext) *h.Element {
type Repo struct { type Repo struct {
StarCount int `json:"stargazers_count"` StarCount int `json:"stargazers_count"`
} }
simpleCache := service.Get[cache.SimpleCache](ctx.ServiceLocator()) fmt.Printf("making github star request\n")
count := cache.GetOrSet(simpleCache, "starCount", 10*time.Minute, func() (int, bool) { count := 0
response, err := httpjson.Get[Repo]("https://api.github.com/repos/maddalax/htmgo") response, err := httpjson.Get[Repo]("https://api.github.com/repos/maddalax/htmgo")
if err != nil {
return 0, false if err == nil && response != nil {
} count = response.StarCount
return response.StarCount, true }
})
return h.A( return h.A(
h.Href("https://github.com/maddalax/htmgo"), h.Href("https://github.com/maddalax/htmgo"),
@ -98,7 +100,7 @@ func NavBar(ctx *h.RequestContext, expanded bool) *h.Element {
), ),
) )
}), }),
Star(ctx), CachedStar(ctx),
), ),
), ),
), ),
@ -130,7 +132,7 @@ func MobileNav(ctx *h.RequestContext, expanded bool) *h.Element {
)), )),
h.Div( h.Div(
h.Class("flex items-center gap-3"), h.Class("flex items-center gap-3"),
h.Div(h.Class("mt-1"), Star(ctx)), h.Div(h.Class("mt-1"), CachedStar(ctx)),
h.Button( h.Button(
h.Boost(), h.Boost(),

View file

@ -69,7 +69,6 @@ func DocSidebar(pages []*dirwalk.Page) *h.Element {
h.Class("pl-4 flex flex-col"), h.Class("pl-4 flex flex-col"),
h.List(entry.Value, func(page *dirwalk.Page, index int) *h.Element { h.List(entry.Value, func(page *dirwalk.Page, index int) *h.Element {
anchor := CreateAnchor(page.Parts) anchor := CreateAnchor(page.Parts)
println(anchor)
return h.A( return h.A(
h.Href("#"+anchor), h.Href("#"+anchor),
h.Text(partsToName(page.Parts)), h.Text(partsToName(page.Parts)),