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) {