자바 플랫폼의 경우, URL을 통한 오브젝트 액세스는 일련의 프로토콜 핸들러에 의해 관리된다. URL의 첫 부분은 사용되는 프로토콜을 알려주는데, 예를 들어 URL이 file:
로 시작되면 로컬 파일 시스템 상에서 리소스를 액세스할 수 있다. 또, URL이 http:로 시작되면 인터넷을 통해 리소스 액세스가 이루어진다. 한편, J2SE 5.0은 시스템 내에 반드시 존재해야 하는 프로토콜 핸들러(http, https, file, ftp, jar 등)를 정의한다.
J2SE 5.0은 http 프로토콜 핸들러 구현의 일부로 CookieHandler
를 추가하는데, 이 클래스는 쿠키를 통해 시스템 내에서 상태(state)가 어떻게 관리될 수 있는지를 보여준다. 쿠키는 브라우저의 캐시에 저장된 데이터의 단편이며, 한번 방문한 웹 사이트를 다시 방문할 경우 쿠키 데이터를 이용하여 재방문자임을 식별한다. 쿠키는 가령 온라인 쇼핑 카트 같은 상태 정보를 기억할 수 있게 해준다. 쿠키에는 브라우저를 종료할 때까지 단일 웹 세션 동안 데이터를 보유하는 단기 쿠키와 1주 또는 1년 동안 데이터를 보유하는 장기 쿠키가 있다.
J2SE 5.0에서 기본값으로 설치되는 핸들러는 없으나, 핸들러를 등록하여 애플리케이션이 쿠키를 기억했다가 http 접속 시에 이를 반송하도록 할 수는 있다.
CookieHandler
클래스는 두 쌍의 관련 메소드를 가지는 추상 클래스이다. 첫 번째 쌍의 메소드는 현재 설치된 핸들러를 찾아내고 각자의 핸들러를 설치할 수 있게 한다.
getDefault()
setDefault(CookieHandler)
보안 매니저가 설치된 애플리케이션의 경우, 핸들러를 얻고 이를 설정하려면 특별 허가를 받아야 한다. 현재의 핸들러를 제거하려면 핸들러로 null을 입력한다. 또한 앞서 얘기했듯이 기본값으로 설정되어 있는 핸들러는 없다.
두 번째 쌍의 메소드는 각자가 관리하는 쿠키 캐시로부터 쿠키를 얻고 이를 설정할 수 있게 한다.
get(URI uri, Map<String, List<String>> requestHeaders)
put(URI uri, Map<String, List<String>> responseHeaders)
get()
메소드는 캐시에서 저장된 쿠기를 검색하여 requestHeaders
를 추가하고, put()
메소드는 응답 헤더에서 쿠키를 찾아내어 캐시에 저장한다.
여기서 보듯이 핸들러를 작성하는 일은 실제로는 간단하다. 그러나 캐시를 정의하는 데는 약간의 추가 작업이 더 필요하다. 일례로, 커스텀 CookieHandler
, 쿠키 캐시, 테스트 프로그램을 사용해 보기로 하자. 테스트 프로그램은 아래와 같은 형태를 띠고 있다.
import java.io.*;
import java.net.*;
import java.util.*;
public class Fetch {
public static void main(String args[]) throws Exception {
if (args.length == 0) {
System.err.println("URL missing");
System.exit(-1);
}
String urlString = args[0];
CookieHandler.setDefault(new ListCookieHandler());
URL url = new URL(urlString);
URLConnection connection = url.openConnection();
Object obj = connection.getContent();
url = new URL(urlString);
connection = url.openConnection();
obj = connection.getContent();
}
}
먼저 이 프로그램은 간략하게 정의될 ListCookieHandler
를 작성하고 설치한다. 그런 다음 URL(명령어 라인에서 입력)의 접속을 열어 내용을 읽는다. 이어서 프로그램은 또 다른 URL의 접속을 열고 동일한 내용을 읽는다. 첫 번째 내용을 읽을 때 응답에는 저장될 쿠키가, 두 번째 요청에는 앞서 저장된 쿠키가 포함된다.
이제 이것을 관리하는 방법에 대해 알아보기로 하자. 처음에는 URLConnection
클래스를 이용한다. 웹 상의 리소스는 URL을 통해 액세스할 수 있으며, URL 작성 후에는 URLConnection
클래스의 도움을 받아 사이트와의 통신을 위한 인풋 또는 아웃풋 스트림을 얻을 수 있다.
String urlString = ...;
URL url = new URL(urlString);
URLConnection connection = url.openConnection();
InputStream is = connection.getInputStream();
// .. read content from stream
접속으로부터 이용 가능한 정보에는 일련의 헤더들이 포함될 수 있는데, 이는 사용중인 프로토콜에 의해 결정된다. 헤더를 찾으려면 URLConnection
클래스를 사용하면 된다. 한편, 클래스는 헤더 정보 검색을 위한 다양한 메소드를 가지는데, 여기에는 다음 사항들이 포함된다.
getHeaderFields()
- 가용한 필드의 Map
을 얻는다.
getHeaderField(String name)
- 이름 별로 헤더 필드를 얻는다.
getHeaderFieldDate(String name, long default)
- 날짜로 된 헤더 필드를 얻는다.
getHeaderFieldInt(String name, int default)
- 숫자로 된 헤더 필드를 얻는다.
getHeaderFieldKey(int n) or getHeaderField(int n)
- 위치 별로 헤더 필드를 얻는다.
일례로, 다음 프로그램은 주어진 URL의 모든 헤더를 열거한다
import java.net.*;
import java.util.*;
public class ListHeaders {
public static void main(String args[]) throws Exception {
if (args.length == 0) {
System.err.println("URL missing");
}
String urlString = args[0];
URL url = new URL(urlString);
URLConnection connection = url.openConnection();
Map<String,List<String>> headerFields =
connection.getHeaderFields();
Set<String> set = headerFields.keySet();
Iterator<String> itor = set.iterator();
while (itor.hasNext()) {
String key = itor.next();
System.out.println("Key: " + key + " / " +
headerFields.get(key));
}
}
}
ListHeaders
프로그램은 가령 http://java.sun.com 같은 URL을 아규먼트로 취하고 사이트로부터 수신한 모든 헤더를 표시한다. 각 헤더는 아래의 형태로 표시된다.
Key: <key> / [<value>]
따라서 다음을 입력하면,
>> java ListHeaders http://java.sun.com
다음과 유사한 내용이 표시되어야 한다.
Key: Set-Cookie / [SUN_ID=192.168.0.1:269421125489956;
EXPIRES=Wednesday, 31- Dec-2025 23:59:59 GMT;
DOMAIN=.sun.com; PATH=/]
Key: Set-cookie /
[JSESSIONID=688047FA45065E07D8792CF650B8F0EA;Path=/]
Key: null / [HTTP/1.1 200 OK]
Key: Transfer-encoding / [chunked]
Key: Date / [Wed, 31 Aug 2005 12:05:56 GMT]
Key: Server / [Sun-ONE-Web-Server/6.1]
Key: Content-type / [text/html;charset=ISO-8859-1]
(위에 표시된 결과에서 긴 행은 수동으로 줄바꿈한 것임)
이는 해당 URL에 대한 헤더들만을 표시하며, 그곳에 위치한 HTML 페이지는 표시하지 않는다. 표시되는 정보에는 사이트에서 사용하는 웹 서버와 로컬 시스템의 날짜 및 시간이 포함되는 사실에 유의할 것. 아울러 2개의 ‘Set-Cookie’ 행에도 유의해야 한다. 이들은 쿠키와 관련된 헤더들이며, 쿠키는 헤더로부터 저장된 뒤 다음의 요청과 함께 전송될 수 있다.
이제 CookieHandler
를 작성해 보자. 이를 위해서는 두 추상 메소드 CookieHandler: get()
과ㅓ put()
을 구현해야 한다.
public void put(
URI uri,
Map<String, List<String>> responseHeaders)
throws IOException
public Map<String, List<String>> get(
URI uri,
Map<String, List<String>> requestHeaders)
throws IOException
우선 put()
메소드로 시작한다. 이 경우 응답 헤더에 포함된 모든 쿠키가 캐시에 저장된다.put()
을 구현하기 위해서는 먼저 ‘Set-Cookie’ 헤더의 List
를 얻어야한다. 이는 Set-cookie
나 Set-Cookie2
같은 다른 해당 헤더로 확장될 수 있다.
List<String> setCookieList =
responseHeaders.get("Set-Cookie");
쿠키의 리스트를 확보한 후 각 쿠키를 반복(loop)하고 저장한다. 쿠키가 이미 존재할 경우에는 기존의 것을 교체하도록 한다.
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);
}
}
여기서 ‘캐시’는 데이터베이스에서 Collections Framework에서 List
에 이르기까지 어떤 것이든 될 수 있다. Cookie
클래스는 나중에 정의되는데, 이는 사전 정의되는 클래스에 속하지 않는다.
본질적으로, 그것이 put()
메소드에 대해 주어진 전부이며, 응답 헤더 내의 각 쿠키에 대해 메소드는 쿠키를 캐시에 저장한다.
get()
메소드는 정반대로 작동한다. URI에 해당되는 캐시 내의 각 쿠키에 대해, get()
메소드는 이를 요청 헤더에 추가한다. 복수의 쿠키에 대해서는 콤마로 구분된(comma-delimited) 리스트를 작성한다. get()
메소드는 맵을 반환하며, 따라서 메소드는 기존의 헤더 세트로 Map
아규먼트를 취하게 된다. 그 아규먼트에 캐시 내의 해당 쿠키를 추가해야 하지만 아규먼트는 불변의 맵이며, 또 다른 불변의 맵을 반환해야만 한다. 따라서 기존의 맵을 유효한 카피에 복사한 다음 추가를 마친 후 불변의 맵을 반환해야 한다.
get()
메소드를 구현하기 위해서는 먼저 캐시를 살펴보고 일치하는 쿠키를 얻은 다음 만료된 쿠키를 모두 제거하도록 한다.
// 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());
}
}
이 경우에도 Cookie
클래스는 간략하게 정의되는데, 여기에는 hasExpired()
와 matches()
등 2개의 요청된 메소드가 표시되어 있다. hasExpired() 메소드는 특정 쿠키의 만료 여부를 보고하고, matches() 메소드는 쿠키가 메소드에 패스된 URI에 적합한지 여부를 보고한다.
get()
메소드의 다음 부분은 작성된 StringBuilder 오브젝트를 취하고 그 스트링필드 버전을 수정 불가능한 Map에 put한다(이 경우에는 해당 키 ‘Cookie’를 이용).
// Map to return
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);
다음은 런타임의 정보 표시를 위해 println
이 일부 추가되어 완성된 CookieHandler
정의이다.
import java.io.*;
import java.net.*;
import java.util.*;
public class ListCookieHandler extends CookieHandler {
// "Long" term storage for cookies, not serialized so only
// for current JVM instance
private List<Cookie> cache = new LinkedList<Cookie>();
/**
* Saves all applicable cookies present in the response
* headers into cache.
* @param uri URI source of cookies
* @param responseHeaders Immutable map from field names to
* lists of field
* values representing the response header fields returned
*/
public void put(
URI uri,
Map<String, List<String>> responseHeaders)
throws IOException {
System.out.println("Cache: " + cache);
List<String> setCookieList =
responseHeaders.get("Set-Cookie");
if (setCookieList != null) {
for (String item : setCookieList) {
Cookie cookie = new Cookie(uri, item);
// Remove cookie if it already exists
// New one will replace
for (Cookie existingCookie : cache) {
if((cookie.getURI().equals(
existingCookie.getURI())) &&
(cookie.getName().equals(
existingCookie.getName()))) {
cache.remove(existingCookie);
break;
}
}
System.out.println("Adding to cache: " + cookie);
cache.add(cookie);
}
}
}
/**
* Gets all the applicable cookies from a cookie cache for
* the specified uri in the request header.
*
* @param uri URI to send cookies to in a request
* @param requestHeaders Map from request header field names
* to lists of field values representing the current request
* headers
* @return Immutable map, with field name "Cookie" to a list
* of cookies
*/
public Map<String, List<String>> get(
URI uri,
Map<String, List<String>> requestHeaders)
throws IOException {
// 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());
}
}
// Map to return
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);
}
System.out.println("Cookies: " + cookieMap);
return Collections.unmodifiableMap(cookieMap);
}
}
퍼즐의 마지막 조각은 Cookie
클래스 그 자체이며, 대부분의 정보는 생성자(constructor) 내에 존재한다. 생성자 내의 정보 조각(비트)들을 uri 및 헤더 필드로부터 파싱해야 한다. 만료일에는 하나의 포맷이 사용되어야 하지만 인기 있는 웹 사이트에서는 복수의 포맷이 사용되는 경우를 볼 수 있다. 여기서는 그다지 까다로운 점은 없고, 쿠키 경로, 만료일, 도메인 등과 같은 다양한 정보 조각을 저장하기만 하면 된다.
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);
}
}
}
}
}
클래스 내의 다른 메소드들은 단지 저장된 데이터를 반환하거나 만료 여부를 확인한다.
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();
}
쿠키가 만료된 경우에는 ‘match’가 표시되면 안 된다.
public boolean matches(URI uri) {
if (hasExpired()) {
return false;
}
String path = uri.getPath();
if (path == null) {
path = "/";
}
return path.startsWith(this.path);
}
Cookie
스펙이 도메인과 경로 양쪽에 대해 매치를 수행할 것을 요구한다는 점에 유의해야 한다. 단순성을 위해 여기서는 경로 매치만을 확인한다.
아래는 전체 Cookie
클래스의 정의이다.
import java.net.*;
import java.text.*;
import java.util.*;
public class Cookie {
String name;
String value;
URI uri;
String domain;
Date expires;
String path;
private static DateFormat expiresFormat1
= new SimpleDateFormat("E, dd MMM yyyy k:m:s 'GMT'", Locale.US);
private static DateFormat expiresFormat2
= new SimpleDateFormat("E, dd-MMM-yyyy k:m:s 'GMT'", Local.US);
/**
* Construct a cookie from the URI and header fields
*
* @param uri URI for cookie
* @param header Set of attributes in header
*/
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);
}
}
}
}
}
public boolean hasExpired() {
if (expires == null) {
return false;
}
Date now = new Date();
return now.after(expires);
}
public String getName() {
return name;
}
public URI getURI() {
return uri;
}
/**
* Check if cookie isn't expired and if URI matches,
* should cookie be included in response.
*
* @param uri URI to check against
* @return true if match, false otherwise
*/
public boolean matches(URI uri) {
if (hasExpired()) {
return false;
}
String path = uri.getPath();
if (path == null) {
path = "/";
}
return path.startsWith(this.path);
}
public String toString() {
StringBuilder result = new StringBuilder(name);
result.append("=");
result.append(value);
return result.toString();
}
}
이제 조각들이 모두 확보되었으므로 앞의 Fetch
예제를 실행할 수 있다.
>> java Fetch http://java.sun.com
Cookies: {Connection=[keep-alive], Host=[java.sun.com],
User-Agent=[Java/1.5.0_04], GET / HTTP/1.1=[null],
Content-type=[application/x-www-form-urlencoded],
Accept=[text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2]}
Cache: []
Adding to cache: SUN_ID=192.168.0.1:235411125667328
Cookies: {Connection=[keep-alive], Host=[java.sun.com],
User-Agent=[Java/1.5.0_04], GET / HTTP/1.1=[null],
Cookie=[SUN_ID=192.168.0.1:235411125667328],
Content-type=[application/x-www-form-urlencoded],
Accept=[text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2]}
Cache: [SUN_ID=192.168.0.1:235411125667328]
(위에 표시된 결과에서 긴 행은 수동으로 줄바꿈한 것임)
‘Cache’로 시작되는 행은 저장된 캐시를 나타낸다. 저장된 쿠키가 즉시 반환되지 않도록 put()
메소드 전에 get()
메소드가 어떻게 호출되는지에 대해 유의하도록 할 것.
쿠키와 URL 접속을 이용한 작업에 관해 자세히 알고 싶으면 자바 튜토리얼의 Custom Networking trail(영문)을 참조할 것. 이는 J2SE 1.4에 기반을 두고 있으므로 튜토리얼에는 아직 여기서 설명한 CookieHandler
에 관한 정보가 실려 있지 않다. Java SE 6 ("Mustang")(영문) 릴리즈에서도 기본 CookieHandler
구현에 관한 내용을 찾아볼 수 있다.