在上一篇文章Hadoop源码分析之IPC中Server端的初始化与启动中,分析了Server端的初始化和启动过程,Server端调用Server.start()方法启动后,就启动了一个Responser线程,一个Listener线程和多个Handler线程。其中在Listener线程中又有多个Reader线程,那么这些线程间怎样和客户端交互,如何接收数据呢?下面就来分析Hadoop的IPC连接建立和方法调用过程。
IPC连接的建立
在文章Hadoop源码分析之IPC机制中说过,HDFS的客户端与服务器的第一次交互是在IPC接口版本的检查方法中,而根据Java动态代理机制,客户端所有的方法调用都重定向到了RPC.Invoker.invoke()方法中,所以分析IPC的连接建立与方法调用就从Invoker类开始。Invoker类的定义和构造方法的代码如下:
private static class Invoker implements InvocationHandler {
/**Client.ConnectionId对象**/
private Client.ConnectionId remoteId;
private Client client;
private boolean isClosed = false;
private Invoker(Class<? extends VersionedProtocol> protocol,
InetSocketAddress address, UserGroupInformation ticket,
Configuration conf, SocketFactory factory,
int rpcTimeout, RetryPolicy connectionRetryPolicy) throws IOException {
this.remoteId = Client.ConnectionId.getConnectionId(address, protocol,
ticket, rpcTimeout, connectionRetryPolicy, conf);//构造ConnectionId
this.client = CLIENTS.getClient(conf, factory);
}
}
从上面类的定义可以看到,Invoker类有3个成员变量Client.ConnectionId类型的remoteId、Client类型的client和一个布尔类型的isClosed变量。isClosed变量用于关闭当前的Client对象的Invoker.close()方法中。client变量表示当前的Client对象在客户端中使用ClientCache缓存了已经创建的Client对象,将客户端获取到的SocketFactory对象和Client对象作为键值对缓存在一个HashMap中。remoteId对应远程连接,它标识了客户端与服务器建立的一个连接,Client.ConnectionId与Client.Connection作为键值对存放在Client类的Hashtable类型connections成员变量中,客户端每创建一个与服务器的连接对象,就将该连接对象对应的ConnectionId和该Connection对象以键值对心形式存入Client.connections成员变量中,这一点后面会使用到。Invoker类实现了java.lang.reflect.InvocationHandler接口,可以需要实现接口定义的invoke()方法,所有对代理对象的方法调用将会重定向到invoke()方法中。invoke()方法代码如下:
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable {
final boolean logDebug = LOG.isDebugEnabled();
long startTime = 0;
if (logDebug) {
startTime = System.currentTimeMillis();
}
/**先根据method参数和args生成RPC.Invocation对象,作为参数传入Client.call()方法**/
ObjectWritable value = (ObjectWritable)
client.call(new Invocation(method, args), remoteId);
if (logDebug) {
long callTime = System.currentTimeMillis() - startTime;
LOG.debug("Call: " + method.getName() + " " + callTime);
}
return value.get();
}
在invoke()中先将实际要调用的方法和参数封装成RPC.Invocation对象,然后调用Client.call()方法,将RPC.Invocation对象和remoteId作为参数传入,来进行远程方法调用。RPC.Invocation类代表了远程方法调用的方法和参数,它在客户端进行远程方法调用的过程中存储了将要调用的方法和所传入的参数,以及参数的类型。RPC.Invocation类的部分代码如下:
/** A method invocation, including the method name and its parameters.*/
private static class Invocation implements Writable, Configurable {
/**方法名**/
private String methodName;
/**方法参数类型数组**/
private Class[] parameterClasses;
/**方法需要的参数**/
private Object[] parameters;
/**Configuration对象**/
private Configuration conf;
public Invocation() {}
public Invocation(Method method, Object[] parameters) {
this.methodName = method.getName();zhe
this.parameterClasses = method.getParameterTypes();
this.parameters = parameters;
}
}
从类的定义中可以看出,RPC.Invocation方法实现了Writable接口和Configuration接口,所以RPC.Invocation类实现类这两个接口规定的write()方法、readFields()方法、setConf()方法和getConf()方法,这样Invocation接口就可以序列化进行远程传输,还可以通过Configuration接口声明的setConf()方法为Invocation传入自定义的参数。Invocation对象创建成功之后,就进入到Client.call()方法中,call()方法的代码如下:
/** 执行一次远程调用,将Invocation对象(param)序列化,并发送到服务器端进行调用
* @param param RPC.Invocation对象,保存了要调用的方法对象和参数
* @param remoteId Client.ConnectionId对象
* @return
* */
public Writable call(Writable param, ConnectionId remoteId)
throws InterruptedException, IOException {
Call call = new Call(param);
Connection connection = getConnection(remoteId, call);
connection.sendParam(call); // send the parameter,在IPC连接上发送调用相关信息
boolean interrupted = false;
synchronized (call) {
while (!call.done) {
try {
call.wait(); // wait for the result,等待调用结果
} catch (InterruptedException ie) {
// save the fact that we were interrupted
interrupted = true;
}
}
if (interrupted) {
// set the interrupt flag now that we are done waiting
Thread.currentThread().interrupt();
}
if (call.error != null) {
if (call.error instanceof RemoteException) {
call.error.fillInStackTrace();
throw call.error;//远程调用异常返回,抛异常给本地调用者
} else { // local exception,本地异常
// use the connection because it will reflect an ip change, unlike
// the remoteId
throw wrapException(connection.getRemoteAddress(), call.error);
}
} else {
return call.value;
}
}
}
在Client.call()方法中,首先根据传入的param参数创建一个客户端的远程调用对象,注意这里的param就是上文中的Invocation类的对象,然后通过getConnection()方法获取(或创建)一个与服务器的连接对象,然后在得到的连接上调用sendParam()方法向远程服务器发送调用相关信息。发送完调用后就在一个循环中等待调用的结束。下面将一一分析上面的3个过程。
创建客户端Call对象
Client.Call类是对客户端的远程方法调用的一个抽象,它表示客户端向服务器的一次方法调用,保存了方法调用所需要的参数,在客户端中区分每个调用的标识,远程调用的返回值,以及远程方法调用的异常信息。Client.Call类的成员变量和构造方法的代码如下:
/** A call waiting for a value. */
private class Call {
int id; // call id
/**RPC.Invocation对象**/
Writable param; // parameter
/**返回值**/
Writable value; // value, null if error,
/**异常返回时的异常**/
IOException error; // exception, null if value,
/**该调用是否已经完成**/
boolean done; // true when call is done,
protected Call(Writable param) {
this.param = param;
synchronized (Client.this) {
this.id = counter++;
}
}
}
在Client.Call的构造方法中,首先给param成员变量赋值,这里的param也是上文中的RPC.Invocation对象,保存了远程方法调用的方法和参数。然后给变量id赋值,id是Call对象在客户端的一个标识,由于客户端可能有很多远程方法调用,所以对于每一个远程调用的方法都需要给定一个唯一标识,加以区分,Client.Call类为变量id的赋值的方法很简单,就是在Client对象中维持一个变量count来进行自增,每进行一次远程方法调用,变量count就自增一次。这样就区分出了一个Client对象中的各个Call对象。
获取连接
Client.Call创建成功之后,就调用getConnection()方法获取与服务器的连接。先来看看getConnection()方法的实现代码:
/** Get a connection from the pool, or create a new one and add it to the
* pool. Connections to a given ConnectionId are reused. */
private Connection getConnection(ConnectionId remoteId,
Call call)
throws IOException, InterruptedException {
if (!running.get()) {
// the client is stopped
throw new IOException("The client is stopped");
}
Connection connection;
/* we could avoid this allocation for each RPC by having a
* connectionsId object and with set() method. We need to manage the
* refs for keys in HashMap properly. For now its ok.
*/
do {
synchronized (connections) {
connection = connections.get(remoteId);
if (connection == null) {
connection = new Connection(remoteId);
connections.put(remoteId, connection);
}
}
} while (!connection.addCall(call));
//we don't invoke the method below inside "synchronized (connections)"
//block above. The reason for that is if the server happens to be slow,
//it will take longer to establish a connection and that will slow the
//entire system down.
connection.setupIOstreams();//和服务器建立连接
return connection;
}
getConnection()方法的实现者给出的注释是:
从一个连接池中获取一个连接或者创建一个新的连接再将新的连接加入到连接池中,通过给定的ConnectionId对象来重用连接。所以在Hadoop IPC机制在客户端维持了一个到服务器的连接池,每次获取连接先根据参数给定的ConnectionId对象从连接池中取出Connection对象,如果没有取到,就新建一个Connection对象,然后将这个连接放入连接池中,以便下次重用,这一点在上文中提到过。
在getConnection()方法中,首先判断客户端是否处于运行状态,如果客户端已经停止就抛出异常,因为不能在一个已经停止的客户端中向服务器发送方法调用。然后根据ConnectionId从客户端的连接集合中取出一个连接,如果取出的连接为空,则创建一个Connection,注意的是这里是在一个循环中去得连接,为什么要在一个循环中去得Connection对象呢?循环条件是while (!connection.addCall(call));,Connection.addCall()方法表示将该这个调用加入到这个连接中,因为一个连接中,可能有多个方法调用,所以在Connection类中维持了一个Hashtable<Integer, Call>类型的对象calls存储该连接上的所有调用,calls对象的键值对分别是客户端调用抽象Call类的唯一标识Call.id和这个调用Call对象。进入到Connecton.addCall()方法中看看:
private synchronized boolean addCall(Call call) {
if (shouldCloseConnection.get())
return false;
calls.put(call.id, call);
notify();
return true;
}
在addCall()方法中,首先判断shouldCloseConnection是否为真,如果为真的话就返回false,这里shouldCloseConnection表示是否处于关闭连接的状态,进入关闭过程,因为IPC连接可以在多个地方触发,但直到Connection.close()方法中,ConnectionId和连接的映射关系才会被删除,所以在Connection.addCall()方法中可能当前连接正准备关闭,但是还未调用Connection.close()方法关闭,此时就需要使用shouldCloseConnection来判断当前的连接是否有效,如果shouldCloseConnection的值为true,那么Connection.addCall()方法会返回false,所以就不会将一个IPC调用放入一个正处于关闭过程中的连接中。如果是新创建的连接,那么shouldCloseConnection为false,所以连接创建成功之后就直接将该连接放入connections中。
上述过程完成之后就调用Connection.setupIOstreams()方法与服务器建立连接。在Connection.setupIOstreams()方法中,先调用setupConnection()方法建立与服务器的Socket连接,并设置Socket的一些参数,如设置TcpNoDelay标识禁用TPC的Nagle算法关闭Socket底层的缓冲,确保数据及时发送。然后得到socket的输入输出流(注意这里与网络相关的类都集中在org.apache.hadoop.net包中)。在Socket连接建立成功后,就调用writeRpcHeader向IPC服务器发送握手信息,即向服务器发送一个RPC头部信息,writeRpcHeader方法写到服务器的内容包括:
- IPC连接魔数(值为“hrpc”,作用类似于文件魔数,四个字节)
- 协议版本号(目前是4,一个字节)
- 认证方法(默认是简单认证方式AuthMethod.SIMPLE,一个字节):由于只发送代码枚举类型AuthMethod有3个值,分别是SIMPLE,KERBEROS和DIGEST,它们对应的代码分别是80,81和82,在writeRpcHeader()方法中只发送认证方式的代码,所以是一个字节
服务器就是根据这些内容,对客户端进行协议版本检查、接口检查和权限检查。然后再调用writeHeader()方法发送ConnectionHeader部分,其实相当于发起一次RPC调用,这样做的原因是Server.Connection是有状态的,连接状态由rpcHeaderRead和headerRead管理,在Server.Connection中如果顺利读取到客户端发送的IPC连接魔数和协议版本号并完成检查,则设置rpcHeaderRead为true,然后进入连接头检查,连接头检查通过后,headerRead为true,服务器开始处理客户端的IPC请求。writeHeader()方法发送给服务器的内容按顺序是:
- 发送内容的长度,占四个字节;
- IPC接口信息长度,占四个字节;
- IPC接口信息,由字节数组表示,其长度由上面那个字段指定;
- 用户认证信息,如果用户信息为空,则不发送这个信息或者用户信息不为空,但是认证方式是AuthMethod.DIGEST,这部分就只有一个字节为布尔值false,如果发送用户信息,则这部分第一个字节为true,然后就是用户名,如果认证方式是AuthMethod.KERBEROS或者真实用户信息为空,那么接下来一个字节为false,表示这部分信息结束,否则就是布尔值true,接下来就是真实用户的用户名。
这部分的代码在ConnectionHeader类中的write()方法中,即使用这样的方式将ConenctionHeader对象序列化发送给服务器。writeHeader()调用结束后,调用touch()记录最后一次IO发生的时间,并启动线程,该线程用于在Socket上读取并响应数据。setupIOstreams()方法的具体代码如下:
/** Connect to the server and set up the I/O streams. It then sends
* a header to the server and starts
* the connection thread that waits for responses.
*/
private synchronized void setupIOstreams() throws InterruptedException {
if (socket != null || shouldCloseConnection.get()) {
return;
}
try {
if (LOG.isDebugEnabled()) {
LOG.debug("Connecting to "+server);
}
short numRetries = 0;//连接建立失败后重新连接的次数
final short maxRetries = 15;//最多可以重复多少次
Random rand = null;
while (true) {
setupConnection();
InputStream inStream = NetUtils.getInputStream(socket);
OutputStream outStream = NetUtils.getOutputStream(socket);
writeRpcHeader(outStream);//向服务器端发送连接头
if (useSasl) {
final InputStream in2 = inStream;
final OutputStream out2 = outStream;
UserGroupInformation ticket = remoteId.getTicket();
if (authMethod == AuthMethod.KERBEROS) {
if (ticket.getRealUser() != null) {
ticket = ticket.getRealUser();
}
}
boolean continueSasl = false;
try {
continueSasl =
ticket.doAs(new PrivilegedExceptionAction<Boolean>() {
@Override
public Boolean run() throws IOException {
return setupSaslConnection(in2, out2);
}
});
} catch (Exception ex) {
if (rand == null) {
rand = new Random();
}
handleSaslConnectionFailure(numRetries++, maxRetries, ex, rand,
ticket);
continue;
}
if (continueSasl) {
// Sasl connect is successful. Let's set up Sasl i/o streams.
inStream = saslRpcClient.getInputStream(inStream);
outStream = saslRpcClient.getOutputStream(outStream);
} else {
// fall back to simple auth because server told us so.
authMethod = AuthMethod.SIMPLE;
header = new ConnectionHeader(header.getProtocol(),
header.getUgi(), authMethod);
useSasl = false;
}
}
this.in = new DataInputStream(new BufferedInputStream
(new PingInputStream(inStream)));
this.out = new DataOutputStream
(new BufferedOutputStream(outStream));
writeHeader();
// update last activity time
touch();
// start the receiver thread after the socket connection has been set up
start();
return;
}
} catch (Throwable t) {
if (t instanceof IOException) {
markClosed((IOException)t);
} else {
markClosed(new IOException("Couldn't set up IO streams", t));
}
close();
}
}
方法调用
getConnection()方法执行完成后就该调用Connection.sendParam()方法了。sendParam()方法完成了客户端方法的调用,即向服务器发送了调用方法需要的所有信息。该方法的参数是一个Client.Call对象,上面说过Client.Call类是对客户端的远程方法调用的一个抽象,所以Connection.sendParam()方法将Client.Call对象序列化后发送给服务器端。方法的具体代码如下:
/** Initiates a call by sending the parameter to the remote server.
* Note: this is not called from the Connection thread, but by other
* threads.
* 向服务器发送调用
* @param call 调用相关的参数
*/
public void sendParam(Call call) {
if (shouldCloseConnection.get()) {//如果当前连接将要关闭,那么马上返回
return;
}
DataOutputBuffer d=null;
try {
synchronized (this.out) {
if (LOG.isDebugEnabled())
LOG.debug(getName() + " sending #" + call.id);
//for serializing the
//data to be written
d = new DataOutputBuffer();
d.writeInt(call.id);
call.param.write(d);//Call.param是Invocation类型,Invocation继承了Writable接口,write()方法调用的是Invocation的wirte方法,将参数都写到DataOutputBuffer中
byte[] data = d.getData();
int dataLength = d.getLength();
out.writeInt(dataLength); //first put the data length
out.write(data, 0, dataLength);//write the data
out.flush();
}
} catch(IOException e) {
markClosed(e);
} finally {
//the buffer is just an in-memory buffer, but it is still polite to
// close early
IOUtils.closeStream(d);
}
}
从上面sendParam()方法的代码可以看到,先创建一个输出缓存,然后将Client.Call对象的id写入缓存,再将Client.Call对象中的param写入缓存,因为Client.Call的成员变量param是一个实现了Writable接口的对象(在这里实际是一个RPC.Invocation对象),所以代码call.param.write(d)实际上会进入到RPC.Invocation类的write()方法中,首先将要调用的方法名写出到输出缓存,然后是方法参数的类型,最后是具体的参数,具体格式如下:
- 发送数据的长度,占四个字节,下面是数据部分;
- 数据部分的第一个部分是当前调用的Call.id值,用于标识不同的Call对象,是int类型变量,占四个字节;
- 要调用的方法名字符串的长度,方法名字符串转换为字节数组发送,这个字节数组长度占四个字节;
- 方法名字符串的字节数组;
- 然后就是方法参数的个数,int类型,占四个字节;
- 接下来就是具体的参数类型和参数值。各个参数及参数类型的格式是相同的,具体可以看ObjectWritable类的writeObject()方法,在这个方法中根据每种类型的参数进行处理。
完成之后,再通过与服务器的连接上的输出流将输出缓存中的数据发送给服务器。发送的数据是数据长度和数据内容。调用输出流发送数据之后,就关闭输出缓存。
总结
上文从客户端的角度分析了与服务器的连接的建立,与方法调用数据的发送的整个过程。从上面客户端发送给服务器端的数据格式可以看出,客户端发送给服务器的数据是采用显示长度的方式来发送数据帧的,即指定每个数据字段的长度,服务器端先读取这个长度值,再根据这个长度读取数据值。这部分是在客户端执行的,服务器端的执行流程将在下一篇文章说继续分析。
--EOF
Reference
《Hadoop技术内幕:深入解析Hadoop Common和HDFS架构设计与实现原理》