二、Java核心实现
2.1 Cookie的实现
在JAVA平台,访问URL资源是通过一系列协议处理器(protocol handler)来实现的。URL的起始部分指定了URL使用的协议。比如某个URL是以file:开头的,这表明这个URL资源是保存在本地文件系统的。J2SE5。0定义了几个必须实现的协议:http,https,file,jar。
作为http协议处理器实现的一部分,J2SE5.0增加了一个CookieHandler。这个类提供了一些用于管理cookies的接口。Cookie是保存在浏览器缓存中的一小块数据。当你访问一个网站然后再次访问的时候,这个cookie数据用于鉴别你的身份。Cookies能够用于保存信息,譬如一个在线商店用于保存以购商品信息。Cookie可以是短期的,为一个单独的web事务保存数据,直到关闭浏览器;也可以是长期的,保存数据一个星期或一年。
在J2SE5中并没有设置默认的CookieHandler。不过你可以注册一个Handler以便程序能够保存cookies并且在http连接的时候得到这些cookies。这一节首先讨论Cookie的实现。
Cookie类的两个方法:hasExpired()和matches().hasExpired()方法用于表明这个cookie是否已经过期;而matches()方法用于检验这个cookie与某个URI是否匹配. 对于一个已经过期的cookie,其matchs方法总是返回false:
public boolean matches(URI uri) {
if (hasExpired()) {
return false;
}
String path = uri.getPath();
if (path == null) {
path = "/";
}
return path.startsWith(this.path);
}
public boolean hasExpired() {
if (expires == null ) {
return false ;
}
Date now = new Date();
return now.after(expires);
}
public String toString() {
StringBuilder result = new StringBuilder(name);
result.append("=" );
result.append(value);
return result.toString();
}
在Cookie的构造函数部分,你需要从URI以及报文头里面解析出所需要的信息.其中的cookie有效日期信息的格式是确定的,虽然其它信息对不同的网站有不同的格式.但只需要把cookie路径,有效日期,域名这些信息保存下来即可。
public Cookie(URI uri, String header) {
String attributes[] = header.split(";" );
String nameValue = attributes[0 ].trim();
this .uri = uri;
this .name = nameValue.substring( 0 , nameValue.indexOf('='));
this .value = nameValue.substring(nameValue.indexOf('=')+ 1 );
this .path = "/" ;
this .domain = uri.getHost();
for ( int i= 1 ; i < attributes.length; i++) {
nameValue = attributes[i].trim();
int equals = nameValue.indexOf('=');
if (equals == - 1 ) {
continue ;
}
String name = nameValue.substring(0 , equals);
String value = nameValue.substring(equals+1 );
if (name.equalsIgnoreCase( "domain" )) {
String uriDomain = uri.getHost();
if (uriDomain.equals(value)) {
this .domain = value;
} else {
if (!value.startsWith( "." )) {
value = "." + value;
}
uriDomain =
uriDomain.substring(uriDomain.indexOf('.' ));
if (!uriDomain.equals(value)) {
throw new IllegalArgumentException(
"Trying to set foreign cookie" );
}
this .domain = value;
}
} else if (name.equalsIgnoreCase( "path" )) {
this .path = value;
} else if (name.equalsIgnoreCase( "expires" )) {
try {
this .expires = expiresFormat1.parse(value);
} catch (ParseException e) {
try {
this .expires = expiresFormat2.parse(value);
} catch (ParseException e2) {
throw new IllegalArgumentException(
"Bad date format in header: " + value);
}
}
}
}
注意:Cookie规范中要求同时检查域名以及路径,为了简单起见,我们这里只检查了路径. 附录三中给出了Cookie的具体实现。
2.2 Cookie Handler的实现
回到CookieHandler这个类,这是个具有两组相关联方法的抽象类.第一组方法让你能得到当前已经设置的Handler或设置你自己的Handler:
* getDefault()
* setDefault(CookieHandler)
对于安装了安全管理器的应用来说,得到或设置handler需要特别的权限.通过设置handler为null可以清除当前设置的handler.正如之前提到的,没有设置默认的handler.
第二组方法允许你从一个你维持的cookie缓存得到cookies,或将cookies保存到这个cookie缓存.
* get(URI uri,Map<String,List<String>>requestHeaders)
* put(URI uri,Map<String,List<String>>responseHeaders)
get()方法从cookie缓存中的到之前保存的cookie并保存到requestHeaders中.put()方法从response headers 中提取cookies并保存到cookie缓存.
在附录一中,你可能注意到这些应答信息里面包含了这个URL站点所用的web服务器以及其日期时间.同意可以看到里面包含了两行Set-Cookie,这就是报头里面携带的cookies.这些cookie能够保存下来,然后在下一次请求的时候被发送.
下面我们来建立一个CookieHandler,我们得实现CookieHandler的两个抽象方法:get()与put()。其中put()方法将所有报头中的cookies保存到一个缓存中.为了实现put()方法,首先要从responseHeaders中得到"Set-Cookie"对应的List.
List<String> setCookieList = responseHeaders.get("Set-Cookie");
当你得到cookies对应的List,将List中所有的值保存下来.如果这个cookie已经存在,就将已保存的替换掉。
if (setCookieList != null ) {
for (String item : setCookieList) {
Cookie cookie = new Cookie(uri, item);
// Remove cookie if it already exists in cache
// New one will replace it
for (Cookie existingCookie : cache) {
...
}
System.out.println("Adding to cache: " + cookie);
cache.add(cookie);
}
}
这里的"cache"可以是一个数据库或者是一个Collections Framework中的List.其中的Cookie类将在下面定义.从本质上说,这些就是put()方法所要做的事:对于响应报头中每一个cookie,这个方法将cookie保存到缓存中.
而get()方法做的是相反的事情:将缓存中所有与URI匹配cookie添加到请求报头中,如果存在多个cookie,则建立一个用','分隔的列表.方法get()返回一个Map,而且用一个包含已有报文头的map作为参数,你应该将cookie缓存与之相匹配的cookie添加这个map里面去,但是这个Map是只读的,所以你应该首先新建另一个map,并将参数map中的内容复制过去,然后再将cookie添加进去,最后返回一个只读的map.
为了实现get()方法,首先要从cookie缓存中查找与URI相匹配的cookie,然后删除那些已经过期的cookie:
// Retrieve all the cookies for matching URI
// Put in comma-separated list
StringBuilder cookies = new StringBuilder();
for (Cookie cookie : cache) {
// Remove cookies that have expired
if (cookie.hasExpired()) {
cache.remove(cookie);
} else if (cookie.matches(uri)) {
if (cookies.length() > 0 ) {
cookies.append(", " );
}
cookies.append(cookie.toString());
}
}
get()方法余下部分将上面的StringBuilder中的文本添加到一个Map中,与之对应的key为"Cookie"。
Map<String, List<String>> cookieMap =
new HashMap<String, List<String>>(requestHeaders);
// Convert StringBuilder to List, store in map
if (cookies.length() > 0 ) {
List<String> list =
Collections.singletonList(cookies.toString());
cookieMap.put("Cookie" , list);
}
return Collections.unmodifiableMap(cookieMap);
Cookie Handler的具体实现可以参见附录三。
2.3 POST方法的实现
在模拟HTTP POST请求的时候,需要首先设置好HTTP POST请求的头部。即connection的setRequestProperty方法。在进行POST提交的时候,同时也需要设置POST请求的内容体。这时候需要首先获得connection的outputStream,然后将需要提交的内容写入流中即可。
URL url = new URL(urlAddress);
HttpURLConnection connection = (HttpsURLConnection) url.openConnection();
connection.setRequestProperty("Accept", ACCEPT);
connection.setRequestProperty("Referer", REFFERER);
connection.setRequestProperty("Accept-Language", ACCEPT_LANGUAGE);
connection.setRequestProperty("Content-Type", CONTENT_TYPE);
connection.setRequestProperty("UA-CPU", UA_CPU);
connection.setRequestProperty("Accept-Encoding", Accept_Encoding);
connection.setRequestProperty("User-Agent", USERAGENT);
connection.setDoOutput(true);
OutputStream outputStream = connection.getOutputStream();
String requestContent = "verifycookie=1&style=-1&product=mail163&username=" + userName + "&password=" + password + "&selType=-1&remUser=on&secure=on&%B5%C7%C2%BC%D3%CA%CF%E4=";
outputStream.write(requestContent.getBytes());
outputStream.flush();
outputStream.close();
2.4 GET方法的实现
在模拟HTTP GET请求的时候,同样需要首先设置好HTTP GET请求的头部。即connection的setRequestProperty方法。这里同POST方法有所不同的是,connection需要设置GET方法。
URL url = new URL(urlAddress);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestProperty("Accept", ACCEPT);
connection.setRequestProperty("Accept-Language", ACCEPT_LANGUAGE);
connection.setRequestProperty("UA-CPU", UA_CPU);
connection.setRequestProperty("Accept-Encoding", Accept_Encoding);
connection.setRequestProperty("User-Agent", USERAGENT);
connection.setRequestMethod("GET");
connection.connect();
在处理应答时,还有一点需要注意。这就是在发送请求后,得到的应答码。若正常返回则应答码为200,若是Redirect则应答码为302。HTTPURLConnection中分别由HTTP_OK和HTTP_MOVED_TEMP这两个常量表示。
2.5 读取应答头部
连接中的信息可能有一部分是属于报文头,这与所用的协议有关.我们可以通过URLConnection来得到这些报文头消息,这个类提供了一些能提取报文头信息的方法,包括:
* getHeaderFields() - Gets a Map of available fields.
* getHeaderField(String name) - Gets header fields by name.
* getHeaderFieldDate(String name, long default) - Gets the header field as a date.
* getHeaderFieldInt(String name, int default) - Gets the header field as a number.
* getHeaderFieldKey(int n) or getHeaderField(int n) - Gets the header field by position.
我们常常需要获取输出流的字符集编码
private String getcharSetNameFromHeaders(HttpURLConnection connection) {
// Content-Type = [text/html; charset=GB2312]
String retValue = UTF8;
String content = connection.getHeaderField("Content-Type");
int startIndex = content.indexOf("=") + 1;
if (startIndex != 0)
retValue = content.substring(startIndex);
return retValue;
}
2.6 读取应答体
在处理HTTP请求的应答时,需要读出HTTP应答的具体内容。根据HTTP协议规范,应答头部有content-length来标明此时的将要返回多少字节的数据流。我们可以利用connection的getContentLength方法获得这个数值。
但有时getContentLength方法的返回值为-1,这时表明,HTTP应答是chunked。我们不知道本次返回多少字节长度的数据流。因此就不能按照上面的流程进行处理。这里我们采用循环等待WAITINGSECONDS秒后,若仍然没有可用的数据流,我们便终止本次读取。
private byte[] readByteArrayFromInputStream(InputStream inputStream, int contentLength) throws Exception {
byte[] array = new byte[contentLength];
int readBytesNumber = 0;
while (readBytesNumber < contentLength) {
int readBytesNumberThisTime = inputStream.read(array, readBytesNumber, contentLength - readBytesNumber);
if (readBytesNumberThisTime == -1) {
throw new Exception("读字节错误");
}
readBytesNumber += readBytesNumberThisTime;
}
return array;
}
private String getResponseContent(HttpURLConnection connection, String charsetName) throws Exception {
InputStream inputStream = connection.getInputStream();
int contentLength = connection.getContentLength();
StringBuilder content = new StringBuilder();
if (contentLength == -1) {
int length = inputStream.available();
int time = 0;
while (time < WAITINGSECONDS) {
if (length > 0) {
time = 0;
content.append(readStringFromInputStream(inputStream, length, charsetName));
} else {
Thread.sleep(1000);
time++;
}
length = inputStream.available();
}
} else {
content.append(readStringFromInputStream(inputStream, contentLength, charsetName));
}
return content.toString();
}
private String readStringFromInputStream(InputStream inputStream, int contentLength, String charSetName) throws Exception {
return new String(readByteArrayFromInputStream(inputStream, contentLength), charSetName);
}
2.7 读入GZIP数据流
如1.2节所示, GET请求返回的应答Content-Encoding可能为gzip,我们就不能直接获取返回的内容信息。我们首先要根据应答的Content-Length,读入对应长度的字节流。然后为了获取联系人列表相关信息,将读入的字节流按照gzip的格式进行解压。这里需要注意的是在读取的过程中,我们可以一次性读入BUFFER_SIZE长度的字节,放入缓冲区。
private String getZipInputStreamContent(InputStream inputStream, int length, String charSet) throws Exception {
byte[] array = readByteArrayFromInputStream(inputStream, length);
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(array);
GZIPInputStream gzipInputStream = new GZIPInputStream(byteArrayInputStream);
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(3 * length);
byte[] buf = new byte[BUFFER_SIZE];
int num;
while ((num = gzipInputStream.read(buf, 0, BUFFER_SIZE)) != -1) {
byteArrayOutputStream.write(buf, 0, num);
}
gzipInputStream.close();
byteArrayOutputStream.close();
return new String(byteArrayOutputStream.toByteArray(), charSet);
}
2.8 常量定义
如2.3节和2.4节所示,在发起HTTP GET或POST请求的时候,需要对请求的头部的一些变量进行设置。这里,给出了获取163邮箱需要定义的常量。
private static final String USERAGENT = "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; MyIE2; .NET CLR 1.1.4322; .NET CLR 2.0.50727; .NET CLR 3.0.04506.30; .NET CLR 3.0.04506.648; CIBA)";
private static final String ACCEPT = "image/gif, image/x-xbitmap, image/jpeg, image/pjpeg, application/vnd.ms-excel, application/vnd.ms-powerpoint, application/msword, application/xaml+xml, application/vnd.ms-xpsdocument, application/x-ms-xbap, application/x-ms-application, application/x-shockwave-flash, */*";
private static final String ACCEPT_LANGUAGE = "zh-cn,en-US;q=0.5";
private static final String UA_CPU = "x86";
private static final String Accept_Encoding = "gzip, deflate";
private static final String REFFERER = "http://mail.163.com/";
private static final String CONTENT_TYPE = "application/x-www-form-urlencoded";
private static final String ADDRESSBOOKADDRESS = "http://tg 1a 4.mail.163.com/coremail/fcg/ldvcapp?funcid=address&sid=SIDVALUE&tempname=address%2Faddress.htm&ifirstv=&listnum=0&showlist=all";
private static final String POSTFORMADDRESS = "https://reg.163.com/logins.jsp?type=1&url=http://fm163.163.com/coremail/fcg/ntesdoor2?lightweight%3D1%26verifycookie%3D1%26language%3D-1%26style%3D-1";
三、相关说明
(1)无意义信息
在1.1节所示,登录过程中的步骤(2)主要目的是在Cookie中设置Province=025,目的是为了登录163后显示与登录相关的广告。因此,在实际操作中,可以直接从步骤(3)直接模拟。
在利用工具EffeTech HTTP Sniffer或IE Http Analyzer进行分析时,这个过程中可以看到许多无意义的信息(例如来自于yadao或microsoft),我们仅需要分析163有关信息即可。
(2)缓冲区和网络阻塞
从connection中阅读返回的信息时,一定要根据HTTP应答的头部来读对应的字节长度。但由于网络不确定等复杂情况的影响,有可能不是一次可以读取全部的信息,因此需要设置一定长度的缓冲区,一次尝试读取固定长度的字节,否则可能造成阻塞。这是多线程中经典的阅读者和写作者的示例。
这里需要注意的是,读取和发送是面向字节的,而不是面向字符的。因此在读取和写入的过程中,只能利用inputstream和outputstream,而不能利用reader和writer。
(3) 重定向问题
如2.4节和在1.1节的步骤(5)中,应答时,正常返回的应答码为302,即2.4节所说的redirect。HTTPConnection获取到本次应答为redirect,则进行自动跳转。但本步骤中,我们需要在应答头部获取SID值和将要访问的实际的163的地址,因此需要阻止跳转。我们可以在具体连接之前调用connection的setInstanceFollowRedirects方法:
connection.setInstanceFollowRedirects(false);
改正后的示例代码如下
URL url = new URL(urlAddress);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestProperty("Accept", ACCEPT);
connection.setRequestProperty("Accept-Language", ACCEPT_LANGUAGE);
connection.setRequestProperty("UA-CPU", UA_CPU);
connection.setRequestProperty("Accept-Encoding", Accept_Encoding);
connection.setRequestProperty("User-Agent", USERAGENT);
connection.setRequestMethod("GET");
connection.setInstanceFollowRedirects(false);
connection.connect();
(4)网络分析
在模拟HTTP 请求和应答的全过程,需要随时利用EffeTech HTTP Sniffer和IE Http Analyzer进行监控。在利用Java模拟的全过程,一定要保证对Java代码模拟的程序运行的网络监控和实际利用浏览器访问163邮箱一致,否则可能在某步骤导致模拟错误。
四、相关工具及文档
(1) 使用CookieHandler管理Cookie数据 http://eastsun.javaeye.com/blog/85182
(2) 163信箱登陆分析 http://uri8.cn/blog/ 2008/11/15 /163信箱登陆分析/
(3) CSDN http://topic.csdn.net/u/20080617/09/9c3b90be-7000-4f8b-bc4d-d500bd5be648.html
(4) EffeTech HTTP Sniffer 独立网络分析工具
(5) IE Http Analyzer IE植入性网络分析工具