golang 开发 web 服务程序

一、概述

开发简单 web 服务程序 cloudgo,了解 web 服务器工作原理。

  • 任务目标
    熟悉 go 服务器工作原理
    基于现有 web 库,编写一个简单 web 应用类似 cloudgo。
    使用 curl 工具访问 web 程序
    对 web 执行压力测试
  • 任务要求
    • 基本要求
      编程 web 服务程序 类似 cloudgo 应用。
      支持静态文件服务
      支持简单 js 访问
      提交表单,并输出一个表格(必须使用模板)
      使用 curl 测试,将测试结果写入 README.md
      使用 ab 测试,将测试结果写入 README.md。并解释重要参数。

    • 扩展要求
      选择以下一个或多个任务,以博客的形式提交。
      通过源码分析、解释一些关键功能实现
      选择简单的库,如 mux 等,通过源码分析、解释它是如何实现扩展的原理,包括一些 golang 程序设计技巧。

二、基本要求的实现

(一)会使用到的第三方库

gorilla/mux
Package gorilla/mux implements a request router and dispatcher for matching incoming requests to their respective handler.
一个请求路由器,对http.ServeMux做了扩展,能够根据请求的url决定调用哪个Handler。
unrolled/render
Render is a package that provides functionality for easily rendering JSON, XML, text, binary data, and HTML templates.
可以看成一个页面生成工具,同一个模板加上不同的参数就可以在网页上渲染出不同的页面。
urfave/negroni
Negroni is an idiomatic approach to web middleware in Go. It is tiny, non-intrusive, and encourages use of net/http Handlers.
Negroni相当于在net/http外又包了一层皮,目前已知的功能就是Negroni可以在加载某个文件时在控制台打印加载返回情况,类似这样:

[negroni] listening on :10001
[negroni] 2020-11-22T13:16:20+08:00 | 200 | 	 18.843043ms | localhost:10001 | GET /
[negroni] 2020-11-22T13:16:20+08:00 | 200 | 	 714.306µs | localhost:10001 | GET /api/test
[negroni] 2020-11-22T13:16:24+08:00 | 200 | 	 68.423µs | localhost:10001 | GET /login
[negroni] 2020-11-22T13:16:31+08:00 | 200 | 	 3.110379ms | localhost:10001 | POST /after_login
(二)静态文件服务

main.go(启动服务器)

package main

import (
	"os"
	"practice/cloudgo/service"
	flag "github.com/spf13/pflag"
)

const (
	PORT string = "8080"
)

func main() {
	port := os.Getenv("PORT")
	if len(port) == 0 {
		port = PORT
	}
	pPort := flag.StringP("port", "p", PORT, "PORT for httpd listening")
	flag.Parse()
	if len(*pPort) != 0 {
		port = *pPort
	}
	server := service.NewServer()
	server.Run(":" + port)
}

第一版server.go主要完成一些初始化工作,包括:

  • 新建一个render并初始化,使其能够在目录“templates”下寻找后缀为html的模板文件
  • 新建一个mux并初始化,对所有以“/”为前缀的url调用FileServer,在assets文件夹下寻找文件并返回
  • 新建一个negroni,与mux绑定并返回给main函数

server.go

package service

import (
	"net/http"
	"os"
	"github.com/codegangsta/negroni"
	"github.com/gorilla/mux"
	"github.com/unrolled/render"
	"fmt"
)

// NewServer configures and returns a Server.
func NewServer() *negroni.Negroni {

	formatter := render.New(render.Options{
		Directory:  "templates",
		Extensions: []string{".html"},
		IndentJSON: true,
	})

	n := negroni.Classic()
	mx := mux.NewRouter()

	initRoutes(mx, formatter)

	n.UseHandler(mx)
	return n
}

func initRoutes(mx *mux.Router, formatter *render.Render) {
	webRoot := os.Getenv("WEBROOT")
	if len(webRoot) == 0 {
		if root, err := os.Getwd(); err != nil {
			panic("Could not retrive working directory")
		} else {
			webRoot = root
			//fmt.Println(webRoot)
			//fmt.Println(root)
		}
	}
	//访问主页时,调用homeHandler
	mx.PathPrefix("/").Handler(http.FileServer(http.Dir(webRoot + "/assets/")))}

运行main.go

go run main.go -p 10000

在浏览器中打开
在这里插入图片描述
可以看到assets下的每一个文件/文件夹,点开查看具体某一个文件
在这里插入图片描述

(三)服务器首页

在这里插入图片描述
第二版server.go增加了对首页的访问

func initRoutes(mx *mux.Router, formatter *render.Render) {
	webRoot := os.Getenv("WEBROOT")
	//fmt.Println(webRoot)
	if len(webRoot) == 0 {
		if root, err := os.Getwd(); err != nil {
			panic("Could not retrive working directory")
		} else {
			webRoot = root
			fmt.Println(webRoot)
			//fmt.Println(root)
		}
	}
	mx.HandleFunc("/", homeHandler(formatter)).Methods("GET")
	mx.PathPrefix("/").Handler(http.FileServer(http.Dir(webRoot + "/assets/")))

}

homeHandler定义如下:

func homeHandler(formatter *render.Render) http.HandlerFunc {
	return func(w http.ResponseWriter, req *http.Request) {
		formatter.HTML(w, http.StatusOK, "index", struct {
			ID      string `json:"id"`
			Content string `json:"content"`
		}{ID: "8675309", Content: "Hello from Go!"})
	}
}

formatter(即render对象)在根目录下的templates目录下寻找index.html,并传一个struct给其作为参数,将生成的html页面写入http.ResponseWriter返回给浏览器。

index.html定义如下:

<html>
<head>
  <link rel="stylesheet" href="css/main.css"/>
</head>
<body>
  <div class="container">
    <img  src="images/cng.png" />
    <p class="big">Sample Go Web Application!!</p>
    <p class="greeting-id">The ID is {{.ID}}</p>
    <p class="greeting-content">The content is {{.Content}}</p>
</div>
</body>
</html>

注意:由于实现静态文件访问时将所有资源定位到assets文件夹下,所以资源如图片、css、js等都是默认放在assets下的,路径是其在assets文件夹下的路径。

(四)简单js访问

对index.html修改如下:

<html>
<head>
  <link rel="stylesheet" href="css/main.css"/>
  <script type="text/javascript" src="js/jquery-3.4.1.js"></script>
  <script type="text/javascript" src="js/hello.js"></script>
</head>
<body>
  <div class="container">
    <img  src="images/cng.png" />
    <p class="big">Sample Go Web Application!!</p>
    <p class="greeting-id">The ID is </p>
    <p class="greeting-content">The content is </p>
</div>
</body>
</html>

它不再是一个模板了,因为没有缺的参数。
现在我们要通过hello.js通过路由访问后端服务器来获得ID和Content的信息。

hello.js

$(document).ready(function() {
    $.ajax({
        url: "/api/test"
    }).then(function(data) {
        console.log(data)
       $('.greeting-id').append(data.id);
       $('.greeting-content').append(data.content);
    });
});

hello.js中做了一件简单的事情:通过ajax向服务器请求/api/test,这个路径不需要真实存在,只要能够通过路由识别出Handler即可。

server.go更新部分如下:

func initRoutes(mx *mux.Router, formatter *render.Render) {
	webRoot := os.Getenv("WEBROOT")
	//fmt.Println(webRoot)
	if len(webRoot) == 0 {
		if root, err := os.Getwd(); err != nil {
			panic("Could not retrive working directory")
		} else {
			webRoot = root
			fmt.Println(webRoot)
			//fmt.Println(root)
		}
	}
	mx.HandleFunc("/api/test", apiTestHandler(formatter))
	mx.HandleFunc("/", homeHandler(formatter)).Methods("GET")
	mx.PathPrefix("/").Handler(http.FileServer(http.Dir(webRoot + "/assets/")))
}

apiTestHandler定义如下,它用render返回了一个json对象。

func apiTestHandler(formatter *render.Render) http.HandlerFunc {
	return func(w http.ResponseWriter, req *http.Request) {
		formatter.JSON(w, http.StatusOK, struct {
			ID      string `json:"id"`
			Content string `json:"content"`
		}{ID: "8675309", Content: "Hello from Go!"})
	}
}

回到hello.js,现在它请求api/test并成功拿到了服务器返回给它的json对象,接下来它就把对象中的ID和Content添加到index.html中,无需重新刷新网页。

(五)提交表单,并输出一个表格(使用模板)

增加两个html,分别是登录login.html和登录后after_login.html。

login.html,包含一个表单。

<html>
<head>
<title></title>
</head>
<body>
<form action="/after_login" method="post">
	用户名:<input type="text" name="username">
	密码:<input type="password" name="password">
	<input type="submit" value="登录">
</form>
</body>
</html> 

after_login.html,包含一个表格,使用模板。

<html>
<head>
<title></title>
</head>
<body>
<table>
    <tr> 
        <th>Username</th>
        <th>Password</th>
    </tr>
    <tr>
        <td>{{.Username}}</td>
        <td>{{.Password}}</td>
    </tr>
</body>
</html> 

server.go中再加两条路由:

func initRoutes(mx *mux.Router, formatter *render.Render) {
	webRoot := os.Getenv("WEBROOT")
	//fmt.Println(webRoot)
	if len(webRoot) == 0 {
		if root, err := os.Getwd(); err != nil {
			panic("Could not retrive working directory")
		} else {
			webRoot = root
			fmt.Println(webRoot)
			//fmt.Println(root)
		}
	}
	mx.HandleFunc("/api/test", apiTestHandler(formatter))
	mx.HandleFunc("/login", login(formatter))
	mx.HandleFunc("/after_login", login(formatter))
	mx.HandleFunc("/", homeHandler(formatter)).Methods("GET")
	mx.PathPrefix("/").Handler(http.FileServer(http.Dir(webRoot + "/assets/")))

}

login定义如下:
根据请求的方法是“GET”或“POST”决定渲染哪个html页面。

func login(formatter *render.Render) http.HandlerFunc {
	return func(w http.ResponseWriter, req *http.Request) {
		if req.Method == "GET" {
			formatter.HTML(w, http.StatusOK, "login", struct{}{})
		} else {
			req.ParseForm()
			formatter.HTML(w, http.StatusOK, "after_login", struct{
				Username string 
				Password string
			} {
				Username: req.Form["username"][0],
				Password: req.Form["password"][0],
			})
		}	
	}
}

登录前
在这里插入图片描述
登录后
在这里插入图片描述

至此,基本功能已经全部实现。

(六)curl测试

访问主页

xumy@xumy-VirtualBox:~$ curl -v http://localhost:10000
* Rebuilt URL to: http://localhost:10000/
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 10000 (#0)
> GET / HTTP/1.1
> Host: localhost:10000
> User-Agent: curl/7.58.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Content-Type: text/html; charset=UTF-8
< Date: Sun, 22 Nov 2020 07:37:12 GMT
< Content-Length: 433
< 
<html>
<head>
  <link rel="stylesheet" href="css/main.css"/>
  <script type="text/javascript" src="js/jquery-3.4.1.js"></script>
  <script type="text/javascript" src="js/hello.js"></script>
</head>
<body>
  <div class="container">
    <img  src="images/cng.png" />
    <p class="big">Sample Go Web Application!!</p>
    <p class="greeting-id">The ID is </p>
    <p class="greeting-content">The content is </p>
</div>
</body>
</html>
* Connection #0 to host localhost left intact

登录

xumy@xumy-VirtualBox:~$ curl -v http://localhost:10000/login
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 10000 (#0)
> GET /login HTTP/1.1
> Host: localhost:10000
> User-Agent: curl/7.58.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Content-Type: text/html; charset=UTF-8
< Date: Sun, 22 Nov 2020 07:38:13 GMT
< Content-Length: 245
< 
<html>
<head>
<title></title>
</head>
<body>
<form action="/after_login" method="post">
	用户名:<input type="text" name="username">
	密码:<input type="password" name="password">
	<input type="submit" value="登录">
</form>
</body>
* Connection #0 to host localhost left intact

访问静态文件

xumy@xumy-VirtualBox:~$ curl -v http://localhost:10000/js/hello.js
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 10000 (#0)
> GET /js/hello.js HTTP/1.1
> Host: localhost:10000
> User-Agent: curl/7.58.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Accept-Ranges: bytes
< Content-Length: 232
< Content-Type: application/javascript
< Last-Modified: Sun, 22 Nov 2020 01:44:03 GMT
< Date: Sun, 22 Nov 2020 07:39:57 GMT
< 
$(document).ready(function() {
    $.ajax({
        url: "/api/test"
    }).then(function(data) {
        console.log(data)
       $('.greeting-id').append(data.id);
       $('.greeting-content').append(data.content);
    });
});


* Connection #0 to host localhost left intact

(七)ab测试
sudo apt-get install apache2-utils

对主页进行1000次请求,10个并发请求。

xumy@xumy-VirtualBox:~$ ab -n 1000 -c 10 http://localhost:10000/
This is ApacheBench, Version 2.3 <$Revision: 1807734 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient)
Completed 100 requests
Completed 200 requests
Completed 300 requests
Completed 400 requests
Completed 500 requests
Completed 600 requests
Completed 700 requests
Completed 800 requests
Completed 900 requests
Completed 1000 requests
Finished 1000 requests


Server Software:        
Server Hostname:        localhost
Server Port:            10000

Document Path:          /
Document Length:        433 bytes

Concurrency Level:      10
Time taken for tests:   0.368 seconds
Complete requests:      1000
Failed requests:        0
Total transferred:      550000 bytes
HTML transferred:       433000 bytes
Requests per second:    2718.02 [#/sec] (mean)
Time per request:       3.679 [ms] (mean)
Time per request:       0.368 [ms] (mean, across all concurrent requests)
Transfer rate:          1459.87 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.1      0       1
Processing:     0    4   2.0      4      15
Waiting:        0    4   2.0      3      12
Total:          0    4   2.0      4      15

Percentage of the requests served within a certain time (ms)
  50%      4
  66%      4
  75%      5
  80%      5
  90%      6
  95%      7
  98%      9
  99%      9
 100%     15 (longest request)

Document Length: 请求的页面大小
Concurrency Level: 并发量
Time taken for tests: 测试总共耗时
Complete requests: 完成的请求
Failed requests: 失败的请求
Total transferred: 总共传输数据量
HTML transferred: html传输数据量
Requests per second: 每秒钟的请求量。(仅仅是测试页面的响应速度)
Time per request: 等于 Time taken for tests/(complete requests/concurrency level) 即平均请求等待时间(用户等待的时间)
Time per request: 等于 Time taken for tests/Complete requests 即服务器平均请求响应时间 在并发量为1时 用户等待时间相同
Transfer rate: 平均每秒多少K,即带宽速率

对js/hello.js进行1000次请求,1个并发请求。

xumy@xumy-VirtualBox:~$ ab -n 1000 -c 1 http://localhost:10000/js/hello.js
This is ApacheBench, Version 2.3 <$Revision: 1807734 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient)
Completed 100 requests
Completed 200 requests
Completed 300 requests
Completed 400 requests
Completed 500 requests
Completed 600 requests
Completed 700 requests
Completed 800 requests
Completed 900 requests
Completed 1000 requests
Finished 1000 requests


Server Software:        
Server Hostname:        localhost
Server Port:            10000

Document Path:          /js/hello.js
Document Length:        232 bytes

Concurrency Level:      1
Time taken for tests:   0.440 seconds
Complete requests:      1000
Failed requests:        0
Total transferred:      415000 bytes
HTML transferred:       232000 bytes
Requests per second:    2270.99 [#/sec] (mean)
Time per request:       0.440 [ms] (mean)
Time per request:       0.440 [ms] (mean, across all concurrent requests)
Transfer rate:          920.37 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.0      0       0
Processing:     0    0   0.7      0       5
Waiting:        0    0   0.6      0       5
Total:          0    0   0.7      0       5

Percentage of the requests served within a certain time (ms)
  50%      0
  66%      0
  75%      0
  80%      0
  90%      0
  95%      2
  98%      3
  99%      4
 100%      5 (longest request)

三、扩展要求

从源码对比DefaultServeMux和gorilla/mux

(一)DefaultServeMux

ServeMux
通过一个map实现路径到Handler的路由。

type ServeMux struct {
        mu    sync.RWMutex
        m     map[string]muxEntry
}
type muxEntry struct {
        explicit bool
        h        Handler
        pattern  string
}

注册路由,如果路径是以斜杆结束的,则去掉斜杆的路径同样可以导到同一个Handler。

func (mux *ServeMux) Handle(pattern string, handler Handler) {
        mux.mu.Lock()
        defer mux.mu.Unlock()
        mux.m[pattern] = muxEntry{explicit: true, h: handler, pattern: pattern}
        n := len(pattern)
        if n > 0 && pattern[n-1] == '/' && !mux.m[pattern[0:n-1]].explicit {
                path := pattern
                mux.m[pattern[0:n-1]] = muxEntry{h: RedirectHandler(path, StatusMovedPermanently), pattern: pattern}
        }
}

根据路径找到匹配的Handler和pattern。
对一个输入的路径path,遍历map中的每一对键和值,如果键和path匹配(pathMatch),则取出对应的Handler,继续遍历直到结束,找出和path匹配的最长的键对应的Handler和pattern。

func (mux *ServeMux) handler(host, path string) (h Handler, pattern string) {
        mux.mu.RLock()
        defer mux.mu.RUnlock()
        if h == nil {
                h, pattern = mux.match(path)
        }
        if h == nil {
                h, pattern = NotFoundHandler(), ""
        }
        return
}

func (mux *ServeMux) match(path string) (h Handler, pattern string) {
        var n = 0
        for k, v := range mux.m {
                if !pathMatch(k, path) {
                continue
                }
                //找出匹配度最长的
                if h == nil || len(k) > n {
                n = len(k)
                h = v.h
                pattern = v.pattern
                }
        }
        return
}

func pathMatch(pattern, path string) bool {
n := len(pattern)
        if pattern[n-1] != '/' {
                return pattern == path
        }
        return len(path) >= n && path[0:n] == pattern
}

总结:DefaultServeMux实现简单,但对每一个路径都要遍历所有路由,似乎有点效率过低。一个改进的方法是对map按键的长度由长到短排序,然后遍历时取匹配的第一个即可。
DefaultServeMux还有其他的缺点:

  • 不支持正则路径匹配
  • 只能做路径匹配,不支持Method,header,host等信息匹配。

gorilla/mux比DefaultServeMux多了一些功能,它支持正则匹配和Method,header,host等信息匹配,因而更适用于开发复杂的服务器。

(二)gorilla/mux

与DefaultServeMux不同,gorilla/mux中路由不是通过map存储,而是将键和值放在一个结构内,用数组存起来。(因为ServeMux匹配要遍历全部键值,完全没有体现map的优势)

type Router struct {
      routes []*Route
}

路由中的的一个“映射”定义如下:

type Route struct {
      // Request handler for the route.
      handler http.Handler
      // List of matchers.
      matchers []matcher
}

不同于ServeMux只有字符串与Handler的匹配,[]matcher还包含了其他信息,比如请求方法是GET还是POST,只有matcher中所有条件满足才算匹配成功,对应的Handler才能被返回。

// Match matches registered routes against the request.
  func (r *Router) Match(req *http.Request, match *RouteMatch) bool {
          for _, route := range r.routes {
                  //Route.Match会检查http.Request是否满足其设定的各种条件(路径,Header,Host..)
                  if route.Match(req, match) {
                  return true
                  }
          }
          return false
  }
  func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
          var match RouteMatch
          var handler http.Handler
          if r.Match(req, &match) {
                  handler = match.Handler
          }
          if handler == nil {
                  handler = http.NotFoundHandler()
          }
          handler.ServeHTTP(w, req)
  }

注意到gorilla/mux是遍历过程中一旦有满足条件的匹配则立即返回Handler。

四、项目地址