是一名程序员,我的主要编程语言是 Java,我更是一名 Web 开发人员,所以我必须要了解 HTTP,所以本篇文章就来带你从 HTTP 入门到进阶,看完让你有一种恍然大悟、醍醐灌顶的感觉。
最初在有网络之前,我们的电脑都是单机的,单机系统是孤立的,我还记得 05 年前那会儿家里有个电脑,想打电脑游戏还得两个人在一个电脑上玩儿,及其不方便。我就想为什么家里人不让上网,我的同学 xxx 家里有网,每次一提这个就落一通批评:xxx上xxx什xxxx么xxxx网xxxx看xxxx你xxxx考xxxx的xxxx那xxxx点xxxx分。虽然我家里没有上网,但是此时互联网已经在高速发展了,HTTP 就是高速发展的一个产物。
首先你听的最多的应该就是 HTTP 是一种 超文本传输协议(Hypertext Transfer Protocol),这你一定能说出来,但是这样还不够,假如你是大厂面试官,这不可能是他想要的最终结果,我们在面试的时候往往把自己知道的尽可能多的说出来,才有和面试官谈价钱的资本。那么什么是超文本传输协议?
超文本传输协议可以进行文字分割:超文本(Hypertext)、传输(Transfer)、协议(Protocol),它们之间的关系如下
按照范围的大小 协议 > 传输 > 超文本。下面就分别对这三个名次做一个解释。
在互联网早期的时候,我们输入的信息只能保存在本地,无法和其他电脑进行交互。我们保存的信息通常都以文本即简单字符的形式存在,文本是一种能够被计算机解析的有意义的二进制数据包。而随着互联网的高速发展,两台电脑之间能够进行数据的传输后,人们不满足只能在两台电脑之间传输文字,还想要传输图片、音频、视频,甚至点击文字或图片能够进行超链接的跳转,那么文本的语义就被扩大了,这种语义扩大后的文本就被称为超文本(Hypertext)。
那么我们上面说到,两台计算机之间会形成互联关系进行通信,我们存储的超文本会被解析成为二进制数据包,由传输载体(例如同轴电缆,电话线,光缆)负责把二进制数据包由计算机终端传输到另一个终端的过程(对终端的详细解释可以参考 你说你懂互联网,那这些你知道么?这篇文章)称为传输(transfer)。
通常我们把传输数据包的一方称为请求方,把接到二进制数据包的一方称为应答方。请求方和应答方可以进行互换,请求方也可以作为应答方接受数据,应答方也可以作为请求方请求数据,它们之间的关系如下
如图所示,A 和 B 是两个不同的端系统,它们之间可以作为信息交换的载体存在,刚开始的时候是 A 作为请求方请求与 B 交换信息,B 作为响应的一方提供信息;随着时间的推移,B 也可以作为请求方请求 A 交换信息,那么 A 也可以作为响应方响应 B 请求的信息。
协议这个名词不仅局限于互联网范畴,也体现在日常生活中,比如情侣双方约定好在哪个地点吃饭,这个约定也是一种协议,比如你应聘成功了,企业会和你签订劳动合同,这种双方的雇佣关系也是一种 协议。注意自己一个人对自己的约定不能成为协议,协议的前提条件必须是多人约定。
那么网络协议是什么呢?
网络协议就是网络中(包括互联网)传递、管理信息的一些规范。如同人与人之间相互交流是需要遵循一定的规矩一样,计算机之间的相互通信需要共同遵守一定的规则,这些规则就称为网络协议。
没有网络协议的互联网是混乱的,就和人类社会一样,人不能想怎么样就怎么样,你的行为约束是受到法律的约束的;那么互联网中的端系统也不能自己想发什么发什么,也是需要受到通信协议约束的。
那么我们就可以总结一下,什么是 HTTP?可以用下面这个经典的总结回答一下: HTTP 是一个在计算机世界里专门在两点之间传输文字、图片、音频、视频等超文本数据的约定和规范
随着网络世界演进,HTTP 协议已经几乎成为不可替代的一种协议,在了解了 HTTP 的基本组成后,下面再来带你进一步认识一下 HTTP 协议。
网络是一个复杂的系统,不仅包括大量的应用程序、端系统、通信链路、分组交换机等,还有各种各样的协议组成,那么现在我们就来聊一下网络中的协议层次。
为了给网络协议的设计提供一个结构,网络设计者以分层(layer)的方式组织协议,每个协议属于层次模型之一。每一层都是向它的上一层提供服务(service),即所谓的服务模型(service model)。每个分层中所有的协议称为 协议栈(protocol stack)。因特网的协议栈由五个部分组成:物理层、链路层、网络层、运输层和应用层。我们采用自上而下的方法研究其原理,也就是应用层 -> 物理层的方式。
应用层是网络应用程序和网络协议存放的分层,因特网的应用层包括许多协议,例如我们学 web 离不开的 HTTP,电子邮件传送协议 SMTP、端系统文件上传协议 FTP、还有为我们进行域名解析的 DNS 协议。应用层协议分布在多个端系统上,一个端系统应用程序与另外一个端系统应用程序交换信息分组,我们把位于应用层的信息分组称为 报文(message)。
因特网的运输层在应用程序断点之间传送应用程序报文,在这一层主要有两种传输协议 TCP和 UDP,利用这两者中的任何一个都能够传输报文,不过这两种协议有巨大的不同。
TCP 向它的应用程序提供了面向连接的服务,它能够控制并确认报文是否到达,并提供了拥塞机制来控制网络传输,因此当网络拥塞时,会抑制其传输速率。
UDP 协议向它的应用程序提供了无连接服务。它不具备可靠性的特征,没有流量控制,也没有拥塞控制。我们把运输层的分组称为 报文段(segment)
因特网的网络层负责将称为 数据报(datagram) 的网络分层从一台主机移动到另一台主机。网络层一个非常重要的协议是 IP 协议,所有具有网络层的因特网组件都必须运行 IP 协议,IP 协议是一种网际协议,除了 IP 协议外,网络层还包括一些其他网际协议和路由选择协议,一般把网络层就称为 IP 层,由此可知 IP 协议的重要性。
现在我们有应用程序通信的协议,有了给应用程序提供运输的协议,还有了用于约定发送位置的 IP 协议,那么如何才能真正的发送数据呢?为了将分组从一个节点(主机或路由器)运输到另一个节点,网络层必须依靠链路层提供服务。链路层的例子包括以太网、WiFi 和电缆接入的 DOCSIS 协议,因为数据从源目的地传送通常需要经过几条链路,一个数据包可能被沿途不同的链路层协议处理,我们把链路层的分组称为 帧(frame)
虽然链路层的作用是将帧从一个端系统运输到另一个端系统,而物理层的作用是将帧中的一个个 比特 从一个节点运输到另一个节点,物理层的协议仍然使用链路层协议,这些协议与实际的物理传输介质有关,例如,以太网有很多物理层协议:关于双绞铜线、关于同轴电缆、关于光纤等等。
五层网络协议的示意图如下
我们上面讨论的计算网络协议模型不是唯一的 协议栈,ISO(国际标准化组织)提出来计算机网络应该按照7层来组织,那么7层网络协议栈与5层的区别在哪里?
从图中可以一眼看出,OSI 要比上面的网络模型多了 表示层 和 会话层,其他层基本一致。表示层主要包括数据压缩和数据加密以及数据描述,数据描述使得应用程序不必担心计算机内部存储格式的问题,而会话层提供了数据交换的定界和同步功能,包括建立检查点和恢复方案。
就如同各大邮箱使用电子邮件传送协议 SMTP 一样,浏览器是使用 HTTP 协议的主要载体,说到浏览器,你能想起来几种?是的,随着网景大战结束后,浏览器迅速发展,至今已经出现过的浏览器主要有
浏览器正式的名字叫做 Web Broser,顾名思义,就是检索、查看互联网上网页资源的应用程序,名字里的 Web,实际上指的就是 World Wide Web,也就是万维网。
我们在地址栏输入URL(即网址),浏览器会向DNS(域名服务器,后面会说)提供网址,由它来完成 URL 到 IP 地址的映射。然后将请求你的请求提交给具体的服务器,在由服务器返回我们要的结果(以HTML编码格式返回给浏览器),浏览器执行HTML编码,将结果显示在浏览器的正文。这就是一个浏览器发起请求和接受响应的过程。
Web 服务器的正式名称叫做 Web Server,Web 服务器一般指的是网站服务器,上面说到浏览器是 HTTP 请求的发起方,那么 Web 服务器就是 HTTP 请求的应答方,Web 服务器可以向浏览器等 Web 客户端提供文档,也可以放置网站文件,让全世界浏览;可以放置数据文件,让全世界下载。目前最主流的三个Web服务器是Apache、 Nginx 、IIS。
CDN的全称是Content Delivery Network,即内容分发网络,它应用了 HTTP 协议里的缓存和代理技术,代替源站响应客户端的请求。CDN 是构建在现有网络基础之上的网络,它依靠部署在各地的边缘服务器,通过中心平台的负载均衡、内容分发、调度等功能模块,使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。CDN的关键技术主要有内容存储和分发技术。
打比方说你要去亚马逊上买书,之前你只能通过购物网站购买后从美国发货过海关等重重关卡送到你的家里,现在在中国建立一个亚马逊分基地,你就不用通过美国进行邮寄,从中国就能把书尽快给你送到。
WAF 是一种 Web 应用程序防护系统(Web Application Firewall,简称 WAF),它是一种通过执行一系列针对HTTP / HTTPS的安全策略来专门为Web应用提供保护的一款产品,它是应用层面的防火墙,专门检测 HTTP 流量,是防护 Web 应用的安全技术。
WAF 通常位于 Web 服务器之前,可以阻止如 SQL 注入、跨站脚本等攻击,目前应用较多的一个开源项目是 ModSecurity,它能够完全集成进 Apache 或 Nginx。
WebService 是一种 Web 应用程序,WebService是一种跨编程语言和跨操作系统平台的远程调用技术。
Web Service 是一种由 W3C 定义的应用服务开发规范,使用 client-server 主从架构,通常使用 WSDL 定义服务接口,使用 HTTP 协议传输 XML 或 SOAP 消息,它是一个基于 Web(HTTP)的服务架构技术,既可以运行在内网,也可以在适当保护后运行在外网。
HTML 称为超文本标记语言,是一种标识性的语言。它包括一系列标签.通过这些标签可以将网络上的文档格式统一,使分散的 Internet 资源连接为一个逻辑整体。HTML 文本是由 HTML 命令组成的描述性文本,HTML 命令可以说明文字,图形、动画、声音、表格、链接等。
Web 页面(Web page)也叫做文档,是由一个个对象组成的。一个对象(Objecy) 只是一个文件,比如一个 HTML 文件、一个 JPEG 图形、一个 Java 小程序或一个视频片段,它们在网络中可以通过 URL 地址寻址。多数的 Web 页面含有一个 HTML 基本文件 以及几个引用对象。
举个例子,如果一个 Web 页面包含 HTML 文件和5个 JPEG 图形,那么这个 Web 页面就有6个对象:一个 HTML 文件和5个 JPEG 图形。HTML 基本文件通过 URL 地址引用页面中的其他对象。
在互联网中,任何协议都不会单独的完成信息交换,HTTP 也一样。虽然 HTTP 属于应用层的协议,但是它仍然需要其他层次协议的配合完成信息的交换,那么在完成一次 HTTP 请求和响应的过程中,需要哪些协议的配合呢?一起来看一下
TCP/IP 协议你一定听过,TCP/IP 我们一般称之为协议簇,什么意思呢?就是 TCP/IP 协议簇中不仅仅只有 TCP 协议和 IP 协议,它是一系列网络通信协议的统称。而其中最核心的两个协议就是 TCP / IP 协议,其他的还有 UDP、ICMP、ARP 等等,共同构成了一个复杂但有层次的协议栈。
TCP 协议的全称是 Transmission Control Protocol 的缩写,意思是传输控制协议,HTTP 使用 TCP 作为通信协议,这是因为 TCP 是一种可靠的协议,而可靠能保证数据不丢失。
IP 协议的全称是 Internet Protocol 的缩写,它主要解决的是通信双方寻址的问题。IP 协议使用 IP 地址 来标识互联网上的每一台计算机,可以把 IP 地址想象成为你手机的电话号码,你要与他人通话必须先要知道他人的手机号码,计算机网络中信息交换必须先要知道对方的 IP 地址。(关于 TCP 和 IP 更多的讨论我们会在后面详解)
你有没有想过为什么你可以通过键入 www.google.com 就能够获取你想要的网站?我们上面说到,计算机网络中的每个端系统都有一个 IP 地址存在,而把 IP 地址转换为便于人类记忆的协议就是 DNS 协议。
DNS 的全称是域名系统(Domain Name System,缩写:DNS),它作为将域名和 IP 地址相互映射的一个分布式数据库,能够使人更方便地访问互联网。
我们上面提到,你可以通过输入 www.google.com 地址来访问谷歌的官网,那么这个地址有什么规定吗?我怎么输都可以?AAA.BBB.CCC 是不是也行?当然不是的,你输入的地址格式必须要满足 URI 的规范。
URI的全称是(Uniform Resource Identifier),中文名称是统一资源标识符,使用它就能够唯一地标记互联网上资源。
URL的全称是(Uniform Resource Locator),中文名称是统一资源定位符,也就是我们俗称的网址,它实际上是 URI 的一个子集。
URI 不仅包括 URL,还包括 URN(统一资源名称),它们之间的关系如下
HTTP 一般是明文传输,很容易被攻击者窃取重要信息,鉴于此,HTTPS 应运而生。HTTPS 的全称为 (Hyper Text Transfer Protocol over SecureSocket Layer),全称有点长,HTTPS 和 HTTP 有很大的不同在于 HTTPS 是以安全为目标的 HTTP 通道,在 HTTP 的基础上通过传输加密和身份认证保证了传输过程的安全性。HTTPS 在 HTTP 的基础上增加了 SSL 层,也就是说 HTTPS=HTTP + SSL。(这块我们后面也会详谈 HTTPS)
你是不是很好奇,当你在浏览器中输入网址后,到底发生了什么事情?你想要的内容是如何展现出来的?让我们通过一个例子来探讨一下,我们假设访问的 URL 地址为 http://www.someSchool.edu/someDepartment/home.index,当我们输入网址并点击回车时,浏览器内部会进行如下操作
至此,键入网址再按下回车的全过程就结束了。上述过程描述的是一种简单的请求-响应全过程,真实的请求-响应情况可能要比上面描述的过程复杂很多。
从上面整个过程中我们可以总结出 HTTP 进行分组传输是具有以下特征
我们上面描述了一下 HTTP 的请求响应过程,流程比较简单,但是凡事就怕认真,你这一认真,就能拓展出很多东西,比如 HTTP 报文是什么样的,它的组成格式是什么? 下面就来探讨一下
HTTP 协议主要由三大部分组成:
其中起始行和头部字段并成为 请求头 或者 响应头,统称为 Header;消息正文也叫做实体,称为 body。HTTP 协议规定每次发送的报文必须要有 Header,但是可以没有 body,也就是说头信息是必须的,实体信息可以没有。而且在 header 和 body 之间必须要有一个空行(CRLF),如果用一幅图来表示一下的话,我觉得应该是下面这样
我们使用上面的那个例子来看一下 http 的请求报文
如图,这是 http://www.someSchool.edu/someDepartment/home.index 请求的请求头,通过观察这个 HTTP 报文我们就能够学到很多东西,首先,我们看到报文是用普通 ASCII 文本书写的,这样保证人能够可以看懂。然后,我们可以看到每一行和下一行之间都会有换行,而且最后一行(请求头部后)再加上一个回车换行符。
每个报文的起始行都是由三个字段组成:方法、URL 字段和 HTTP 版本字段。
HTTP 请求方法一般分为 8 种,它们分别是
我们一般最常用的方法也就是 GET 方法和 POST 方法,其他方法暂时了解即可。下面是 HTTP1.0 和 HTTP1.1 支持的方法清单
HTTP 协议使用 URI 定位互联网上的资源。正是因为 URI 的特定功能,在互联网上任意位置的资源都能访问到。URL 带有请求对象的标识符。在上面的例子中,浏览器正在请求对象 /somedir/page.html 的资源。
我们再通过一个完整的域名解析一下 URL
比如 http://www.example.com:80/path/to/myfile.html?key1=value1&key2=value2#SomewhereInTheDocument 这个 URL 比较繁琐了吧,你把这个 URL 搞懂了其他的 URL 也就不成问题了。
首先出场的是 http
http://告诉浏览器使用何种协议。对于大部分 Web 资源,通常使用 HTTP 协议或其安全版本,HTTPS 协议。另外,浏览器也知道如何处理其他协议。例如, mailto: 协议指示浏览器打开邮件客户端;ftp:协议指示浏览器处理文件传输。
第二个出场的是 主机
www.example.com 既是一个域名,也代表管理该域名的机构。它指示了需要向网络上的哪一台主机发起请求。当然,也可以直接向主机的 IP address 地址发起请求。但直接使用 IP 地址的场景并不常见。
第三个出场的是 端口
我们前面说到,两个主机之间要发起 TCP 连接需要两个条件,主机 + 端口。它表示用于访问 Web 服务器上资源的入口。如果访问的该 Web 服务器使用HTTP协议的标准端口(HTTP为80,HTTPS为443)授予对其资源的访问权限,则通常省略此部分。否则端口就是 URI 必须的部分。
上面是请求 URL 所必须包含的部分,下面就是 URL 具体请求资源路径
第四个出场的是 路径
/path/to/myfile.html 是 Web 服务器上资源的路径。以端口后面的第一个 / 开始,到 ? 号之前结束,中间的 每一个/ 都代表了层级(上下级)关系。这个 URL 的请求资源是一个 html 页面。
紧跟着路径后面的是 查询参数
?key1=value1&key2=value2 是提供给 Web 服务器的额外参数。如果是 GET 请求,一般带有请求 URL 参数,如果是 POST 请求,则不会在路径后面直接加参数。这些参数是用 & 符号分隔的键/值对列表。key1=value1 是第一对,key2=value2 是第二对参数
紧跟着参数的是锚点
#SomewhereInTheDocument 是资源本身的某一部分的一个锚点。锚点代表资源内的一种“书签”,它给予浏览器显示位于该“加书签”点的内容的指示。 例如,在HTML文档上,浏览器将滚动到定义锚点的那个点上;在视频或音频文档上,浏览器将转到锚点代表的那个时间。值得注意的是 # 号后面的部分,也称为片段标识符,永远不会与请求一起发送到服务器。
表示报文使用的 HTTP 协议版本。
这部分内容只是大致介绍一下,内容较多,后面会再以一篇文章详述
在表述完了起始行之后我们再来看一下请求头部,现在我们向上找,找到http://www.someSchool.edu/someDepartment/home.index,来看一下它的请求头部
Host: www.someschool.edu
Connection: close
User-agent: Mozilla/5.0
Accept-language: fr
复制代码
这个请求头信息比较少,首先 Host 表示的是对象所在的主机。你也许认为这个 Host 是不需要的,因为 URL 不是已经指明了请求对象的路径了吗?这个首部行提供的信息是 Web 代理高速缓存所需要的。Connection: close 表示的是浏览器需要告诉服务器使用的是非持久连接。它要求服务器在发送完响应的对象后就关闭连接。User-agent: 这是请求头用来告诉 Web 服务器,浏览器使用的类型是 Mozilla/5.0,即 Firefox 浏览器。Accept-language 告诉 Web 服务器,浏览器想要得到对象的法语版本,前提是服务器需要支持法语类型,否则将会发送服务器的默认版本。下面我们针对主要的实体字段进行介绍(具体的可以参考 developer.mozilla.org/zh-CN/docs/… MDN 官网学习)
HTTP 的请求标头分为四种: 通用标头、请求标头、响应标头 和 实体标头,依次来进行详解。
通用标头主要有三个,分别是 Date、Cache-Control 和 Connection
Date
Date 是一个通用标头,它可以出现在请求标头和响应标头中,它的基本表示如下
Date: Wed, 21 Oct 2015 07:28:00 GMT
复制代码
表示的是格林威治标准时间,这个时间要比北京时间慢八个小时
Cache-Control
Cache-Control 是一个通用标头,他可以出现在请求标头和响应标头中,Cache-Control 的种类比较多,虽然说这是一个通用标头,但是又一些特性是请求标头具有的,有一些是响应标头才有的。主要大类有 可缓存性、阈值性、 重新验证并重新加载 和其他特性
可缓存性是唯一响应标头才具有的特性,我们会在响应标头中详述。
阈值性,这个我翻译可能不准确,它的原英文是 Expiration,我是根据它的值来翻译的,你看到这些值可能会觉得我翻译的有点道理
Connection
Connection 决定当前事务(一次三次握手和四次挥手)完成后,是否会关闭网络连接。Connection 有两种,一种是持久性连接,即一次事务完成后不关闭网络连接
Connection: keep-alive
复制代码
另一种是非持久性连接,即一次事务完成后关闭网络连接
Connection: close
复制代码
HTTP1.1 其他通用标头如下
实体标头是描述消息正文内容的 HTTP 标头。实体标头用于 HTTP 请求和响应中。头部Content-Length、 Content-Language、 Content-Encoding 是实体头。
Content-Language: de-DE
Content-Language: en-US
Content-Language: de-DE, en-CA
复制代码
Accept-Encoding: gzip, deflate //请求头
Content-Encoding: gzip //响应头
复制代码
下面是一些实体标头字段
上面给出的例子请求报文的属性比较少,下面给出一个 MDN 官网的例子
GET /home.html HTTP/1.1
Host: developer.mozilla.org
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:50.0) Gecko/20100101 Firefox/50.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: https://developer.mozilla.org/testpage.html
Connection: keep-alive
Upgrade-Insecure-Requests: 1
If-Modified-Since: Mon, 18 Jul 2016 02:36:04 GMT
If-None-Match: "c561c68d0ba92bbeb8b0fff2a9199f722e3a621a"
Cache-Control: max-age=0
复制代码
Host
Host 请求头指明了服务器的域名(对于虚拟主机来说),以及(可选的)服务器监听的TCP端口号。如果没有给定端口号,会自动使用被请求服务的默认端口(比如请求一个 HTTP 的 URL 会自动使用80作为端口)。
Host: developer.mozilla.org
复制代码
上面的 Accpet、 Accept-Language、Accept-Encoding 都是属于内容协商的请求标头,我们会在下面说明
Referer
HTTP Referer 属性是请求标头的一部分,当浏览器向 web 服务器发送请求的时候,一般会带上 Referer,告诉服务器该网页是从哪个页面链接过来的,服务器因此可以获得一些信息用于处理。
Referer: https://developer.mozilla.org/testpage.html
复制代码
Upgrade-Insecure-Requests
Upgrade-Insecure-Requests 是一个请求标头,用来向服务器端发送信号,表示客户端优先选择加密及带有身份验证的响应。
Upgrade-Insecure-Requests: 1
复制代码
If-Modified-Since
HTTP 的 If-Modified-Since 使其成为条件请求:
If-Modified-Since 通常会与 If-None-Match 搭配使用,If-Modified-Since 用于确认代理或客户端拥有的本地资源的有效性。获取资源的更新日期时间,可通过确认首部字段 Last-Modified 来确定。
大白话说就是如果在 Last-Modified 之后更新了服务器资源,那么服务器会响应200,如果在 Last-Modified 之后没有更新过资源,则返回 304。
If-Modified-Since: Mon, 18 Jul 2016 02:36:04 GMT
复制代码
If-None-Match
If-None-Match HTTP请求标头使请求成为条件请求。 对于 GET 和 HEAD 方法,仅当服务器没有与给定资源匹配的 ETag 时,服务器才会以200状态发送回请求的资源。 对于其他方法,仅当最终现有资源的ETag与列出的任何值都不匹配时,才会处理请求。
If-None-Match: "c561c68d0ba92bbeb8b0fff2a9199f722e3a621a"
复制代码
ETag 属于响应标头,后面进行介绍。
内容协商机制是指客户端和服务器端就响应的资源内容进行交涉,然后提供给客户端最为适合的资源。内容协商会以响应资源的语言、字符集、编码方式等作为判断的标准。
内容协商主要有以下3种类型:
这种协商方式是由服务器端进行内容协商。服务器端会根据请求首部字段进行自动处理
这种协商方式是由客户端来进行内容协商。
是服务器驱动和客户端驱动的结合体,是由服务器端和客户端各自进行内容协商的一种方法。
内容协商的分类有很多种,主要的几种类型是 Accept、Accept-Charset、Accept-Encoding、Accept-Language、Content-Language。
Accept
接受请求 HTTP 标头会通告客户端其能够理解的 MIME 类型
那么什么是 MIME 类型呢?在回答这个问题前你应该先了解一下什么是 MIME
MIME: MIME (Multipurpose Internet Mail Extensions) 是描述消息内容类型的因特网标准。MIME 消息能包含文本、图像、音频、视频以及其他应用程序专用的数据。
也就是说,MIME 类型其实就是一系列消息内容类型的集合。那么 MIME 类型都有哪些呢?
文本文件: text/html、text/plain、text/css、application/xhtml+xml、application/xml
图片文件: image/jpeg、image/gif、image/png
视频文件: video/mpeg、video/quicktime
应用程序二进制文件: application/octet-stream、application/zip
比如,如果浏览器不支持 PNG 图片的显示,那 Accept 就不指定image/png,而指定可处理的 image/gif 和 image/jpeg 等图片类型。
一般 MIME 类型也会和 q 这个属性一起使用,q 是什么?q 表示的是权重,来看一个例子
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
复制代码
这是什么意思呢?若想要给显示的媒体类型增加优先级,则使用 q=来额外表示权重值,没有显示权重的时候默认值是1.0 ,我给你列个表格你就明白了
q MIME 1.0 text/html 1.0 application/xhtml+xml 0.9 application/xml 0.8 * / *
也就是说,这是一个放置顺序,权重高的在前,低的在后,application/xml;q=0.9 是不可分割的整体。
Accept-Charset
accept-charset 属性规定服务器处理表单数据所接受的字符集。
accept-charset 属性允许您指定一系列字符集,服务器必须支持这些字符集,从而得以正确解释表单中的数据。
该属性的值是用引号包含字符集名称列表。如果可接受字符集与用户所使用的字符即不相匹配的话,浏览器可以选择忽略表单或是将该表单区别对待。
此属性的默认值是 unknown,表示表单的字符集与包含表单的文档的字符集相同。
常用的字符集有: UTF-8 - Unicode 字符编码 ; ISO-8859-1 - 拉丁字母表的字符编码
Accept-Language
首部字段 Accept-Language 用来告知服务器用户代理能够处理的自然语言集(指中文或英文等),以及自然语言集的相对优先级。可一次指定多种自然语言集。 和 Accept 首部字段一样,按权重值 q来表示相对优先级。
Accept-Language: en-US,en;q=0.5
复制代码
请求标头我们大概就介绍这几种,后面会有一篇文章详细深挖所有的响应头的,下面是一个响应头的汇总,基于 HTTP 1.1
响应标头是可以在 HTTP 响应种使用的 HTTP 标头,这听起来是像一句废话,不过确实是这样解释。并不是所有出现在响应中的标头都是响应标头。还有一些特殊的我们上面说过,有通用标头和实体标头也会出现在响应标头中,比如 Content-Length 就是一个实体标头,但是,在这种情况下,这些实体请求通常称为响应头。下面以一个例子为例和你探讨一下响应头
200 OK
Access-Control-Allow-Origin: *
Connection: Keep-Alive
Content-Encoding: gzip
Content-Type: text/html; charset=utf-8
Date: Mon, 18 Jul 2016 16:06:00 GMT
Etag: "c561c68d0ba92bbeb8b0f612a9199f722e3a621a"
Keep-Alive: timeout=5, max=997
Last-Modified: Mon, 18 Jul 2016 02:36:04 GMT
Server: Apache
Set-Cookie: mykey=myvalue; expires=Mon, 17-Jul-2017 16:06:00 GMT; Max-Age=31449600; Path=/; secure
Transfer-Encoding: chunked
Vary: Cookie, Accept-Encoding
x-frame-options: DENY
复制代码
响应状态码
首先出现的应该就是 200 OK,这是 HTTP 响应标头的状态码,它表示着响应成功完成。HTTP 响应标头的状态码有很多,并做了如下规定
以 2xx 为开头的都表示请求成功响应。
状态码 含义 200 成功响应 204 请求处理成功,但是没有资源可以返回 206 对资源某一部分进行响应,由Content-Range 指定范围的实体内容。
以 3xx 为开头的都表示需要进行附加操作以完成请求
状态码 含义 301 永久性重定向,该状态码表示请求的资源已经重新分配 URI,以后应该使用资源现有的 URI 302 临时性重定向。该状态码表示请求的资源已被分配了新的 URI,希望用户(本次)能使用新的 URI 访问。 303 该状态码表示由于请求对应的资源存在着另一个 URI,应使用 GET 方法定向获取请求的资源。 304 该状态码表示客户端发送附带条件的请求时,服务器端允许请求访问资源,但未满足条件的情况。 307 临时重定向。该状态码与 302 Found 有着相同的含义。
以 4xx 的响应结果表明客户端是发生错误的原因所在。
状态码 含义 400 该状态码表示请求报文中存在语法错误。当错误发生时,需修改请求的内容后再次发送请求。 401 该状态码表示发送的请求需要有通过 HTTP 认证(BASIC 认证、DIGEST 认证)的认证信息。 403 该状态码表明对请求资源的访问被服务器拒绝了。 404 该状态码表明服务器上无法找到请求的资源。
以 5xx 为开头的响应标头都表示服务器本身发生错误
状态码 含义 500 该状态码表明服务器端在执行请求时发生了错误。 503 该状态码表明服务器暂时处于超负载或正在进行停机维护,现在无法处理请求。
Access-Control-Allow-Origin
一个返回的 HTTP 标头可能会具有 Access-Control-Allow-Origin ,Access-Control-Allow-Origin 指定一个来源,它告诉浏览器允许该来源进行资源访问。 否则-对于没有凭据的请求 *通配符,告诉浏览器允许任何源访问资源。例如,要允许源 https://mozilla.org 的代码访问资源,可以指定:
Access-Control-Allow-Origin: https://mozilla.org
Vary: Origin
复制代码
如果服务器指定单个来源而不是 *通配符的话 ,则服务器还应在 Vary 响应标头中包含 Origin ,以向客户端指示 服务器响应将根据原始请求标头的值而有所不同。
Keep-Alive
上面我们提到,HTTP 报文标头会分为四种,这其实是按着上下文来分类的
还有一种分类是根据代理进行分类,根据代理会分为端到端头 和 逐跳标头
而 Keep-Alive 表示的是 Connection 非持续连接的存活时间,如下
Connection: Keep-Alive
Keep-Alive: timeout=5, max=997
复制代码
Keep-Alive 有两个参数,它们是以逗号分隔的参数列表,每个参数由一个标识符和一个由等号=分隔的值组成。
timeout:指示空闲连接必须保持打开状态的最短时间(以秒为单位)。
max:指示在关闭连接之前可以在此连接上发送的最大请求数。
上述 HTTP 代码的意思就是限制最大的超时时间是 5s 和 最大的连接请求是 997 个。
Server
服务器标头包含有关原始服务器用来处理请求的软件的信息。
应该避免使用过于冗长和详细的 Server 值,因为它们可能会泄露内部实施细节,这可能会使攻击者容易地发现并利用已知的安全漏洞。例如下面这种写法
Server: Apache/2.4.1 (Unix)
复制代码
Set-Cookie
Cookie 又是另外一个领域的内容了,我们后面文章会说道 Cookie,这里需要记住 Cookie、Set-Cookie 和 Content-Disposition 等在其他 RFC 中定义的首部字段,它们不是属于 HTTP 1.1 的首部字段,但是使用率仍然很高。
Transfer-Encoding
首部字段 Transfer-Encoding 规定了传输报文主体时采用的编码方式。
Transfer-Encoding: chunked
复制代码
HTTP /1.1 的传输编码方式仅对分块传输编码有效。
X-Frame-Options
HTTP 首部字段是可以自行扩展的。所以在 Web 服务器和浏览器的应用上,会出现各种非标准的首部字段。
首部字段 X-Frame-Options 属于 HTTP 响应首部,用于控制网站内容在其他 Web 网站的 Frame 标签内的显示问题。其主要目的是为了防止点击劫持(clickjacking)攻击。
下面是一个响应头的汇总,基于 HTTP 1.1
在 HTTP 协议通信交互中使用到的首部字段,不限于 RFC2616 中定义的 47 种首部字段。还有 Cookie、Set-Cookie 和 Content-Disposition 等在其他 RFC 中定义的首部字段,它们的使用频率也很高。 这些非正式的首部字段统一归纳在 RFC4229 HTTP Header Field Registrations 中。
HTTP 首部字段将定义成缓存代理和非缓存代理的行为,分成 2 种类型。
一种是 End-to-end 首部 和 Hop-by-hop 首部
这些标头必须发送给消息的最终接收者 : 请求的服务器,或响应的客户端。中间代理必须重新传输未经修改的标头,并且缓存必须存储这些信息
分在此类别中的首部只对单次转发有效,会因通过缓存或代理而不再转发。
下面列举了 HTTP/1.1 中的逐跳首部字段。除这 8 个首部字段之外,其他所有字段都属于端到端首部。
Connection、Keep-Alive、Proxy-Authenticate、Proxy-Authorization、Trailer、TE、Transfer-Encoding、Upgrade
HTTP 最重要也是最突出的优点是 简单、灵活、易于扩展。
HTTP 的协议比较简单,它的主要组成就是 header + body,头部信息也是简单的文本格式,而且 HTTP 的请求报文根据英文也能猜出来个大概的意思,降低学习门槛,能够让更多的人研究和开发 HTTP 应用。
所以,在简单的基础上,HTTP 协议又多了灵活 和 易扩展 的优点。
HTTP 协议里的请求方法、URI、状态码、原因短语、头字段等每一个核心组成要素都没有被制定死,允许开发者任意定制、扩充或解释,给予了浏览器和服务器最大程度的信任和自由。
因为过于简单,普及,因此应用很广泛。因为 HTTP 协议本身不属于一种语言,它并不限定某种编程语言或者操作系统,所以天然具有跨语言、跨平台的优越性。而且,因为本身的简单特性很容易实现,所以几乎所有的编程语言都有 HTTP 调用库和外围的开发测试工具。
随着移动互联网的发展, HTTP 的触角已经延伸到了世界的每一个角落,从简单的 Web 页面到复杂的 JSON、XML 数据,从台式机上的浏览器到手机上的各种 APP、新闻、论坛、购物、手机游戏,你很难找到一个没有使用 HTTP 的地方。
无状态其实既是优点又是缺点。因为服务器没有记忆能力,所以就不需要额外的资源来记录状态信息,不仅实现上会简单一些,而且还能减轻服务器的负担,能够把更多的 CPU 和内存用来对外提供服务。
既然服务器没有记忆能力,它就无法支持需要连续多个步骤的事务操作。每次都得问一遍身份信息,不仅麻烦,而且还增加了不必要的数据传输量。由此出现了 Cookie 技术。
HTTP 协议里还有一把优缺点一体的双刃剑,就是明文传输。明文意思就是协议里的报文(准确地说是 header 部分)不使用二进制数据,而是用简单可阅读的文本形式。
对比 TCP、UDP 这样的二进制协议,它的优点显而易见,不需要借助任何外部工具,用浏览器、Wireshark 或者 tcpdump 抓包后,直接用肉眼就可以很容易地查看或者修改,为我们的开发调试工作带来极大的便利。
当然缺点也是显而易见的,就是不安全,可以被监听和被窥探。因为无法判断通信双方的身份,不能判断报文是否被更改过。
HTTP 的性能不算差,但不完全适应现在的互联网,还有很大的提升空间。
作者:cxuan链接:https://juejin.im/post/5e1870736fb9a02fef3a5dcb
者 2019 年参加了一次 Gopher 大会,有幸听探探的架构师分享了他们 2019 年微服务化的过程。
图片来自 Pexels
本文快速搭建的 IM 系统也是使用 Go 语言来快速实现的,这里先和各位分享一下探探 App 的架构图:
本文的目的是帮助读者较为深入的理解 Socket 协议,并快速搭建一个高可用、可拓展的 IM 系统(文章标题纯属引人眼球,不是真的,请读者不要在意),同时帮助读者了解 IM 系统后续可以做哪些优化和改进。
麻雀虽小,五脏俱全,该 IM 系统包含基本的注册、登录、添加好友基础功能,另外提供单聊、群聊,并且支持发送文字、表情和图片,在搭建的系统上,读者可轻松的拓展语音、视频聊天、发红包等业务。
为了帮助读者更清楚的理解 IM 系统的原理:
深入理解 WebSocket 协议
Web Sockets 的目标是在一个单独的持久连接上提供全双工、双向通信。在 Javascript 创建了 WebSocket 之后,会有一个 HTTP 请求发送到浏览器以发起连接。
在取得服务器响应后,建立的连接会将 HTTP 升级从 HTTP 协议交换为 WebSocket 协议。
由于 WebSocket 使用自定义的协议,所以 URL 模式也略有不同。未加密的连接不再是 http://,而是 ws://;加密的连接也不是 https://,而是 wss://。
在使用 WebSocket URL 时,必须带着这个模式,因为将来还有可能支持其他的模式。
使用自定义协议而非 HTTP 协议的好处是,能够在客户端和服务器之间发送非常少量的数据,而不必担心 HTTP 那样字节级的开销。由于传递的数据包很小,所以 WebSocket 非常适合移动应用。
上文中只是对 Web Sockets 进行了笼统的描述,接下来的篇幅会对 Web Sockets 的细节实现进行深入的探索。
本文接下来的几个小节不会涉及到大量的代码片段,但是会对相关的 API 和技术原理进行分析,相信大家读完下文之后再来看这段描述,会有一种豁然开朗的感觉。
①WebSocket 复用了 HTTP 的握手通道
“握手通道”是 HTTP 协议中客户端和服务端通过"TCP 三次握手"建立的通信通道。
客户端和服务端使用 HTTP 协议进行的每次交互都需要先建立这样一条“通道”,然后通过这条通道进行通信。
我们熟悉的 Ajax 交互就是在这样一个通道上完成数据传输的,只不过 Ajax 交互是短连接,在一次 Request→Response 之后,“通道”连接就断开了。
下面是 HTTP 协议中建立“握手通道”的过程示意图:
上文中我们提到:在 Javascript 创建了 WebSocket 之后,会有一个 HTTP 请求发送到浏览器以发起连接,然后服务端响应,这就是“握手“的过程。
在这个握手的过程当中,客户端和服务端主要做了两件事情:
建立了一条连接“握手通道”用于通信:这点和 HTTP 协议相同,不同的是 HTTP 协议完成数据交互后就释放了这条握手通道,这就是所谓的“短连接”,它的生命周期是一次数据交互的时间,通常是毫秒级别的。
将 HTTP 协议升级到 WebSocket 协议,并复用 HTTP 协议的握手通道,从而建立一条持久连接。
说到这里可能有人会问:HTTP 协议为什么不复用自己的“握手通道”,而非要在每次进行数据交互的时候都通过 TCP 三次握手重新建立“握手通道”呢?
答案是这样的:虽然“长连接”在客户端和服务端交互的过程中省去了每次都建立“握手通道”的麻烦步骤。
但是维持这样一条“长连接”是需要消耗服务器资源的,而在大多数情况下,这种资源的消耗又是不必要的,可以说 HTTP 标准的制定经过了深思熟虑的考量。
到我们后边说到 WebSocket 协议数据帧时,大家可能就会明白,维持一条“长连接”服务端和客户端需要做的事情太多了。
说完了握手通道,我们再来看 HTTP 协议如何升级到 WebSocket 协议的。
②HTTP 协议升级为 WebSocket 协议
升级协议需要客户端和服务端交流,服务端怎么知道要将 HTTP 协议升级到 WebSocket 协议呢?它一定是接收到了客户端发送过来的某种信号。
下面是我从谷歌浏览器中截取的“客户端发起协议升级请求的报文”,通过分析这段报文,我们能够得到有关 WebSocket 中协议升级的更多细节。
首先,客户端发起协议升级请求。采用的是标准的 HTTP 报文格式,且只支持 GET 方法。
下面是重点请求的首部的意义:
Connection:Upgrade:表示要升级的协议。
Upgrade: websocket:表示要升级到 WebSocket 协议。
Sec-WebSocket-Version: 13:表示 WebSocket 的版本。
Sec-WebSocket-Key:UdTUf90CC561cQXn4n5XRg==:与 Response Header 中的响应首部 Sec-WebSocket-Accept: GZk41FJZSYY0CmsrZPGpUGRQzkY=是配套的,提供基本的防护,比如恶意的连接或者无意的连接。
其中 Connection 就是我们前边提到的,客户端发送给服务端的信号,服务端接受到信号之后,才会对 HTTP 协议进行升级。
那么服务端怎样确认客户端发送过来的请求是否是合法的呢?在客户端每次发起协议升级请求的时候都会产生一个唯一码:Sec-WebSocket-Key。
服务端拿到这个码后,通过一个算法进行校验,然后通过 Sec-WebSocket-Accept 响应给客户端,客户端再对 Sec-WebSocket-Accept 进行校验来完成验证。
这个算法很简单:
258EAFA5-E914-47DA-95CA-C5AB0DC85B11 这个字符串又叫“魔串",至于为什么要使用它作为 WebSocket 握手计算中使用的字符串,这点我们无需关心,只需要知道它是 RFC 标准规定就可以了。
官方的解析也只是简单的说此值不大可能被不明白 WebSocket 协议的网络终端使用。
我们还是用世界上最好的语言来描述一下这个算法吧:
public function dohandshake($sock, $data, $key) {
if (preg_match("/Sec-WebSocket-Key: (.*)\r\n/", $data, $match)) {
$response=base64_encode(sha1($match[1] . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true));
$upgrade="HTTP/1.1 101 Switching Protocol\r\n" .
"Upgrade: websocket\r\n" .
"Connection: Upgrade\r\n" .
"Sec-WebSocket-Accept: " . $response . "\r\n\r\n";
socket_write($sock, $upgrade, strlen($upgrade));
$this->isHand[$key]=true;
}
}
服务端响应客户端的头部信息和 HTTP 协议的格式是相同的,HTTP1.1 协议是以换行符(\r\n)分割的,我们可以通过正则匹配解析出 Sec-WebSocket-Accept 的值,这和我们使用 curl 工具模拟 get 请求是一个道理。
这样展示结果似乎不太直观,我们使用命令行 CLI 来根据上图中的 Sec-WebSocket-Key 和握手算法来计算一下服务端返回的 Sec-WebSocket-Accept 是否正确:
从图中可以看到,通过算法算出来的 base64 字符串和 Sec-WebSocket-Accept 是一样的。
那么假如服务端在握手的过程中返回一个错误的 Sec-WebSocket-Accept 字符串会怎么样呢?
当然是客户端会报错,连接会建立失败,大家可以尝试一下,例如将全局唯一标识符 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 改为 258EAFA5-E914-47DA-95CA-C5AB0DC85B12。
③WebSocket 的帧和数据分片传输。
下图是我做的一个测试:将小说《飘》的第一章内容复制成文本数据,通过客户端发送到服务端,然后服务端响应相同的信息完成了一次通信
可以看到一篇足足有将近 15000 字节的数据在客户端和服务端完成通信只用了 150ms 的时间。
我们还可以看到浏览器控制台中 Frame 栏中显示的客户端发送和服务端响应的文本数据,你一定惊讶 WebSocket 通信强大的数据传输能力。
数据是否真的像 Frame 中展示的那样客户端直接将一大篇文本数据发送到服务端,服务端接收到数据之后,再将一大篇文本数据返回给客户端呢?
这当然是不可能的,我们都知道 HTTP 协议是基于 TCP 实现的,HTTP 发送数据也是分包转发的,就是将大数据根据报文形式分割成一小块一小块发送到服务端,服务端接收到客户端发送的报文后,再将小块的数据拼接组装。
关于 HTTP 的分包策略,大家可以查看相关资料进行研究,WebSocket 协议也是通过分片打包数据进行转发的,不过策略上和 HTTP 的分包不一样。
Frame(帧)是 WebSocket 发送数据的基本单位,下边是它的报文格式:
报文内容中规定了数据标示,操作代码、掩码、数据、数据长度等格式。不太理解没关系,下面我通过讲解大家只要理解报文中重要标志的作用就可以了。
首先我们明白了客户端和服务端进行 WebSocket 消息传递是这样的:
服务端在接收到客户端发送的帧消息的时候,将这些帧进行组装,它怎么知道何时数据组装完成的呢?
这就是报文中左上角 FIN(占一个比特)存储的信息,1 表示这是消息的最后一个分片(fragment)如果是 0,表示不是消息的最后一个分片。
WebSocket 通信中,客户端发送数据分片是有序的,这一点和 HTTP 不一样。
HTTP 将消息分包之后,是并发无序的发送给服务端的,包信息在数据中的位置则在 HTTP 报文中存储,而 WebSocket 仅仅需要一个 FIN 比特位就能保证将数据完整的发送到服务端。
接下来的 RSV1,RSV2,RSV3 三个比特位的作用又是什么呢?这三个标志位是留给客户端开发者和服务端开发者开发过程中协商进行拓展的,默认是 0。
拓展如何使用必须在握手的阶段就协商好,其实握手本身也是客户端和服务端的协商。
④WebSocket 连接保持和心跳检测
WebSocket 是长连接,为了保持客户端和服务端的实时双向通信,需要确保客户端和服务端之间的 TCP 通道保持连接没有断开。
但是对于长时间没有数据往来的连接,如果依旧保持着,可能会浪费服务端资源。
不排除有些场景,客户端和服务端虽然长时间没有数据往来,仍然需要保持连接,就比如说你几个月没有和一个 QQ 好友聊天了,突然有一天他发 QQ 消息告诉你他要结婚了,你还是能在第一时间收到。
那是因为,客户端和服务端一直再采用心跳来检查连接。客户端和服务端的心跳连接检测就像打乒乓球一样:
等什么时候没有 ping、pong 了,那么连接一定是存在问题了。
说了这么多,接下来我使用 Go 语言来实现一个心跳检测,WebSocket 通信实现细节是一件繁琐的事情,直接使用开源的类库是比较不错的选择,我使用的是:gorilla/websocket。
这个类库已经将 WebSocket 的实现细节(握手,数据解码)封装的很好啦。下面我就直接贴代码了:
package main
import (
"net/http"
"time"
"github.com/gorilla/websocket"
)
var (
//完成握手操作
upgrade=websocket.Upgrader{
//允许跨域(一般来讲,websocket都是独立部署的)
CheckOrigin:func(r *http.Request) bool {
return true
},
}
)
func wsHandler(w http.ResponseWriter, r *http.Request) {
var (
conn *websocket.Conn
err error
data []byte
)
//服务端对客户端的http请求(升级为websocket协议)进行应答,应答之后,协议升级为websocket,http建立连接时的tcp三次握手将保持。
if conn, err=upgrade.Upgrade(w, r, nil); err !=nil {
return
}
//启动一个协程,每隔1s向客户端发送一次心跳消息
go func() {
var (
err error
)
for {
if err=conn.WriteMessage(websocket.TextMessage, []byte("heartbeat")); err !=nil {
return
}
time.Sleep(1 * time.Second)
}
}()
//得到websocket的长链接之后,就可以对客户端传递的数据进行操作了
for {
//通过websocket长链接读到的数据可以是text文本数据,也可以是二进制Binary
if _, data, err=conn.ReadMessage(); err !=nil {
goto ERR
}
if err=conn.WriteMessage(websocket.TextMessage, data); err !=nil {
goto ERR
}
}
ERR:
//出错之后,关闭socket连接
conn.Close()
}
func main() {
http.HandleFunc("/ws", wsHandler)
http.ListenAndServe("0.0.0.0:7777", nil)
}
借助 Go 语言很容易搭建协程的特点,我专门开启了一个协程每秒向客户端发送一条消息。
打开客户端浏览器可以看到,Frame 中每秒的心跳数据一直在跳动,当长链接断开之后,心跳就没有了,就像人没有了心跳一样:
大家对 WebSocket 协议已经有了了解,接下来就让我们一起快速搭建一个高性能、可拓展的 IM 系统吧。
快速搭建高性能、可拓展的 IM 系统
①系统架构和代码文件目录结构
下图是一个比较完备的 IM 系统架构:包含了 C 端、接入层(通过协议接入)、S 端处理逻辑和分发消息、存储层用来持久化数据。
我们本节 C 端使用的是 Webapp, 通过 Go 语言渲染 Vue 模版快速实现功能,接入层使用的是 WebSocket 协议,前边已经进行了深入的介绍。
S 端是我们实现的重点,其中鉴权、登录、关系管理、单聊和群聊的功能都已经实现,读者可以在这部分功能的基础上再拓展其他的功能,比如:视频语音聊天、发红包、朋友圈等业务模块。
存储层我们做的比较简单,只是使用 MySQL 简单持久化存储了用户关系,然后聊天中的图片资源我们存储到了本地文件中。
虽然我们的 IM 系统实现的比较简化,但是读者可以在次基础上进行改进、完善、拓展,依然能够作出高可用的企业级产品。
我们的系统服务使用 Go 语言构建,代码结构比较简洁,但是性能比较优秀(这是 Java 和其他语言所无法比拟的),单机支持几万人的在线聊天。
下边是代码文件的目录结构:
app
│ ├── args
│ │ ├── contact.go
│ │ └── pagearg.go
│ ├── controller //控制器层,api入口
│ │ ├── chat.go
│ │ ├── contract.go
│ │ ├── upload.go
│ │ └── user.go
│ ├── main.go //程序入口
│ ├── model //数据定义与存储
│ │ ├── community.go
│ │ ├── contract.go
│ │ ├── init.go
│ │ └── user.go
│ ├── service //逻辑实现
│ │ ├── contract.go
│ │ └── user.go
│ ├── util //帮助函数
│ │ ├── md5.go
│ │ ├── parse.go
│ │ ├── resp.go
│ │ └── string.go
│ └── view //模版资源
│ │ ├── ...
asset //js、css文件
resource //上传资源,上传图片会放到这里
从入口函数 main.go 开始,我们定义了 Controller 层,是客户端 API 的入口。Service 用来处理主要的用户逻辑,消息分发、用户管理都在这里实现。
Model 层定义了一些数据表,主要是用户注册和用户好友关系、群组等信息,存储到 MySQL。
Util 包下是一些帮助函数,比如加密、请求响应等。View 下边存储了模版资源信息,上边所说的这些都在 App 文件夹下存储,外层还有 asset 用来存储 css、js 文件和聊天中会用到的表情图片等。
Resource 下存储用户聊天中的图片或者视频等文件。总体来讲,我们的代码目录机构还是比较简洁清晰的。
了解了我们要搭建的 IM 系统架构,我们再来看一下架构重点实现的功能吧。
②10 行代码万能模版渲染
Go 语言提供了强大的 HTML 渲染能力,非常简单的构建 Web 应用,下边是实现模版渲染的代码,它太简单了,以至于可以直接在 main.go 函数中实现:
func registerView() {
tpl, err :=template.ParseGlob("./app/view/**/*")
if err !=nil {
log.Fatal(err.Error())
}
for _, v :=range tpl.Templates() {
tplName :=v.Name()
http.HandleFunc(tplName, func(writer http.ResponseWriter, request *http.Request) {
tpl.ExecuteTemplate(writer, tplName, nil)
})
}
}
...
func main() {
......
http.Handle("/asset/", http.FileServer(http.Dir(".")))
http.Handle("/resource/", http.FileServer(http.Dir(".")))
registerView()
log.Fatal(http.ListenAndServe(":8081", nil))
}
Go 实现静态资源服务器也很简单,只需要调用 http.FileServer 就可以了,这样 HTML 文件就可以很轻松的访问依赖的 js、css 和图标文件了。
使用 http/template 包下的 ParseGlob、ExecuteTemplate 又可以很轻松的解析 Web 页面,这些工作完全不依赖与 Nginx。
现在我们就完成了登录、注册、聊天 C 端界面的构建工作:
③注册、登录和鉴权
之前我们提到过,对于注册、登录和好友关系管理,我们需要有一张 user 表来存储用户信息。
我们使用 github.com/go-xorm/xorm 来操作 MySQL,首先看一下 MySQL 表的设计:
app/model/user.go:
package model
import "time"
const (
SexWomen="W"
SexMan="M"
SexUnknown="U"
)
type User struct {
Id int64 `xorm:"pk autoincr bigint(64)" form:"id" json:"id"`
Mobile string `xorm:"varchar(20)" form:"mobile" json:"mobile"`
Passwd string `xorm:"varchar(40)" form:"passwd" json:"-"` // 用户密码 md5(passwd + salt)
Avatar string `xorm:"varchar(150)" form:"avatar" json:"avatar"`
Sex string `xorm:"varchar(2)" form:"sex" json:"sex"`
Nickname string `xorm:"varchar(20)" form:"nickname" json:"nickname"`
Salt string `xorm:"varchar(10)" form:"salt" json:"-"`
Online int `xorm:"int(10)" form:"online" json:"online"` //是否在线
Token string `xorm:"varchar(40)" form:"token" json:"token"` //用户鉴权
Memo string `xorm:"varchar(140)" form:"memo" json:"memo"`
Createat time.Time `xorm:"datetime" form:"createat" json:"createat"` //创建时间, 统计用户增量时使用
}
我们 user 表中存储了用户名、密码、头像、用户性别、手机号等一些重要的信息,比较重要的是我们也存储了 Token 标示用户在用户登录之后,HTTP 协议升级为 WebSocket 协议进行鉴权,这个细节点我们前边提到过,下边会有代码演示。
接下来我们看一下 model 初始化要做的一些事情吧:
app/model/init.go:
package model
import (
"errors"
"fmt"
_ "github.com/go-sql-driver/mysql"
"github.com/go-xorm/xorm"
"log"
)
var DbEngine *xorm.Engine
func init() {
driverName :="mysql"
dsnName :="root:root@(127.0.0.1:3306)/chat?charset=utf8"
err :=errors.New("")
DbEngine, err=xorm.NewEngine(driverName, dsnName)
if err !=nil && err.Error() !=""{
log.Fatal(err)
}
DbEngine.ShowSQL(true)
//设置数据库连接数
DbEngine.SetMaxOpenConns(10)
//自动创建数据库
DbEngine.Sync(new(User), new(Community), new(Contact))
fmt.Println("init database ok!")
}
我们创建一个 DbEngine 全局 MySQL 连接对象,设置了一个大小为 10 的连接池。
Model 包里的 init 函数在程序加载的时候会先执行,对 Go 语言熟悉的同学应该知道这一点。
我们还设置了一些额外的参数用于调试程序,比如:设置打印运行中的 SQL,自动的同步数据表等,这些功能在生产环境中可以关闭。
我们的 Model 初始化工作就做完了,非常简陋,在实际的项目中,像数据库的用户名、密码、连接数和其他的配置信息,建议设置到配置文件中,然后读取,而不像本文硬编码的程序中。
注册是一个普通的 API 程序,对于 Go 语言来说,完成这件工作太简单了,我们来看一下代码:
############################
//app/controller/user.go
############################
......
//用户注册
func UserRegister(writer http.ResponseWriter, request *http.Request) {
var user model.User
util.Bind(request, &user)
user, err :=UserService.UserRegister(user.Mobile, user.Passwd, user.Nickname, user.Avatar, user.Sex)
if err !=nil {
util.RespFail(writer, err.Error())
} else {
util.RespOk(writer, user, "")
}
}
......
############################
//app/service/user.go
############################
......
type UserService struct{}
//用户注册
func (s *UserService) UserRegister(mobile, plainPwd, nickname, avatar, sex string) (user model.User, err error) {
registerUser :=model.User{}
_, err=model.DbEngine.Where("mobile=? ", mobile).Get(?isterUser)
if err !=nil {
return registerUser, err
}
//如果用户已经注册,返回错误信息
if registerUser.Id > 0 {
return registerUser, errors.New("该手机号已注册")
}
registerUser.Mobile=mobile
registerUser.Avatar=avatar
registerUser.Nickname=nickname
registerUser.Sex=sex
registerUser.Salt=fmt.Sprintf("%06d", rand.Int31n(10000))
registerUser.Passwd=util.MakePasswd(plainPwd, registerUser.Salt)
registerUser.Createat=time.Now()
//插入用户信息
_, err=model.DbEngine.InsertOne(?isterUser)
return registerUser, err
}
......
############################
//main.go
############################
......
func main() {
http.HandleFunc("/user/register", controller.UserRegister)
}
首先我们使用 util.Bind(request, &user) 将用户参数绑定到 user 对象上,使用的是 util 包中的 Bind 函数,具体实现细节读者可以自行研究,主要模仿了 Gin 框架的参数绑定,可以拿来即用,非常方便。
然后我们根据用户手机号搜索数据库中是否已经存在,如果不存在就插入到数据库中,返回注册成功信息,逻辑非常简单。
登录逻辑更简单:
############################
//app/controller/user.go
############################
...
//用户登录
func UserLogin(writer http.ResponseWriter, request *http.Request) {
request.ParseForm()
mobile :=request.PostForm.Get("mobile")
plainpwd :=request.PostForm.Get("passwd")
//校验参数
if len(mobile)==0 || len(plainpwd)==0 {
util.RespFail(writer, "用户名或密码不正确")
}
loginUser, err :=UserService.Login(mobile, plainpwd)
if err !=nil {
util.RespFail(writer, err.Error())
} else {
util.RespOk(writer, loginUser, "")
}
}
...
############################
//app/service/user.go
############################
...
func (s *UserService) Login(mobile, plainpwd string) (user model.User, err error) {
//数据库操作
loginUser :=model.User{}
model.DbEngine.Where("mobile=?", mobile).Get(&loginUser)
if loginUser.Id==0 {
return loginUser, errors.New("用户不存在")
}
//判断密码是否正确
if !util.ValidatePasswd(plainpwd, loginUser.Salt, loginUser.Passwd) {
return loginUser, errors.New("密码不正确")
}
//刷新用户登录的token值
token :=util.GenRandomStr(32)
loginUser.Token=token
model.DbEngine.ID(loginUser.Id).Cols("token").Update(&loginUser)
//返回新用户信息
return loginUser, nil
}
...
############################
//main.go
############################
......
func main() {
http.HandleFunc("/user/login", controller.UserLogin)
}
实现了登录逻辑,接下来我们就到了用户首页,这里列出了用户列表,点击即可进入聊天页面。
用户也可以点击下边的 Tab 栏查看自己所在的群组,可以由此进入群组聊天页面。
具体这些工作还需要读者自己开发用户列表、添加好友、创建群组、添加群组等功能,这些都是一些普通的 API 开发工作,我们的代码程序中也实现了,读者可以拿去修改使用,这里就不再演示了。
我们再重点看一下用户鉴权这一块吧,用户鉴权是指用户点击聊天进入聊天界面时,客户端会发送一个 GET 请求给服务端。
请求建立一条 WebSocket 长连接,服务端收到建立连接的请求之后,会对客户端请求进行校验,以确实是否建立长连接,然后将这条长连接的句柄添加到 Map 当中(因为服务端不仅仅对一个客户端服务,可能存在千千万万个长连接)维护起来。
我们下边来看具体代码实现:
############################
//app/controller/chat.go
############################
......
//本核心在于形成userid和Node的映射关系
type Node struct {
Conn *websocket.Conn
//并行转串行,
DataQueue chan []byte
GroupSets set.Interface
}
......
//userid和Node映射关系表
var clientMap map[int64]*Node=make(map[int64]*Node, 0)
//读写锁
var rwlocker sync.RWMutex
//实现聊天的功能
func Chat(writer http.ResponseWriter, request *http.Request) {
query :=request.URL.Query()
id :=query.Get("id")
token :=query.Get("token")
userId, _ :=strconv.ParseInt(id, 10, 64)
//校验token是否合法
islegal :=checkToken(userId, token)
conn, err :=(&websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return islegal
},
}).Upgrade(writer, request, nil)
if err !=nil {
log.Println(err.Error())
return
}
//获得websocket链接conn
node :=&Node{
Conn: conn,
DataQueue: make(chan []byte, 50),
GroupSets: set.New(set.ThreadSafe),
}
//获取用户全部群Id
comIds :=concatService.SearchComunityIds(userId)
for _, v :=range comIds {
node.GroupSets.Add(v)
}
rwlocker.Lock()
clientMap[userId]=node
rwlocker.Unlock()
//开启协程处理发送逻辑
go sendproc(node)
//开启协程完成接收逻辑
go recvproc(node)
sendMsg(userId, []byte("welcome!"))
}
......
//校验token是否合法
func checkToken(userId int64, token string) bool {
user :=UserService.Find(userId)
return user.Token==token
}
......
############################
//main.go
############################
......
func main() {
http.HandleFunc("/chat", controller.Chat)
}
......
进入聊天室,客户端发起 /chat 的 GET 请求,服务端首先创建了一个 Node 结构体,用来存储和客户端建立起来的 WebSocket 长连接句柄。
每一个句柄都有一个管道 DataQueue,用来收发信息,GroupSets 是客户端对应的群组信息,后边我们会提到。
type Node struct {
Conn *websocket.Conn
//并行转串行,
DataQueue chan []byte
GroupSets set.Interface
}
服务端创建了一个 Map,将客户端用户 ID 和其 Node 关联起来:
//userid和Node映射关系表
var clientMap map[int64]*Node=make(map[int64]*Node, 0)
接下来是主要的用户逻辑了,服务端接收到客户端的参数之后,首先校验 Token 是否合法,由此确定是否要升级 HTTP 协议到 WebSocket 协议,建立长连接,这一步称为鉴权。
//校验token是否合法
islegal :=checkToken(userId, token)
conn, err :=(&websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return islegal
},
}).Upgrade(writer, request, nil)
鉴权成功以后,服务端初始化一个 Node,搜索该客户端用户所在的群组 ID,填充到群组的 GroupSets 属性中。
然后将 Node 节点添加到 ClientMap 中维护起来,我们对 ClientMap 的操作一定要加锁,因为 Go 语言在并发情况下,对 Map 的操作并不保证原子安全:
//获得websocket链接conn
node :=&Node{
Conn: conn,
DataQueue: make(chan []byte, 50),
GroupSets: set.New(set.ThreadSafe),
}
//获取用户全部群Id
comIds :=concatService.SearchComunityIds(userId)
for _, v :=range comIds {
node.GroupSets.Add(v)
}
rwlocker.Lock()
clientMap[userId]=node
rwlocker.Unlock()
服务端和客户端建立了长链接之后,会开启两个协程专门来处理客户端消息的收发工作,对于 Go 语言来说,维护协程的代价是很低的。
所以说我们的单机程序可以很轻松的支持成千上完的用户聊天,这还是在没有优化的情况下。
......
//开启协程处理发送逻辑
go sendproc(node)
//开启协程完成接收逻辑
go recvproc(node)
sendMsg(userId, []byte("welcome!"))
......
至此,我们的鉴权工作也已经完成了,客户端和服务端的连接已经建立好了,接下来我们就来实现具体的聊天功能吧。
④实现单聊和群聊
实现聊天的过程中,消息体的设计至关重要,消息体设计的合理,功能拓展起来就非常的方便,后期维护、优化起来也比较简单。
我们先来看一下,我们消息体的设计:
############################
//app/controller/chat.go
############################
type Message struct {
Id int64 `json:"id,omitempty" form:"id"` //消息ID
Userid int64 `json:"userid,omitempty" form:"userid"` //谁发的
Cmd int `json:"cmd,omitempty" form:"cmd"` //群聊还是私聊
Dstid int64 `json:"dstid,omitempty" form:"dstid"` //对端用户ID/群ID
Media int `json:"media,omitempty" form:"media"` //消息按照什么样式展示
Content string `json:"content,omitempty" form:"content"` //消息的内容
Pic string `json:"pic,omitempty" form:"pic"` //预览图片
Url string `json:"url,omitempty" form:"url"` //服务的URL
Memo string `json:"memo,omitempty" form:"memo"` //简单描述
Amount int `json:"amount,omitempty" form:"amount"` //其他和数字相关的
}
每一条消息都有一个唯一的 ID,将来我们可以对消息持久化存储,但是我们系统中并没有做这件工作,读者可根据需要自行完成。
然后是 userid,发起消息的用户,对应的是 dstid,要将消息发送给谁。还有一个参数非常重要,就是 cmd,它表示是群聊还是私聊。
群聊和私聊的代码处理逻辑有所区别,我们为此专门定义了一些 cmd 常量:
//定义命令行格式
const (
CmdSingleMsg=10
CmdRoomMsg=11
CmdHeart=0
)
Media 是媒体类型,我们都知道微信支持语音、视频和各种其他的文件传输,我们设置了该参数之后,读者也可以自行拓展这些功能。
Content 是消息文本,是聊天中最常用的一种形式。Pic 和 URL 是为图片和其他链接资源所设置的。
Memo 是简介,Amount 是和数字相关的信息,比如说发红包业务有可能使用到该字段。
消息体的设计就是这样,基于此消息体,我们来看一下,服务端如何收发消息,实现单聊和群聊吧。
还是从上一节说起,我们为每一个客户端长链接开启了两个协程,用于收发消息,聊天的逻辑就在这两个协程当中实现。
############################
//app/controller/chat.go
############################
......
//发送逻辑
func sendproc(node *Node) {
for {
select {
case data :=<-node.DataQueue:
err :=node.Conn.WriteMessage(websocket.TextMessage, data)
if err !=nil {
log.Println(err.Error())
return
}
}
}
}
//接收逻辑
func recvproc(node *Node) {
for {
_, data, err :=node.Conn.ReadMessage()
if err !=nil {
log.Println(err.Error())
return
}
dispatch(data)
//todo对data进一步处理
fmt.Printf("recv<=%s", data)
}
}
......
//后端调度逻辑处理
func dispatch(data []byte) {
msg :=Message{}
err :=json.Unmarshal(data, &msg)
if err !=nil {
log.Println(err.Error())
return
}
switch msg.Cmd {
case CmdSingleMsg:
sendMsg(msg.Dstid, data)
case CmdRoomMsg:
for _, v :=range clientMap {
if v.GroupSets.Has(msg.Dstid) {
v.DataQueue <- data
}
}
case CmdHeart:
//检测客户端的心跳
}
}
//发送消息,发送到消息的管道
func sendMsg(userId int64, msg []byte) {
rwlocker.RLock()
node, ok :=clientMap[userId]
rwlocker.RUnlock()
if ok {
node.DataQueue <- msg
}
}
......
服务端向客户端发送消息逻辑比较简单,就是将客户端发送过来的消息,直接添加到目标用户 Node 的 Channel 中去就好了。
通过 WebSocket 的 WriteMessage 就可以实现此功能:
func sendproc(node *Node) {
for {
select {
case data :=<-node.DataQueue:
err :=node.Conn.WriteMessage(websocket.TextMessage, data)
if err !=nil {
log.Println(err.Error())
return
}
}
}
}
收发逻辑是这样的,服务端通过 WebSocket 的 ReadMessage 方法接收到用户信息,然后通过 dispatch 方法进行调度:
func recvproc(node *Node) {
for {
_, data, err :=node.Conn.ReadMessage()
if err !=nil {
log.Println(err.Error())
return
}
dispatch(data)
//todo对data进一步处理
fmt.Printf("recv<=%s", data)
}
}
dispatch 方法所做的工作有两件:
Go 语言中的 Channel 是协程间通信的强大工具,dispatch 只要将消息添加到 Channel 当中,发送协程就会获取到信息发送给客户端,这样就实现了聊天功能。
单聊和群聊的区别只是服务端将消息发送给群组还是个人,如果发送给群组,程序会遍历整个 clientMap,看看哪个用户在这个群组当中,然后将消息发送。
其实更好的实践是我们再维护一个群组和用户关系的 Map,这样在发送群组消息的时候,取得用户信息就比遍历整个 clientMap 代价要小很多了。
func dispatch(data []byte) {
msg :=Message{}
err :=json.Unmarshal(data, &msg)
if err !=nil {
log.Println(err.Error())
return
}
switch msg.Cmd {
case CmdSingleMsg:
sendMsg(msg.Dstid, data)
case CmdRoomMsg:
for _, v :=range clientMap {
if v.GroupSets.Has(msg.Dstid) {
v.DataQueue <- data
}
}
case CmdHeart:
//检测客户端的心跳
}
}
......
func sendMsg(userId int64, msg []byte) {
rwlocker.RLock()
node, ok :=clientMap[userId]
rwlocker.RUnlock()
if ok {
node.DataQueue <- msg
}
}
可以看到,通过 Channel,我们实现用户聊天功能还是非常方便的,代码可读性很强,构建的程序也很健壮。
下边是笔者本地聊天的示意图:
⑤发送表情和图片
下边我们再来看一下聊天中经常使用到的发送表情和图片功能是如何实现的吧。
其实表情也是小图片,只是和聊天中图片不同的是,表情图片比较小,可以缓存在客户端,或者直接存放到客户端代码的代码文件中(不过现在微信聊天中有的表情包都是通过网络传输的)。
下边是一个聊天中返回的图标文本数据:
{
"dstid":1,
"cmd":10,
"userid":2,
"media":4,
"url":"/asset/plugins/doutu//emoj/2.gif"
}
客户端拿到 URL 后,就加载本地的小图标。聊天中用户发送图片也是一样的原理,不过聊天中用户的图片需要先上传到服务器,然后服务端返回 URL,客户端再进行加载,我们的 IM 系统也支持此功能。
我们看一下图片上传的程序:
############################
//app/controller/upload.go
############################
func init() {
os.MkdirAll("./resource", os.ModePerm)
}
func FileUpload(writer http.ResponseWriter, request *http.Request) {
UploadLocal(writer, request)
}
//将文件存储在本地/im_resource目录下
func UploadLocal(writer http.ResponseWriter, request *http.Request) {
//获得上传源文件
srcFile, head, err :=request.FormFile("file")
if err !=nil {
util.RespFail(writer, err.Error())
}
//创建一个新的文件
suffix :=".png"
srcFilename :=head.Filename
splitMsg :=strings.Split(srcFilename, ".")
if len(splitMsg) > 1 {
suffix="." + splitMsg[len(splitMsg)-1]
}
filetype :=request.FormValue("filetype")
if len(filetype) > 0 {
suffix=filetype
}
filename :=fmt.Sprintf("%d%s%s", time.Now().Unix(), util.GenRandomStr(32), suffix)
//创建文件
filepath :="./resource/" + filename
dstfile, err :=os.Create(filepath)
if err !=nil {
util.RespFail(writer, err.Error())
return
}
//将源文件拷贝到新文件
_, err=io.Copy(dstfile, srcFile)
if err !=nil {
util.RespFail(writer, err.Error())
return
}
util.RespOk(writer, filepath, "")
}
......
############################
//main.go
############################
func main() {
http.HandleFunc("/attach/upload", controller.FileUpload)
}
我们将文件存放到本地的一个磁盘文件夹下,然后发送给客户端路径,客户端通过路径加载相关的图片信息。
关于发送图片,我们虽然实现功能,但是做的太简单了,我们在接下来的章节详细的和大家探讨一下系统优化相关的方案。怎样让我们的系统在生产环境中用的更好。
程序优化和系统架构升级方案
我们上边实现了一个功能健全的 IM 系统,要将该系统应用在企业的生产环境中,需要对代码和系统架构做优化,才能实现真正的高可用。
本节主要从代码优化和架构升级上谈一些个人观点,能力有限不可能面面俱到,希望读者也在评论区给出更多好的建议。
代码优化
我们的代码没有使用框架,函数和 API 都写的比较简陋,虽然进行了简单的结构化,但是很多逻辑并没有解耦,所以建议大家业界比较成熟的框架对代码进行重构,Gin 就是一个不错的选择。
系统程序中使用 clientMap 来存储客户端长链接信息,Go 语言中对于大 Map 的读写要加锁,有一定的性能限制。
在用户量特别大的情况下,读者可以对 clientMap 做拆分,根据用户 ID 做 Hash 或者采用其他的策略,也可以将这些长链接句柄存放到 Redis 中。
上边提到图片上传的过程,有很多可以优化的地方,首先是图片压缩(微信也是这样做的),图片资源的压缩不仅可以加快传输速度,还可以减少服务端存储的空间。
另外对于图片资源来说,实际上服务端只需要存储一份数据就够了,读者可以在图片上传的时候做 Hash 校验。
如果资源文件已经存在了,就不需要再次上传了,而是直接将 URL 返回给客户端(各大网盘厂商的妙传功能就是这样实现的)。
代码还有很多优化的地方,比如我们可以将鉴权做的更好,使用 wss:// 代替 ws://。
在一些安全领域,可以对消息体进行加密,在高并发领域,可以对消息体进行压缩。
对 MySQL 连接池再做优化,将消息持久化存储到 Mongo,避免对数据库频繁的写入,将单条写入改为多条一块写入;为了使程序耗费更少的 CPU,降低对消息体进行 Json 编码的次数,一次编码,多次使用......
系统架构升级
我们的系统太过于简单,所在在架构升级上,有太多的工作可以做,笔者在这里只提几点比较重要的:
①应用/资源服务分离
我们所说的资源指的是图片、视频等文件,可以选择成熟厂商的 Cos,或者自己搭建文件服务器也是可以的,如果资源量比较大,用户比较广,CDN 是不错的选择。
②突破系统连接数,搭建分布式环境
对于服务器的选择,一般会选择 Linux,Linux 下一切皆文件,长链接也是一样。
单机的系统连接数是有限制的,一般来说能达到 10 万就很不错了,所以在用户量增长到一定程序,需要搭建分布式。
分布式的搭建就要优化程序,因为长链接句柄分散到不同的机器,实现消息广播和分发是首先要解决的问题,笔者这里不深入阐述了,一来是没有足够的经验,二来是解决方案有太多的细节需要探讨。
搭建分布式环境所面临的问题还有:怎样更好的弹性扩容、应对突发事件等。
③业务功能分离
我们上边将用户注册、添加好友等功能和聊天功能放到了一起,真实的业务场景中可以将它们做分离,将用户注册、添加好友、创建群组放到一台服务器上,将聊天功能放到另外的服务器上。
业务的分离不仅使功能逻辑更加清晰,还能更有效的利用服务器资源。
④减少数据库I/O,合理利用缓存
我们的系统没有将消息持久化,用户信息持久化到 MySQL 中去。
在业务当中,如果要对消息做持久化储存,就要考虑数据库 I/O 的优化,简单讲:合并数据库的写次数、优化数据库的读操作、合理的利用缓存。
上边是就是笔者想到的一些代码优化和架构升级的方案。
结束语
不知道大家有没有发现,使用 Go 搭建一个 IM 系统比使用其他语言要简单很多,而且具备更好的拓展性和性能(并没有吹嘘 Go 的意思)。
在当今这个时代,5G 将要普及,流量不再昂贵,IM 系统已经广泛渗入到了用户日常生活中。
对于程序员来说,搭建一个 IM 系统不再是困难的事情,如果读者根据本文的思路,理解 WebSocket,Copy 代码,运行程序,应该用不了半天的时间就能上手这样一个 IM 系统。
IM 系统是一个时代,从 QQ、微信到现在的人工智能,都广泛应用了即时通信,围绕即时通信,又可以做更多产品布局。
笔者写本文的目的就是想要帮助更多人了解 IM,帮助一些开发者快速的搭建一个应用,燃起大家学习网络编程知识的兴趣,希望的读者能有所收获,能将 IM 系统应用到更多的产品布局中。
GitHub 可下载查看源代码:
https://github.com/GuoZhaoran/fastIM
作者:绘你一世倾城
编辑:陶家龙
出处:https://juejin.im/post/5e1b29366fb9a02fc31dda24
外的题果然考得与众不同
[secrypt_cen.html]
这次是HTML网页,然后JS加密判断
翻看JS代码
{width="5.75in" height="3.375in"}
很显然,关键的代码在checkPassword
JS混淆是必备的
去混淆一条龙走起
先将关键代码提取出来
JavaScript
function _0x4857(_0x398c7a, _0x2b4590) { const _0x104914=_0x25ec(); _0x4857=function (_0x22f014, _0x212d58) { _0x22f014=_0x22f014 - (0x347 + 0x46a * -0x7 + 0x1cc6); let _0x321373=_0x104914[_0x22f014]; return _0x321373; }; return
_0x4857(_0x398c7a, _0x2b4590); } (function (_0x414f9c, _0x3d4799)
{
//...................省略大量代码
} function safe_add(a, b) { var c=(65535 & a) + (65535 & b); return
(a >> 16) + (b >> 16) + (c >> 16) << 16 | 65535 & c } function
bit_rol(a, b) { return a << b | a >>> 32 - b }
使用在线的javascript去混淆即可
deobfuscate.relative.im
得到去混淆后的结果
function checkPassword(_0x38d32a) {
try {
if (_0x38d32a.length !==21) {
return false
}
if (
//......省略大量代码
return [c, d, j, k]
}
function md5_cmn(a, b, c, d, e, f) {
return safe_add(bit_rol(safe_add(safe_add(b, a), safe_add(d, f)), e),
c)
}
function md5_ff(a, b, c, d, e, f, g) {
return md5_cmn((b & c) | (~b & d), a, b, e, f, g)
}
function md5_gg(a, b, c, d, e, f, g) {
return md5_cmn((b & d) | (c & ~d), a, b, e, f, g)
}
function md5_hh(a, b, c, d, e, f, g) {
return md5_cmn(b ^ c ^ d, a, b, e, f, g)
}
function md5_ii(a, b, c, d, e, f, g) {
return md5_cmn(c ^ (b | ~d), a, b, e, f, g)
}
function safe_add(a, b) {
var c=(65535 & a) + (65535 & b)
return (((a >> 16) + (b >> 16) + (c >> 16)) << 16) | (65535 &
c)
}
function bit_rol(a, b) {
return (a << b) | (a >>> (32 - b))
}
flag长度21
发现了MD5加密,和两个MD5字符串
看起来无关联?
后来审计整个代码发现,对输入的flag分部分进行判断比较
写出对应的部分,在控制台console输出相关信息是一个不错的选择
function checkPassword(_0x38d32a) {
try {
// Password length is 21.
if (_0x38d32a.length !==21) {
return false;
}
if (
_0x38d32a.slice(1, 2) !==(String.fromCodePoint + "")[
parseInt((parseInt + "").charCodeAt(3), 16) - 147
] /* password[1]='o' */ ||
_0x38d32a[(parseInt(41, 6) >> 2) - 2] !==String.fromCodePoint(123) /* password[4]='{' */ ||
_0x38d32a[4].charCodeAt(0) !==_0x38d32a[7].charCodeAt(0) + 72 /* password[7]='3'. */ ||
JSON.stringify(
Array.from(
_0x38d32a.slice(5, 7).split("").reverse().join(),
(_0x2d4d73)=> _0x2d4d73.codePointAt(0)
).map((_0x5b85c5)=> _0x5b85c5 + 213)
) !==JSON.stringify([
285, 257, 297,
]) /* password[5]='T', password[6]='H' password[7]='3'*/
) {
return false;
}
/* For password[8], password[9], password[10], password[11]
*/
let _0x3c7a5c=_0x38d32a.slice(8, 12).split("").reverse();
try {
for (let _0x396662=0; _0x396662 < 5; _0x396662++) {
_0x3c7a5c[_0x396662]=_0x3c7a5c[_0x396662].charCodeAt(0) + _0x396662 +
getAdder(_0x396662);
}
} catch (_0x1fbd51) {
_0x3c7a5c=_0x3c7a5c.map(
(_0x24cda7)=> (_0x24cda7 +=_0x1fbd51.constructor.name.length -
4)
);
}
if (
MD5(String.fromCodePoint(..._0x3c7a5c)) !=="098f6bcd4621d373cade4e832627b4f6" /* password[8]='0',
password[9]='R', password[10]='3', password[11]='M'
*/
) {
return false;
}
if (
MD5(_0x38d32a.charCodeAt(12) + "") !=="812b4ba287f5ee0bc9d43bbf5bbe87fb" /* password[12]='_' */
) {
return false;
}
_0x3c7a5c=(_0x38d32a[8] + _0x38d32a[11]).split("");
_0x3c7a5c.push(_0x3c7a5c.shift());
if (
_0x38d32a.substring(14, 16) !==String.fromCodePoint(
..._0x3c7a5c.map((_0x5b5ec8)=>
Number.isNaN(+_0x5b5ec8) ? _0x5b5ec8.charCodeAt(0) + 5 : 48
)
) /* password[14]='R' password[15]='0' */ ||
_0x38d32a[_0x38d32a[7] - _0x38d32a[10]] !==atob("dQ==") /* password[0]='u' */ ||
_0x38d32a.indexOf(String.fromCharCode(117)) !==_0x38d32a[7] - _0x38d32a[17] /* password[17]='3' */ ||
JSON.stringify(
_0x38d32a
.slice(2, 4)
.split("")
.map(
(_0x7bf0a6)=>
_0x7bf0a6.charCodeAt(0) ^
getAdder.name[_0x38d32a[7]].charCodeAt(0)
)
) !==JSON.stringify(
[72, 90].map(
(_0x40ab0d)=>
_0x40ab0d ^
String.fromCodePoint.name[_0x38d32a[17] - 1].charCodeAt(0)
)
) /* password[2]='f', password[3]='t' */
) {
return false;
}
if (
String.fromCodePoint(
..._0x38d32a
.split("")
.filter(
(_0x5edfac, _0x2965d2)=> _0x2965d2 > 15 && _0x2965d2 % 2==0
)
.map(
(_0x2ffa6d)=>
_0x2ffa6d.charCodeAt(0) ^ (_0x38d32a.length + _0x38d32a[7])
)
) !==atob(
"g5Go"
) /* password[16]='V', password[18]='D', password[20]='}' */
) {
return false;
}
if (
_0x38d32a[_0x38d32a.length - 2] !==String.fromCharCode(Math.floor((({} + "").charCodeAt(0) + 9) / 3))
||
_0x38d32a[1 + _0x38d32a[7]] !==giggity()[5] /* password[19]=! */
) {
return false;
}
return true;
} catch (_0x4d4983) {
return false;
}
}
function getAdder(_0x430c9d) {
switch (_0x430c9d) {
case 0:
return 34;
case 1:
return 44;
case 2:
return 26;
case 3:
return 60;
}
return 101;
}
function giggity() {
return giggity.caller.name;
}
得到flag
uoft{TH30R3M_PR0V3D!}
*请认真填写需求信息,我们会在24小时内与您取得联系。