golang统一错误处理、日志文件输出、zap库和mux库源码分析、函数式编程

2,830 阅读10分钟

本文做的事情:在开发了一个简单的web服务程序cloudgo之后,进行功能完善和源码分析。

项目地址为:github.com/Winszheng/c…

一、统一错误处理

错误处理是golang开发中必不可少的环节,最基本的错误处理是panic和在控制台打印错误信息,我们不希望有太多panic,因为这样会妨碍程序正常工作;也不希望因为处理错误信息的代码太多太分散而降低代码可读性。因此,可以考虑尽量把panic处理成error,把逻辑处理和错误处理解耦,用一个errWrapper函数包装错误,逻辑处理函数只需要处理逻辑并返回错误,而具体错误的处理将由errWrapper完成。

在展示文件静态处理的函数中,处理函数为:

func HandleFileList(writer http.ResponseWriter,
	request *http.Request) error {
	// 判断前缀是否合法
	fmt.Println("path:",request.URL.Path)
	if strings.Index(request.URL.Path, prefix) != 0 {
		return userError("path must start with "+prefix)
	}
	// 取路径
	path := request.URL.Path[len(prefix):]
	file, err := os.Open("assets/"+path)
	if err!= nil {
		return err
	}
	defer file.Close()
	content, err := ioutil.ReadAll(file)
	writer.Write(content)
	return nil  // 没有出错,返回nil
}

而返回的错误将被这样处理:

func errWrapper(
	handler appHandler) func(writer http.ResponseWriter, request *http.Request) {
		return func(writer http.ResponseWriter,
			request *http.Request){
			// http库自带的panic处理内容太多,没有必要
			// 自行修改panic的处理
			defer func() {
				if r := recover(); r != nil {
					// 日志打印到控制台
					log.Printf("panic:%v\n", r)
					http.Error(writer,
						http.StatusText(http.StatusInternalServerError),
						http.StatusInternalServerError)
				}
			}()

			// 调用处理函数
			err := handler(writer, request)
			if err != nil {
				// 日志信息输出到控制台
				log.Printf("error handling request:%s\n",err.Error())

				// 判断是系统错误,还是用户可见
				if userErr, ok := err.(userError); ok {
					// 用户可见
					http.Error(writer, userErr.Message(), http.StatusBadRequest)
					return
				}

				code := http.StatusOK   // 默认200, 正常
				switch {
				case os.IsNotExist(err):
					code = http.StatusNotFound  // 404 not found
				case os.IsPermission(err):
					code = http.StatusForbidden // 403 没权限
				default:
					code = http.StatusInternalServerError   // else
				}
				http.Error(writer, http.StatusText(code), code)
			}
		}
}

为了区分哪些错误是系统可见的,哪些错误是用户可见的,例如,假定服务器中只有assets文件夹中的静态资源才可以被访问,那么当用户试图访问其他地方的静态资源时,给予"path must start with /assets/"的提示显然是合理的,这就属于用户可见的错误,为了实现错误信息的区分,需要一个userError接口:

type userError interface {
	error // 系统看的
	Message() string    // for user
}

需要一个userError接口将标准库已有的error接口和一个函数签名Message() string组合起来,那么,使用者只要实现userError接口即可,本项目这样实现:

type userError string

func (e userError) Error() string {
	return string(e)
}

func (e userError) Message() string{
	return string(e)
}

二、日志文件输出

2.1 日志文件的要求

在开发中,我们常常需要一个好的日志记录器,我们通常希望logger能够实现这样的功能:

  • 文件化日志输出,而不是把所有内容都输出到控制台
  • 日志切割,能根据文件大小、时间或间隔分割日志文件
  • 日志分级,能清晰判断是info、debug还是error等
  • 信息详尽,包含调用文件/函数名/行号/时间等 golang提供了log库,但内容不多,实现的功能比较简单。现在比较流行的第三方日志库也不少,本文关心的是zap库。那么,可以这样做:用log库和zap库分别为本次开发的程序实现日志文件输出,并做比较。

2.2 用log库实现

在原项目增加logfile文件夹,存放日志文件;增加logger文件夹,存放实现日志输出的代码,如此以来,项目结构是这样的: 实现思路很简单,先在logger.go设置日志文件路径:

func SetupLogger() {
	// 设置log文件output路径
	logFileLocation, _ := os.OpenFile("logfile/zap.log", os.O_CREATE|os.O_APPEND|os.O_RDWR, 0744)
	log.SetOutput(logFileLocation)
}

然后在启动函数调用: 之后正常启动,原本log.Printf的内容都会打印在控制台,现在则都会打印到zap.log,就像这样:

这样做的优点

简单!

存放在哪里完全可以随意指定,只要是实现了io.Writer的类型就可以了!

缺点

实现起来简单,所以也记录不了复杂的内容,比如无法分级日志,也不能记录太多详细的信息。

2.3 用zap库实现

zap提供了快速的、结构化的、层次化的日志记录,特点之一:比标准库更快。

zap提供了两种类型的日志记录器:SugaredLogger和Logger,选择的依据:

In contexts where performance is nice, but not critical, use the SugaredLogger

在性能好但性能并非关键的情形中,使用SugaredLogger.

When performance and type safety are critical, use the Logger.

在性能和安全都很关键的情形下,使用Logger.

2.4 看看源码

那么,要使用zap库实现日志文件输出,先看zap库的关键函数New的源码:

// New constructs a new Logger from the provided zapcore.Core and Options. If
// the passed zapcore.Core is nil, it falls back to using a no-op
// implementation.
//
// This is the most flexible way to construct a Logger, but also the most
// verbose. For typical use cases, the highly-opinionated presets
// (NewProduction, NewDevelopment, and NewExample) or the Config struct are
// more convenient.
//
// For sample code, see the package-level AdvancedConfiguration example.
func New(core zapcore.Core, options ...Option) *Logger {
	if core == nil {
		return NewNop()
	}
	log := &Logger{
		core:        core,
		errorOutput: zapcore.Lock(os.Stderr),
		addStack:    zapcore.FatalLevel + 1,
	}
	return log.WithOptions(options...)
}

// NewCore creates a Core that writes logs to a WriteSyncer.
func NewCore(enc Encoder, ws WriteSyncer, enab LevelEnabler) Core {
	return &ioCore{
		LevelEnabler: enab,
		enc:          enc,
		out:          ws,
	}
}

New函数根据传入的core和options构造新Logge并返回指针,core指构造编码器时必须要有的配置信息,options指额外设定的信息,比如是否记录调用函数等,使用NewCore函数构造core,传入的三个参数作用分别为:指定编码器、指定日志级别、指定日志输出。

2.5 具体实现

而具体到这次开发,整个logger.go的逻辑是:

var logger *zap.Logger
var SugarLogger *zap.SugaredLogger // SugarLogger需要全局使用记录日志

func InitLogger() {
	writeSyncer := getLogWriter()
	encoder := getEncoder()
	// zapcore.DebugLevel: 日志级别
	core := zapcore.NewCore(encoder, writeSyncer, zapcore.DebugLevel)

	logger := zap.New(core, zap.AddCaller()) // zap.AddCaller(): 记录调用函数
	SugarLogger = logger.Sugar()             // 通过主logger的方法获取SugarLogger
}

// getEncoder获取编码器(如何写入日志)
// 时间使用人类可读的方式
// 使用大写字母标识日志级别
func getEncoder() zapcore.Encoder {
	encoderConfig := zap.NewProductionEncoderConfig()
	encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
	encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
	return zapcore.NewConsoleEncoder(encoderConfig)
}

// 指定日志输出
func getLogWriter() zapcore.WriteSyncer {
	file, _ := os.Create("logfile/zap.log")
	return zapcore.AddSync(file)
}

在整个程序中,只需要在入口函数添加:

	logger.InitLogger()
	defer logger.SugarLogger.Sync()

之后在需要输出日志的地方以类似logger.SugarLogger.Panicf("panic:%v\n", r)、logger.SugarLogger.Errorf("error handling request:%s\n", err.Error())的方式调用即可,本质上和log.Printf的使用是一样的。

2.6 结果

日志明确地展示了时间、类型、文件、行号和自定义输出内容,和原生的log库相比显然可读性高很多,而且这样的输出已经基本满足普通程序的需求,logger.go文件以后可以直接复用。

三、gorilla/mux库源码分析

3.1 http vs. gorilla/mux

golang自带的http库能够实现一些路由功能,事实上,这次程序只使用http.HandFunc也能完成,但http库存在这样地问题:

  • 不支持路由参数设定
  • 不能友好地支持REST模式
  • 无法限制访问方法(POST\GET...)
  • 不支持正则 而这些问题,gorilla/mux库都能解决,另外还支持中间件等功能,所以比原生http的使用更广泛,把代码从使用原生http库改成使用mux库:
	
	router := mux.NewRouter()
	// 因为mux库严格按照正则匹配,当需要处理前缀变长的路由时需要额外设置
	router.PathPrefix("/assets").Handler(http.StripPrefix("/assets/", http.FileServer(http.Dir("assets/"))))
	// 限制路由器只处理指定的请
	router.HandleFunc("/api/test", service.ApiTestHandler(formatter))
	router.HandleFunc("/api/table", handler.HandleTable(formatter))
	router.HandleFunc("/unknow", handler.UnknownHandler(formatter))
	err := http.ListenAndServe(":8080", router)
	if err != nil {
		logger.SugarLogger.Error(err)
	} else {
		logger.SugarLogger.Debug("Normal start-up")
	}

http库默认匹配变长前缀,而mux库由于支持正则表达,所以需要额外设置。这里有一个坑,因为我分别用http库和mux库做了一次,于是出现这样一个问题:chrome缓存导致页面不能如预期显示,本来不应当重定向的页面发生了重定向,打开f12,发现状态码是301,显示根据磁盘缓存跳转,于是清除了近一天的缓存,解决了问题。

使用mux库时需要注意的是,mux支持正则匹配,所以并不是自动支持变长的路由匹配,mux.NewRouter().HandleFunc(path string, f func(http.ResponseWriter, *http.Request))仅匹配指定的url,变长前缀的匹配可以参考老师给的例子。

3.2 至于源码

mux库代码框架

  • mux.go: 核心函数定义
  • route.go: 路由相关函数和类
  • middleware.go: 中间件定义
  • regexp.go: 正则处理 mux库有两个重要的类,一个是Route, 是表征路由器的类型:
type Router struct {
	// Configurable Handler to be used when no route matches.
	NotFoundHandler http.Handler

	// Configurable Handler to be used when the request method does not match the route.
	MethodNotAllowedHandler http.Handler

	// Routes to be matched, in order.
	routes []*Route

	// Routes by name for URL building.
	namedRoutes map[string]*Route

	// If true, do not clear the request context after handling the request.
	//
	// Deprecated: No effect, since the context is stored on the request itself.
	KeepContext bool

	// Slice of middlewares to be called after a match is found
	middlewares []middleware

	// configuration shared with `Route`
	routeConf
}

另一个是Route,表征某条具体路由:

// Route stores information to match a request and build URLs.
type Route struct {
	// Request handler for the route.
	handler http.Handler
	// If true, this route never matches: it is only used to build URLs.
	buildOnly bool
	// The name used to build URLs.
	name string
	// Error resulted from building a route.
	err error

	// "global" reference to all named routes
	namedRoutes map[string]*Route

	// config possibly passed in from `Router`
	routeConf
}

而使用中重要的函数也有两个, 一个是func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request),代码太常就不贴了,函数首先判断是否需要清晰路径(比如去除不需要的斜杠并重定位),然后获取路由匹配器,并用相应的handler调用http.Handler.ServeHTTP。

另一ige重要的函数是func (r *Router) HandleFunc(path string, f func(http.ResponseWriter,*http.Request)) *Route,和http.HandleFunc不同,这个函数被设计成只能支持指定路由(自然是因为mux能够支持正则表达式)。

四、函数式编程

函数式编程是一种“编程范式”,主要思想是把过程尽量写成一系列嵌套的函数调用,go语言对函数式编程的支持主要体现在闭包上。

理解闭包

可以这样认为:

golang闭包 = 内部函数 + 绑定的变量

这样看起来也许有些玄学,从内存回收的角度考虑会更容易理解,正常函数调用完后内部的变量就会销毁(变量在栈上),但闭包用到的外部变量会被一直保存(用到的外部变量放在了堆上)。

当然,因为gc的存在,程序员并不需要关心变量具体在堆上还是栈上。

一个例子

// 该函数类型实现了一个方法
func (c *CalculateType) Serve() {
  fmt.Println("我是一个函数类型")
}

// 加法函数
func add(a, b int) {
  fmt.Println(a + b)
}

// 乘法函数
func mul(a, b int) {
  fmt.Println(a * b)
}

func main() {
  a := CalculateType(add) // 将add函数强制转换成CalculateType类型
  b := CalculateType(mul) // 将mul函数强制转换成CalculateType类型
  a(2, 3)
  b(2, 3)
  a.Serve()
  b.Serve()
}

// 5
// 6
// 我是一个函数类型
// 我是一个函数类型

但在实际中,我们很少用这种方法来使用闭包/函数式编程,上面这个例子虽然好理解,但思想上其实近似c++。

另一个例子

来自http库:

// (1)按给定pattern注册handler函数,然后调用函数(2)
func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
  DefaultServeMux.HandleFunc(pattern, handler)
}
// (2)按给定pattern注册handler函数
// 然后调用func (mux *ServeMux) Handle(pattern string, handler Handler)
// 这里需要注意的是,用到了(3)定义的类型做强制类型转换
// 调用的太长不复制,反正在这里也不重要
// 强制类型转换之后,我们自己的handler就也实现了ServeHTTP(),即实现了Handler接口
func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
  mux.Handle(pattern, HandlerFunc(handler))
}

// (3)
type HandlerFunc func(ResponseWriter, *Request)

// (4) f调用自己,即调用了自己的处理函数
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
  f(w, r)
}

简单粗暴点理解,就是参数是函数,返回也是函数,这在标准库里用的不少,多看点就好。

最后,写博客好累,就算理解,但是要图文代码并茂且好理解的写出来,好难(裂开)。