浏览器的跨域访问

图片来自nidan-455298会员

跨域问题是在web开发中一个常见的问题,其源自浏览器的同源安全策略,却影响着web网页对cookie/ajax/web storage等信息的跨域共享访问,本文将对同源策略、跨域问题的由来、影响的场景类别和常见解决方案进行了梳理,最后给出代码演示样例。

1. 同源安全策略

在浏览器中,有一个最核心和基本的安全策略,叫做同源策略(same-origin policy),它是指两个网页,若其协议、主机名、端口都相同的情况下,则两个网页属于同源,两者共享同源的数据存储,允许对共享数据的互通操作。

这个同源策略的概念最早由Netscape公司在1995年由其浏览器产品Netscape Navigator 2中引入,此后被所有浏览器采用。

为了理解同源策略,首先了解一个常见的网页url访问地址格式,如下,

scheme://host:port/path

其中:

  • scheme为通信协议,常见的有http/https/ftp等。
  • host为访问的主机名,常见的有域名、IP地址等。
  • port为通信端口号,若不指定的话,则为通信协议的缺省设置。
  • path为访问路径。

根据上面的概念,可以知道同源是指scheme + host + port三者都相同。下面我们看看哪些情况符合同源策略,哪些情况下不符合。

如下给出一些同源策略的规则判断样例,对标的网页:http://a.demo.com/dir/x.html

URL 是否同源 说明
http://a.demo.com/dir2/y.html 同源 只是路径不同,无影响
http://a.demo.com/dir/inner/y.html 同源 只是路径不同,无影响
http://a.demo.com:81/dir/z.html 非同源 端口不同
http://b.demo.com/dir/x.html 非同源 主机名不同,b.demo.com/td>
http://c.a.demo.com/dir/x.html 非同源 主机名不同,c.a.demo.com
https://a.demo.com/dir/x.html 非同源 通信协议不同,https
http://127.0.0.1/dir/x.html 非同源 主机名不同,即a.demo.com指向127.0.0.1的IP地址。
http://a.demo.com:80/dir/x.html 看情况 根据浏览器的实现
| URL | 是否同源 | 说明 |
| ---: | --- | --- |
| http://a.demo.com/dir/inner/y.html | 同源 | 只是路径不同,无影响|
| http://a.demo.com/dir2/y.html | 同源 | 只是路径不同,无影响 |
| http://a.demo.com:81/dir/z.html | 非同源 | 端口不同 |
| http://b.demo.com/dir/x.html | 非同源 | 主机名不同,b.demo.com |
| http://c.a.demo.com/dir/x.html | 非同源 | 主机名不同,c.a.demo.com |
| https://a.demo.com/dir/x.html | 非同源 | 通信协议不同,https |
| http://127.0.0.1/dir/x.html | 非同源 | 主机名不同,即a.demo.com指向127.0.0.1的IP地址。 |
| http://a.demo.com:80/dir/x.html | 看情况 | 根据浏览器的实现 |

2. 什么是跨域问题?

非同源的网页之间,由于同源安全策略的限制,一些数据信息(比如cookie\storage)会被浏览器隔离,JavaScript的Ajax请求也会被拦截并禁止。

但是,不同源的网页之间信息共享是很常见的场景需求,以典型的天猫电商网站为例,其有如下几个子域,

  • tmall.com 天猫电商根域名
  • list.tmall.com 天猫电商子域-列表
  • vip.tmall.com 天猫电商子域-会员
  • chaoshi.tmal.com 天猫电商子域-超市

上面各个子域页面互相属于非同源,受到同源策略的限制。

要实现这些子域之间数据信息访问共享,这就是本文要讨论的跨域问题。

3. 影响的场景类别

跨域问题本身是一个跨源的需求,根据同源策略的定义可知,这是在浏览器的应用场景中才会出现的问题。

浏览器在实现同源策略,保证网页安全的同时,也为跨源的信息共享提供各种途径和技术解决方案。为了方便讨论,根据浏览器的信息存储类别和获取方式,我们可以将跨域访问的需求划分如下三种,

  • cookie:获取另外一个域的cookie信息
  • ajax:通过ajax访问另外一个域的API接口,获取跨域接口数据
  • web storage:获取另外一个域的web storage存储信息

下面将分别对这三种应用场景下的跨域访问进行讨论。

4. Cookie的跨域访问

Cookie是在服务器端生成,发送给浏览器保存的一个键值对,其主要包括如下几个属性,

  • Key/Value:这是cookie的键名和值
  • Domain:这是cookie所属的域,比如.demo.com,则表明该cookie属于所有*.demo.com的域
  • Path:这是cookie所属的路径
  • Expires / Max-Age:过期的日期时间 / 最大存活时长
  • Secure:是否以安全方式传输cookie,以防止中间人攻击
  • HttpOnly:是否只能以Http/Https方式传输cookie,使得JS脚本无法获取,防止XSS攻击

上面Domain/Path的两个属性控制着cookie可以共享的域和路径。需要注意的是,cookie只和域+路径有关,和端口无关,例如a.demo.com:80和a.demo.com:81其实属于同一个域,两者共享cookie。

下面我们看下cookie的共享策略和规则判断。

假如对标的一个网页为:http://a.demo.com/dir/x.html,其页面的cookie属性设置为,

  • domain = .demo.com
  • path = /

那么这个网页上的cookie和如下网页的共享关系见下表,

URL 是否共享 说明
http://a.demo.com/dir2/y.html 共享 路径不同,无影响
http://a.demo.com/dir/inner/y.html 共享 路径不同,无影响
http://a.demo.com:81/dir/z.html 共享 端口不同,无影响
http://b.demo.com/dir/x.html 共享 二级域名不同,无影响
http://c.a.demo.com/dir/x.html 共享 三级域名不同,无影响
https://a.demo.com/dir/x.html 共享 通信协议为HTTPS,无影响
http://127.0.0.1/dir/x.html 不共享 主机名不同,无法共享。
http://a.demo.com:80/dir/x.html 共享
| URL | 是否共享 | 说明 |
| ---: | --- | --- |
| http://a.demo.com/dir/inner/y.html | 共享 | 路径不同,无影响 |
| http://a.demo.com/dir2/y.html | 共享 | 路径不同,无影响 |
| http://a.demo.com:81/dir/z.html | 共享 | 端口不同,无影响 |
| http://b.demo.com/dir/x.html | 共享 | 二级域名不同,无影响 |
| http://c.a.demo.com/dir/x.html | 共享 | 三级域名不同,无影响 |
| https://a.demo.com/dir/x.html | 共享 | 通信协议为HTTPS,无影响 |
| http://127.0.0.1/dir/x.html | 不共享 | 主机名不同,无法共享。 |
| http://a.demo.com:80/dir/x.html | 共享 | - |

可以对上表和上一个同源策略规则判断表,两者有很大的差别。

除了设置每个cookie的domain属性,两个网页之间还可以设置全局文档属性document.domain,实现cookie共享。若document.domain = demo.com,则下面两个网页的cookie共享,

  • http://a.demo.com/dir/x.html
  • http://b.demo.com/dir/y.html

5. Ajax的跨域请求

Ajax是一个异步请求访问后端API接口的方法,受到同源策略的影响,在请求跨域的接口时,会收到如下的错误消息(Chrome浏览器),

Failed to load http://a.demo.com/test No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://a.demo.com' is therefore not allowed access.

可以看到,浏览器禁止了Ajax的跨域请求。

为了实现Ajax的跨域请求,一般有如下两种途径,

  • JSONP:JSON with Padding,数据信息是JavaScript脚本获取
  • CORS:Cross-Origin Resource Sharing跨源资源共享,通过配置服务器实现跨域支持

此外,HTML5还引入了异步通信WebSocket技术,其专门用于长连接实时数据的通信,它一直作为Ajax的异步替代方案应用于异步请求。WebSocket其由于在协议中自带origin字段,所以不受同源策略限制,其跨域的安全主要由后端服务器控制。但是目前WebSocket的浏览器兼容性没有Ajax技术支持的好,应用推广还比较受限。

5.1 JSONP

英文全称为JSON with Padding,数据信息是通过一段JavaScript脚本进行传输,其信息由JS编译器识别,而不是通过JS解析器解析。

JSONP的使用方法很简单,其首先在HTML DOM中通过JavaScript代码动态添加一个script元素,

<script type="text/javascript" src="http://a.demo.com/test"></script>

这个script元素指向一个跨源的API接口地址。浏览器发现有一个新增script元素后,会向这个地址发送一个GET请求,而服务器则会返回一个JavaScript脚本的消息给浏览器,例如,

alert("Hello, This is message from a.demo.com.");

上面的"Hello, This is message from a.demo.com."就是跨域获取的数据信息。浏览器在拿到返回消息后,执行该JS脚本。上面的脚本将会在浏览器中弹出一个提示框。需要注意的是,执行的函数可以通过参数传给服务器,

<script type="text/javascript" src="http://a.demo.com/test?callback=test"></script>

则服务器可以将返回的JavaScript脚本变为,

test("Hello, This is message from a.demo.com.");

JSONP的方式简单易用,兼容所有浏览器,主要缺点是只能应用于GET请求。

5.2 CORS

英文全称为Cross-Origin Resource Sharing,中文意思为跨源资源共享,这是一个W3C的标准,其支持所有类型的HTTP请求跨域调用,包括GET/PUT/POST/DELETE等等。

CORS需要浏览器和服务器的支持,目前绝大多数的浏览器都支持该功能,所以只需要配置好服务器的支持即可。

整个CORS的大概流程为,即将发起一个跨Ajax调用时,当浏览器发现这是一个跨域请求,会在请求头中添加Origin字段,表明该请求的来源,并先向服务发起一次查询,询问是否该Ajax请求调用是否合法,若合法,则像和正常Ajax请求一样执行(请求头中会添加Origin字段)。

请求头中的Origin需要和服务器上接口配置的Access-Control-Allow-Origin/Access-Control-Allow-Methods相匹配,下面是给出几个样例的匹配判断。

CORS的接口配置 说明 请求样例
Access-Control-Allow-Origin: http://a.demo.com/test
Access-Control-Allow-Methods: GET, PUT
允许来自指定origin的GET/PUT请求 GET http://a.demo.com/test
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: *
允许来自任何域的任何请求 路径不同,无影响

有时候希望服务器允许多个origin的请求,按照W3C的建议,这个一般在服务器动态配置,如下是一个实现的简单代码样例,

$http_origin = $_SERVER['HTTP_ORIGIN'];

if ($http_origin == "http://a.demo.com" || $http_origin == "http://b.demo.com" || $http_origin == "http://c.demo.com")
{  
    header("Access-Control-Allow-Origin: $http_origin");
}

6. Web Storage的跨域访问

浏览器的Web存储分为如下几种,

  • Local Storage:通过key/value形式进行的数据存储,没有时间限制
  • Session Storage:在一个会话中的数据存储,一旦重启浏览器,则数据会消失
  • IndexedDB:索引数据存储(HTML5引入)

由于同源策略的影响,非同源的网页之间的存储信息是相互隔离的。同时,同源安全策略禁止了不同域的网页之间不能进行相互的DOM直接操作,以防止xss安全攻击。这些都有效地保证网页的安全和隐私,但另一方面,网页之间的沟通交互也收到了限制,即使已知两个网页之间的通信是安全的。

为了保持同源安全策略,同时实现跨域的网页通信,在HTML5中引入了cross-document messaging(跨文档通信)的功能,使得网页之间消息通信成为可能。

一个常见的做法是,在当前页面中嵌入一个隐藏的iframe窗口,这个iframe引入另外一个域的网页。通过父窗口和iframe窗口的跨文档通信,实现存储信息的相互访问读取。整个通信主要使用到两个API,(下面是对API的简要说明)

window.postMessage(message, targetOrigin);

第一参数为发送的消息,第二参数为接受窗口的origin,例如"http://a.demo.com",或者设为"*",表示不限制接受窗口的origin,

window.addEventListener(type, receiver, userCapture);

第一个参数为消息类别,第二个参数为回调函数,第三个参数为在DOM树中是否优先于它下方的任何其它事件目标。

父窗口代码,

$("#btn_send_msg").click(function () {
   let iframe = document.getElementsByTagName('iframe')[0];
   iframe.contentWindow.postMessage('Hello world', '*');
});

iframe窗口代码,

window.addEventListener('msg', function (msg) {
  if (msg.origin == window.parent) {
    localStorage.setItem('message', msg.data);
    msg.source.postMessage('message is saved into local storage.', msg.origin);
  }        
}, false);

可以看到,我们通过cross-document messaging这个消息通道,可以实现跨域的存储信息共享。

7. 小结

下面对浏览器的跨域解决方案做个总结,

  • Cookie
    • 通过设置Cookie的共享域属性 domain = .demo.com。
    • 通过设置网页文档的共享域属性document.domain = demo.com。
  • Ajax
    • JSONP:通过加载JavaScript脚本实现跨域的GET请求。
    • CORS:通过配置服务器的CORS,接受跨域请求。
    • WebSocket:作为Ajax的替代方案,无跨域限制。
  • Web Storage
    • Cross-Document Messaging:通过隐藏的iframe窗口,打开跨域访问的通信通道,实现对跨域的存储信息访问。

8. 演示代码样例

演示代码样例见下面的git代码仓库地址,详细使用说明见工程README文件。

https://gitee.com/pphh/simple-demo/tree/master/demo-cors

9. 参考文献

  1. Wiki – Same Origin Policy
    https://en.wikipedia.org/wiki/Same-origin_policy

  2. MDN – Same Origin Policy
    https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy

  3. Uniform Resource Locators (URL)
    https://tools.ietf.org/html/rfc1738

  4. Wiki - Cookie
    https://en.wikipedia.org/wiki/HTTP_cookie

  5. Wiki – Cross Site Scripting (XSS)
    https://en.wikipedia.org/wiki/Cross-site_scripting

  6. W3C – WebSockets
    https://www.w3.org/TR/websockets/

  7. W3C – CORS
    https://www.w3.org/TR/cors/

  8. W3C - 多个origin的CORS配置
    https://www.w3.org/TR/cors/#resource-implementation

  9. W3C - HTML5 Web Messaging
    https://www.w3.org/TR/webmessaging/

  10. W3C - HTML5
    https://www.w3.org/TR/html5/

  11. 阮一峰 - 浏览器同源政策及其规避方法
    http://www.ruanyifeng.com/blog/2016/04/same-origin-policy.html

Java NIO的核心组件简介和使用

图片来自nidan-455298会员

在Java Socket通信中,Java NIO是一个很重要的底层实现类,理解其核心组件和使用方法,将会帮助了解更多在Java NIO基础之上搭建的其它Socket通信应用,例如Netty IO等。

本文将对Java NIO 核心组件做一个简单介绍,读完本文,你将可以读Java NIO有个总体上的认识,并能够通过代码样例,编程实现一个多线程异步消息通信。

1. Java NIO概览

Java NIO全称是Java New Input/Output,是很早在JDK1.4中引入的输入输出类库,之后在JDK7中提供了升级版的NIO2,提供了异步编程模型的IO操作。下文若没有说明,Java NIO是指在JDK7之后的最新NIO类库。

有一个首先需要了解的问题是,为什么会有NIO?和之前的IO类库相比,其带来了哪些优点和改进?

在JDK1.4之前,所有IO操作都是基于流(stream)进行数据读写,其读写操作方法会阻塞,这将大大影响程序的效率。Java NIO一方面解决了阻塞的问题,另一方面对数据块存储、数据处理方面进行了抽象,提供了缓存区、通信通道和选择器三个组件概念,使得程序开发者可以在更深入的层次上介入IO处理过程,对相关的通信进行多线程优化,编程可扩展性大大提高。

下表列出IO和NIO在主要特性上的区别对比,

IO NIO
操作对象 面向流 面向缓存区
操作特性 单向操作,或读,或写 支持读写双向操作
读写单位 按字节一一进行读/写 Bytes 按指定块存储大小进行读/写 Block Buffer
支持非阻塞 只支持阻塞读写 支持阻塞和非阻塞读写
通信通道 无,一个流本身就是一个通道 基于通道进行数据传输
单线程 多通道 不支持 支持,通过选择器实现一个线程处理多个通道
异步编程 不支持 支持异步编程模型(自JDK7)

可以看到Java NIO在IO操作上进行了很大幅度的改进和提升。

在Java NIO类库中,有四个核心组件接口,

  1. Channel通道
  2. Buffer缓存区
  3. Selector 选择器
  4. CompletionHandler 异步回调处理器

本文将对这四个组件逐一进行讲解,了解其作用和使用方法,并提供简单的代码使用样例。最后给出一个多线程异步通信的使用样例。

2. 通道Channel

一个通道是数据传输的连接,可以和IO设备建立连接,进行数据的获取和传送。在一定程度上,可以认为通道是流的升级版实现。

整个Java NIO中最常用的通道类有如下几个,

  • FileChannel,since 1.4
  • DatagramChannel,since 1.4
  • SocketChannel,since 1.4
  • ServerSocketChannel,since 1.4
  • AsynchronousFileChannel,since 1.7
  • AsynchronousSocketChannel,since 1.7
  • AsynchronousServerSocketChannel,since 1.7

其中Asynchronous*这几个类是在JDK7开始提供的NIO 2.0类库,主要提供了异步编程模型,详细见后面异步回调处理器的讨论。

(注:AsynchronousDatagramChannel在JDK7中并没有提供,原先是有准备发布这个类库的,但是由于某些原因,在发布前被删除。)

通道类中使用最多的四个方法是,

  • Channel.bind() 绑定连接
  • Channel.accept() 建立连接
  • Channel.read(buffer) 把数据从缓冲区读到通道
  • Channel.write(buffer) 把数据从通道写到缓冲区

一个简单的代码样例如下,

// please note: configure channel blocking state as false
ServerSocketChannel server = ServerSocketChannel.open();
server.configureBlocking(false);

System.out.println("A server is started on port 9000");
ServerSocket serverSocket = server.socket();
serverSocket.bind(new InetSocketAddress(9000));

// please note: server.accept() will not block current thread
// Accept method will return null directly if no client is connected
SocketChannel channel = null;
int count = 0;
while (channel == null) {
    System.out.println("The server is trying to connect client, count=" + count++);
    channel = server.accept();
    Thread.sleep(500);
}
//channel.read(ByteBuffer)

System.out.println("the end");
channel.close();
server.close();

3. 缓冲区Buffer

Java NIO是基于块进行IO操作,缓冲区就是对这个块的抽象定义,在这个缓冲区中可以反复进行读写,通过通道实现数据的传输。

我们先看看缓冲区是什么样子,一个缓冲区是一个有指定大小的数组,其有读模式和写模式两种状态,请注意在读写不同模式下其Position和Limit的位置指向,

上图的缓冲区中显示了如下三个属性,

  • Capacity:缓冲区容量大小
  • Position:缓冲区的读写位置,根据当前读/写模式,含义如下
    • 在读模式下,读的当前位置
    • 在写模式下,写的当前位置
  • Limit:缓冲区的读写限制位,根据当前读/写模式,含义如下
    • 在读模式下,可以读的最大位置,等于当前缓冲区内数据量,防止读溢出
    • 在写模式下,可以写的最大位置,其实就等于Capacity值,防止写溢出

Java NIO提供如下7个缓冲区类,

  • ByteBuffer
  • CharBuffer
  • ShortBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer

这几个缓冲类基本覆盖了Java中8个基本数据类型的7个,还有一个boolean没有支持,原因是Java的boolean使用1bit进行存储,而在IO操作中,最小单位是bytes。

缓冲区类中使用最多的几个方法是,

  • Buffer.allocate(size) 创建一个指定大小的空缓冲区
  • Buffer.wrap(array) 通过一个数组创建一个缓冲区
  • Buffer.put() 保存一个数据
  • Buffer.flip() 切换写模式到读模式,
  • Buffer.get() 读取一个数据
  • Buffer.clear() 清空缓冲区

一个简单的代码样例如下,

IntBuffer buffer = IntBuffer.allocate(10);  // #1

System.out.println("write 5 int number into buffer");
for (int i = 0; i < 6; i++) {
    buffer.put(i);                          // #2
}

buffer.flip();                              // #3

for (int i = 0; i < 6; i++) {
    int n = buffer.get();                   // #4
    System.out.println("read number: " + n);
}

buffer.clear();                             // #5

结合样例代码,我们对缓冲区的操作流程描绘成下图,以助理解,

各个步骤的说明,

  1. 第一步:初始化一个整型缓冲区,大小为10
  2. 第二步:写入5个整型值到缓冲区
  3. 第三步:切换写模式到读模式
  4. 第四步:从缓冲区读取5个整型值
  5. 第五步:重置整型缓冲区,可以进行下一步的写操作

4. 选择器Selector

选择器能够同时监控多个通道的通信情况,方便多线程处理通道事件。开发者可以通过选择器为如下四个通道状态进行监听,

  • Connect:一个客户端通道已经准备好了连接远程服务器
  • Accept:一个服务器通道已经准备连接客户端,请
  • Read:一个通道已经准备好了读操作
  • Write:一个通道已经准备好了写操作

上述各个通道状态对应的SelectionKey事件为,

  • SelectionKey.OP_CONNECT
  • SelectionKey.OP_ACCEPT
  • SelectionKey.OP_READ
  • SelectionKey.OP_WRITE

需要注意的是,只有通道设置为非阻塞(non-blocking),才能注册到选择器,否则注册时会收到一个IllegalBlockingModeException的异常。

对选择器的通用操作流程为,

  • 创建一个选择器Selector selector=Selector.open()
  • 注册通道channel.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE)
  • 获取事件key = selector.select()

一个简单的代码样例如下,

Selector selector = Selector.open();
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);

while(true) {
  int readyChannels = selector.select();
  if(readyChannels == 0) continue;

  Set<SelectionKey> selectedKeys = selector.selectedKeys();
  Iterator<SelectionKey> keyIterator = selectedKeys.iterator();

  while( keyIterator.hasNext() ) {
    SelectionKey key = keyIterator.next();

    if(key.isAcceptable()) {
        // a connection was accepted by a ServerSocketChannel.
    } else if (key.isConnectable()) {
        // a connection was established with a remote server.
    } else if (key.isReadable()) {
        // a channel is ready for reading
    } else if (key.isWritable()) {
        // a channel is ready for writing
    }

    keyIterator.remove();
  }
}

5. 异步回调处理器CompletionHandler

异步回调处理器是在JDK 7之后属于Java NIO 2的异步通信组件之一,它提供一种回调机制,让在通信完成之后异步执行通信结果的处理。CompletionHandler本身是一个接口定义,我们先看看这个接口提供的两个回调方法,

public interface CompletionHandler<V,A> {
  void completed(V result, A attachment);
  void failed(Throwable exc, A attachment);
}

接口的两个回调方法,
1. completed() 当操作成功时调用,返回结果
2. failed() 当操作失败时调用,返回异常信息

在我们看如何使用异步回调处理器之前,我们先了解下为什么要异步回调。

前面讲到,我们知道IO操作是一个耗时的操作,于是在Java NIO编程中提供了非阻塞编程模型,使得在执行通信连接、读、写操作时,当前线程并不会挂起等待,

  • Channel.configureBlocking(false);
  • Channel.accept()
  • Channel.read()
  • Channel.write()

非阻塞的执行方式提高了IO操作线程的效率,通过非阻塞,使得当前线程不会没事也被长时间挂起,为当前线程的处理提供了灵活编程空间。但是仔细观察会发现,为了获取某个事件,在程序中会不得不启动一个循环线程,定时的检查连接情况、是否有数据到达等等。下面是两段循环线程的代码样例,

while (channel == null) {
    channel = server.accept();            // #1
    Thread.sleep(500);
}

while(true) {
  int readyChannels = selector.select();  // #2
  if(readyChannels == 0) continue;
  // read the event keys
  Set<SelectionKey> selectedKeys = selector.selectedKeys();
}

第一个:通过定时查询Channel的连接状态
第二个:通过选择器Selector查询Channel的连接和数据读写状态

这种循环线程若配合多线程处理,可以达到非常强大的效果,一个参考实现见后面提供的“非阻塞通信-多子线程异步处理”代码样例,实践过程中也多采用这种方式,通过ServerSocketChannel + Selector + Buffer实现“非阻塞通信-多子线程异步处理”。

但有没有一个办法,使得不再需要起一个循环线程?答案有,在JDK7 NIO 2中引入的三个异步通信通道就实现了这样的需求,

  • AsynchronousFileChannel,since 1.7
  • AsynchronousSocketChannel,since 1.7
  • AsynchronousServerSocketChannel,since 1.7

上述几个异步通信通道在accept/read/write方法上,需要接受一个CompletionHandler回调对象。(注:异步通信通道还可以通过Future方式进行回调,本文为了更好地描述异步实现方式,将不对Future方式进行展开。)

这就是异步回调处理器是作为Java NIO 2的异步通信组件之一,配合异步通信通道实现异步处理机制。异步通信通道执行accept/read/write完毕后,JVM将会启动子线程,根据执行结果,若成功完成就调用对应的回调对象的completed方法,若失败则调用failed方法。

异步回调处理器解决了循环线程检查的问题,但随之而来就是出现各种嵌套回调,大大增加软件编程的复杂度。鱼和熊掌,不可兼得。

下面是一个简单的代码样例,

public static void main(String[] args) throws Exception {
    AsynchronousServerSocketChannel server =
          AsynchronousServerSocketChannel.open();
    server = server.bind(new InetSocketAddress(9000));

   OnAcceptCompletionHandler onAcceptCompletion =
          new OnAcceptCompletionHandler();
    server.accept(null, onAcceptCompletion);

    // app continue to run...
Thread.sleep(100000);
    server.close();
}

public static class OnAcceptCompletionHandler implements
              CompletionHandler<AsynchronousSocketChannel, Object> {
    @Override
    public void completed(AsynchronousSocketChannel srv, Object attachment) {
        System.out.println("Received a new client connection.");
        //srv.read(buffer...)
    }

    @Override
    public void failed(Throwable exc, Object attachment) {
        System.out.println("Received a failure message on client connection.");
    }
}

6. 多线程异步消息通信

为了更加深入的理解Java NIO通信类库,请点击这里查看一个多线程异步消息通信的简单实现。

在代码中实现了如下的消息通信流程,

在ServerSocketChannel通道有消息时,通过线程池启动异步线程处理消息。测试时,可以使用命令telnet localhost 9000同时启动多个客户端和服务端连接,查看消息通信情况。

7. 更多样例:使用Java NIO进行Socket通信

下面提供各种应用样例,实现从阻塞通信、非阻塞通信,到多线程通信、异步通信的各个实现方法,可供参考和调研时使用,

  1. 阻塞通信,通过ServerSocket,请点击这里查看代码。
  2. 阻塞通信,通过ServerSocketChannel,请点击这里查看代码。
  3. 非阻塞通信,通过ServerSocketChannel,请点击这里查看代码。
  4. 非阻塞通信-主线程处理,通过ServerSocketChannel + Selector + Buffer,请点击这里查看代码。
  5. 非阻塞通信-多子线程处理,通过ServerSocketChannel + Selector + Buffer,请点击这里查看代码。
  6. 非阻塞通信-多子线程异步处理,通过ServerSocketChannel + Selector + Buffer,请点击这里查看代码。
  7. 异步通信,通过AsynchronousServerSocketChannel + CompletionHandler,请点击这里查看代码。

上述样例在JDK 8中编译运行通过,并使用Windows 7 SP1 x64的Telnet工具、使用Ubuntu 16.10中的Telnet工具进行测试通信验证。

可以使用如下方法来运行样例和查看通信过程,

  1. 启动通信服务端,样例中所有通信服务端将启动在9000端口
  2. 使用Telnet工具
    • 在Windows的Shell命令行中,执行telnet 127.0.0.1 9000命令,连接服务端。成功后,按ctrl+ }键退出telnet交互,然后可以通过send hello,world命令发送消息到服务端。若想关闭连接,可以通过close命令关闭连接。更多telnet详细命令见help。
    • 在Linux的Shell命令行中,执行telnet 127.0.0.1 9000命令,连接服务端。成功后,然后可以通过输入hello,world字符,按回车键发送消息到服务端。若想关闭连接,按ctrl+ }键退出telnet交互,然后可以通过close命令关闭连接。更多telnet详细命令见help。
  3. 启动通信客户端
    • 查看客户端消息
    • 查看服务端接受消息

8. 本文讨论的概念和范围

在本文中提及了阻塞、非阻塞、异步的这三个概念,为了避免歧义,这里有必要对三个概念进行描述,这个描述主要针对其在本文中的定义,以助理解,

  • 阻塞:在执行通信的连接、读操作、写操作时,若对方无响应,则当前线程会被挂起
  • 非阻塞:在执行通信的连接、读操作、写操作时,若对方无响应,当前线程不会被挂起
  • 异步:在执行通信的连接、读操作、写操作时,当前线程的执行不受其影响,当通信操作结束后,JVM会启动子线程以异步回调的方式处理通信结果。

上面的阻塞和非阻塞主要是从是否挂起线程的角度来判断。

本文讨论Java NIO的通信过程,讨论范围主要是Java应用程序和JVM之间的交互,

对于JDK/JVM和各个操作系统内核的IO通信交互实现,将需要另外一篇文章进行深入讨论,这将会涉及到JDK IO的更底层实现细节,也需要对各个操作系统的IO操作进行深入的了解。

9. 参考资料

  1. Java NIO Tutorial,作者Jakob Jenkov
  2. IBM DeveloperWorks – NIO入门
  3. IBM DeveloperWorks – NIO 2.0入门
  4. JDK-6993126 : (aio) remove AsynchronousDatagramChannel

RPC框架的技术架构和未来

图片来自markusspiske会员

RPC(远程服务调用)框架是现代互联网公司中最基础、最关键的微服务技术架构组件,它涉及到微服务的注册和发现、高可用、服务治理、服务监控,直接关系到应用服务的研发流程,对持续交付和运维直接相关联,是一个牵一发而动全身的基础技术组件。如何选择和设计好一个RPC框架,是一个互联网公司微服务技术架构中的百年大计工程。

本文对业界开源RPC框架的调研基础上,分析RPC框架的技术架构和各个关键技术模块组成,同时对各个RPC框架进行了相关技术需求数据的收集,以帮助考察对比各个RPC框架的技术参数。

  • 阿里的Dubbo,开源于2011年10月
  • 微博的Motan,开源于2016年5月
  • Spring Cloud,开源于2014年10月

由于Spring Cloud的目标也是实现微服务架构,提供很多相关的组件,在RPC框架对比中,我们也将其加入了分析行列。

但是,本文并没有将gRpc/Thrift加入对比行列,主要是这两个框架更多关注跨语言的服务调用,不提供服务治理功能,所以对微服务技术架构无法全面支持。而本文主要是讨论能够支撑微服务架构落地的RPC框架及其技术架构。

最后讨论当前各个主流RPC框架所面临的问题,及其未来5-10年Service Mesh在引领RPC框架上的创新变化。

1. RPC架构

一个典型的RPC架构如下图所示,

一个RPC框架的典型架构

它包括了如下几个组件,

  • 服务提供者:远程服务的被调用方,提供服务实现。
  • 服务消费者:远程服务的调用方。
  • 注册中心:提供服务的注册和发现。
  • 调用监控:监控远程服务调用情况。

在服务提供者/消费者内部,一般会包括如下5个组件,

  • proxy:服务接口代理,将服务接口的本地调用转接为远程调用。
  • service:服务的定义,这个Service的定义决定了支持的服务粒度。在Dubbo和Motan的缺省实现中,一个Service被定义为一个服务类实例的一个方法(Method),通过反射的方式调用服务的Method。
  • register:服务的注册和发现,在服务启动时,注册服务URL地址到注册中心,启动后会定时从注册中心获取最新服务列表。
  • protocol:对RPC的封装抽象,主要包含两个功能:一个服务导出(exporter),用于服务提供者,将服务导出并注册URL,RPC时获取服务定义并调用实现;还有就是服务引用(referer),用于服务消费者,获取远程服务URL并发起RPC。
  • transport:远程服务通信层,里面包括用于远程通信的服务器端和客户端,常见使用的有Netty/Apache Mina/Grizzly等网络通信框架,简单的有直接使用OkHttp/Unirest进行Http通信。

上述是RPC的典型架构,各个RPC框架基本上都提供了上述RPC架构的各个组件,不一样之处在于语言实现及其相关组件模块。

2. RPC框架组件

业界的RPC框架百花齐放,种类繁多,各个RPC框架基本上都实现了上述典型RPC架构,不一样之处在于实现,比如,

  • 不同的实现语言,比如Java语言的Dubbo和Motan,Go语言的rpcx等等
  • 支持不同的注册中心,从consule、zookeeper、eureka、自带注册中心等
  • 服务提供者和消费者的架构组件

其中RPC框架在服务提供者和消费者的架构组件上的实现,最能体现其框架对微服务架构的功能支持粒度和深度。

下面两个图主要列出了Dubbo和Motan的服务提供者和消费者架构组件,对比其实现模块,可以看到,两者的基本模块主要还是围绕着Service/Proxy/Protocol/Transport进行展开。理解这两个图对于解读Dubbo和Motan的开源代码会有帮助。

2.1 Dubbo的架构实现

Dubbo的架构实现

2.2 Motan的架构实现

Motan的架构实现

2.3 一个RPC框架的简单实现

请点击这里查看一个RPC的简单演示项目,也可以通过如下命令克隆项目代码到本地。

git clone git@gitee.com:pphh/simple-rpc.git

这个演示项目参考motan/dubbo的架构实现,以尽量简洁的方式演示一个RPC框架中所使用的核心组件技术,包括,

  • 服务提供方和消费方实现
  • 服务的注册和发现机制
  • 服务集群的高可用和负载均衡
  • 远程调用的底层通信实现
  • 等等。

3. RPC的技术需求分析

一个远程服务调用的开发和上线,按流程先后顺序会接触到,

  • 服务定义
  • 服务开发
  • 服务调用
  • 服务通信
  • 服务监控运行

下面将从这几个方面逐一分析RPC框架所涉及的各项技术需求。

1)服务的定义

RPC是一个两方(消费方和提供方)的通信调用关系,这需要在两方之间建立一个通信契约,服务定义就是实现这个目的。服务定义的内容主要包括服务名、入参请求和出参响应。

gRpc/Thrift是以一个规范的文件格式定义服务,

syntax = “proto3”;
package helloworld;

// The greeting service definition.
Service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

而Dubbo和Motan则使用Java Interface的形式。

Public interface Greeter {
    String sayHello(String name);
}

好的服务定义方式能够简洁清除地表达服务名、入参请求和出参响应,方便服务的开发和调用,服务的定义对服务的版本升级兼容也应有相应的考虑。

2)服务的开发

在服务定义好了之后,马上服务提供者需要根据定义进行服务的开发。

大多数RPC框架都对服务的开发进行了非常友好的支持,例如gRpc/Thrift提供代码的自动生成,开发者只需要继承生成的服务基类,添加相应的逻辑代码即可,Dubbo和Motan由于直接使用Java Interface,开发也是非常容易。

下面是一个Java语言服务的开发,其实现了Greeter Interface所定义的服务接口,

public class GreeterImpl implements Greeter {
    public String sayHello(String name) {
        return “Hello “ + name;
    }
}

服务的开发一般需要考虑编程语言的支持,Dubbo和Motan主要支持Java语言,有些RPC框架支持Go语言(比如rpcx),而有些则支持多语言,这个需要根据实际情况考虑。

3)服务的调用

服务开发之后,接下来就要看服务如何被使用了。

服务调用比较多样化,主要考虑包括如下几个方面,

  • 同步和异步调用的支持。
  • 对容错、负载均衡的支持,对服务的多版本和分组支持,以保证高可用的目的。
  • 对服务熔断和服务降级的支持,以保证服务故障隔离的目的。

容错策略解决的是,一旦发现远程调用失败,如果进行下一步操作以减少错误影响,常见的容错策略有,

  • FailFast 快速失败:当消费者调用远程服务失败时,立即报错,消费者只发起一次调用请求。
  • FailOver 失败自动切换:当消费者调用远程服务失败时,重新尝试调用服务,重试的次数一般需要指定,防止无限次重试。
  • FailSafe 失败安全:当消费者调用远程服务失败时,直接忽略,请求正常返回报成功。一般用于可有可无的服务调用。
  • FailBack 失败自动恢复:当消费者调用远程服务失败时,定时重发请求。一般用于消息通知。
  • Forking 并行调用:消费者同时调用多个远程服务,任一成功响应则返回。

负载均衡机制解决的是如何在多个可用远程服务提供者中,选择下一个进行调用,常见的负载均衡策略有,

  • Random 随机选择:在可用的远程服务提供者中,随机选择一个进行调用。
  • RoundRobin 轮询选择:在可用的远程服务提供者中,依次轮询选择一个进行调用。
  • LeastActive 按最少活跃调用数选择:在可用的远程服务提供者中,选择最少调用过的远程进行调用。
  • ConsistentHash 按一致哈希选择:获取调用请求的哈希值,根据哈希值选择远程服务提供者,保证相同参数的请求总是发到同一提供者。

服务熔断是指一旦服务调用无法正常工作,在故障时间内,尽量减少对服务的调用,直接返回null或抛异常,直到服务恢复正常。

各个RPC框架对服务调用的支持程度不一,但基本的高可用和熔断措施都会提供,而且一般会有相应的扩展点供自定义。在高并发量的情况下,对高可用和熔断措施会是相当大的考验,能否正常稳定运行,是一个RPC框架走向生产环境的关键,一般会和远程服务通信模块一并进行性能测试,详细的性能测试参数见后续的远程服务通信。

4)应用开发框架集成

为了方便服务的开发和使用,RPC框架还会集成业界主流应用开发框架,例如Dubbo和Motan集成了对Spring Framework的集成。

下面是Motan以注解的形式提供服务,

@MotanService(export = “demoMotan:8002”)
public class GreeterImpl implements Greeter {

    public String sayHello (String name) {
        return “Hello “ + name;
    }

}

下面是Motan以注解的形式消费服务,

public class HelloController {

    @MotanReferer(basicReferer = “clientConfig”, directUrl = “127.0.0.1:8002”)
    Greeter greeter;

    public String home() {
        return greeter.sayHello(“test”);
    }

}

可以看到,RPC框架所集成的应用开发框架,及其集成的支持程度,其能够很大程度上影响服务开发的效率,这在评价RPC框架时也是重要考虑因素。

5)远程服务通信

服务通信组件是底层,其通信吞吐量和性能,决定了整个微服务框架的高并发,其通信一般通过性能测试报告获得。

一般的测试场景包括,

数据传输性能测试场景(考察最大通信吞吐量TPS,响应时间,10并发)

  • 传入1k string,原样返回
  • 传入50k string,原样返回
  • 传入200k string,原样返回
  • 传入1k POJO,原样返回,考察序列化模块
  • 传入的string size在1k-200k随机变化,原样返回

高并发性能测试场景(考察最大通信吞吐量TPS,响应时间,负载均衡)

  • 10并发1k string,原样返回
  • 20并发 1k string,原样返回

稳定性测试场景(考察框架的稳定性, 通信吞吐量TPS、成功率、响应时间的趋势)

  • 时长:24小时,7天,一个月
  • 吞吐量:10并发 1k string,原样返回

服务消费方性能测试(考察消费方的稳定性和高可用)

  • 熔断:每隔1分钟触发一次熔断,1分钟后恢复,一次轮询24小时
  • 负载均衡:对每个负载均衡策略,持续运行24小时

更多的测试场景需依据业务情景进行考虑。

一份Dubbo的性能测试报告见如下链接,

  • https://dubbo.gitbooks.io/dubbo-user-book/content/perf-test.html

6)服务运行监控

服务调用监控是微服务运营的一个关键环节,通过服务监控,我们可以获取服务的调用次数,调用时长,成功和失败数,及其分析整个服务调用链,获取远程服务调用的健康状态,辅助改进远程服务调用。

RPC框架对监控指标的收集和分析,是RPC框架本身重要的治理功能。

4. 开源RPC框架的技术需求指标

下表统计了Dubbo和Motan各项技术需求指标情况,同时加入了Spring Cloud在微服务架构的相关功能支持,方便对比,

RPC框架 Dubbon Spring Cloud Motan
公司 阿里 微博
开源时间 2012.1 2014.10 2016.5
框架开发语言 Java Java Java
项目代码量 120K ? 140K
社区活跃度 15000+ ? 4000+
服务开发语言 Java Java Java/PHP
服务开发框架集成 Spring Framework 4.3.10.RELEASE
分布式服务-注册中心 Default, Multicast, Redis, Zookeeper, Simple Registry, Direct Netflix Eureka Consul, Zookeeper, Direct
分布式服务-高可用 Failfast, Failover, FailSafe, FailBack, Forking, Broadcast 通过Netflix Zuul提供FailOver Failfast, Failover
分布式服务-负载均衡 Random, Roundrobin, LeastActive, ConsistentHash 通过Netflix Zuul提供: Random, Roundrobin Random, Roundrobin, LocalFirst
分布式服务-服务的定义 Java Interface 无强契约, 支持Swagger API Java Interface
分布式服务-服务粒度 服务类方法Method 服务类方法Method
分布式服务-多版本 支持 支持
分布式服务-配置 无,外接 Spring Cloud Config 无,外接
分布式服务-服务故障 提供服务降级, 直接返回null Netflix Hystrix 服务故障隔离
远程服务调用定义-Protocol Dubbo协议(Netty NIO), Hessian协议, Injvm协议(内部调用), Rmi协议(TCP), Memcached协议, Redis协议, Thrift协议, HTTP协议 Restful API Motan2协议 (Netty NIO), Injvm协议(内部调用), Yar(PHP), Restful (JAX-RS), Grpc
远程服务通信 Transport Netty, Netty4, Apache Mima (TCP), Grizzly, P2p Netty, Netty4
服务调用跟踪 Spring Cloud Sleuth OpenTracing
性能报告 1k String,并发量10K+ 1k String,并发量10K+
序列化协议 Hessian2序列化, Dubbo序列化, JSON序列化 Simple序列化, Hessian2序列化
可扩展性 SPI,扩展点丰富 SPI,扩展点丰富
特色 开源较早的服务化框架,业界应用广泛,功能丰富 和Dubbo相比,提供更加简化的框架模块和架构

上述表格的数据统计于2018年1月25日。
注:有?标记说明该项指标待查。

5. RPC框架选择

每一个RPC框架都有其自身的特点和适应性,只有符合当前业务场景需要的RPC框架,才是最好的选择。前文对服务定义、服务开发、服务调用、服务通信和服务监控运行各个方面需要考虑的技术因素进行了描述,除此之外,我们还需要考虑更多的方面。

若准备选择已开源的RPC框架上进行自研,则还需要考虑如下几个方面,

  • 生产应用:只有经过生产环境的运行和流量冲击,才能证明RPC框架的稳定性
  • 架构和代码复杂度:这个关系到后续框架的可维护性和可扩展性
  • 社区活跃度:好的活跃社区,有助于框架的成熟,支撑未来发展

如果选择全新自研RPC,则建议全面考虑上述因素。

6. RPC框架的未来

RPC框架发展至今,其基本的架构组件没有太大变化,服务的注册和发现、服务的通信和治理、故障熔断恢复、配置、监控等等,都是一个RPC框架需要提供实现的核心技术组件。但是2017年微服务业界对Service Mesh的热烈讨论,让我们看到对RPC框架未来5-10年的变化。

1) 当前RPC框架面临的问题

上面对RPC框架的技术需求分析中,可以看到RPC框架涉及技术广泛,架构模块组件繁多,在对微服务架构提供技术支持的同时,其框架本身的治理问题也亟待解决,这是问题之一。

还有一个更大的问题是,RPC架构本身是和语言无关的,但为了开发效率,各个RPC框架都对某一个开发语言有特定的支持。目前通过RPC框架开发微服务时,带来最大的问题便是其框架侵入性,开发者在项目的开发过程中,不仅引入框架组件,还不得不在业务代码中添加各种“胶水”配置代码。

请见一个Spring Cloud项目,在引入各个组件之后,还需通过注解方式打开各个组件开关,

@SpringBootApplication
@EnableConfigServer
@EnableEurekaServer
@EnableEurekaClient
@EnableHystrix
@EnableHystrixDashboard
@EnableTurbine
@EnableDiscoveryClient
@EnableFeignClients
@EnableZuulProxy
@EnableZuulServer
Public class Application {
   SpringApplication.run (Application.class, args);
}

Dubbo和Motan对组件的注解和配置也是如此。下面是一个简化的motan服务端配置,

<beans>
    <!-- 业务具体实现类 -->
    <bean id=" motanDemo" class="MotanDemo"/>
    <!-- 注册中心配置 -->
    <motan:registry regProtocol="zookeeper" name="registry" address="127.0.0.1:2181" />
    <!-- 协议配置。-->
    <motan:protocol id="demoMotan" default="true" name="motan"  maxConn="80000" />
    <!-- 具体rpc服务配置,声明实现的接口类。-->
    <motan:service interface="Demo" ref="motanDemo" export="demo:8001" >
    </motan:service>
</beans>

可以看到,即使开发一个简单的项目,各个框架组件一个都不能少,框架对业务代码的侵入性强,各种服务配置的更新会直接涉及项目代码变化。特别是一旦涉及框架的升级,当前“嵌入式”的RPC框架将需要各个应用项目配合,升级时间长,依赖冲突,分散,不可控制,RPC框架的升级治理完全靠“手工”,是一项非常困难、痛苦的过程。

2) 未来5-10年

上述RPC框架面临的问题正是Service Mesh所要解决的,Service Mesh是一个基础设施层,其独立运行在应用服务之外,提供应用服务之间安全、可靠、高效的通信,并为服务通信实现了微服务运行所需的基本组件功能,包括服务注册发现、负载均衡、故障恢复、监控、权限控制等等。

Service Mesh并没有脱离RPC典型架构,它提出一个Sidecar的Proxy组件,实现RPC典型架构中的服务提供者和服务消费者功能,注册中心将成为一个更加强大的控制管理平台。

一个典型的Service Mesh部署网络拓扑图如下,

其中绿色方块为应用服务,蓝色方块为Sidecar Proxy,应用服务之间通过Sidecar Proxy进行通信,整个服务通信形成图中的蓝色网络连线,图中所有蓝色部分就形成了Service Mesh。

可以看到,通过sidecar将本地服务调用转为远程服务调用,sidecar的部署和运行独立于应用服务之外,通过这种方式减少了框架的侵入性,应用的开发语言无限制,能够做到真正的跨语言,同时整个sidecar的运行治理也将自成系统。

未来的5-10年,Service Mesh在引领RPC框架上的创新变化,为下一代微服务架构提供全新技术支持。

7. 参考文献

  1. Dubbo的技术文档 https://www.gitbook.com/@dubbo
  2. Motan的技术文档 https://github.com/weibocom/motan/wiki
  3. 微博轻量级RPC框架Motan正式开源:支撑千亿调用
  4. WHAT'S A SERVICE MESH? AND WHY DO I NEED ONE? ,作者William Morgan,为BuoYant公司CEO,讲述其对Service Mesh的定义和意义。
  5. Pattern: Service Mesh,作者Phil Calçado,该文章以TCP/IP在网络通信的发展历程和作用作为类比,来解释Service Mesh要解决的问题及在微服务中的作用。