DEV Community

baize
baize

Posted on

Source Code Analysis for Go HTTP Framework Hertz

CloudWeGo-Hertz

Hertz [həːts] is a high-performance, high-usability, extensible HTTP framework for Go. It’s designed to simplify building microservices for developers.

Hertz was inspired by other open-source frameworks like fasthttp, gin, and echo, in combination with unique challenges faced by ByteDance, Hertz has become production ready and powered ByteDance’s internal services over the years.

Nowadays, as Go gain popularity in microservice development, Hertz will be the right choice if you are looking for a customizable, high-performance framework to support a variety of use case.

This article will explain the structure of the Hertz source code and analyze the startup of Hertz.

Next, you need to open the official documentation for Hertz and clone the repository locally, let's get started.

Architecture

image-20220901154829735

Above is an architectural design diagram from the official Hertz documentation. The components in the picture correspond to the package folders within the hertz source code package.

image-20220901175437136

Getting Started

Following the instructions in the documentation, we could initialize a minimal hertz project via the hertz cmd tool.

# Install the command line tool for hertz, used to generate the initial hertz code
go install github.com/cloudwego/hertz/cmd/hz@latest
# Generate code via the hz tool. If the project created is not in the GOPATH/src path, "-module" needs to be declared
hz new -module hertz-study
Enter fullscreen mode Exit fullscreen mode

image-20220901181637689

Run the project then you can access the default HTTP service by /ping.

curl http://127.0.0.1:8888/ping
# response
{"message":"pong"}% 
Enter fullscreen mode Exit fullscreen mode

Source Code Analysis

server

Look at the main.go function, which is the startup entry for the hertz service. It initializes a default hertz service, does some registration work, and runs the hertz service (HTTP service).

package main

import (
    "github.com/cloudwego/hertz/pkg/app/server"
)

func main() {
    h := server.Default()

    register(h)
    h.Spin()
}
Enter fullscreen mode Exit fullscreen mode

Thinking back to the interface about http://127.0.0.1:8888/ping which was declared by server.Default().

Conversely, if you want to specify custom configurations for HTTP service, you need to pass parameters to the server.Default(), Or using the server.New() method.

server.Default()

// Default creates a hertz instance with default middlewares.
func Default(opts ...config.Option) *Hertz {
   h := New(opts...)
   h.Use(recovery.Recovery())

   return h
}

// New creates a hertz instance without any default config.
func New(opts ...config.Option) *Hertz {
    options := config.NewOptions(opts)
    h := &Hertz{
        Engine: route.NewEngine(options),
    }
    return h
}
Enter fullscreen mode Exit fullscreen mode

Looking at the Default() method, we find it calls server.New() internally, which accepts an Option array of indeterminate length as a parameter.

// Option is the only struct that can be used to set Options.
type Option struct {
    F func(o *Options)
}

// New creates a hertz instance without any default config.
func New(opts ...config.Option) *Hertz {
    options := config.NewOptions(opts)
    h := &Hertz{
        Engine: route.NewEngine(options),
    }
    return h
}
Enter fullscreen mode Exit fullscreen mode

Then we go to the config.NewOptions method to see how this Option slice will apply our custom content to the Hertz configuration.

func NewOptions(opts []Option) *Options {
    options := &Options{
        KeepAliveTimeout: defaultKeepAliveTimeout,
        ReadTimeout: defaultReadTimeout,
        IdleTimeout: defaultReadTimeout,
        RedirectTrailingSlash: true,
        RedirectFixedPath: false,
        HandleMethodNotAllowed: false,
        UseRawPath: false,
        RemoveExtraSlash: false,
        UnescapePathValues: true,
        DisablePreParseMultipartForm: false,
        Network: defaultNetwork,
        Addr: defaultAddr,
        MaxRequestBodySize: defaultMaxRequestBodySize,
        MaxKeepBodySize: defaultMaxRequestBodySize,
        GetOnly: false,
        DisableKeepalive: false,
        StreamRequestBody: false,
        NoDefaultServerHeader: false,
        ExitWaitTimeout: defaultWaitExitTimeout,
        TLS: nil,
        ReadBufferSize: defaultReadBufferSize,
        ALPN: false,
        H2C: false,
        Tracers: []interface{}{},
        TraceLevel: new(interface{}),
        Registry: registry.NoopRegistry,
    }
    // apply a custom []Option to Options
    options.Apply(opts)
    return options
}

func (o *Options) Apply(opts []Option) {
    for _, op := range opts {
        op.F(o)
    }
}
Enter fullscreen mode Exit fullscreen mode

By looking at the config.NewOptions source code, it first initializes an Options structure that holds various initialization information for the Hertz service. The Options properties are fixed by default until the options.Apply(options) method is called to apply the custom configuration.

It passes a pointer to the Options structure created by default as an argument to the F method of each Option you declared. It assigns values to the Options structure via the F method call, which, because it is a pointer, will naturally apply all the assignments to the same Options.

And how the specific Option's F method is defined can be implemented flexibly, which is one of the reasons why Hertz has good scalability.

// Default creates a hertz instance with default middlewares.
func Default(opts ...config.Option) *Hertz {
    // "Hertz" is the core structure of the framework
    h := New(opts...)
    h.Use(recovery.Recovery())

    return h
}
Enter fullscreen mode Exit fullscreen mode

Notice that there is also a h.Use(recovery.Recovery()) method.

// Recovery returns a middleware that recovers from any panic.
func Recovery(opts ...Option) app.HandlerFunc {
    cfg := newOptions(opts...)

    return func(c context.Context, ctx *app.RequestContext) {
        defer func() {
            if err := recover(); err != nil {
                stack := stack(3)

                cfg.recoveryHandler(c, ctx, err, stack)
            }
        }()
        ctx.Next(c)
    }
}
Enter fullscreen mode Exit fullscreen mode

This is a middleware for recovering from panic. By default, it will print the time, content, and stack information of the error and write a 500. Overriding the Config configuration, you can customize the error printing logic.

By the way, if you Initialize a Hertz HTTP service by server.New(), which won't hold the Recovery() method, and you need to inject it yourself.

package main

import (
    "context"

    "github.com/cloudwego/hertz/pkg/app"
    "github.com/cloudwego/hertz/pkg/app/middlewares/server/recovery"
    "github.com/cloudwego/hertz/pkg/app/server"
    "github.com/cloudwego/hertz/pkg/common/hlog"
    "github.com/cloudwego/hertz/pkg/protocol/consts"
)

func main() {
    h := server.New()

    h.Use(recovery.Recovery(
        recovery.WithRecoveryHandler(func(c context.Context, ctx *app.RequestContext, err interface{}, stack []byte) {
            hlog.SystemLogger().CtxErrorf(c, "[Recovery] err=%v\nstack=%s", err, stack)
            ctx.AbortWithStatus(consts.StatusInternalServerError)
        })))
    register(h)
    h.Spin()
}
Enter fullscreen mode Exit fullscreen mode

register()

package main

import (
    "github.com/cloudwego/hertz/pkg/app/server"
)

func main() {
    h := server.Default()

    register(h)
    h.Spin()
}
Enter fullscreen mode Exit fullscreen mode

After the above analysis, we know that server.Default() completes the declaration of the Hertz structure. Let's pay more attention to the content of the register(h).

// register registers all routers.
func register(r *server.Hertz) {

    router.GeneratedRegister(r)

    customizedRegister(r)
}

// GeneratedRegister registers routers generated by IDL.
func GeneratedRegister(r *server.Hertz) {
    //INSERT_POINT: DO NOT DELETE THIS LINE!
}

// customizeRegister registers customize routers.
func customizedRegister(r *server.Hertz) {
    r.GET("/ping", handler.Ping)

    // your code ...
}
Enter fullscreen mode Exit fullscreen mode

register(h) does two types of route registration internally. The annotation of GeneratedRegister() indicates that this part of the route is generated by the IDL.

customizedRegister() is used to register custom routes which initializes a /ping by default, using it in a very similar way to gin.

Spin()

Finally, let's analyse the third part of the main() method, the h.Spin().

// Spin runs the server until catching os.Signal or error returned by h.Run().
func (h *Hertz) Spin() {
    errCh := make(chan error)
    h.initOnRunHooks(errCh)
    go func() {
        // core
        errCh <- h.Run()
    }()

    signalWaiter := waitSignal
    if h.signalWaiter != nil {
        signalWaiter = h.signalWaiter
    }

    if err := signalWaiter(errCh); err != nil {
        hlog.Errorf("HERTZ: Receive close signal: error=%v", err)
        if err := h.Engine.Close(); err != nil {
            hlog.Errorf("HERTZ: Close error=%v", err)
        }
        return
    }

    hlog.Infof("HERTZ: Begin graceful shutdown, wait at most num=%d seconds...", h.GetOptions().ExitWaitTimeout/time.Second)

    ctx, cancel := context.WithTimeout(context.Background(), h.GetOptions().ExitWaitTimeout)
    defer cancel()

    if err := h.Shutdown(ctx); err != nil {
        hlog.Errorf("HERTZ: Shutdown error=%v", err)
    }
}
Enter fullscreen mode Exit fullscreen mode

After a series of initialization and declaration operations, Spin() is responsible for triggering the run of Hertz and handling any errors during the runtime. The most important step is errCh <- h.Run().

func (engine *Engine) Run() (err error) {
    if err = engine.Init(); err != nil {
        return err
    }

    if !atomic.CompareAndSwapUint32(&engine.status, statusInitialized, statusRunning) {
        return errAlreadyRunning
    }
    defer atomic.StoreUint32(&engine.status, statusClosed)

    // trigger hooks if any
    ctx := context.Background()
    for i := range engine.OnRun {
        if err = engine.OnRun[i](ctx); err != nil {
            return err
        }
    }

    return engine.listenAndServe()
}
Enter fullscreen mode Exit fullscreen mode

Then you see the engine.listenAndServe() method at the end, which is declared in an interface. Looking at its implementation classes, you find that it can be traced back to the standard and netpoll packages.

image-20220901215952290

As an HTTP framework, the most important thing is to provide network communication capabilities. hertz uses the pluggable network library netpoll to handle network communication and further optimize performance.

The service is now running, and you can send requests via the console:

curl http://127.0.0.1:8888/ping
{"message":"pong"}% 
Enter fullscreen mode Exit fullscreen mode

Summary

After generating the simplest Hertz code using the hz tool, this article roughly analyses the contents of the main method, dividing it into three parts, the service configuration declaration Default(), the route registration register() and the HTTP service start Spin().

If you want to know more about Hertz, you could visit the Reference.

Reference List

Top comments (0)