add caching component support
This commit is contained in:
parent
3ee6a35b73
commit
010ab1fdd6
9 changed files with 289 additions and 26 deletions
117
framework/h/cache.go
Normal file
117
framework/h/cache.go
Normal 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()
|
||||
}
|
||||
|
|
@ -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 {
|
||||
if condition {
|
||||
return node
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import (
|
|||
"golang.org/x/net/html"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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
|
||||
func parseSortAndRenderHTML(input string) string {
|
||||
func sortHtmlAttributes(input string) string {
|
||||
// Parse the HTML string into a node tree
|
||||
doc, err := html.Parse(strings.NewReader(input))
|
||||
if err != nil {
|
||||
|
|
@ -73,8 +75,8 @@ func TestRender(t *testing.T) {
|
|||
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>`
|
||||
expected := parseSortAndRenderHTML(expectedRaw)
|
||||
result := parseSortAndRenderHTML(Render(div))
|
||||
expected := sortHtmlAttributes(expectedRaw)
|
||||
result := sortHtmlAttributes(Render(div))
|
||||
|
||||
assert.Equal(t,
|
||||
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) {
|
||||
b.ReportAllocs()
|
||||
ctx := RenderContext{
|
||||
|
|
@ -146,19 +206,32 @@ func BenchmarkMailToDynamic(b *testing.B) {
|
|||
}
|
||||
}
|
||||
|
||||
func BenchmarkComplexPage(b *testing.B) {
|
||||
b.Skip()
|
||||
func BenchmarkCachedComplexPage(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
ctx := RenderContext{
|
||||
builder: &strings.Builder{},
|
||||
}
|
||||
page := ComplexPage()
|
||||
for i := 0; i < b.N; i++ {
|
||||
page.Render(&ctx)
|
||||
CachedComplexPage().Render(&ctx)
|
||||
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 {
|
||||
return Html(
|
||||
Head(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
type CustomElement = string
|
||||
|
||||
var (
|
||||
CachedNodeTag CustomElement = "htmgo_cache_node"
|
||||
)
|
||||
|
||||
/*
|
||||
*
|
||||
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) {
|
||||
// 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 != "" {
|
||||
context.builder.WriteString("<")
|
||||
context.builder.WriteString(node.tag)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ type PartialFunc = func(ctx *RequestContext) *Partial
|
|||
type Element struct {
|
||||
tag string
|
||||
attributes map[string]string
|
||||
meta any
|
||||
children []Ren
|
||||
}
|
||||
|
||||
|
|
|
|||
51
htmgo-site/md/docs/5_performance/1_caching.md
Normal file
51
htmgo-site/md/docs/5_performance/1_caching.md
Normal 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.
|
||||
|
|
@ -8,7 +8,7 @@ import (
|
|||
func IndexPage(ctx *h.RequestContext) *h.Page {
|
||||
return h.NewPage(
|
||||
base.RootPage(ctx, h.Div(
|
||||
h.Class("flex items-center justify-center "),
|
||||
h.Class("flex items-center justify-center"),
|
||||
h.Div(
|
||||
h.Class("w-full px-4 flex flex-col prose md:max-w-3xl mt-6 mx-auto"),
|
||||
h.Div(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
package partials
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/maddalax/htmgo/framework/h"
|
||||
"github.com/maddalax/htmgo/framework/service"
|
||||
"htmgo-site/internal/cache"
|
||||
"htmgo-site/internal/httpjson"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -13,6 +12,15 @@ type NavItem struct {
|
|||
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 {
|
||||
return h.SwapManyPartial(
|
||||
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 {
|
||||
|
||||
type Repo struct {
|
||||
StarCount int `json:"stargazers_count"`
|
||||
}
|
||||
|
||||
simpleCache := service.Get[cache.SimpleCache](ctx.ServiceLocator())
|
||||
count := cache.GetOrSet(simpleCache, "starCount", 10*time.Minute, func() (int, bool) {
|
||||
fmt.Printf("making github star request\n")
|
||||
count := 0
|
||||
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(
|
||||
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.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.Boost(),
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,6 @@ func DocSidebar(pages []*dirwalk.Page) *h.Element {
|
|||
h.Class("pl-4 flex flex-col"),
|
||||
h.List(entry.Value, func(page *dirwalk.Page, index int) *h.Element {
|
||||
anchor := CreateAnchor(page.Parts)
|
||||
println(anchor)
|
||||
return h.A(
|
||||
h.Href("#"+anchor),
|
||||
h.Text(partsToName(page.Parts)),
|
||||
|
|
|
|||
Loading…
Reference in a new issue