Skip to content

Commit 34009ba

Browse files
authored
Lazy-load HTML templates behind sync.Once (#59)
Templates are parsed on first Render call instead of at server startup. API-only traffic never pays the ~780µs parsing cost. Closes #53
1 parent ec9c437 commit 34009ba

4 files changed

Lines changed: 91 additions & 62 deletions

File tree

internal/server/server.go

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -121,20 +121,12 @@ func New(cfg *config.Config, logger *slog.Logger) (*Server, error) {
121121
return nil, fmt.Errorf("verifying storage connectivity: %w", err)
122122
}
123123

124-
// Load templates
125-
templates, err := NewTemplates()
126-
if err != nil {
127-
_ = store.Close()
128-
_ = db.Close()
129-
return nil, fmt.Errorf("loading templates: %w", err)
130-
}
131-
132124
return &Server{
133125
cfg: cfg,
134126
db: db,
135127
storage: store,
136128
logger: logger,
137-
templates: templates,
129+
templates: &Templates{},
138130
}, nil
139131
}
140132

internal/server/server_test.go

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,21 +79,13 @@ func newTestServer(t *testing.T) *testServer {
7979
r.Mount("/go", http.StripPrefix("/go", goHandler.Routes()))
8080
r.Mount("/pypi", http.StripPrefix("/pypi", pypiHandler.Routes()))
8181

82-
// Load templates
83-
templates, err := NewTemplates()
84-
if err != nil {
85-
_ = db.Close()
86-
_ = os.RemoveAll(tempDir)
87-
t.Fatalf("failed to load templates: %v", err)
88-
}
89-
9082
// Create a minimal server struct for the handlers
9183
s := &Server{
9284
cfg: cfg,
9385
db: db,
9486
storage: store,
9587
logger: logger,
96-
templates: templates,
88+
templates: &Templates{},
9789
}
9890

9991
r.Get("/health", s.handleHealth)

internal/server/templates.go

Lines changed: 44 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -5,61 +5,70 @@ import (
55
"html/template"
66
"net/http"
77
"path/filepath"
8+
"sync"
89
)
910

1011
//go:embed templates/**/*.html
1112
var templatesFS embed.FS
1213

13-
// Templates holds parsed templates for each page.
14+
// Templates holds lazily-parsed templates for each page.
1415
type Templates struct {
16+
once sync.Once
1517
pages map[string]*template.Template
18+
err error
1619
}
1720

18-
// NewTemplates loads and parses all templates from the embedded filesystem.
19-
func NewTemplates() (*Templates, error) {
20-
pages := make(map[string]*template.Template)
21+
// load parses all templates from the embedded filesystem on first call.
22+
func (t *Templates) load() error {
23+
t.once.Do(func() {
24+
pages := make(map[string]*template.Template)
2125

22-
// Define custom template functions
23-
funcMap := template.FuncMap{
24-
"add": func(a, b int) int { return a + b },
25-
"sub": func(a, b int) int { return a - b },
26-
"supportedEcosystems": supportedEcosystems,
27-
"ecosystemBadgeClass": ecosystemBadgeClasses,
28-
"ecosystemBadgeLabel": ecosystemBadgeLabel,
29-
}
30-
31-
// Get all page files
32-
pageFiles, err := templatesFS.ReadDir("templates/pages")
33-
if err != nil {
34-
return nil, err
35-
}
36-
37-
for _, pageFile := range pageFiles {
38-
if pageFile.IsDir() {
39-
continue
26+
funcMap := template.FuncMap{
27+
"add": func(a, b int) int { return a + b },
28+
"sub": func(a, b int) int { return a - b },
29+
"supportedEcosystems": supportedEcosystems,
30+
"ecosystemBadgeClass": ecosystemBadgeClasses,
31+
"ecosystemBadgeLabel": ecosystemBadgeLabel,
4032
}
4133

42-
pageName := pageFile.Name()
43-
pageName = pageName[:len(pageName)-len(filepath.Ext(pageName))]
44-
45-
// Parse all layout files + components + this page with custom functions
46-
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templatesFS,
47-
"templates/layout/*.html",
48-
"templates/components/*.html",
49-
"templates/pages/"+pageFile.Name(),
50-
)
34+
pageFiles, err := templatesFS.ReadDir("templates/pages")
5135
if err != nil {
52-
return nil, err
36+
t.err = err
37+
return
5338
}
5439

55-
pages[pageName] = tmpl
56-
}
40+
for _, pageFile := range pageFiles {
41+
if pageFile.IsDir() {
42+
continue
43+
}
5744

58-
return &Templates{pages: pages}, nil
45+
pageName := pageFile.Name()
46+
pageName = pageName[:len(pageName)-len(filepath.Ext(pageName))]
47+
48+
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templatesFS,
49+
"templates/layout/*.html",
50+
"templates/components/*.html",
51+
"templates/pages/"+pageFile.Name(),
52+
)
53+
if err != nil {
54+
t.err = err
55+
return
56+
}
57+
58+
pages[pageName] = tmpl
59+
}
60+
61+
t.pages = pages
62+
})
63+
return t.err
5964
}
6065

6166
// Render renders a page template with the given data.
6267
func (t *Templates) Render(w http.ResponseWriter, pageName string, data any) error {
68+
if err := t.load(); err != nil {
69+
return err
70+
}
71+
6372
w.Header().Set("Content-Type", "text/html; charset=utf-8")
6473

6574
tmpl, ok := t.pages[pageName]

internal/server/templates_test.go

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,7 @@ import (
1111
)
1212

1313
func TestTemplatesRenderAllPages(t *testing.T) {
14-
templates, err := NewTemplates()
15-
if err != nil {
16-
t.Fatalf("failed to load templates: %v", err)
17-
}
14+
templates := &Templates{}
1815

1916
tests := []struct {
2017
page string
@@ -156,14 +153,26 @@ func TestTemplatesRenderAllPages(t *testing.T) {
156153
}
157154
}
158155

159-
func TestTemplatesRenderUnknownPage(t *testing.T) {
160-
templates, err := NewTemplates()
161-
if err != nil {
162-
t.Fatalf("failed to load templates: %v", err)
156+
func TestTemplatesLazyLoading(t *testing.T) {
157+
templates := &Templates{}
158+
159+
if templates.pages != nil {
160+
t.Fatal("expected pages to be nil before first Render call")
163161
}
164162

165163
w := httptest.NewRecorder()
166-
err = templates.Render(w, "nonexistent_page", nil)
164+
_ = templates.Render(w, "dashboard", DashboardData{})
165+
166+
if templates.pages == nil {
167+
t.Fatal("expected pages to be populated after first Render call")
168+
}
169+
}
170+
171+
func TestTemplatesRenderUnknownPage(t *testing.T) {
172+
templates := &Templates{}
173+
174+
w := httptest.NewRecorder()
175+
err := templates.Render(w, "nonexistent_page", nil)
167176
if err == nil {
168177
t.Error("expected error for unknown page")
169178
}
@@ -424,3 +433,30 @@ func TestCategorizeLicense(t *testing.T) {
424433
}
425434
}
426435
}
436+
437+
func BenchmarkTemplatesParse(b *testing.B) {
438+
for b.Loop() {
439+
t := &Templates{}
440+
if err := t.load(); err != nil {
441+
b.Fatal(err)
442+
}
443+
}
444+
}
445+
446+
func BenchmarkServerCreate(b *testing.B) {
447+
for b.Loop() {
448+
_ = &Server{
449+
templates: &Templates{},
450+
}
451+
}
452+
}
453+
454+
func BenchmarkFirstRender(b *testing.B) {
455+
for b.Loop() {
456+
t := &Templates{}
457+
w := httptest.NewRecorder()
458+
if err := t.Render(w, "dashboard", DashboardData{}); err != nil {
459+
b.Fatal(err)
460+
}
461+
}
462+
}

0 commit comments

Comments
 (0)