From 010ab1fdd60b30539f8043642fa2cdaa2487d8e2 Mon Sep 17 00:00:00 2001 From: maddalax Date: Fri, 27 Sep 2024 21:29:53 -0500 Subject: [PATCH] add caching component support --- framework/h/cache.go | 117 ++++++++++++++++++ framework/h/conditionals.go | 8 ++ framework/h/render_test.go | 87 +++++++++++-- framework/h/renderer.go | 12 ++ framework/h/tag.go | 1 + htmgo-site/md/docs/5_performance/1_caching.md | 51 ++++++++ htmgo-site/pages/index.go | 2 +- htmgo-site/partials/navbar.go | 36 +++--- htmgo-site/partials/sidebar.go | 1 - 9 files changed, 289 insertions(+), 26 deletions(-) create mode 100644 framework/h/cache.go create mode 100644 htmgo-site/md/docs/5_performance/1_caching.md diff --git a/framework/h/cache.go b/framework/h/cache.go new file mode 100644 index 0000000..0c02056 --- /dev/null +++ b/framework/h/cache.go @@ -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() +} diff --git a/framework/h/conditionals.go b/framework/h/conditionals.go index 63b62fa..7705b26 100644 --- a/framework/h/conditionals.go +++ b/framework/h/conditionals.go @@ -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 diff --git a/framework/h/render_test.go b/framework/h/render_test.go index 12464fe..926c485 100644 --- a/framework/h/render_test.go +++ b/framework/h/render_test.go @@ -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 := `
hello, world
hello, child
` - 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( diff --git a/framework/h/renderer.go b/framework/h/renderer.go index 9f245c9..79877fd 100644 --- a/framework/h/renderer.go +++ b/framework/h/renderer.go @@ -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) diff --git a/framework/h/tag.go b/framework/h/tag.go index 9e62bb3..9fde493 100644 --- a/framework/h/tag.go +++ b/framework/h/tag.go @@ -12,6 +12,7 @@ type PartialFunc = func(ctx *RequestContext) *Partial type Element struct { tag string attributes map[string]string + meta any children []Ren } diff --git a/htmgo-site/md/docs/5_performance/1_caching.md b/htmgo-site/md/docs/5_performance/1_caching.md new file mode 100644 index 0000000..b793e30 --- /dev/null +++ b/htmgo-site/md/docs/5_performance/1_caching.md @@ -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. \ No newline at end of file diff --git a/htmgo-site/pages/index.go b/htmgo-site/pages/index.go index d60283d..91cde87 100644 --- a/htmgo-site/pages/index.go +++ b/htmgo-site/pages/index.go @@ -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( diff --git a/htmgo-site/partials/navbar.go b/htmgo-site/partials/navbar.go index b336e22..ea9a396 100644 --- a/htmgo-site/partials/navbar.go +++ b/htmgo-site/partials/navbar.go @@ -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) { - response, err := httpjson.Get[Repo]("https://api.github.com/repos/maddalax/htmgo") - if err != nil { - return 0, false - } - return response.StarCount, true - }) + fmt.Printf("making github star request\n") + count := 0 + response, err := httpjson.Get[Repo]("https://api.github.com/repos/maddalax/htmgo") + + if err == nil && response != nil { + count = response.StarCount + } 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(), diff --git a/htmgo-site/partials/sidebar.go b/htmgo-site/partials/sidebar.go index d54e4fd..dd5fd6e 100644 --- a/htmgo-site/partials/sidebar.go +++ b/htmgo-site/partials/sidebar.go @@ -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)),