上篇,我们对HSQLDB的入口,Server.java做了分析。Server主要工作就是启动Tcp监听,等待客户端来进行连接。
我们知道,在计算机中,不能是数据也好,逻辑也好,最终都是通过二进制来进行存储、传输的。
一段数据,以二进制形式从一台计算机,通过网络,传输到另外一台计算机,必然需要双方遵守一些约定,以确保接收方做的事情与发送方的要求是一致的——这就是通信协议,如我们常见的http就是一种通信协议。
上篇结尾中说到,Server接收到请求之后,就转到ServerConnection来处理,ServerConnection就负责了对通信协议的解析,将不同的指令,转到相应的函数中处理。
HSQLDB支持3种协议:odbc、hsql_stream_protocal和http。下面通过源码来分析,这些协议在Hsqldb中是如何处理的,如果你清楚二进制协议是怎么回事,你大概可以略过这篇文章。
我们先看构造函数:
-
ServerConnection(Socket socket, Server server) {
-
-
RowOutputBinary rowOutTemp = new RowOutputBinary(mainBuffer);
-
-
rowIn = new RowInputBinary(rowOutTemp);
-
rowOut = rowOutTemp;
-
-
//
-
Thread runnerThread;
-
-
this.socket = socket;
-
this.server = server;
-
mThread = mCurrentThread.getAndIncrement();
-
-
synchronized (server.serverConnSet) {
-
server.serverConnSet.add(this);
-
}
-
}
其中的rowIn, rowOut,是用来读写数据的。
将socket对象和server对象都保存起来,并将客户端连接数增加1。
ServerConnection本身是实现了Runnable接口,也就是说ServerConnection的入口函数就是run方法
-
public void run() {
-
-
int msgType;
-
-
init();
-
-
if (session != null) {
-
try {
-
while (keepAlive) {
-
msgType = dataInput.readByte();
-
-
if (msgType < ResultConstants.MODE_UPPER_LIMIT) {
-
receiveResult(msgType);
-
} else {
-
receiveOdbcPacket((char) msgType);
-
}
-
}
-
} catch (CleanExit ce) {
-
keepAlive = false;
-
} catch (IOException e) {
-
-
// fredt - is thrown when connection drops
-
server.printWithThread(mThread + ":disconnected " + user);
-
} catch (HsqlException e) {
-
-
// fredt - is thrown in unforeseen circumstances
-
if (keepAlive) {
-
server.printStackTrace(e);
-
}
-
} catch (Throwable e) {
-
-
// fredt - is thrown in unforeseen circumstances
-
if (keepAlive) {
-
server.printStackTrace(e);
-
}
-
}
-
}
-
-
close();
-
}
在这里,先是初始化init,然后一直处理客户端的数据receiveResult。我们先来看init,然后再看receiveResult。(知道init是怎样一个流程,则发现receiveResult也是差不多的)
-
private void init() {
-
-
runnerThread = Thread.currentThread();
-
keepAlive = true;
-
-
try {
-
socket.setTcpNoDelay(true);
-
-
dataInput = new DataInputStream(
-
new BufferedInputStream(socket.getInputStream()));
-
dataOutput = new DataOutputStream(socket.getOutputStream());
-
-
int firstInt = handshake();
-
-
switch (streamProtocol) {
-
-
case HSQL_STREAM_PROTOCOL :
-
if (firstInt
-
!= ClientConnection
-
.NETWORK_COMPATIBILITY_VERSION_INT) {
-
if (firstInt == -1900000) {
-
firstInt = -2000000;
-
}
-
-
String verString =
-
ClientConnection.toNetCompVersionString(firstInt);
-
-
throw Error.error(
-
null, ErrorCode.SERVER_VERSIONS_INCOMPATIBLE, 0,
-
new String[] {
-
verString, HsqlDatabaseProperties.THIS_VERSION
-
});
-
}
-
-
Result resultIn = Result.newResult(dataInput, rowIn);
-
-
resultIn.readAdditionalResults(session, dataInput, rowIn);
-
-
Result resultOut;
-
-
resultOut = setDatabase(resultIn);
-
-
resultOut.write(session, dataOutput, rowOut);
-
break;
-
-
case ODBC_STREAM_PROTOCOL :
-
odbcConnect(firstInt);
-
break;
-
-
default :
-
-
// Protocol detection failures should already have been
-
// handled.
-
keepAlive = false;
-
}
-
} catch (Exception e) {
-
-
// Only "unexpected" failures are caught here.
-
// Expected failures will have been handled (by sending feedback
-
// to user-- with an output Result for normal protocols), then
-
// continuing.
-
StringBuffer sb = new StringBuffer(mThread
-
+ ":Failed to connect client.");
-
-
if (user != null) {
-
sb.append(" User '" + user + "'.");
-
}
-
-
server.printWithThread(sb.toString() + " Stack trace follows.");
-
server.printStackTrace(e);
-
}
-
}
通过在handshake函数中,读到的第一个字节的信息,取出了当前的通信协议,ServerConnection支持两种协议:odbc和HSQL_STREAM_PROTOCOL,值得注意的是,当使用HSQL_STREAM_PROTOCOL时,第一个字节值为当前协议版本号。
下面,我们将仅对HSQL_STREAM_PROTOCOL进行分析,因为odbc也是大同小异的。
当HSQL_STREAM_PROTOCOL版本号没有问题,程序中使用Result对客户端与服务端的数据进行抽象,也就是客户端请求为一个Result,服务端响应也是一个Result。对于客户端发来的数据,在服务端就是read操作,要发往客户端的数据,就是write操作。
-
Result resultIn = Result.newResult(dataInput, rowIn);
-
-
resultIn.readAdditionalResults(session, dataInput, rowIn);
-
-
Result resultOut;
-
-
resultOut = setDatabase(resultIn);
-
-
resultOut.write(session, dataOutput, rowOut);
先初始化Result对象,我们知道一个协议中包含很多命令(也就是很多种请求),如:连接、断开连接、执行sql语句,每个命令还包含相应的参数,如执行sql语句,就要把sql语句传过来)。我们接下来就看newResult中是怎么处理的:
-
private static Result newResult(Session session, DataInput dataInput,
-
RowInputBinary in,
-
int mode)
-
throws IOException, HsqlException {
-
-
Result result = newResult(mode);
-
int length = dataInput.readInt();
-
-
in.resetRow(0, length);
-
-
byte[] byteArray = in.getBuffer();
-
final int offset = 4;
-
-
dataInput.readFully(byteArray, offset, length - offset);
-
-
switch (mode) {
-
-
case ResultConstants.GETSESSIONATTR :
-
result.statementReturnType = in.readByte();
-
break;
-
-
case ResultConstants.DISCONNECT :
-
case ResultConstants.RESETSESSION :
-
case ResultConstants.STARTTRAN :
-
break;
-
-
case ResultConstants.PREPARE :
-
result.setStatementType(in.readByte());
-
-
result.mainString = in.readString();
-
result.rsProperties = in.readByte();
-
result.generateKeys = in.readByte();
-
-
if (result.generateKeys == ResultConstants
-
.RETURN_GENERATED_KEYS_COL_NAMES || result
-
.generateKeys == ResultConstants
-
.RETURN_GENERATED_KEYS_COL_INDEXES) {
-
result.generatedMetaData = new ResultMetaData(in);
-
}
-
break;
-
-
case ResultConstants.CLOSE_RESULT :
-
result.id = in.readLong();
-
break;
-
-
case ResultConstants.FREESTMT :
-
result.statementID = in.readLong();
-
break;
-
-
case ResultConstants.EXECDIRECT :
-
result.updateCount = in.readInt();
-
result.fetchSize = in.readInt();
-
result.statementReturnType = in.readByte();
-
result.mainString = in.readString();
-
result.rsProperties = in.readByte();
-
result.queryTimeout = in.readShort();
-
result.generateKeys = in.readByte();
-
-
if (result.generateKeys == ResultConstants
-
.RETURN_GENERATED_KEYS_COL_NAMES || result
-
.generateKeys == ResultConstants
-
.RETURN_GENERATED_KEYS_COL_INDEXES) {
-
result.generatedMetaData = new ResultMetaData(in);
-
}
-
break;
-
-
case ResultConstants.CONNECT :
-
result.databaseName = in.readString();
-
result.mainString = in.readString();
-
result.subString = in.readString();
-
result.zoneString = in.readString();
-
result.updateCount = in.readInt();
-
break;
-
-
case ResultConstants.ERROR :
-
case ResultConstants.WARNING :
-
result.mainString = in.readString();
-
result.subString = in.readString();
-
result.errorCode = in.readInt();
-
break;
-
-
case ResultConstants.CONNECTACKNOWLEDGE :
-
result.databaseID = in.readInt();
-
result.sessionID = in.readLong();
-
result.databaseName = in.readString();
-
result.mainString = in.readString();
-
break;
-
-
case ResultConstants.UPDATECOUNT :
-
result.updateCount = in.readInt();
-
break;
-
-
case ResultConstants.ENDTRAN : {
-
int type = in.readInt();
-
-
result.setActionType(type); // endtran type
-
-
switch (type) {
-
-
case ResultConstants.TX_SAVEPOINT_NAME_RELEASE :
-
case ResultConstants.TX_SAVEPOINT_NAME_ROLLBACK :
-
result.mainString = in.readString(); // savepoint name
-
break;
-
-
case ResultConstants.TX_COMMIT :
-
case ResultConstants.TX_ROLLBACK :
-
case ResultConstants.TX_COMMIT_AND_CHAIN :
-
case ResultConstants.TX_ROLLBACK_AND_CHAIN :
-
case ResultConstants.PREPARECOMMIT :
-
break;
-
-
default :
-
throw Error.runtimeError(ErrorCode.U_S0500, "Result");
-
}
-
-
break;
-
}
-
case ResultConstants.SETCONNECTATTR : {
-
int type = in.readInt(); // attr type
-
-
result.setConnectionAttrType(type);
-
-
switch (type) {
-
-
case ResultConstants.SQL_ATTR_SAVEPOINT_NAME :
-
result.mainString = in.readString(); // savepoint name
-
break;
-
-
// case ResultConstants.SQL_ATTR_AUTO_IPD :
-
// - always true
-
// default: throw - case never happens
-
default :
-
throw Error.runtimeError(ErrorCode.U_S0500, "Result");
-
}
-
-
break;
-
}
-
case ResultConstants.PREPARE_ACK :
-
result.statementReturnType = in.readByte();
-
result.statementID = in.readLong();
-
result.rsProperties = in.readByte();
-
result.metaData = new ResultMetaData(in);
-
result.parameterMetaData = new ResultMetaData(in);
-
break;
-
-
case ResultConstants.CALL_RESPONSE :
-
result.updateCount = in.readInt();
-
result.fetchSize = in.readInt();
-
result.statementID = in.readLong();
-
result.statementReturnType = in.readByte();
-
result.rsProperties = in.readByte();
-
result.metaData = new ResultMetaData(in);
-
result.valueData = readSimple(in, result.metaData);
-
break;
-
-
case ResultConstants.EXECUTE :
-
result.updateCount = in.readInt();
-
result.fetchSize = in.readInt();
-
result.statementID = in.readLong();
-
result.rsProperties = in.readByte();
-
result.queryTimeout = in.readShort();
-
-
Statement statement =
-
session.statementManager.getStatement(session,
-
result.statementID);
-
-
if (statement == null) {
-
-
// invalid statement
-
result.mode = ResultConstants.EXECUTE_INVALID;
-
result.valueData = ValuePool.emptyObjectArray;
-
-
break;
-
}
-
-
result.statement = statement;
-
result.metaData = result.statement.getParametersMetaData();
-
result.valueData = readSimple(in, result.metaData);
-
break;
-
-
case ResultConstants.UPDATE_RESULT : {
-
result.id = in.readLong();
-
-
int type = in.readInt();
-
-
result.setActionType(type);
-
-
result.metaData = new ResultMetaData(in);
-
result.valueData = readSimple(in, result.metaData);
-
-
break;
-
}
-
case ResultConstants.BATCHEXECRESPONSE :
-
case ResultConstants.BATCHEXECUTE :
-
case ResultConstants.BATCHEXECDIRECT :
-
case ResultConstants.SETSESSIONATTR : {
-
result.updateCount = in.readInt();
-
result.fetchSize = in.readInt();
-
result.statementID = in.readLong();
-
result.queryTimeout = in.readShort();
-
result.metaData = new ResultMetaData(in);
-
-
result.navigator.readSimple(in, result.metaData);
-
-
break;
-
}
-
case ResultConstants.PARAM_METADATA : {
-
result.metaData = new ResultMetaData(in);
-
-
result.navigator.read(in, result.metaData);
-
-
break;
-
}
-
case ResultConstants.REQUESTDATA : {
-
result.id = in.readLong();
-
result.updateCount = in.readInt();
-
result.fetchSize = in.readInt();
-
-
break;
-
}
-
case ResultConstants.DATAHEAD :
-
case ResultConstants.DATA :
-
case ResultConstants.GENERATED : {
-
result.id = in.readLong();
-
result.updateCount = in.readInt();
-
result.fetchSize = in.readInt();
-
result.rsProperties = in.readByte();
-
result.metaData = new ResultMetaData(in);
-
result.navigator = new RowSetNavigatorClient();
-
-
result.navigator.read(in, result.metaData);
-
-
break;
-
}
-
case ResultConstants.DATAROWS : {
-
result.metaData = new ResultMetaData(in);
-
result.navigator = new RowSetNavigatorClient();
-
-
result.navigator.read(in, result.metaData);
-
-
break;
-
}
-
default :
-
throw Error.runtimeError(ErrorCode.U_S0500, "Result");
-
}
-
-
return result;
-
}
在一个二进制内容的协议中,字节摆放的先后顺序是严格要求的,这点与一些字符串协议有着区别(如http中,"url?p1=&p2=",与"url?p2=&p1=" 在服务器上的处理并无任何区别)
在不同的命令(请求)中,协议严格规定了字节的先后摆放顺序,以prepare命令来说
-
case ResultConstants.PREPARE :
-
result.setStatementType(in.readByte());
-
-
result.mainString = in.readString();
-
result.rsProperties = in.readByte();
-
result.generateKeys = in.readByte();
-
-
if (result.generateKeys == ResultConstants
-
.RETURN_GENERATED_KEYS_COL_NAMES || result
-
.generateKeys == ResultConstants
-
.RETURN_GENERATED_KEYS_COL_INDEXES) {
-
result.generatedMetaData = new ResultMetaData(in);
-
}
-
break;
在命令标识字节之后,第1个字节为statementType, 第2段为执行的sql语句,sql语句之后为rsProperties,后面依此类推。
in.readByte之类的,都很平常,不过是InputStream往前读了一个字节。
我们感兴趣的是readString,我们知道一个sql语句是不定长的,那么in.readString怎么知道该读多长的内容作为字符串呢?
进入到readString方法中一窥究竟
-
public String readString() throws IOException {
-
-
int length = readInt();
-
String s = StringConverter.readUTF(buffer, pos, length);
-
-
s = ValuePool.getString(s);
-
pos += length;
-
-
return s;
-
}
原来,对于每一个字符串,在其开头部分,先是放置一个int,告知后面字符串长度,然后接下来这么长的内容是字符串的内容。这在二进制协议中,对于不定长数据通常的一个手法。
这里还有一个ValuePool.getString方法,进入之后好像是做缓存计数器,具体做什么用,我还不清楚。
现在我们再回到上面
init函数部分,看看
-
Result resultOut;
-
-
resultOut = setDatabase(resultIn);
-
-
resultOut.write(session, dataOutput, rowOut);
setDatabase是服务器端的处理,我们这里先略过,在这篇文章里,我们关注的是数据如何解析(上面已经知道),数据是如何返回的。
write函数如下:
-
public void write(SessionInterface session, DataOutputStream dataOut,
-
RowOutputInterface rowOut)
-
throws IOException, HsqlException {
-
-
rowOut.reset();
-
rowOut.writeByte(mode);
-
-
int startPos = rowOut.size();
-
-
rowOut.writeSize(0);
-
-
switch (mode) {
-
-
case ResultConstants.GETSESSIONATTR :
-
rowOut.writeByte(statementReturnType);
-
break;
-
-
case ResultConstants.DISCONNECT :
-
case ResultConstants.RESETSESSION :
-
case ResultConstants.STARTTRAN :
-
break;
-
-
case ResultConstants.PREPARE :
-
rowOut.writeByte(statementReturnType);
-
rowOut.writeString(mainString);
-
rowOut.writeByte(rsProperties);
-
rowOut.writeByte(generateKeys);
-
-
if (generateKeys == ResultConstants
-
.RETURN_GENERATED_KEYS_COL_NAMES || generateKeys == ResultConstants
-
.RETURN_GENERATED_KEYS_COL_INDEXES) {
-
generatedMetaData.write(rowOut);
-
}
-
break;
-
-
case ResultConstants.FREESTMT :
-
rowOut.writeLong(statementID);
-
break;
-
-
case ResultConstants.CLOSE_RESULT :
-
rowOut.writeLong(id);
-
break;
-
-
case ResultConstants.EXECDIRECT :
-
rowOut.writeInt(updateCount);
-
rowOut.writeInt(fetchSize);
-
rowOut.writeByte(statementReturnType);
-
rowOut.writeString(mainString);
-
rowOut.writeByte(rsProperties);
-
rowOut.writeShort(queryTimeout);
-
rowOut.writeByte(generateKeys);
-
-
if (generateKeys == ResultConstants
-
.RETURN_GENERATED_KEYS_COL_NAMES || generateKeys == ResultConstants
-
.RETURN_GENERATED_KEYS_COL_INDEXES) {
-
generatedMetaData.write(rowOut);
-
}
-
break;
-
-
case ResultConstants.CONNECT :
-
rowOut.writeString(databaseName);
-
rowOut.writeString(mainString);
-
rowOut.writeString(subString);
-
rowOut.writeString(zoneString);
-
rowOut.writeInt(updateCount);
-
break;
-
-
case ResultConstants.ERROR :
-
case ResultConstants.WARNING :
-
rowOut.writeString(mainString);
-
rowOut.writeString(subString);
-
rowOut.writeInt(errorCode);
-
break;
-
-
case ResultConstants.CONNECTACKNOWLEDGE :
-
rowOut.writeInt(databaseID);
-
rowOut.writeLong(sessionID);
-
rowOut.writeString(databaseName);
-
rowOut.writeString(mainString);
-
break;
-
-
case ResultConstants.UPDATECOUNT :
-
rowOut.writeInt(updateCount);
-
break;
-
-
case ResultConstants.ENDTRAN : {
-
int type = getActionType();
-
-
rowOut.writeInt(type); // endtran type
-
-
switch (type) {
-
-
case ResultConstants.TX_SAVEPOINT_NAME_RELEASE :
-
case ResultConstants.TX_SAVEPOINT_NAME_ROLLBACK :
-
rowOut.writeString(mainString); // savepoint name
-
break;
-
-
case ResultConstants.TX_COMMIT :
-
case ResultConstants.TX_ROLLBACK :
-
case ResultConstants.TX_COMMIT_AND_CHAIN :
-
case ResultConstants.TX_ROLLBACK_AND_CHAIN :
-
case ResultConstants.PREPARECOMMIT :
-
break;
-
-
default :
-
throw Error.runtimeError(ErrorCode.U_S0500, "Result");
-
}
-
-
break;
-
}
-
case ResultConstants.PREPARE_ACK :
-
rowOut.writeByte(statementReturnType);
-
rowOut.writeLong(statementID);
-
rowOut.writeByte(rsProperties);
-
metaData.write(rowOut);
-
parameterMetaData.write(rowOut);
-
break;
-
-
case ResultConstants.CALL_RESPONSE :
-
rowOut.writeInt(updateCount);
-
rowOut.writeInt(fetchSize);
-
rowOut.writeLong(statementID);
-
rowOut.writeByte(statementReturnType);
-
rowOut.writeByte(rsProperties);
-
metaData.write(rowOut);
-
writeSimple(rowOut, metaData, (Object[]) valueData);
-
break;
-
-
case ResultConstants.EXECUTE :
-
rowOut.writeInt(updateCount);
-
rowOut.writeInt(fetchSize);
-
rowOut.writeLong(statementID);
-
rowOut.writeByte(rsProperties);
-
rowOut.writeShort(queryTimeout);
-
writeSimple(rowOut, metaData, (Object[]) valueData);
-
break;
-
-
case ResultConstants.UPDATE_RESULT :
-
rowOut.writeLong(id);
-
rowOut.writeInt(getActionType());
-
metaData.write(rowOut);
-
writeSimple(rowOut, metaData, (Object[]) valueData);
-
break;
-
-
case ResultConstants.BATCHEXECRESPONSE :
-
case ResultConstants.BATCHEXECUTE :
-
case ResultConstants.BATCHEXECDIRECT :
-
case ResultConstants.SETSESSIONATTR : {
-
rowOut.writeInt(updateCount);
-
rowOut.writeInt(fetchSize);
-
rowOut.writeLong(statementID);
-
rowOut.writeShort(queryTimeout);
-
metaData.write(rowOut);
-
navigator.writeSimple(rowOut, metaData);
-
-
break;
-
}
-
case ResultConstants.PARAM_METADATA : {
-
metaData.write(rowOut);
-
navigator.write(rowOut, metaData);
-
-
break;
-
}
-
case ResultConstants.SETCONNECTATTR : {
-
int type = getConnectionAttrType();
-
-
rowOut.writeInt(type); // attr type / updateCount
-
-
switch (type) {
-
-
case ResultConstants.SQL_ATTR_SAVEPOINT_NAME :
-
rowOut.writeString(mainString); // savepoint name
-
break;
-
-
// case ResultConstants.SQL_ATTR_AUTO_IPD // always true
-
// default: // throw, but case never happens
-
default :
-
throw Error.runtimeError(ErrorCode.U_S0500, "Result");
-
}
-
-
break;
-
}
-
case ResultConstants.REQUESTDATA : {
-
rowOut.writeLong(id);
-
rowOut.writeInt(updateCount);
-
rowOut.writeInt(fetchSize);
-
-
break;
-
}
-
case ResultConstants.DATAROWS :
-
metaData.write(rowOut);
-
navigator.write(rowOut, metaData);
-
break;
-
-
case ResultConstants.DATAHEAD :
-
case ResultConstants.DATA :
-
case ResultConstants.GENERATED :
-
rowOut.writeLong(id);
-
rowOut.writeInt(updateCount);
-
rowOut.writeInt(fetchSize);
-
rowOut.writeByte(rsProperties);
-
metaData.write(rowOut);
-
navigator.write(rowOut, metaData);
-
break;
-
-
default :
-
throw Error.runtimeError(ErrorCode.U_S0500, "Result");
-
}
-
-
rowOut.writeIntData(rowOut.size() - startPos, startPos);
-
dataOut.write(rowOut.getOutputStream().getBuffer(), 0, rowOut.size());
-
-
int count = getLobCount();
-
Result current = this;
-
-
for (int i = 0; i < count; i++) {
-
ResultLob lob = current.lobResults;
-
-
lob.writeBody(session, dataOut);
-
-
current = current.lobResults;
-
}
-
-
if (chainedResult == null) {
-
dataOut.writeByte(ResultConstants.NONE);
-
} else {
-
chainedResult.write(session, dataOut, rowOut);
-
}
-
-
dataOut.flush();
-
}
与newResult差不多,之前是逐个位置的读数据,现在是逐个位置的写入数据。跟不同的命令/请求需要用不同的读方法一样,写入内容时,也是根据命令/请求内容的不同,写入不同的数据。
writeByte之类的比较容易理解,我们这里同样关注一下writeString
-
public void writeString(String s) {
-
-
int temp = count;
-
-
writeInt(0);
-
-
if (s != null && s.length() != 0) {
-
StringConverter.stringToUTFBytes(s, this);
-
writeIntData(count - temp - INT_STORE_SIZE, temp);
-
}
-
}
这里先writeInt(0), 这个位置其实起到占位作用,因为这个位置本身需要写入字符串的长度。然后再写入字符串内容,之后再返回到长度的写入位置,写入真实的长度。
[总结]
至此,我们通过分析ServerConnection和Result,得知了HSQL_Stream_Protocol的解析和返回过程。作为二进制协议,在写代码之前是应该有文档规定的,文档的内容就是什么内容,存在协议的什么位置。当然,通过源代码,我们也可以逆推出文档。
协议解析出来了,处理之后也返回回去了,在它们之间存在一个处理过程,在下一篇文章中将对处理过程进行分析。
[相关源码文件]
ServerConnection.java
WebServerConnection.java
result目录(Result.java在此目录中)
rowio目录(字节读写就靠这个)
[思考]
命令/请求与回复/响应,都在Result里,是因为各种命令的参数信息、响应的返回的内容,基本类似,如果各种命令的参数差别极大(比如一个命令有100项参数信息,另一个命令只有1项参数),这样的情况下,都放在Result里还合适吗?或者,阅读源码,在Result能找到处理办法吗?
阅读(4198) | 评论(0) | 转发(0) |