HTTP服务器。


在这个单元里,我们以一个极简单的HTTP服务器开始,引入请求和响应的概念与处理,如何定义服务器端的处理函数,如何响应404错误,对于响应头和响应不同类型的内容,不同类型的请求,如何进行编码,如何获取请求数据,最后我们来做单元总结。


首先我们来看,一个极简服务器。


大道至简。使用Go语言构建世界上最简单的HTTP服务器,仅需四行代码。在标准库net/http包提供了多种用于创建HTTP服务器的方法,其中包含HandleFunc函数和ListenAndServe函数。HandleFunc函数接受两个参数,第一个参数是一个访问路径,也就是我们通常所讲的URL地址,第二个参数是一个处理函数。这个函数的功能就是来创建路由,即为不同的访问路径来指定不同的处理函数。ListenAndServe函数同样接受两个参数,第一个参数是一个侦听端口,第二个参数我们暂且不用,可将其指定为空。该函数用于启动服务,侦听给定的通信端口。针对给定访问路径的处理函数,需要自己实现。该函数的函数名可以自定,但它一定会带有两个特定类型的参数,并且没有返回值。这两个参数的类型分别是,http包中ResponseWriter类型的写入器,和http包Request类型的指针,代表请求。可见该函数两个参数,一个对应的是响应,另一个则描述了请求。这里的响应其实是一个写入器,我们可以通过它将返回给客户端的响应信息写入其中,而针对于不同的请求,所做的业务处理,则在该函数的函数体中予以实现。比如在上面的例子中,我们就直接调用了写入器中的Write方法,将一个被转换为字节切片形式的字符串“Hello World!”,写入了向客户端的响应。


在下面的例子里,首先,我们定义rootHandler路由处理函数,以相同的方式,向写入器写入了“Hello World!”字符串。请注意,我们需要先将其转换为字节切片,这是为了与该函数的参数要求一致。而在main函数中,我们只完成了两个函数调用。首先,通过HandleFunc,将前面编写的路由处理函数rootHandler,与指定的访问路径“/”,建立了联系。在第二个函数ListenAndServe中,我们指定让服务器去侦听8000端口。这时,我们打开浏览器,在浏览器的地址栏中,直接输入“localhost:8000/”,就可以直接看到来自服务器端的响应,“Hello World!”。


请求与响应。


除了通过浏览器向HTTP服务器发起请求并接收响应以外,还可以使用更专业化的工具,比如curl,来查看往返于客户端和服务器之间的请求响应数据包。Linux和MacOS操作系统通常都预先安装了curl工具,Windows操作系统虽然没有预先安装,但可以从这里免费下载并安装。


curl安装好以后,就可以在开发HTTP服务器时,用它做客户端向服务器发送各种请求,并查看来自服务器的响应。首先启动前面编写的“极简服务器”程序,go run main.go。打开终端或命令提示符窗口,输入如下命令:curl -is "http://localhost:8000"。这里的选项“is”指定打印包头,并忽略一些次要信息。如果命令执行成功,将看到来自HTTP服务器的响应,其中包含响应头和响应体。


服务器返回的响应头包含如下信息:第一行,表明了协议使用的是1.1版的HTTP协议,状态码200,对应的状态描述是OK,代表成功;第二行,是以格林尼治时间表示的日期和时间;第三行,表明内容长度共有12个字节;最后一行,指定内容的类型为使用UTF-8编码的纯文本。服务器返回的响应体包含了由12个字节UTF-8编码纯文本构成的响应字符串,“Hello World!”。


HandleFunc函数用于注册响应给定URL地址映射的处理函数。比如我们可以将rootHandler函数关联于“/”地址,也就是根目录;将usersHandler处理函数关联于“/users/”目录;将projectsHandler函数关联于“/projects/”目录。服务器将依据如下路由规则,将对特定URL地址的访问路由到相应的处理函数中:URL地址必须完全匹配,包括大小写和斜杠,对“/projects”的访问不会被路由到与“/projects/”相对应的处理函数projectsHandler中;如果找不到严格匹配的URL地址,则一律按“/”处理,因此“/”也被称为缺省路由;路由与请求类型无关,只负责调用与URL地址相对应的函数,对每一种请求类型,如GET、POST、PUT或者DELETE等,其具体处理,统统在处理函数的内部实现。


处理函数。


处理来自客户端的请求,并回之以特定的响应,这是处理函数的主要任务。在处理函数中,我们通常会完成如下工作:比如验证请求路径,我们可以从请求包的URL属性中获得其Path字段的值,如果这个路径与我们所能处理的不相吻合,我们可以通过HTTP包的NotFound函数,向客户端返回404页面;我们也可以根据请求包中的Method字段,来区分请求的类型,并针对不同类型的请求,做出不同的处理;如果我们想获取请求的数据,可以从URL中获得Query,进行表单字段的查询,或者直接访问请求包中的Body,拿到请求包的包体;在处理业务逻辑的过程中,我们往往需要验证身份的合法性,然后读取并处理请求中的数据;


响应类型的选择,我们可以借助于请求包中的Header,代表请求包的头部,通过其Get方法,参数给定Accept,来了解客户端所能够接受的响应类型,在传输响应时,利用响应写入器,从Header的方法中获取头部,通过Set的方法,将Content-Type字段指定为特定的值,以表明响应的类型;而对于响应报文的返回,则直接使用响应写入器的Write方法,写入字节流。返回响应报文一定是整个处理过程的最后一步,因为响应报文一旦返回客户端,之后再对它做任何修改都无济于事。比如我们只要调用了Write,并传递了“Hello World!”这样的字母串作为响应内容,此后再去设置响头部已经是无效操作。


响应404错误。


缺省路由的任务是将所有没有指定处理函数的请求都定向到“/”。鉴于所请求的路由并不存在,于此可以响应以404错误。比如我们在针对“/”的路由处理函数rootHandler中,检查请求包URL字段中的Path值,如果不是“/”,表示所请求的路径并非此处可以处理的根目录,那么我们就直接调用HTTP包的NotFound函数,将写入器和请求包作为参数传入其中,该函数将向客户端返回404错误信息,而如果所请求的路径是根目录,那么我们就按照正常的逻辑予以处理。客户端所收到的404错误的响应报文,类似下边这个样子。请注意其第一行的状态码为404,而关于它的描述为Not Found,表明资源找不到。


下面的代码演示了响应404错误的过程。我们在针对根目录的路由处理函数rootHandler中,进行了路径的检查,发现客户端所请求的URL路径并非“/”,即根目录,直接调用http.NotFound,将响应写入器和请求包所对应的变量传入其中,直接返回,而对于正常的路径则响应以“Hello World!”。


响应头。


HTTP服务器在构建返回客户端的响应时,经常需要设置响应头。例如为了向客户端返回一些JSON格式的数据,服务器必须将响应头的“Content-Type”字段设置为“application/json”。Go语言标准库http包的响应写入器ResponseWriter为此提供了支持。例如我们可以通过写入器的方法,获取其头部写入器,调用其中的Set方法,将特定的字段名设置为特定的字段值。比如将“Content-Type”字段设置为“application/json”。设置后的响应头类似下面这个样子。我们可以看到,其中的“Content-Type”字段的值为“application/json”。客户端在接收到来自服务器的响应时,可以根据其对响应头的设置,来决定对后续响应体的接收和处理方式。


在下面这段代码中,我们在完成访问路径检测以后,将响应头中的“Content-Type”字段设置为“application/json”,而在响应体的内容中,果然设置了一个JSON格式的字母串,键为“Greeting”,值为“Hello World!”。我们从curl打印出的结果可以清晰地看到,头部所描述的内容类型“application/json”,与体部实际的数据格式是严格吻合的,这对于方便客户机做正确的响应处理非常重要。


响应不同类型的内容。


在向客户端返回响应时,服务器应根据客户端的具体需求,为其提供不同类型的内容。一些常见的内容类型包括:“text/plain”,纯文本格式,“text/html”,HTML格式,“application/json”,JSON格式,“application/xml”,XML格式。


服务器通过查询请求头的“Accept”字段,获悉客户端期望接受的内容类型,并返回相应类型的数据内容。为此我们做switch-case分支,检查请求包Header字段Get方法的返回值,其参数为“Accept”,返回请求头中“Accept”字段的值,如果是“application/json”,或者是“application/xml”,我们通过响应写入器的Header方法获取其头部,调用其Set方法,写入“Content-Type”字段的值为“application/json”,或者“application/xml”,并在包体部分填以相应格式的内容。如果客户端所请求的内容类型服务器无法满足,也可以在default分支中,以缺省的,比如纯文本的方式,给出相应的响应。


在下面的例子里,我们于rootHandler路由处理函数中······


通过switch-case检查请求头中的“Accept”字段的值,并分别针对于JSON和XML格式,在响应中予以体现,并且在default分支中,以“text/plain”纯文本的方式作为默认的内容类型。


通过curl向服务器发起请求时,可以使用“-H”选项来设置请求头中的特定字段。比如我们可以借助于“-H”选项,将请求中的“Accept”字段设置为“application/json”、“application/xml”,或者是一个在服务器完全无法处理的“dummy/dummy”类型。观察从服务器返回的响应,其“Content-Type”字段和实际的包体内容,是不是与请求所要求的内容类型相一致。


响应不同类型的请求。


为了获知来自客户端的请求类型,可以访问Request中的Method,并通过switch语句针对不同类型的请求,执行不同的操作。我们可以看到,在请求包Method字段的值为“GET”或者“POST”时,我们的分支可以给出相应的不同的处理,并针对服务器所无法处理的请求类型,以“StatusNotImplemented”予以响应。HTTP协议中的GET请求一般用于从服务器获取数据,而POST请求则意在向服务器提交要被处理的数据,返回的响应里面含有被获取到或处理过的数据。


在下面的代码中,我们于路由处理函数rootHandler中······


针对请求包中的Method字段,做不同的switch-case分支。对GET和POST请求,分别响应了不同的内容,并对无法处理的请求,以“StatusNotImplemented”予以响应。


通过curl向服务器发起请求,可使用“-X”选项来设置请求的类型。我们在实验中以GET、POST和DUMMY向服务器发起请求,观察服务器的响应,是不是与我们在客户端所预期的一致。


获取请求数据。


对于GET请求,其携带的数据通常位于查询字符串中。在查询自符串中可以“?”表示请求数据的开始,以“&”作为数据项的分割符,每个数据项以“键=值”的形式给出。对于这样的数据,可通过调用Request类型URL字段的Query方法获得。通过基于range的for循环,来遍历Query方法所返回的键值对序列。请求中的每个数据项,均被视为一个键值对,等号左边为键,右边为值。对于POST请求,数据通常位于请求体内。至于这样的数据,可以通过读取Request中的Body属性获得。借助ioutil包的ReadAll函数,可以将请求包中的Body字段视为一个输入流,从中读取即可返回包体的字节流。其字节切片的内容,还需要自行根据相应的格式进行解析。注意所有进入服务器的数据都应被视为是不安全的,需经过滤后再使用。


在下面的例子中······


我们在rootHandler路由处理函数中,针对于不同的请求类型,GET或者POST,于相应的case分支中,以不同的方式获取了它的数据。对于GET请求,我们用k和v作为键和值,写了基range的for循环,遍历了Query所返回的键值序列,拿到从URL字符串中所携带的请求数据。


而针对于POST方法,我们用ReadAll读取请求中的Body字段,获取请求数据的字节切片reqBody,而后将其以字符串的形式予以打印。我们可以看到,当我们从客户端借助curl来向服务器提交数据时,对于GET请求,我们携带的键值对为“username”和“tarena”、“password”和“123456”,服务器正确的解析到相应的结果并予以打印,而对于POST请求,我们发送数据是一个字符串“some data to send”,同样在服务器的日志中,打印出了正确的结果。


最后我们对这个单元做一下总结。


在路由模式中必须对访问路径做精确匹配吗?对访问路径的匹配必须是精确的,而且是静态的,其中不能包含可变的部分,也不能使用正则表达式。一些第三方路由可以支持变量,甚至将内容或请求的类型也作为路由的依据。Go语言也象其它语言那样提供用于构建服务器的框架吗?Go语言也提供服务器框架,但在大多数情况下,net/http包已足以应付服务器开发的需要。如何创建HTTPS服务器?net/http包通过ListenAndServeTLS函数创建HTTPS服务器,该函数的工作原理与ListenAndServe函数相同,但必须为其提供证书和密钥文件。TLS即传输层安全协议(Transport Layer Security Protocol),是进行HTTPS连接的重要环节。经过TLS层协商,后续HTTP请求都可以使用协商好的对称密钥进行加密。


HTTP客户端。


在这个单元中,我们首先简要介绍HTTP协议,了解从客户端所发出的GET请求和POST请求,以及定制HTTP请求的方法,关于HTTP的调试和响应超时的控制,最后做单元总结。


HTTP协议。


TCP/IP协议栈由上至下分为以下四层:应用层,为用户提供应用服务时的通信活动,比如DNS,域名解析,FTP,文件传输,HTTP,超文本传输;传输层,网络中两台计算机之间的数据传输,比如TCP、UDP和SPX;网络层,网络中相邻节点之间的数据帧传送,如IP、IPX;链路层,构成网络传输介质的各种基础设施,如以太网、ADSL、TD-LTE等等。通过协议栈传输数据包的过程,其实就是不断封装和解封装的过程。发送数据时,应用层组织出HTTP数据,由传输层封装TCP首部,网络层封装IP首部,链路层封装以太网首部,以以太网包的形式在网内传输。接收端将接收到的以太网包,在链路层解析以太网首部,其余部分传输给网络层,解析其IP首部,再将其余内容传输于传输层,解析TCP首部,最后得到HTTP数据,传递给应用层。数据的发送,就是逐层增加首部,逐层封包,数据的接收,就是逐层删除首部,解除封包。


作为TCP/IP协议的一部分,HTTP协议工作在协议栈的最高层——应用层,负责为用户提供针对超文本数据的网络应用,如:Apache服务器、Nginx服务器、IIS服务器,以及各种浏览器客户端等等。借助curl可以了解客户端发送给服务器的HTTP请求。比如在下面这个请求中,”GET“代表请求的类型,其后”/ip“代表访问路径,”HTTP/2“是我们所使用的协议。”Host“、”User-Agent“和”Accept“,这三个字段分别表示所要访问的主机域名、客户端信息和期望接受的内容类型。Go语言的HTTP客户端功能齐备,在最基本的情况下,它为各种选项提供了合理的默认设置,让开发者可以无需关心底层,同时也提供了细力度的控制。


GET请求。


通过Go语言标准库net/http包的Get函数,可以直接向服务器发送GET请求。GET请求用于从服务器获取数据。对于从服务器获取一些数据而言,使用默认参数配置的客户端和请求头完全够用。例如,客户端可以通过向https://ifconfig.io/ip发送GET请求,来获取自己的IP地址。GET函数的参数,即URL的路径。它的返回值除去错误信息以外,还包括响应包。响应包的Body字段即为包体,是一个输入流,可以通过ioutil包的ReadAll函数读取,返回该流中的字节序列。通过“%s”格式打印,将字节序列转换为字符串,即为客户端的IP地址。


在下面的例子中,我们即仿照刚才的方法,向ifconfig.io发送了这样的GET请求。从打印出的字符串形式的响应结果可以看到,发送此请求的客户端,其IP地址为124.20.192.18。


POST请求。


通过Go语言标准库net/http包的Post函数,可以直接向服务器发送POST请求。POST请求用于向服务器提交数据。调用Post函数除了需要指定访问路径,还需要提供被提交的数据及其内容类型。比如,客户端试图通过向https://httpbin.org/post发送POST请求,来获取关于自己的描述信息。这时我们首先用strings包的NewReader构建一个基于字符串的输入流,流中的字符串内容为一个JSON字符串,键为“some”值为“json”。然后调用http包的Post函数,第一个参数为访问服务器的URL路径,第二个参数为对数据格式的描述,这里用的是“application/json”,而第三个参数即为输入流,也即我们刚才所构建的面向内存的字符串输入流。Post函数的执行,即从第三个参数所表示的输入流中读取字节序列,以之作为数据向第一个参数所描述的URL发送POST请求,请求中对数据类型的描述由第二个参数决定,并返回服务器的响应,其中的Body字段,字节流,可以通过ReadAll来读取,返回字节切片后,用“%s”转换为字符串打印。


在下面的代码中,我们即遵循于刚才的方式,向服务器发出了POST请求,并将返回的结果以字符串形式打印出来,所得到的是关于客户端的描述。


https://httpbin.org是一个用于测试HTTP客户端的工具,向“/post”路径发送POST请求,服务器将返回客户端发送给它的数据,以及一些客户端的描述信息。我们这里看到“data: {some: json}”,这是客户端发送给服务器的数据,原样返回。后面的内容为对客户端的描述,包括其IP地址:124.207.192.18。


定制HTTP请求。


如果需要向服务器发送的HTTP请求做更多超越于默认设置的定制化,那么这时我们可以首先用net/http包所提供的一个导出类型Client去创建一个代表客户端的变量。之后用net/http包所提供的一个导出函数NewRequest来构建一个HTTP请求。在这个请求中会包含请求的类型、目标主机的URL。它所返回的是一个代表请求的变量。我们可以用这个变量作为参数,去调用Client变量中的Do函数,来完成对请求的发送,并获得其响应,以返回值Response形式得到。用这种方法我们可以单独设置请求头、基本身份验证,甚至于cookies,这些请求参数。一般而言,除非要完成的任务非常简单,否则推荐使用这种相对定制化的方法。


比如我们在下面的例子中,完成了和之前同样的功能,只不过我们用的是定制化的方法。首先,利用http中的Client,创建了一个代表客户机的变量。其次,执行http包中的NewRequest,构建了一个请求,同样是向ifconfig.io/ip中发送GET请求。最后,执行Client中的Do方法,其参数即为我们的Request请求,得到的是它的响应,再将其转换为字符串以后打印,显示出客户端的IP地址。


调试HTTP。


Go语言标准库的net/http/httputil包提供了一些方法,可以用于调试往返于客户端和服务器之间的HTTP请求及响应。比如,我们可以通过DumpRequestOut将请求打印出来,或者通过DumpResponse将响应打印出来。这两个函数所返回的,都是关于响应或者请求的字节切片,转换成字符串格式,即可显示。如果我们希望只在调试的情况下才打印这些信息,也可以将“DEBUG”设为一个环境变量,利用os包的Getenv函数获取环境变量“DEBUG”的值,来决定是否打印这些调试信息。


比如下面的程序,当我们试图向ifconfig.io/ip发送GET请求,来获取本机IP时,指定了可接受的内容类型为“application/json”。我们试图以相同的方式来处理它的响应,但是其结果却并非如我们所预期。因此这时我们就用DumpRequestOut······


和DumpResponse打印出了请求和响应的内容。结果发现,请求包我们的确把"Accept"字段设置为“application/json”,表明我们期望得到一个JSON字母串作为响应,但服务器实际给我们传回的响应,它的“Content-Type”字段却是“text/plain”,是一个纯文本。因此我们通过这样的信息就可以了解,服务器实际给我们传回的响应,是否如我们所预期,以及我们应该用怎样的方式,来处理这些响应报文。


响应超时。


客户端向服务器发送请求后,完全无法知道服务器会在多长时间内返回响应。在系统的底层,有太多因素会对响应时间构成影响。在客户端一侧,DNS查找速度、创建TCP套接字的速度、与服务器建立TCP连接的速度、如果使用的是HTTPS, TLS握手的速度、向服务器发送数据的速度,都会构成影响。而在服务器的一侧,重定向的速度、业务处理的速度、向客户端发送数据的速度,同样也会影响响应时间。


以默认方式创建的客户端,没有对响应时间做任何设置,这也就意味着:如果服务器很久甚至永远没有向客户端返回响应,客户端将一直等待;维持这条连接的内存和表示这个套接字的文件描述符,也将一直存在;如果发出的多个请求都是这种情况,那么客户端的资源将会很快耗尽。建议为客户端设置响应超时,一旦超过时间还没有收到响应,即宣告错误。设置超时的方法就是,当我们用http的导出类型Client来创建代表客户端的变量client时,可以将其Timeout字段设置为一个时间。这时,当我们通过这样的Client中的Do方法,来发送请求获取响应时,一旦超出了我们所规定的时间,此方法即会返回错误。


在下面的代码中,我们试图以“GET”类型向服务器ifconfig.io/ip发出请求,同时我们在Client变量的创建过程中,将其Timeout字段设置为1秒钟。这时我们看到,Do函数返回的就是一个错误,而错误信息······


从我们打印的结果来看,就是一个关于超时的描述。这就是设置了响应超时所带来的效果。


使用Transport可以更精细化地控制超时,甚至为传输的每个阶段单独设置超时。这时我们所要填充的,是Client结构体中的Transport字段。该字段值是一个由http包的导出类型Transport所创建的结构体变量。该结构体中,可以包含多个字段,通过每个字段,来为传输的每个阶段,独立地设置超时时间。


比如我们在下面的例子中,就创建了一个Transport类型的变量tr,并为其中的每个字段,设置了单独的超时时间。用这样的变量,填充了Client结构体中的Transport字段······


在执行这个Client变量的Do函数时,我们同样接收了它所返回的错误值err,并对其信息进行了打印······


我们可以看到,发生超时的阶段,会被更加精确地显示出来。


最后我们对这个单元做一个总结。


开发互联网应用,了解HTTP是否是必须的?如果使用Go语言开发互联网应用,只要知道HTTP定义了客户端和服务器之间的交互方式就够了。只使用Go语言标准库,完全可以开发出功能强大的互联网应用。当然,对HTTP规范了解得越多,开发基于HTTP协议的互联网应用就越得心应手。作为客户端,可以同时发出多个HTTP请求吗?可以。借助Goroutine,客户端可以同时发出多个HTTP请求。可以根据HTTP响应头中的状态码决定程序即将采取的措施吗?可以。在Response类型的变量中,包含了StatusCode字段,其中即存放了最近一次的响应状态码。


谢谢,再见!