、http基础篇
简介
http(超文本传输文本协议), 用于web应用传输数据的协议, 只能由客户端发起, 由服务端响应。 具有无状态等特点。
结构
http协议的传输单位是http报文(请求报文、响应报文)。 报文的结构可分为:请求/响应行、 首部字段、实体部分。
get请求报文
GET /index.html HTTP/1.1 //请求行
Host: test.com //首部字段
1
2
3
get响应报文
HTTP/1.1 200 OK //响应行
Date: Tue, 10 Jul 2012 06;50:15 GMT //首部字段
Content-Length: 362 //首部字段
Content-Type: text/html //首部字段
<html> //实体
...
1
2
3
4
5
6
7
请求行用于说明请求方法 , 请求地址, http版本号
响应行用于说明服务器http版本号, 响应状态码, 状态码的原因短句
首部字段分为: 通用首部字段、 请求首部字段、 响应首部字段、 实体首部字段
对于实体内的内容, 可以用实体首部字段加以说明。 最常使用的是content-type: xxxx, 说明实体内容的类型。
二、javaScript操作http
浏览器中, http请求可以由浏览器中的如下内容发送:
1. 浏览器中的url地址栏
2. 页面有src属性的标签(img、script、 link等)
3. 带有action属性的form表单
4. XMLHttpRequest对象
1. XMLHttpRequest的基本用法
在这些方法中, XMLHttpRequest对象提供了接口让我们操作http.基本用法如下:
var xhr=new XMLHttpRequest();//此时readyState属性值为0
xhr.open('post', 'http://www.test.com', false)//此时readyState属性值为1
xhr.send("name=yang&psd=123")//readyState属性值为2
xhr.onreadyStatechange=function(){
if(xhr.readState===4 && xhr.status===200 ){
console.log(xhr.responseText)
}else{
console.log('Request was unsuccessfull:' + xhr.status)
}
}
1
2
3
4
5
6
7
8
9
10
11
12
以上是XMLHttpRequest的基本使用方法。
1). 发送数据, 使用send方法
这里的发送数据指的是post方法发送数据
xhr.send("name=yang&psd=123")//post方法发送了一个form表单数据
1
如果是get方法则数据拼接到url后面(使用encodeURIComponent()将名和值进行编码之后), send方法参数必须是null
xhr.open('get', 'http://www.test.com?name='yang'&psd=123, false)//将name和value进行encodeURIComponent编码, (同cookie的value一样), 其中open方法最后一个参数代表是否异步
xhr.send(null)//不能不写
1
2
2). 使用readyState可以查看当前xhr对象的状态, 状态有:
0– 没调用open方法
1– 没调用send方法
2– 调用send方法, 未接受到响应
3– 正在接受响应, 未接受完成
4– 响应全部接受
3). 获得响应的状态, 使用status属性, 当属性的值为200表示请求成功
var httpStatus=xhr.status
if(httpStatus===200){
//请求成功,可以做接下来的事情了
}
1
2
3
4
4). 获得响应的数据,使用responseText属性
var result=xhr.responseText
1
5). 添加首部字段, 使用setRequestHeader方法
xhr.setRequestHeader('myHeader', 'myValue')//这里必须放在open方法, 和send方法中间, 否则不能成功添加首部字段
1
6). 获得首部字段, 使用getResponseHeader或getAllResponseHeaders方法
var header=xhr.getResponseHeader('myHeader')//传入首部字段名
var headers=xhr.getAllResponseHeader()//获得全部的首部字段,返回多行文本内容
//这是headers的结果
Date: Sun, 14 Nov 2004 18:04:03 GMT
Server: Apache/1.3.29(Unix)
Vary: Accept
X-Powered-By: PHP/4.3.8
Connection: close
Content-Type: text/html;charset=ios-8859-1
1
2
3
4
5
6
7
8
9
10
2. XMLHttpRequest跨域用法
使用XHR对象通信,有一个限制就是跨域安全策略。 默认情况下, XHR对下只能访问包含它的页面位于同一个域中的资源。 但是有时我们开发不能不进行跨域请求。
1). CORS跨域源资源共享
基本思想: 使用自定义的首部字段让给浏览器与服务器沟通, 从而决定请求或响应是否应该成功。
整个CORS通信过程,都是浏览器自动完成,不需要用户参与。对于开发者来说,CORS通信与同源的AJAX通信没有差别,代码完全一样。浏览器一旦发现AJAX请求跨源,就会自动添加一些附加的头信息(Origin首部字段),有时还会多出一次附加的请求,但用户不会有感觉。
2). 原理
客户端
浏览器一旦发现AJAX请求跨源,就会自动添加一些附加的头信息(Origin首部字段),有时还会多出一次附加的请求(分简单请求),但用户不会有感觉。
服务端
服务器读取Origin首部字段的值, 判断是否应该成功, 如果成功返回的响应报文中首部字段包含Access-control-allow-Origin:xxxxxx。 如果xxxxx为*或与自己发送的Origin的值相同, 浏览器就会判断请求成功。
3). CORS的简单请求与非简单请求
局限
CORS跨域请求, 存在以下限制, 例如:
求方法为post/get/head,
首部字段只设置Content-Type
不能访问响应头部
cookie不随请求发送
简单情求
请求方法为post/get/head, 首部字段只设置content-type(只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain等
), 这样的请求为简单请求。 这是浏览器将会在请求报文中添加Origin的首部字段,完成情趣。
GET /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...
1
2
3
4
5
6
非简单请求
如果不是简单请求, 浏览器将不会想处理简单请求一样处理, 例如我们希望添加其他的首部字段。 这浏览器将会发送一个预检请求(Preflighted Requests)
Preflighted Requests,如下
OPTIONS /cors HTTP/1.1 //请求的方法, 地址, http版本
Origin: http://api.bob.com // 客户端的域名
Access-Control-Request-Method: PUT //即将发起非简单请求的方法, 用于服务器判断是否支持该方法
Access-Control-Request-Headers: X-Custom-Header //即将发起非简单请求携带的首部字段, 用于服务器判断是否支持该字段
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...
1
2
3
4
5
6
7
8
这种请求的方法是options方法, 用于服务器询问。 如果服务都满足, 将会如下
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://api.bob.com //允许跨域的域
Access-Control-Allow-Methods: GET, POST, PUT //支持的请求方法
Access-Control-Allow-Headers: X-Custom-Header //支持的头部
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain
1
2
3
4
5
6
7
8
9
10
11
12
浏览器将会用响应报文的首部字段中以Access-control开头的字段与即将发送的请求比对, 如果服务将会如同简单请求一样发送请求。 故,非简单请求会有一个预检请求。
同时, 浏览器会将响应按照这个时间:(Access-Control-Max-Age: 1728000)保存, 在该时间未过期期间, 就不必发送预检请求, 而直接发起请求。
携带cookie
默认情况下, 跨域请求不会携带cookie。 需要我们设置一个属性值–withCredentials
xhr.withCredentials=true
1
当然跨域携带cookie也需要服务器支持才行, 如果服务愿意接受携带cookie的跨域信息, 就会在预检请求响应头部添加如下首部字段:
Access-Control-Allow-Credentials: true
1
3. 跨浏览器的CORS
function createCORSRequest(method, url){
var xhr=new XMLHttpRequest()
if("withCredentials" in xhr){
xhr.open(method, url, true);
}else if (typeof XDomainRequest() !='undefined') {
xhr=new XDomainRequest()
xhr.open(method, url)
}else{
xhr=null
}
return xhr
}
var request=createCORSRequest('get', 'http://test.com')
if(request){
request.onload=function(){//XMLHttpRequest 2级增加的事件
//对request.responseText进行处理
}
request.send(null)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
总结
详细了解http呢是有必要的, 对于我们理解很多东西都有非常大的好处。 比如这篇文章, 关于操作http部分, 其重点就是添加实体, 添加首部字段的操作。 而关于添加首部字段呢, 就有必要明白各个首部字段的意义了。
面试过程总会被问到“HTTP协议如何工作?“,”一次完整的http请求是经历什么过程“...... 确实此题能衡量程序员的功底,如果你回答非常完整,说明你对网络请求过程是非常了解的,对大流量和大并发场景你就很清楚如何进行优化,本篇文章从输入URL到浏览器显示页面发生了什么这视角大体了解一下,当你在浏览器地址栏输入网址后浏览器是怎么把最终的页面呈现出来的呢?这个过程从程序员理解的角度可以分为以下几个步骤:
我先给大家看看整体的请求过程,为能更好地让读者明白,作者会分期完整介绍以下过程。
请求整体过程
域名解析 -> 发起TCP的3次握手 -> 建立TCP连接后发起http请求 -> 服务器响应http请求->浏览器得到html代码 -> 浏览器解析html代码同时请求html代码中的资源(如js、css、图片等) -> 浏览器对页面进行渲染呈现给用户。
获取内容请求
以上过程大致进行分析细节,以方便大家更加详细地认识整体的过程,但是有些过程没有能理解透彻并且过程比较复杂未能提炼通俗易懂语言给大家分析,不过后续会不断分析给大家的。
1.域名解析
我们以www.cnblogs.com为例:请问www.cnblogs.com这个域名的IP地址是多少?
目的是通过域名地址转化到资源URL的IP地址,对用户使用域名是为了方便记忆,但是为了让计算机理解这个地址还需要把它解析为IP地址,当用户在地址栏输入URL中,浏览器会首先搜索浏览器自身的DNS缓存,先看自身的缓存中是否存在没有过期对应的条目,如果找到且没有过期则停止搜索解析到此结束,如果没有浏览器会搜索操作系统的DNS缓存,在操作系统也没有找到,那么尝试读hosts文件,看看里面是否配置对应域名的IP地址,如果在hosts文件中也没有找到对应的条目,浏览器就会发起一次DNS的系统调用,这过程是通过UDP协议向DNS的53端口发起请求递归迭代请求,这过程有运营商DNS服务提供给我们,运营商的DNS服务器必须得提供给我们对应域名的IP地址,先向本地配置的首选DNS服务器发起域名解析请求(一般是由电信运营商提供或者各大互联网厂商提供的DNS服务器)运营商的DNS服务器首先查找自身的缓存,找到对应的条目,且没有过期,则解析成功。如果没有找到对应的条目,则运营商的DNS代浏览器发起迭代DNS解析请求,它首先是会找根域的DNS的IP地址(这台DNS服务器都内置13台根域的DNS的IP地址),找到根域的DNS地址,就会向其发起请求,来一场寻址之旅:
运营商DNS:请问www.cnblogs.com这个域名的IP地址是多少呢?
根域DNS:你一个顶级域com域的一个域名,我不知道这个域名的IP地址,但是我知道com域的IP地址,你去找它去问一问呢?
运营商DNS:请问www.cnblogs.com这个域名的IP地址是多少呢?
COM域:我不知道www.cnblogs.com这个域名的IP地址,但是我知道cnblogs.com这个域的DNS地址,你去找它去去问一问呢?
cnblogs.com域名的DNS:这个时候cnblogs.com域的DNS服务器一查,诶,果真在我这里,一般就是由域名注册商提供的,像万网,新网等。
于是就把找到的结果发送给运营商的DNS服务器,这个时候运营商的DNS服务器就拿到了域名对应的IP地址,并返回给操作系统内核,内核又把结果返回给浏览器,终于浏览器拿到了。
域名解析流程
备注:
浏览器:可以使用 chrome://net-internals/#dns 来进行查看
操作系统:Mac的dns缓存查询 nslookup www.baidu.com
击上方蓝色“Go语言中文网”关注我们,领全套Go资料,每天学习 Go 语言
如果你有运行的 HTTP 服务,你可能想记录 HTTP 请求。
请求日志有助于诊断问题。(哪些请求失败了?我们一天处理多少请求?哪些请求比较慢?)
这对于分析是必需的。(哪个页面受欢迎?网页的浏览者都来自哪里?)
这篇文章介绍了在 Go Web 服务器中,记录 HTTP 请求日志相关的全部内容。
这不是关于可复用的库,而是关于实现你自己的解决方案需要知道的事情,以及关于我日志记录的选择的描述。
你可以在示例应用上查看详细内容:https://github.com/essentialbooks/books/tree/master/code/go/logging_http_requests
我在 Web 服务 OnePage[1] 中用到了这个记录系统。
记录什么信息[2]
获取要记录的信息[3]
日志文件的格式[4]
每日滚动日志[5]
长期存储以及分析[6]
更多的 Go 资源[7]
招聘 Go 开发者[8]
为了展示通常会记录什么信息,这里有一条 Apache 的扩展日志文件格式的日志记录样本。
111.222.333.123 HOME - [01/Feb/1998:01:08:39 -0800] "GET /bannerad/ad.htm HTTP/1.0" 200 198 "http://www.referrer.com/bannerad/ba_intro.htm" "Mozilla/4.01 (Macintosh; I; PPC)"
我们能看到:
我们可以记录更多的信息,或者选择不去记录上面的某些信息。
个人而言:
Go 中标准 HTTP 处理函数的签名如下:
func(w http.ResponseWriter, r *http.Request)
我们会把日志记录作为所谓的中间件,这是一种向 HTTP 服务管道中添加可复用功能的一个方法。
我们有 logReqeustHandler 函数,它以 http.Handler 接口作为参数,然后返回另一个包装了原有处理器并添加了日志记录功能的 http.Handler。
func logRequestHandler(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
// 在我们包装的时候调用原始的 http.Handle
h.ServeHTTP(w, r)
// 得到请求的有关信息,并记录之
uri := r.URL.String()
method := r.Method
// ... 更多信息
logHTTPReq(uri, method, ....)
}
// 用 http.HandlerFunc 包装函数,这样就实现了 http.Handler 接口
return http.HandlerFunc(fn)
}
我们可以把中间件处理器嵌套到每一个(HTTP 处理器)的顶部,这样所有(处理器)都会拥有这些功能。
下面介绍了我们如何使用它来把日志记录功能添加到所有的请求函数:
func makeHTTPServer() *http.Server {
mux := &http.ServeMux{}
mux.HandleFunc("/", handleIndex)
// ... 可能会添加更多处理器
var handler http.Handler = mux
// 用我们的日志记录器包装 mux 。this will (译者注:应当是注释没写全)
handler = logRequestHandler(handler)
// ... 可能会添加更多中间件处理器
srv := &http.Server{
ReadTimeout: 120 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 120 * time.Second, // Go 1.8 开始引进
Handler: handler,
}
return srv
}
首先,我们定义一个 struct 封装所有需要记录的信息:
// LogReqInfo 描述了有关 HTTP 请求的信息(译者注:此处为作者笔误,应当是 HTTPReqInfo)
type HTTPReqInfo struct {
// GET 等方法
method string
uri string
referer string
ipaddr string
// 响应状态码,如 200,204
code int
// 所发送响应的字节数
size int64
// 处理花了多长时间
duration time.Duration
userAgent string
}
下面是 logRequestHandler 的全部实现:
func logRequestHandler(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ri := &HTTPReqInfo{
method: r.Method,
uri: r.URL.String(),
referer: r.Header.Get("Referer"),
userAgent: r.Header.Get("User-Agent"),
}
ri.ipaddr = requestGetRemoteAddress(r)
// this runs handler h and captures information about
// HTTP request
// 这里运行处理器 h 并捕获有关 HTTP 请求的信息
m := httpsnoop.CaptureMetrics(h, w, r)
ri.code = m.Code
ri.size = m.BytesWritten
ri.duration = m.Duration
logHTTPReq(ri)
}
return http.HandlerFunc(fn)
}
我们复盘下这个简单的例子:
其他的信息则比较难获取。
获取客户端 IP 地址的问题是有可能涉及到 HTTP 代理。客户端向代理发起请求,然后代理向我们请求。于是,我们拿到了代理的 IP 地址,而不是客户端的。
因为这样,代理通常在请求的 HTTP 头部信息中以 X-Real-Ip 或者 X-Forwarded-For 来携带客户端真正的 IP 地址。
下面展示了如何提取这个信息:
// Request.RemoteAddress 包含了端口,我们需要把它删掉,比如: "[::1]:58292" => "[::1]"
func ipAddrFromRemoteAddr(s string) string {
idx := strings.LastIndex(s, ":")
if idx == -1 {
return s
}
return s[:idx]
}
// requestGetRemoteAddress 返回发起请求的客户端 ip 地址,这是出于存在 http 代理的考量
func requestGetRemoteAddress(r *http.Request) string {
hdr := r.Header
hdrRealIP := hdr.Get("X-Real-Ip")
hdrForwardedFor := hdr.Get("X-Forwarded-For")
if hdrRealIP == "" && hdrForwardedFor == "" {
return ipAddrFromRemoteAddr(r.RemoteAddr)
}
if hdrForwardedFor != "" {
// X-Forwarded-For 可能是以","分割的地址列表
parts := strings.Split(hdrForwardedFor, ",")
for i, p := range parts {
parts[i] = strings.TrimSpace(p)
}
// TODO: 应当返回第一个非本地的地址
return parts[0]
}
return hdrRealIP
}
捕获响应写对象(ResponseWriter)的状态码以及响应的大小更为困难。
http.ResponseWriter 并没有给我们这些信息。但幸运的是,这是一个简单的接口:
type ResponseWriter interface {
Header() Header
Write([]byte) (int, error)
WriteHeader(statusCode int)
}
写一个包装了原始响应的接口实现,并记录我们想要了解的信息,这是可行的。幸运如我们,已经有人在包 httpsnoop[9] 中实现了。
Apache 的日志格式比较紧凑,虽然具备人类可读性但却难于解析。
有的时候,我们也需要阅读日志分析,然后我不赞成为这个格式的实现解析器的想法。
从实现的角度来看,一个简单的方式是用 JSON 来记录,并且换行隔开。
对于这种方法我不喜欢的是:JSON 不易于阅读。
作为一个中间层,我创建了 siser 库,它实现了一个可扩展,易于实现和人类可读的序列化格式。它非常适合用于记录结构化信息,我已经在多个项目用到它了。
下面展示了一个简单请求是如何被序列化的:
171 1567185903788 httplog
method: GET
uri: /favicon.ico
ipaddr: 204.14.239.58
code: 404
size: 758
duration: 0
ua: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:68.0) Gecko/20100101 Firefox/68.0
每个记录的第一行包含了以下信息:
然后第一行之后的数据都是 key:value 格式。
下面展示了我们如何序列化一条记录并把它写到日志文件:
var (
muLogHTTP sync.Mutex
)
func logHTTPReq(ri *HTTPReqInfo) {
var rec siser.Record
rec.Name = "httplog"
rec.Append("method", ri.method)
rec.Append("uri", ri.uri)
if ri.referer != "" {
rec.Append("referer", ri.referer)
}
rec.Append("ipaddr", ri.ipaddr)
rec.Append("code", strconv.Itoa(ri.code))
rec.Append("size", strconv.FormatInt(ri.size, 10))
durMs := ri.duration / time.Millisecond
rec.Append("duration", strconv.FormatInt(int64(durMs), 10))
rec.Append("ua", ri.userAgent)
muLogHTTP.Lock()
defer muLogHTTP.Unlock()
_, _ = httpLogSiser.WriteRecord(&rec)
}
我通常在 Ubuntu 上部署服务器,并把日志记录到 /data/<service-name./log 目录。
我们不能一直往同一个日志文件里写。否则到最后会用完所有空间。
对于长时间的日志,我通常每天一个日志文件,以日期命名。如 2019-09-23.txt, 2019-09-24.txt 等等。
这有时称为日志滚动 ( log rotate).
为了避免重复实现这个功能,我写了一个库 dailyrotate[10]。
它实现了 Write, Close 以及 Flush 方法,所以它易于接入到现有已使用 io.Reader 等的代码。
你要指定使用哪个目录,以及日志命名的格式。这个格式通过 Go 的时间格式化函数来实现的。我通常使用 2006-01-02.txt 每天生成一个唯一的时间,并根据日期来排序,txt 则是工具识别文本文件而不是二进制文件的标志。
接着就和写普通的文件一样,以及确保代码会每天创建文件。
你也可以提供一个通知的回调,当发生日志滚动时会通知你,这样就可以做一些动作,例如把刚刚关闭的文件上传线上存储,或者对它做分析。
下面是代码:
pathFormat := filepath.Join("dir", "2006-01-02.txt")
func onClose(path string, didRotate bool) {
fmt.Printf("we just closed a file '%s', didRotate: %v\n", path, didRotate)
if !didRotate {
return
}
// process just closed file e.g. upload to backblaze storage for backup
go func() {
// if processing takes a long time, do it in a background goroutine
}()
}
w, err := dailyrotate.NewFile(pathFormat, onClose)
panicIfErr(err)
_, err = io.WriteString(w, "hello\n")
panicIfErr(err)
err = w.Close()
panicIfErr(err)
为了长期存储我把它们压缩成 gzip 并把文件上传到线上存储。这有很多选择:S3, Google Storage, Digital Ocean Spaces, BackBlaze。
我倾向于使用 Digital Ocean Spaces 或者 BackBlaze,因为他们足够廉价(存储成本和贷款成本)。
它们均支持 S3 协议,所以我使用 go-minio[11] 库。
为了分析,我每天都会运行代码,生成大部分有用信息的总结。
还有其他的做法,可以把数据引入到如 BigQuery[12] 的系统。
如果你正在寻找程序员一起工作,希望一起谈一下[17]。
由 Krzysztof Kowalczyk[18] 所著。
via: https://onepage.nopub.io/p/Logging-HTTP-requests-in-Go-233de7fe59a747078b35b82a1b035d36
作者:Krzysztof Kowalczyk[19]译者:LSivan[20]校对:JYSDeveloper[21]
本文由 GCTT[22] 原创编译,Go 中文网[23] 荣誉推出
[1]
OnePage: https://onepage.nopub.io/
[2]
记录什么信息: https://onepage.nopub.io/p/Logging-HTTP-requests-in-Go-233de7fe59a747078b35b82a1b035d36#63fd0006-6ebd-442c-a463-d11862e8c33c
[3]
获取要记录的信息: https://onepage.nopub.io/p/Logging-HTTP-requests-in-Go-233de7fe59a747078b35b82a1b035d36#c8a27402-1650-402a-8679-69214078b88a
[4]
日志文件的格式: https://onepage.nopub.io/p/Logging-HTTP-requests-in-Go-233de7fe59a747078b35b82a1b035d36#97da9f14-289e-42f6-94fd-936a4eb88f26
[5]
每日滚动日志: https://onepage.nopub.io/p/Logging-HTTP-requests-in-Go-233de7fe59a747078b35b82a1b035d36#99565a90-2f57-4aab-a5e7-5eb9a9194adc
[6]
长期存储以及分析: https://onepage.nopub.io/p/Logging-HTTP-requests-in-Go-233de7fe59a747078b35b82a1b035d36#a099947d-2079-4d1d-a996-41e4ed1ff02a
[7]
更多的 Go 资源: https://onepage.nopub.io/p/Logging-HTTP-requests-in-Go-233de7fe59a747078b35b82a1b035d36#4405e240-bd60-45a8-ba47-65e175eb7f8f
[8]
招聘 Go 开发者: https://onepage.nopub.io/p/Logging-HTTP-requests-in-Go-233de7fe59a747078b35b82a1b035d36#5076eef2-d176-43f3-bab5-c0d3030efa23
[9]
httpsnoop: https://github.com/felixge/httpsnoop
[10]
dailyrotate: https://github.com/kjk/dailyrotate
[11]
go-minio: https://github.com/minio/minio-go
[12]
BigQuery: https://cloud.google.com/bigquery/what-is-bigquery
[13]
Essential Go: https://www.programming-books.io/essential/go/
[14]
siser: https://github.com/kjk/siser
[15]
深度文章: https://blog.kowalczyk.info/article/fc9203f7c72a4532b1ae51d018fef7b3/trade-offs-in-designing-versatile-log-format.html
[16]
dailyrotate: https://github.com/kjk/dailyrotate
[17]
希望一起谈一下: https://blog.kowalczyk.info/goconsultantforhire.html
[18]
Krzysztof Kowalczyk: https://blog.kowalczyk.info/
[19]
Krzysztof Kowalczyk: https://onepage.nopub.io/u/bb760e2dd6794b64b2a903005b21870a
[20]
LSivan: https://github.com/LSivan
[21]
JYSDeveloper: https://github.com/JYSDeveloper
[22]
GCTT: https://github.com/studygolang/GCTT
[23]
Go 中文网: https://studygolang.com/
*请认真填写需求信息,我们会在24小时内与您取得联系。