Repository: cloudwego/hertz
Pull request #223 (2022-08-31 16:02:44)

optimize: add RequestContext.SetCookie Method
#### What type of PR is this?
optimize

#### Check the PR title.
- [x] This PR title match the format: \<type\>(optional scope): \<description\>
- [x] The description of this PR title is user-oriented and clear enough for others to understand.


#### (Optional) Translate the PR title into Chinese.
- 添加 RequestContext.SetCookie 方法

#### (Optional) More detail description for this PR(en: English/zh: Chinese).
en: add RequestContext.SetCookie Method
zh(optional): 添加 RequestContext.SetCookie 方法

#### Which issue(s) this PR fixes:
currently hertz is used to set cookies:
```go
 h.GET("/cookie", func(ctx context.Context, c *app.RequestContext) {
     // set a cookie
     cookie := protocol.AcquireCookie()
     cookie.SetKey("myCookie")
     cookie.SetValue("a nice cookie!")
     cookie.SetExpire(time.Now().Add(3600 * time.Second))
     cookie.SetPath("/")
     cookie.SetHTTPOnly(true)
     cookie.SetSecure(false)
     c.Response.Header.SetCookie(cookie)
     protocol.ReleaseCookie(cookie)
     c.WriteString("A cookie is ready.")
     return
}
```
after:
```go
 h.GET("/cookie", func(ctx context.Context, c *app.RequestContext) {
     // set a cookie
     c.SetCookie("user", "hertz", 1, "/", "localhost", true, true)
     c.WriteString("A cookie is ready.")
     return
}
```


li-jin-gou: currently hertz is used to set cookies:
```go
 h.GET("/cookie", func(ctx context.Context, c *app.RequestContext) {
    key := "myCookie"
     // set a cookie
     cookie := protocol.AcquireCookie()
     cookie.SetKey("myCookie")
     cookie.SetValue("a nice cookie!")
     cookie.SetExpire(time.Now().Add(3600 * time.Second))
     cookie.SetPath("/")
     cookie.SetHTTPOnly(true)
     cookie.SetSecure(false)
     c.Response.Header.SetCookie(cookie)
     protocol.ReleaseCookie(cookie)
     c.WriteString("A cookie is ready.")
     return
}
```
after:
```go
 h.GET("/cookie", func(ctx context.Context, c *app.RequestContext) {
    key := "myCookie"
     // set a cookie
     c.SetCookie("user", "gin", 1, "/", "localhost", true, true)
     c.WriteString("A cookie is ready.")
     return
}
```
li-jin-gou: > 把那个详细注释加上呗

done
welkeyever: https://github.com/gin-gonic/gin/pull/1615/files 按照这个直接改呢？看起来目前这个形态是gin为了做兼容性的让步，我们没这个历史包袱，应该可以直接一步到位？
li-jin-gou: > https://github.com/gin-gonic/gin/pull/1615/files 按照这个直接改呢？看起来目前这个形态是gin为了做兼容性的让步，我们没这个历史包袱，应该可以直接一步到位？

感谢，的确是这个样子 :heart_eyes:




Patch:
diff --git a/examples/html_rendering/index.tmpl b/examples/html_rendering/index.tmpl
new file mode 100644
index 0000000..5e5a261
--- /dev/null
+++ b/examples/html_rendering/index.tmpl
@@ -0,0 +1,5 @@
+<html>
+<h1>
+    {[{ .title }]}
+</h1>
+</html>
\ 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 0000000..ad5c080
--- /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 0000000..b37104a
--- /dev/null
+++ b/examples/html_rendering/template.html
@@ -0,0 +1,1 @@
+<h1>Date: {[{.now | formatAsDate}]}</h1>
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 9cf0e61..e6029c7 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 8444b8e..ed2865d 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/context.go b/pkg/app/context.go
index 8409e5e..b146425 100644
--- a/pkg/app/context.go
+++ b/pkg/app/context.go
@@ -47,6 +47,7 @@ import (
 	"io"
 	"mime/multipart"
 	"net"
+	"net/url"
 	"os"
 	"reflect"
 	"strings"
@@ -950,6 +951,40 @@ func (ctx *RequestContext) Cookie(key string) []byte {
 	return ctx.Request.Header.Cookie(key)
 }
 
+// SetCookie adds a Set-Cookie header to the Response's headers.
+//  Parameter introduce:
+//  name and value is used to set cookie's name and value, eg. Set-Cookie: name=value
+//  maxAge is use to set cookie's expiry date, eg. Set-Cookie: name=value; max-age=1
+//  path and domain is used to set the scope of a cookie, eg. Set-Cookie: name=value;domain=localhost; path=/;
+//  secure and httpOnly is used to sent cookies securely; eg. Set-Cookie: name=value;HttpOnly; secure;
+//  sameSite let servers specify whether/when cookies are sent with cross-site requests; eg. Set-Cookie: name=value;HttpOnly; secure; SameSite=Lax;
+//
+//  For example:
+//  1. ctx.SetCookie("user", "hertz", 1, "/", "localhost",protocol.CookieSameSiteLaxMode, true, true)
+//  add response header --->  Set-Cookie: user=hertz; max-age=1; domain=localhost; path=/; HttpOnly; secure; SameSite=Lax;
+//  2. ctx.SetCookie("user", "hertz", 10, "/", "localhost",protocol.CookieSameSiteLaxMode, false, false)
+//  add response header --->  Set-Cookie: user=hertz; max-age=10; domain=localhost; path=/; SameSite=Lax;
+//  3. ctx.SetCookie("", "hertz", 10, "/", "localhost",protocol.CookieSameSiteLaxMode, false, false)
+//  add response header --->  Set-Cookie: hertz; max-age=10; domain=localhost; path=/; SameSite=Lax;
+//  4. ctx.SetCookie("user", "", 10, "/", "localhost",protocol.CookieSameSiteLaxMode, false, false)
+//  add response header --->  Set-Cookie: user=; max-age=10; domain=localhost; path=/; SameSite=Lax;
+func (ctx *RequestContext) SetCookie(name, value string, maxAge int, path, domain string, sameSite protocol.CookieSameSite, secure, httpOnly bool) {
+	if path == "" {
+		path = "/"
+	}
+	cookie := protocol.AcquireCookie()
+	defer protocol.ReleaseCookie(cookie)
+	cookie.SetKey(name)
+	cookie.SetValue(url.QueryEscape(value))
+	cookie.SetMaxAge(maxAge)
+	cookie.SetPath(path)
+	cookie.SetDomain(domain)
+	cookie.SetSecure(secure)
+	cookie.SetHTTPOnly(httpOnly)
+	cookie.SetSameSite(sameSite)
+	ctx.Response.Header.SetCookie(cookie)
+}
+
 // UserAgent returns the value of the request user_agent.
 func (ctx *RequestContext) UserAgent() []byte {
 	return ctx.Request.Header.UserAgent()
diff --git a/pkg/app/server/option.go b/pkg/app/server/option.go
index c67ddcc..ccfa97a 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/render/html.go b/pkg/app/server/render/html.go
index 12f503f..03cf687 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/common/config/option.go b/pkg/common/config/option.go
index 868b476..1fc2789 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 618d19e..06f1f62 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.


Test patch:
diff --git a/pkg/app/context_test.go b/pkg/app/context_test.go
index 0a4202b..c83ee6e 100644
--- a/pkg/app/context_test.go
+++ b/pkg/app/context_test.go
@@ -919,3 +919,15 @@ func TestBindAndValidate(t *testing.T) {
 		t.Fatalf("unexpected nil, expected an error")
 	}
 }
+
+func TestRequestContext_SetCookie(t *testing.T) {
+	c := NewContext(0)
+	c.SetCookie("user", "hertz", 1, "/", "localhost", protocol.CookieSameSiteLaxMode, true, true)
+	assert.DeepEqual(t, "user=hertz; max-age=1; domain=localhost; path=/; HttpOnly; secure; SameSite=Lax", c.Response.Header.Get("Set-Cookie"))
+}
+
+func TestRequestContext_SetCookiePathEmpty(t *testing.T) {
+	c := NewContext(0)
+	c.SetCookie("user", "hertz", 1, "", "localhost", protocol.CookieSameSiteDisabled, true, true)
+	assert.DeepEqual(t, "user=hertz; max-age=1; domain=localhost; path=/; HttpOnly; secure", c.Response.Header.Get("Set-Cookie"))
+}
diff --git a/pkg/app/server/option_test.go b/pkg/app/server/option_test.go
index 4f3a885..f45eb22 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_test.go b/pkg/app/server/render/html_test.go
new file mode 100644
index 0000000..86fa981
--- /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/route/engine_test.go b/pkg/route/engine_test.go
index c646658..9b99180 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("<h1>Date: 2017/07/01</h1>"), 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("<h1>Date: 2017/07/01</h1>"), 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) {