Chromium网络协议栈架构浅析

代码风格浅析

类型封装

在Chromium代码中,不管多么简单的类型,哪怕只是一个int类型的文件描述符,也要通过typedef来重命名为一个合乎命名规范的类型。而对于稍微复杂点的结构体如sockaddr,则会直接为它封装一个类出来。这种做法目的是弥合跨平台之间系统调用接口设计的差异性,从而暴露给上层应用代码一套统一规范的接口。

事件循环

首先明确一点,Chromium项目里所有的网络IO的API都是异步非阻塞的,真实的IO操作通过事件循环异步调用的方式来完成,但这中间经过了多个层次的抽象。因此,每当需要执行网络IO的时候,网络库会先尝试以非阻塞的方式来执行,如果能够成功,那当然可以直接就返回了;如果失败,才注册到事件循环里面去,等待完成后执行回调。注册完成以后,对于该异步方法,会返回一个ERR_IO_PENDING的错误码,用以标示该任务正在进行中。

在POSIX兼容的系统上,Chromium的事件循环是基于libevent来编写的。

异步回调

个人认为,Chromium里面的异步回调机制,本质上是一种协程思想的面向对象方式的实现。上面已经提到,每一个正常情况下会产生阻塞的IO调用,都会注册一个回调函数,然后返回一个ERR_IO_PENDING状态码,那么这个状态码其实等同于协程思想中的yield——出让控制流。而当真正的IO完成以后,这个回调函数会被调用,由于该函数多数情况下会是一个类方法,能够访问该类的各种成员变量,而在发起IO调用以前,当时控制流的上下文信息已经保存到了类成员变量中。因此,回调函数相当于读取了这些上下文信息以后,继续按照既定的流程,来执行相应的操作。也就类似于从出让点继续执行控制流了。考虑到Chromium具有非常复杂的控制流,因此比起单纯的协程框架,这样细粒度的操作能够最大限度地满足设计的灵活性。

引用计数

base::Unretained()

base::Owned()

base::Passed()

base::ConstRef()

base::IgnoreResult()

智能指针

scoped_ptr

这属于某种意义上的智能指针,部分实现了C++11的unique_ptr的语义,即"movable but not copyable":可移动但不可拷贝,也就是不支持拷贝构造函数以及赋值构造函数。我们基本可以把它当做一个兼容老版本C++的,在作用域内用来管理内存的机制。如果在作用域内需要发起函数调用之类的,则需要用std::move来转移所属关系给调用函数,而不是直接按值传递参数,否则的话会导致调用拷贝构造函数,然而并不允许这样做。

事实上如果函数需要接受一个scoped_ptr作为参数的话,仅限右值(rvalue)能够被作为参数传递进去。能够产生右值的方法有std::move或者函数返回值。总而言之,scoped_ptr上面执行拷贝或者赋值操作是不允许的

scoped_refptr

与scoped_ptr稍有区别,这种指针只能指向显式实现了引用计数(AddRef/Release)接口的类(也就是继承自RefCounted)。换句话说,这种智能指针是专门为引用计数以及自动垃圾回收而设计的,本质上相当于实现了C++11中的shared_ptr的功能。至于为什么不直接用shared_ptr,我个人的理解是,一方面开发Chromium的时候C艹的最新标准还没实现;另一方面则是这种特定的智能指针能够更好地与项目中的类型接口设计耦合起来,使得类型约束更强,从而有效提升代码质量。

weak_ptr

这是一种不影响其所指向的对象生命周期的智能指针,主要用于供非所有者以外的对象来访问某个对象时使用。举个例子,假如我们需要临时访问一下某个被引用计数所管理的对象,可能是读取一下状态,或者是做什么简单的事情;如果我们使用正常的智能指针,会导致引用计数的增加,但实际上我们并不需要在此时维持这个引用——事实上,如果这个对象已经被析构了,我们可以不去访问它也没关系。在这种需求场景下,如果维持引用计数,那么这就导致对象的生命周期变得更加复杂化;如果使用裸指针,那么我们并不能判断这货是不是已经被析构了,因此在这样的特定条件下我们需要用到weak_ptr

DISALLOW_COPY_AND_ASSIGN

用于显示地禁止拷贝构造函数以及赋值构造函数,方法是将其声明为私有函数。代码中所有的类必须使用这个宏,这是为了禁止意外的对象复制/构造,严格使用引用计数方式以及scoped_ptr来管理对象。

HANDLE_EINTR

用于包装最底层的IO系统调用如send/recv,因为这些操作会被signal给打断。

NET_EXPORT

在Win平台上定义DLL的导出符号属性,而在POSIX平台上干脆就是空的了,我们可以直接忽略。

编译相关

Android接口

JNI_OnLoad

JNI在加载时,会调用JNI_OnLoad,而卸载时会调用JNI_UnLoad,所以我们可以通过在JNI_OnLoad里面注册我们的Native函数来实现JNI。使用这个方法的话,我们可以不使用生成头文件再编写Native实现的方式,而是在运行时加载动态库的时候把相应的实现注册给Java的相应类。

CronetOnLoad

上述初始化函数会直接调用cronet提供的这个函数。然而这个初始化函数也仅仅是层皮,它实际上是以函数绑定的方式让base::android::OnJNIOnLoadRegisterJNI去执行实际的初始化函数来注册JNI。而在这个“执行者”里面实际上还进行了一些初始化动作,然后才会调用各个初始化函数,这一层一层的封装很是令人头痛。

ChromiumUrlRequestRegisterJni

这里仅举一个注册URLRequest相关函数的例子。这个初始化函数在调用的时候实际上是去调用了相应的RegisterNativesImpl函数,并且蛋疼的是,这个函数是自动生成并保存在一个头文件里面的,显然是个static函数。总之它完成的工作是把包装过后的Native函数实现给真的注册进Java虚拟机里面去,比如我们可以在这个自动生成的头文件里看到各种extern "C"之类的语句。

C++/Java对象传递

这里我们考察包装过后的Native函数声明,比如:

:::C++
static void AddHeader(JNIEnv* env,
                      const JavaParamRef<jobject>& jcaller,
                      jlong jurl_request_adapter,
                      const JavaParamRef<jstring>& jheader_name,
                      const JavaParamRef<jstring>& jheader_value)

我们会发现传入了一个类型为jlong的参数jurl_request_adapter。从命名上来看,这显然应该是个对象指针,但它却被声明为整型。事实上,在跨语言调用的时候,JRE并不比Python高明到哪里去,这个所谓的整型,其实就是用来存储C++指针的,在代码里我们会看到,它被强制转换成为一个对象指针。总而言之,通过一系列的包装,使得Java的上层调用被顺利翻译到C++上面的对象操作去。

设计模式

在线参考资料:《图说设计模式

单例模式

这是最简单的设计模式就不用废话了。Chromium将事件循环绑定到每个线程的设计可以认为是某种单例模式,只是并非在整个进程的内存空间中仅有唯一实例,而是针对每个线程拥有一个实例。所以在向事件循环注册IO任务的时候会这样写:

:::C++
if (!base::MessageLoopForIO::current()->WatchFileDescriptor(
          socket_fd_, true, base::MessageLoopForIO::WATCH_WRITE,
          &write_socket_watcher_, this)) {
    PLOG(ERROR) << "WatchFileDescriptor failed on write, errno " << errno;
    return MapSystemError(errno);
  }

命令模式

观察者模式

定义:建立一种对象与对象之间的依赖关系,一个对象发生改变时将自动通知其他对象,其他对象将相应做出反应。在此,发生改变的对象称为观察目标,而被通知的对象称为观察者,一个观察目标可以对应多个观察者,而且这些观察者之间没有相互联系,可以根据需要增加和删除观察者,使得系统更易于扩展,这就是观察者模式的模式动机。

这里简单分析NetLog类是如何应用这个设计模式的。事实上与直观命名相悖的是,NetLog类本身实际上是作为一种Subject也就是观察目标的概念,它内部包含各个Observer实例的列表,如果发生了事件,就遍历自己内部的这个列表,依次通知所有监听实例对象。而所有的Observer对象需要继承自ThreadSafeObserver,这个对象定义在NetLog类的内部。

在socket层面,如果我们需要添加自己的自定义错误的话,最好的方式是直接新建一个观察者对象,然后在合适的位置将其注册进NetLog类即可。这个观察者对象在记录数据的时候,需要负责发起异步调用,进行数据回传。cronet中现有的观察者对象有:TraceNetLogObserverNetLogObserverWriteToFileNetLogObserver

委托模式

工厂模式

URLRequest类的构造就是遵循典型的工厂模式。这个类本身的构造函数被设为私有的,它的实际构造是由URLRequestContext::CreateRequest来负责,并且URLRequestContext也是其友元类。在构造过程中,URLRequestContext将自身的指针传入URLRequest的构造函数,这实际上描述了URL请求对于当前环境(Cookie、Cache等)的依赖关系,也就是根据环境来发起请求。

适配器模式

我们来分析一下Observer是如何注册进相应的NetLog对象的。这里我们以一个具体的NetLogObserver类(继承自net::NetLog::ThreadSafeObserver)为例,来分析它的注册流程。首先,这个类被作为成员变量包裹在URLRequestContextAdapter类中,在该类的InitRequestContextOnNetworkThread初始化方法中,会去检测当前的日志级别,代码如下:

:::C++
if (VLOG_IS_ON(2)) {
    net_log_observer_.reset(new NetLogObserver());
    context_->net_log()->DeprecatedAddObserver(
        net_log_observer_.get(),
        net::NetLogCaptureMode::IncludeCookiesAndCredentials());
  }

如果打开了开关,需要记录这个日志,就会把相应的观察者对象注册到当前Adapter所对应的net::URLRequestContext对象所包含的NetLog类中去。那么这个NetLog来自哪里呢?实际上它源自于URLRequestContext的建造者对象:URLRequestContextBuilder。建造者对象中的这个指针事实上又是来自CronetURLRequestContextAdapter::InitializeOnNetworkThread这个方法,从名字上就可以推断出,这是个用于初始化网络库的方法,它在同一个类中的InitRequestContextOnMainThread方法中被调用。

所以,最后这个CronetURLRequestContextAdapter是个什么鬼呢?事实上它是将Java的CronetUrlRequestContext适配到net::URLRequestContext的适配器方法。至此,整个日志流程就很清晰了:从Java应用层调用并初始化,然后NetLog对象会被沿着URLRequest一路透传到最底层的socket处理的层面,可以接收到所有这些网络状态信息,并进行相应的日志记录。

base

logging.cc

定义了通用的日志系统LogMessage类,使用的时候直接用宏而不是调用它的具体方法。

net

net/log

net_log.cc

考虑到我们需要把URL请求时产生的socket错误进行记录,因此在这里我们深入讨论一下NetLog类在一个URL请求的生存周期内所扮演的角色。首先我们确认,在URLRequest类中包含一个BoundNetLog对象,这个对象是在创建对象的时候由URLRequestContext所传入的NetLog对象所构造的。构造过程实际上是将NetLog指针绑定到一个特定的Source上面而已,并没有太多特殊的地方。

net/socket

socket_descriptor.cc

这里提供跨平台的对socket文件描述符的操作,也就只有一个函数:CreatePlatformSocket,以及对int类型的typedef

socket_posix.cc

在这里提供最原始的对BSD socket descriptor的封装:SocketPosix类。这个类把所有的POSIX阻塞操作给包装成非阻塞的类方法。值得注意的是,每个被包装后的阻塞操作的类方法都会接受一个类型为base::Callback<void(int)>的回调函数对象,用以执行后续的操作如打日志等等。如果需要统计socket层面的错误日志,个人认为应该考虑插在这里。

那么什么时候回调函数会被执行呢?注意到SocketPosix实际上继承自base::MessageLoopForIO::Watcher,这是个虚基类,定义了OnFileCanReadWithoutBlocking以及OnFileCanWriteWithoutBlocking这两个虚方法。因此,当事件循环中fd可读或者可写的时候,SocketPosix类的这两个方法会被调用,从而完成实际的IO读写操作,然后解除在事件循环中注册的Watcher,最后再调用先前传入的回调函数。

此外,注意到这个类中封装了一个WaitForWrite方法,并且在Write内部会调用它。也就是说对于Write操作,把尝试IO的操作与注册事件的操作分开了,但对Read操作却并没有。那么为什么要这样设计呢?事实上这是因为在更上一层的封装中实现了TcpFastOpenWrite方法,并且需要调用这部分功能而已。

最后注意下IsConnected以及IsConnectedAndIdle这两个方法。他们通过MSG_PEEK方式读取socket,即不影响缓冲区数据而是只取出数据查看。对于recv调用,如果返回零或者其他值,说明连接已经死掉;如果返回EAGAIN或者EWOULDBLOCK,则表明连接还存活。

tcp_socket.cc

这里没什么代码,只是include了系统平台与socket相关的头文件,并且提供一些平台特性相关的函数而已,如TCP_NODELAY之类的特性。总之这是个为不同平台进行包装的兼容性源文件。

tcp_socket_posix.cc

这个文件中最前面提供用于判断/设置系统特性的函数,如SetTCPKeepAliveSystemSupportsTCPFastOpen之类。接下来是基于SocketPosix之上对于TCP socket的一层封装:TCPSocketPosix类。如果说SocketPosix类的封装体现的是BSD socket的基本特性,那么这一层封装体现的则是TCP协议的基本特性。在这个类中,起最主要功能的其实还是ReadWrite方法,但还有一些方法如TcpFastOpenWriteSetKeepAlive之类,实际上就是在实现TCP的行为。

首先分析一下回调函数的封装情况。事实上该文件中的IO操作同样需要传入回调参数,那么外部传入的回调函数并非原封不动地送入最底层的SocketPosix类,而是在TCPSocketPosix这一层被加入了自己的私货:

:::C++
int rv =
      socket_->Connect(storage, base::Bind(&TCPSocketPosix::ConnectCompleted,
                                           base::Unretained(this), callback));

本质上这就是函数式编程的思想:将函数作为参数,生成新的函数。这样的话,在SocketPosix类中发起回调的时候,回调的其实是TCPSocketPosix的类方法,由这个类方法中执行完自己的操作,再去回调外部传入的那个callback,这是个层层嵌套的关系。

然后分析一下对TCP Fast Open的支持。在Connect函数中,如果设置了要使用该特性,那么Connect函数就会立刻返回成功。然后真正的连接建立逻辑会在需要Write的时候开始执行。也就是说在Write的时候,如果还没有尝试过写入,那就以Fast Open的方式写入一发数据试试。基于同样的逻辑,IsConnected函数同样会在使用fast open且尚未建立连接的时候,“假设”连接已经被建立了。

在TCP Fast Open写入的过程中存在这样一个细节:如果OS内核中已经有了相应的cookie,那么IO操作会无阻塞直接返回,因为内核可以立刻把搭载了cookie的数据包发出去。如果内核中没有cookie,那么就需要等待连接建立,然后才能写入数据。此时,因为已经尝试过写入却失败了,所以调用的是SocketPosix中的WaitForWrite,而不是再去直接Write

socket.h

这个头文件定义了Socket的纯抽象定义,即一个套接字应该包括ReadWriteSetReceiveBufferSizeSetSendBufferSize这四个最纯粹的IO属性。这个类的目的应该是供更上层次的类进行继承。

stream_socket.cc

文件定义了StreamSocket类,除了其内部的UseHistory类具有具体的实现以外,这基本是一个继承自Socket类的虚基类,在裸的套接字的概念上叠加了流Stream的语义,如ConnectDisConnect之类,但并不包含这些语义具体的实现,而是要再封装一层。

tcp_client_socket.cc

继承自StreamSocket,定义了TCPClientSocket类。这个类中就实现的是客户端基于TCP协议的Stream数据传输。由此我们注意到,到这个非常具体的层次以后,服务端与客户端的Stream传输接口定义已经有了很大的差异,因此这里将其分拆成两个文件中的两个类来实现。

此外,注意到这个类相对于TCPSocketPosix类实际上是 "Has A" 的关系。它也提供了ReadWrite方法,本质上当然是对TCPSocketPosix的封装,只不过又在Stream的这一层次加入了日志系统而已,对于callback的处理也是基本一样的。

ssl_socket.h

基于StreamSocket之上定义了SSLSocket类,但这个类其实也是抽象类,为流的语义增加了两个与加密相关的虚函数接口,并且这个接口也是通用于客户端以及服务端的,所以才会“横插”在这一层。

ssl_client_socket.cc

这里我们重点关注一下SSL客户端一侧的实现。这个文件中定义了SSLClientSocket类,但其实这也是一个插在中间的抽象层次,有一些方法是公有的,但还有很多就是纯虚的方法,需要到具体的OpenSSL实现层次再去定义。

net/dns

comments powered by Disqus
Published:
2016-03-24
分类:
Tag: