diff --git a/examples/html_rendering/index.tmpl b/examples/html_rendering/index.tmpl
new file mode 100644
index 000000000..5e5a2612c
--- /dev/null
+++ b/examples/html_rendering/index.tmpl
@@ -0,0 +1,5 @@
+
+
+ {[{ .title }]}
+
+
\ No newline at end of file
diff --git a/examples/html_rendering/main.go b/examples/html_rendering/main.go
new file mode 100644
index 000000000..ad5c08058
--- /dev/null
+++ b/examples/html_rendering/main.go
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2022 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package main
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "time"
+
+ "github.com/cloudwego/hertz/pkg/app"
+ "github.com/cloudwego/hertz/pkg/app/server"
+ "github.com/cloudwego/hertz/pkg/common/utils"
+ "github.com/cloudwego/hertz/pkg/protocol/consts"
+)
+
+func formatAsDate(t time.Time) string {
+ year, month, day := t.Date()
+ return fmt.Sprintf("%d/%02d/%02d", year, month, day)
+}
+
+func main() {
+ // set interval to 0 means using fs-watching mechanism.
+ h := server.Default(server.WithAutoReloadRender(true, 0))
+
+ h.Delims("{[{", "}]}")
+
+ h.SetFuncMap(template.FuncMap{
+ "formatAsDate": formatAsDate,
+ })
+ h.LoadHTMLGlob("./examples/html_rendering/*")
+
+ h.GET("/index", func(c context.Context, ctx *app.RequestContext) {
+ ctx.HTML(consts.StatusOK, "index.tmpl", utils.H{
+ "title": "Main website",
+ })
+ })
+
+ h.GET("/raw", func(c context.Context, ctx *app.RequestContext) {
+ ctx.HTML(consts.StatusOK, "template.html", utils.H{
+ "now": time.Date(2017, 0o7, 0o1, 0, 0, 0, 0, time.UTC),
+ })
+ })
+
+ h.Spin()
+}
diff --git a/examples/html_rendering/template.html b/examples/html_rendering/template.html
new file mode 100644
index 000000000..b37104a7c
--- /dev/null
+++ b/examples/html_rendering/template.html
@@ -0,0 +1 @@
+Date: {[{.now | formatAsDate}]}
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 9cf0e6156..e6029c73f 100644
--- a/go.mod
+++ b/go.mod
@@ -7,7 +7,8 @@ require (
github.com/bytedance/gopkg v0.0.0-20220413063733-65bf48ffb3a7
github.com/bytedance/sonic v1.3.0
github.com/cloudwego/netpoll v0.2.4
+ github.com/fsnotify/fsnotify v1.5.4
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
- golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe
+ golang.org/x/sys v0.0.0-20220412211240-33da011f77ad
google.golang.org/protobuf v1.27.1
)
diff --git a/go.sum b/go.sum
index 8444b8eec..ed2865da6 100644
--- a/go.sum
+++ b/go.sum
@@ -11,6 +11,8 @@ github.com/cloudwego/netpoll v0.2.4/go.mod h1:1T2WVuQ+MQw6h6DpE45MohSvDTKdy2DlzC
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI=
+github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
github.com/goccy/go-json v0.9.4 h1:L8MLKG2mvVXiQu07qB6hmfqeSYQdOnqPot2GhsIwIaI=
github.com/goccy/go-json v0.9.4/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -58,8 +60,9 @@ golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VA
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe h1:W8vbETX/n8S6EmY0Pu4Ix7VvpsJUESTwl0oCK8MJOgk=
golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0=
+golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
diff --git a/pkg/app/server/option.go b/pkg/app/server/option.go
index c67ddcc58..ccfa97a01 100644
--- a/pkg/app/server/option.go
+++ b/pkg/app/server/option.go
@@ -277,3 +277,14 @@ func WithRegistry(r registry.Registry, info *registry.Info) config.Option {
o.RegistryInfo = info
}}
}
+
+// WithAutoReloadRender sets the config of auto reload render.
+// If auto reload render is enabled:
+// 1. interval = 0 means reload render according to file watch mechanism.(recommended)
+// 2. interval > 0 means reload render every interval.
+func WithAutoReloadRender(b bool, interval time.Duration) config.Option {
+ return config.Option{F: func(o *config.Options) {
+ o.AutoReloadRender = b
+ o.AutoReloadInterval = interval
+ }}
+}
diff --git a/pkg/app/server/option_test.go b/pkg/app/server/option_test.go
index 4f3a885ed..f45eb22c7 100644
--- a/pkg/app/server/option_test.go
+++ b/pkg/app/server/option_test.go
@@ -56,6 +56,7 @@ func TestOptions(t *testing.T) {
WithALPN(true),
WithTraceLevel(stats.LevelDisabled),
WithRegistry(nil, info),
+ WithAutoReloadRender(true, 5*time.Second),
})
assert.DeepEqual(t, opt.ReadTimeout, time.Second)
assert.DeepEqual(t, opt.IdleTimeout, time.Second)
@@ -79,6 +80,8 @@ func TestOptions(t *testing.T) {
assert.DeepEqual(t, opt.TraceLevel, stats.LevelDisabled)
assert.DeepEqual(t, opt.RegistryInfo, info)
assert.DeepEqual(t, opt.Registry, nil)
+ assert.DeepEqual(t, opt.AutoReloadRender, true)
+ assert.DeepEqual(t, opt.AutoReloadInterval, 5*time.Second)
}
func TestDefaultOptions(t *testing.T) {
@@ -103,5 +106,8 @@ func TestDefaultOptions(t *testing.T) {
assert.DeepEqual(t, opt.ReadBufferSize, 4096)
assert.DeepEqual(t, opt.ALPN, false)
assert.DeepEqual(t, opt.Registry, registry.NoopRegistry)
+ assert.DeepEqual(t, opt.AutoReloadRender, false)
assert.Assert(t, opt.RegistryInfo == nil)
+ assert.DeepEqual(t, opt.AutoReloadRender, false)
+ assert.DeepEqual(t, opt.AutoReloadInterval, time.Duration(0))
}
diff --git a/pkg/app/server/render/html.go b/pkg/app/server/render/html.go
index 12f503fda..03cf687a5 100644
--- a/pkg/app/server/render/html.go
+++ b/pkg/app/server/render/html.go
@@ -43,8 +43,13 @@ package render
import (
"html/template"
+ "log"
+ "sync"
+ "time"
+ "github.com/cloudwego/hertz/pkg/common/hlog"
"github.com/cloudwego/hertz/pkg/protocol"
+ "github.com/fsnotify/fsnotify"
)
// Delims represents a set of Left and Right delimiters for HTML template rendering.
@@ -59,12 +64,12 @@ type Delims struct {
type HTMLRender interface {
// Instance returns an HTML instance.
Instance(string, interface{}) Render
+ Close() error
}
// HTMLProduction contains template reference and its delims.
type HTMLProduction struct {
Template *template.Template
- Delims Delims
}
// HTML contains template reference and its name with given interface object.
@@ -85,6 +90,10 @@ func (r HTMLProduction) Instance(name string, data interface{}) Render {
}
}
+func (r HTMLProduction) Close() error {
+ return nil
+}
+
// Render (HTML) executes template and writes its result with custom ContentType for response.
func (r HTML) Render(resp *protocol.Response) error {
r.WriteContentType(resp)
@@ -99,3 +108,105 @@ func (r HTML) Render(resp *protocol.Response) error {
func (r HTML) WriteContentType(resp *protocol.Response) {
writeContentType(resp, htmlContentType)
}
+
+type HTMLDebug struct {
+ sync.Once
+ Template *template.Template
+ RefreshInterval time.Duration
+ updateTimeStamp time.Time
+
+ Files []string
+ FuncMap template.FuncMap
+ Delims Delims
+
+ reloadCh chan struct{}
+ watcher *fsnotify.Watcher
+}
+
+func (h *HTMLDebug) Instance(name string, data interface{}) Render {
+ h.Do(func() {
+ h.startChecker()
+ })
+
+ select {
+ case <-h.reloadCh:
+ h.reload()
+ default:
+ }
+
+ return HTML{
+ Template: h.Template,
+ Name: name,
+ Data: data,
+ }
+}
+
+func (h *HTMLDebug) Close() error {
+ if h.watcher == nil {
+ return nil
+ }
+ return h.watcher.Close()
+}
+
+func (h *HTMLDebug) reload() {
+ h.Template = template.Must(template.New("").
+ Delims(h.Delims.Left, h.Delims.Right).
+ Funcs(h.FuncMap).
+ ParseFiles(h.Files...))
+}
+
+func (h *HTMLDebug) startChecker() {
+ h.reloadCh = make(chan struct{})
+
+ if h.RefreshInterval > 0 {
+ go func() {
+ hlog.Debugf("HERTZ[HTMLDebug]: HTML template reloader started with interval %v", h.RefreshInterval)
+ for {
+ n := time.Now()
+ if n.UTC().Sub(h.updateTimeStamp.UTC()) > h.RefreshInterval {
+ hlog.Debugf("HERTZ[HTMLDebug]: triggering HTML template reloader")
+ h.reloadCh <- struct{}{}
+ hlog.Debugf("HERTZ[HTMLDebug]: HTML template has been reloaded, next reload in %v", h.RefreshInterval)
+ h.updateTimeStamp = time.Now()
+ }
+ }
+ }()
+ return
+ }
+
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ log.Fatal(err)
+ }
+ h.watcher = watcher
+ for _, f := range h.Files {
+ err := watcher.Add(f)
+ hlog.Debugf("HERTZ[HTMLDebug]: watching file: %s", f)
+ if err != nil {
+ hlog.Errorf("HERTZ[HTMLDebug]: add watching file: %s, error happened: %v", f, err)
+ }
+
+ }
+
+ go func() {
+ hlog.Debugf("HERTZ[HTMLDebug]: HTML template reloader started with file watcher")
+ for {
+ select {
+ case event, ok := <-watcher.Events:
+ if !ok {
+ return
+ }
+ if event.Op&fsnotify.Write == fsnotify.Write {
+ hlog.Debugf("HERTZ[HTMLDebug]: modified file: %s, html render template will be reloaded at the next rendering", event.Name)
+ h.reloadCh <- struct{}{}
+ hlog.Debugf("HERTZ[HTMLDebug]: HTML template has been reloaded")
+ }
+ case err, ok := <-watcher.Errors:
+ if !ok {
+ return
+ }
+ hlog.Errorf("HERTZ: error happened when watching the rendering files: %v", err)
+ }
+ }
+ }()
+}
diff --git a/pkg/app/server/render/html_test.go b/pkg/app/server/render/html_test.go
new file mode 100644
index 000000000..86fa98103
--- /dev/null
+++ b/pkg/app/server/render/html_test.go
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2022 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package render
+
+import (
+ "os"
+ "testing"
+ "time"
+)
+
+func TestHTMLDebug_StartChecker_timer(t *testing.T) {
+ render := &HTMLDebug{RefreshInterval: 2 * time.Second}
+ select {
+ case <-render.reloadCh:
+ t.Fatalf("should not be triggered")
+ default:
+ }
+ render.startChecker()
+ select {
+ case <-time.After(50 * time.Millisecond):
+ t.Fatalf("should be triggered immediately")
+ case <-render.reloadCh:
+
+ }
+ select {
+ // some ci servers have poor computing power, so we add some extra time here.
+ case <-time.After(2*time.Second + 50*time.Millisecond):
+ t.Fatalf("should be triggered before 2 seconds")
+ case <-render.reloadCh:
+ t.Logf("paas")
+ }
+}
+
+func TestHTMLDebug_StartChecker_fs_watcher(t *testing.T) {
+ f, _ := os.CreateTemp("./", "test.tmpl")
+ defer func() {
+ f.Close()
+ os.Remove(f.Name())
+ }()
+ render := &HTMLDebug{Files: []string{f.Name()}}
+ select {
+ case <-render.reloadCh:
+ t.Fatalf("should not be triggered")
+ default:
+ }
+ render.startChecker()
+ f.Write([]byte("hello"))
+
+ select {
+ case <-time.After(50 * time.Millisecond):
+ t.Fatalf("should be triggered immediately")
+ case <-render.reloadCh:
+ }
+ select {
+ case <-render.reloadCh:
+ t.Fatalf("should not be triggered")
+ default:
+ }
+}
diff --git a/pkg/common/config/option.go b/pkg/common/config/option.go
index 868b47686..1fc2789ab 100644
--- a/pkg/common/config/option.go
+++ b/pkg/common/config/option.go
@@ -71,6 +71,12 @@ type Options struct {
Registry registry.Registry
// RegistryInfo is base info used for service registry.
RegistryInfo *registry.Info
+ // Enable automatically HTML template reloading mechanism.
+ AutoReloadRender bool
+ // If AutoReloadInterval is set to 0(default).
+ // The HTML template will reload according to files' changing event
+ // otherwise it will reload after AutoReloadInterval.
+ AutoReloadInterval time.Duration
}
func (o *Options) Apply(opts []Option) {
diff --git a/pkg/route/engine.go b/pkg/route/engine.go
index 618d19ed7..06f1f6218 100644
--- a/pkg/route/engine.go
+++ b/pkg/route/engine.go
@@ -47,6 +47,7 @@ import (
"fmt"
"html/template"
"io"
+ "path/filepath"
"reflect"
"runtime"
"strings"
@@ -419,6 +420,9 @@ func getRemoteAddrFromCloser(conn io.Closer) string {
}
func (engine *Engine) Close() error {
+ if engine.htmlRender != nil {
+ engine.htmlRender.Close() //nolint:errcheck
+ }
return engine.transport.Close()
}
@@ -754,23 +758,54 @@ func (engine *Engine) Use(middleware ...app.HandlerFunc) IRoutes {
// LoadHTMLGlob loads HTML files identified by glob pattern
// and associates the result with HTML renderer.
func (engine *Engine) LoadHTMLGlob(pattern string) {
- left := engine.delims.Left
- right := engine.delims.Right
- templ := template.Must(template.New("").Delims(left, right).Funcs(engine.funcMap).ParseGlob(pattern))
+ tmpl := template.Must(template.New("").
+ Delims(engine.delims.Left, engine.delims.Right).
+ Funcs(engine.funcMap).
+ ParseGlob(pattern))
+
+ if engine.options.AutoReloadRender {
+ files, err := filepath.Glob(pattern)
+ if err != nil {
+ hlog.Errorf("LoadHTMLGlob: %v", err)
+ return
+ }
+ engine.SetAutoReloadHTMLTemplate(tmpl, files)
+ return
+ }
- engine.SetHTMLTemplate(templ)
+ engine.SetHTMLTemplate(tmpl)
}
// LoadHTMLFiles loads a slice of HTML files
// and associates the result with HTML renderer.
func (engine *Engine) LoadHTMLFiles(files ...string) {
- templ := template.Must(template.New("").Delims(engine.delims.Left, engine.delims.Right).Funcs(engine.funcMap).ParseFiles(files...))
- engine.SetHTMLTemplate(templ)
+ tmpl := template.Must(template.New("").
+ Delims(engine.delims.Left, engine.delims.Right).
+ Funcs(engine.funcMap).
+ ParseFiles(files...))
+
+ if engine.options.AutoReloadRender {
+ engine.SetAutoReloadHTMLTemplate(tmpl, files)
+ return
+ }
+
+ engine.SetHTMLTemplate(tmpl)
}
// SetHTMLTemplate associate a template with HTML renderer.
-func (engine *Engine) SetHTMLTemplate(templ *template.Template) {
- engine.htmlRender = render.HTMLProduction{Template: templ.Funcs(engine.funcMap)}
+func (engine *Engine) SetHTMLTemplate(tmpl *template.Template) {
+ engine.htmlRender = render.HTMLProduction{Template: tmpl.Funcs(engine.funcMap)}
+}
+
+// SetAutoReloadHTMLTemplate associate a template with HTML renderer.
+func (engine *Engine) SetAutoReloadHTMLTemplate(tmpl *template.Template, files []string) {
+ engine.htmlRender = &render.HTMLDebug{
+ Template: tmpl,
+ Files: files,
+ FuncMap: engine.funcMap,
+ Delims: engine.delims,
+ RefreshInterval: engine.options.AutoReloadInterval,
+ }
}
// SetFuncMap sets the funcMap used for template.funcMap.
diff --git a/pkg/route/engine_test.go b/pkg/route/engine_test.go
index c646658ce..9b99180e1 100644
--- a/pkg/route/engine_test.go
+++ b/pkg/route/engine_test.go
@@ -391,6 +391,48 @@ func (m *mockTransporter) Shutdown(ctx context.Context) error {
panic("implement me")
}
+func TestRenderHtmlOfGlobWithAutoRender(t *testing.T) {
+ opt := config.NewOptions([]config.Option{})
+ opt.AutoReloadRender = true
+ e := NewEngine(opt)
+ e.Delims("{[{", "}]}")
+ e.SetFuncMap(template.FuncMap{
+ "formatAsDate": formatAsDate,
+ })
+ e.LoadHTMLGlob("../common/testdata/template/htmltemplate.html")
+ e.GET("/templateName", func(c context.Context, ctx *app.RequestContext) {
+ ctx.HTML(http.StatusOK, "htmltemplate.html", map[string]interface{}{
+ "now": time.Date(2017, 0o7, 0o1, 0, 0, 0, 0, time.UTC),
+ })
+ })
+ rr := performRequest(e, "GET", "/templateName")
+ b, _ := ioutil.ReadAll(rr.Body)
+ assert.DeepEqual(t, 200, rr.Code)
+ assert.DeepEqual(t, []byte("Date: 2017/07/01
"), b)
+ assert.DeepEqual(t, "text/html; charset=utf-8", rr.Header().Get("Content-Type"))
+}
+
+func TestRenderHtmlOfFilesWithAutoRender(t *testing.T) {
+ opt := config.NewOptions([]config.Option{})
+ opt.AutoReloadRender = true
+ e := NewEngine(opt)
+ e.Delims("{[{", "}]}")
+ e.SetFuncMap(template.FuncMap{
+ "formatAsDate": formatAsDate,
+ })
+ e.LoadHTMLFiles("../common/testdata/template/htmltemplate.html")
+ e.GET("/templateName", func(c context.Context, ctx *app.RequestContext) {
+ ctx.HTML(http.StatusOK, "htmltemplate.html", map[string]interface{}{
+ "now": time.Date(2017, 0o7, 0o1, 0, 0, 0, 0, time.UTC),
+ })
+ })
+ rr := performRequest(e, "GET", "/templateName")
+ b, _ := ioutil.ReadAll(rr.Body)
+ assert.DeepEqual(t, 200, rr.Code)
+ assert.DeepEqual(t, []byte("Date: 2017/07/01
"), b)
+ assert.DeepEqual(t, "text/html; charset=utf-8", rr.Header().Get("Content-Type"))
+}
+
type mockConn struct{}
func (m *mockConn) ReadBinary(n int) (p []byte, err error) {