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 {
|
func IfElse[T any](condition bool, node T, node2 T) T {
|
||||||
if condition {
|
if condition {
|
||||||
return node
|
return node
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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 {
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue