2015년 9월 22일 화요일

OkHttp 이해하기 - 6

첫 HttpEngine 생성시는 OkHttpClient와 Request와 forWebSocket의 여부를 참조합니다. forWebSocket은 HTTP 요청이 synchronous call(execute 함수 사용)인 경우에는 항상 false입니다. AsyncCall 을 사용한 asynchronous call(enqueue 함수 사용)인 경우에는 파라메타로 forWebSocket을 넘겨주게 되어 있고 이 값이 HttpEngine의 생성시 넘어오게 됩니다.
sendRequest 부터 시작해 봅니다. 이 함수에서 가장 먼저 하는 일은 헤더의 재생성입니다. 확인하는 헤더의 값은 Host, Connection, Accept-Encoding, Cookie, User-Agent입니다.
Host가 없으면 요청 url로부터 host 값을 찾은 후 이 값을 Host 헤더에 넣어줍니다.
if (request.header("Host") == null) {
  result.header("Host", hostHeader(request.url()));
}
public static String hostHeader(URL url) {
  // getEffectivePort 함수는 url에서 port를 찾고 getDefaultPort는 url의 프로토콜로부터 기본 포트(http는 80, https는 443)를 찾습니다.
  return getEffectivePort(url) != getDefaultPort(url.getProtocol())
      ? url.getHost() + ":" + url.getPort()
      : url.getHost();
}
Connection이 없고 connection의 프로토콜이 http 1.0이 아니면 Connection에 Keep-Alive를 넣어줍니다.
if ((connection == null || connection.getProtocol() != Protocol.HTTP_1_0)
    && request.header("Connection") == null) {
  result.header("Connection", "Keep-Alive");
}
Accep-Encoding이 없으면 OkHttp는 gzip을 기본으로 사용하므로 gzip을 설정해 줍니다.
if (request.header("Accept-Encoding") == null) {
  transparentGzip = true;
  result.header("Accept-Encoding", "gzip");
}
OkHttpClient의 cookie handler를 사용해서 url에 관련된 쿠키들을 찾습니다. 이렇게 찾은 값들을 Cookie와 Cookie2 헤더에 넣어줍니다. OkHttpClient에 별도로 지정하지 않았으면 기본 핸들러가 사용됩니다.
result.cookieHandler = CookieHandler.getDefault();

User-Agent가 없으면 OkHttp의 버전 정보를 넣어줍니다.
if (request.header("User-Agent") == null) {
  result.header("User-Agent", Version.userAgent());
}
다음으로는 내부 캐시를 확인합니다.
InternalCache responseCache = Internal.instance.internalCache(client);
특별한 작업을 해주지 않았으면 null이 될 것입니다. CacheStrategy.Factory를 통해 캐시된 Response가 있는지 찾습니다. 있으면 실제 connect가 필요없이 이 Response를 사용하면 될 것입니다. 캐시된 Response가 없으면 connect 함수를 통해 Connection을 만듭니다. connect 함수에서는 address를 생성하고 라우팅을 위한 정보를 찾아낸 후 이러한 정보들을 담고 있는 Connection을 생성해 줍니다. 이때 Connection은 ConnectionPool을 통해 관리가 됩니다.
Connection을 만들기 위해 제일 먼저 하는 일은 Address를 만드는 것입니다. Request로부터 url을 가져오고 나머지 Address 생성에 필요한 값들은 OkHttpClient로부터 가져옵니다.
private static Address createAddress(OkHttpClient client, Request request)
    throws RequestException {
  String uriHost = request.url().getHost();
  if (uriHost == null || uriHost.length() == 0) {
    throw new RequestException(new UnknownHostException(request.url().toString()));
  }

  SSLSocketFactory sslSocketFactory = null;
  HostnameVerifier hostnameVerifier = null;
  CertificatePinner certificatePinner = null;
  if (request.isHttps()) {
    sslSocketFactory = client.getSslSocketFactory();
    hostnameVerifier = client.getHostnameVerifier();
    certificatePinner = client.getCertificatePinner();
  }

  return new Address(uriHost, getEffectivePort(request.url()),
      client.getSocketFactory(), sslSocketFactory, hostnameVerifier, certificatePinner,
      client.getAuthenticator(), client.getProxy(), client.getProtocols(),
      client.getConnectionSpecs(), client.getProxySelector());
}
Address class는 connection에 관련된 정보들을 모아 놓고 있는 class입니다. 대부분 get함수로 이루어져 있습니다.
다음으로는 서버에 연결하기 위한 route를 설정합니다. RouterSelector.get 함수를 통해 RouteSelector를 생성할 수 있습니다.
public static RouteSelector get(Address address, Request request, OkHttpClient client)
    throws IOException {
  return new RouteSelector(address, request.uri(), client);
}

OkHttp 이해하기 - 5

HTTP 요청을 할 때마다 이에 대한 정보를 OkHttpClient가 저장하고 있습니다. 큐에 저장해 놨다가 나중에 실행한다던가 실행중인 요청을 취소한다던가 하는 것들을 하기 위해서 입니다.
크게 보면 바로 실행하는(Call에서의 execute 함수를 통한 실행)하는 것과 큐에 저장해 놓았다가 나중에 실행하는(Call에서의 enqueue 함수를 통한 실행)이 있습니다.
바로 실행하는 요청을 저장하기 위해 아래의 Deque를 사용합니다.
private final Deque<Call> executedCalls = new ArrayDeque<>();
바로 실행하기 때문에 이의 관리는 간단합니다.executed가 호출되면 executedCalls에 넣고 finished가 호출되면 executedCalls에서 빼줍니다.
나중에 실행하는 요청을 저장하기 위해서는 아래 두개의 Deque를 사용합니다. 현재 실행중인 요청은 runningCalls에 넣고 최대 요청수가 다 찾으면 readyCalls에 넣어줍니다. runningCall에서의 요청중 끝난 것이 있으면 최대 요청수를 넘었는지 아닌지 확인한 후 넘지 않았으면 readyCalls에서 앞에서 부터 순서대로 꺼내어 최대 요청수까지 실행시켜줍니다.
private final Deque<AsyncCall> readyCalls = new ArrayDeque<>();
private final Deque<AsyncCall> runningCalls = new ArrayDeque<>();
쓰레드 관리를 위해 기본으로 ThreadPoolExecutor를 제공하고 최대 요청수는 64를, 호스트당 최대 요청 수는 5를 기본 값으로 합니다. 비동기 요청을 위해 enqueue를 사용하면 아래와 같이 현재 실행중인 요청이 최대 요청수를 넘지 않고, 호스트당 최대 요청 수도 최대 값을 넘지 않으면 runningCalls에 요청을 넣고 바로 실행을 시켜줍니다. 이 조건을 만족하지 못하면 나중에 다른 요청이 끝나면 실행될 수 있도록 readyCall에 넣어줍니다.
synchronized void enqueue(AsyncCall call) {
  if (runningCalls.size() < maxRequests && runningCallsForHost(call) < maxRequestsPerHost) {
    runningCalls.add(call);
    getExecutorService().execute(call);
  } else {
    readyCalls.add(call);
  }
}
앞의 Call안의 AsyncCall의 execute 함수를 보면 맨 마지막에 모든 작업이 끝나면 finished 를 호출해 줍니다. 이 함수에서 작업이 끝난 AsyncCall을 runningCalls에서 제거하고 promoteCalls 함수를 호출하여 또다른 요청의 실행이 가능하면 readyCalls에서 하나를 꺼내어 runningCalls에 넣고 실행을 시켜줍니다.
synchronized void finished(AsyncCall call) {
  if (!runningCalls.remove(call)) throw new AssertionError("AsyncCall wasn't running!");
  promoteCalls();
}
private void promoteCalls() {
  if (runningCalls.size() >= maxRequests) return; // Already running max capacity.
  if (readyCalls.isEmpty()) return; // No ready calls to promote.

  for (Iterator<AsyncCall> i = readyCalls.iterator(); i.hasNext(); ) {
    AsyncCall call = i.next();

    if (runningCallsForHost(call) < maxRequestsPerHost) {
      i.remove();
      runningCalls.add(call);
      getExecutorService().execute(call);
    }

    if (runningCalls.size() >= maxRequests) return; // Reached max capacity.
  }
}
요청의 취소는 cancel 함수를 통해 할 수 있습니다. 이때 파라메터로 tag를 넣어줍니다. 실행 요청은 readyCall와 runningCalls와 executedCalls 이 셋중의 하나에 들어 있을 것입니다. 그러므로 이 셋을 순서대로 루프를 돌면서 원하는 Call을 찾아서 cancel 함수를 호출해 줍니다. 음 근데 readyCalls와 runningCalls는 같은 것인데 취소해주는 방식이 다르네요. 결국 같은거 같은데... 왜 이렇게 되어 있을까요?

OkHttp 이해하기 - 4

OkHttp를 통한 HTTP 요청을 하려면 가장 먼저 아래처럼 클라이언트를 만듭니다.
OkHttpClient client = new OkHttpClient();
그리고 나서 여기에 넘겨줄 Request를 만들어 줍니다.
Request request = new Request.Builder().url("http://www.google.com").build();
그리고 아래처럼 execute() 함수를 호출하면 요청에 대한 응답이 넘어오게 됩니다.
Response response = client.newCall(request).execute();

OkHttpClient는 앞으로의 HTTP 요청에 사용되는 설정등을 가지고 있습니다. 따라서, 이후 HTTP 요청을 하면 이를 기준으로 요청이 이루어 집니다. 다음으로 newCall은 어떤 역할을 할까요? newCall은 다음과 같이 Call의 인스턴스를 생성합니다.
public Call newCall(Request request) {
  return new Call(this, request);
}
Call 클래스는 하나의 HTTP 요청에 따른 Response/Request를 처리합니다. 생성시 OkHttpClient와 Request를 받아서 저장하고 있으며 필요에 따라 이것들의 값을 사용하게 됩니다. 그런데 OkHttpClient는 그대로 저장하지 않고 client.copyWithDefaults()를 통해 저장합니다. 왜 그럴까요? 이렇게 복사를 해서 저장해 놓지 않으면 혹시라도 나중에 client가 변경이 되면 그 변경사항이 지금하고 있는 HTTP 요청에 반영이 되기 때문입니다. 지금의 요청은 지금시점의 client가 적용이 되어야 하겠죠? 그래서 복사를 하는 것입니다.
Call 클래스는 바로 요청을 날리는 execute() 함수와 나중에 실행되는 enqueue() 함수가 있습니다. 먼저 execute 함수부터 살펴보죠. 한번만 실행이 가능하도록 하기 위해 executed 변수를 사용합니다. 시작시 true로 설정을 해주어서 한번 실행을 확인합니다. 다음으로는 client의 dispatcher에 요청이 실행되었음을 알리고 끝나면 끝났음을 알립니다. 이것을 함으로서 실행 중간에 취소할 수 있게 합니다. getResponseWithInterceptorChain를 통해서 서버에 Request를 보내고 이에 대한 Response를 받아옵니다.
public Response execute() throws IOException {
  synchronized (this) {
    if (executed) throw new IllegalStateException("Already Executed");
    executed = true;
  }
  try {
    client.getDispatcher().executed(this);
    Response result = getResponseWithInterceptorChain(false);
    if (result == null) throw new IOException("Canceled");
    return result;
  } finally {
    client.getDispatcher().finished(this);
  }
}
그런데 요청을 하기전에 한가지 할 수 있는 것이 있습니다. Request를 변경하는 것입니다. 예를 들어, Request를 항상 zip으로 압축해서 서버에 보내고자 한다고 생각해 봅시다. 이 경우 client의 interceptor에 zip 압축을 하는 interceptor를 저장해 놓습니다. 그러면 Request를 보내기 전에 interceptor를 통해 변경된 Request를 받은 후 이 Request를 서버에 보내는 일을 하게 됩니다. 이 일을 해주는 함수가 getResponseWithInterrceptorChain 함수입니다. 보시다시피 ApplicationInterceptorChain에 원래의 Request를 넣은 후 Interceptor chain을 실행합니다.
private Response getResponseWithInterceptorChain(boolean forWebSocket) throws IOException {
  Interceptor.Chain chain = new ApplicationInterceptorChain(0, originalRequest, forWebSocket);
  return chain.proceed(originalRequest);
}

ApplicationInterceptorChain은 Interceptor.Chain을 구현한 클래스로서 proceed 함수를 보면 가장 먼저 하는 일이 client에 interceptor가 있는지 확인합니다. 그래서 있으면 현재 index의 interceptor에 Chain을 넣어줍니다. index가 0에서 부터 1씩 증가하는 걸 보면 결국 제일 앞에서부터 순서대로 마지막까지 interceptor가 호출될 것임을 알 수 있습니다. interceptor는 Chain의 Request를 받아서 변경을 하고 다시 Chain의 proceed를 호출해주는 역할을 합니다. 그러면 가장 마지막에는 결국 getResponse가 호출되겠네요.
@Override public Response proceed(Request request) throws IOException {
  if (index < client.interceptors().size()) {
    // There's another interceptor in the chain. Call that.
    Interceptor.Chain chain = new ApplicationInterceptorChain(index + 1, request, forWebSocket);
    return client.interceptors().get(index).intercept(chain);
  } else {
    // No more interceptors. Do HTTP.
    return getResponse(request, forWebSocket);
  }
}

다음으로 enqueue를 살펴보지요. 한번 실행을 위해 executed를 true로 설정하는 것은 똑같습니다. 다음으로는 dispatcher에서 enqueue 함수를 호출하는데 이때 AsyncCall을 사용합니다.
void enqueue(Callback responseCallback, boolean forWebSocket) {
  synchronized (this) {
    if (executed) throw new IllegalStateException("Already Executed");
    executed = true;
  }
  client.getDispatcher().enqueue(new AsyncCall(responseCallback, forWebSocket));
}
나중에 dispatcher가 HTTP 요청을 실행할 상황이 되면 큐에 넣어둔 AsyncCall의 execute 함수가 호출이 됩니다. 함수를 살펴보면 간단합니다. 앞에서의 Call의 execute 함수와 같이 getResponseWithInterceptorChain를 호출하여 Response를 얻어오고 끝났음을 dispatcher에 알려줍니다. 하나의 차이점은 여기서의 호출은 다른 쓰레드에서 이루어지고 있는 것이므로 정상적으로 끝났는지 아니면 중간에 취소가 있었는지를 원래의 호출쪽에 알려주는 콜백이 들어가 있다는 점입니다. 이 역할은 Callback 클래스에서의 onResponse와 onFailure 함수를 통해 이루어지게 됩니다.
이제 getResponse 함수를 살펴봅시다.
가장 먼저 body를 확인하여 body가 있으면 새로운 Request를 만들어 줍니다. 이렇게 새 Request를 만드는 이유는 body가 있다면 헤더의 내용이 달라지기 때문입니다. body와 관련된 헤더를 처리하는 부분은 아래와 같습니다.
  // Copy body metadata to the appropriate request headers.
  RequestBody body = request.body();
  if (body != null) {
    Request.Builder requestBuilder = request.newBuilder();

    MediaType contentType = body.contentType();
    if (contentType != null) {
      // Content Type이 지정되어 있으면 헤더에 넣어준다.
      requestBuilder.header("Content-Type", contentType.toString());
    }

    long contentLength = body.contentLength();
    if (contentLength != -1) {
      // 길이를 알면
      requestBuilder.header("Content-Length", Long.toString(contentLength));
      requestBuilder.removeHeader("Transfer-Encoding");
    } else {
      // 길이를 모르면
      requestBuilder.header("Transfer-Encoding", "chunked");
      requestBuilder.removeHeader("Content-Length");
    }

    request = requestBuilder.build();
  }
다음으로 HttpEngine을 생성해 줍니다. 이 HttpEngine에서 request와 response를 처리해줍니다. 이에 사용되는 함수는 다음과 같습니다. sendRequest, readResponse, getResponse. 응답을 받고 나면 followUpRequest 함수를 통해 redirect등의 작업이 필요한지를 확인합니다. followUp이 null이 아니면 이 작업이 필요하다는 것입니다. 그럴 경우엔 기존의 HttpEngine을 종료(close 함수 사용)하고 새로운 HttpEngine을 만들어서 사용합니다. 이때의 차이점은 Connection은 이전에 사용하던 것을 그대로 재활용 한다는 것입니다.

OkHttp 이해하기 - 3

Request를 만들 때 body를 넣어주고 싶으면 RequestBody를 먼저 만들고 이것을 Request에 넣어주어야 합니다.
기억을 더듬어 보면 method 관련 함수인 post와 delete와 put과 patch를 설정해 줄 때 RequestBody를 같이 설정해 줄 수 있게 되어 있는 것이 기억나실 겁니다. 이 이외의 방법은 없죠.
그런데 RequestBody는 abstract로 다음과 같이 선언되어 있습니다.
public abstract class RequestBody {
  public abstract MediaType contentType();
  public long contentLength() throws IOException {
    return -1;
  }
  public abstract void writeTo(BufferedSink sink) throws IOException;
}

이런 우리가 직접 RequestBody를 생성할 수 없게 되어 있네요. 그럼 어떻게 해야 할까요? 그래서 RequestBody를 생성해주는 static함수들이 정의되어 있습니다. 정의를 보면 이 함수들은 content type과 content를 파라메타로 받아서 이것을 가지고 RequestBody를 생성하게 되어 있습니다. 대부분 비슷하므로 대표로 하나만 살펴보겠습니다.
public static RequestBody create(MediaType contentType, String content) {
  Charset charset = Util.UTF_8;
  if (contentType != null) {
    // content type이 설정되어 있는데 charset이 없으면 UTF-8로 설정해 줍니다.
    charset = contentType.charset();
    if (charset == null) {
      charset = Util.UTF_8;
      contentType = MediaType.parse(contentType + "; charset=utf-8");
    }
  }
  byte[] bytes = content.getBytes(charset);
  return create(contentType, bytes);
}

public static RequestBody create(final MediaType contentType, final byte[] content) {
  return create(contentType, content, 0, content.length);
}

content가 null이면 NullPointerException을 발생시키고요, abstract인 RequestBody를 바로 간단하게 생성해 줍니다.
public static RequestBody create(final MediaType contentType, final byte[] content,
    final int offset, final int byteCount) {
  if (content == null) throw new NullPointerException("content == null");
  // content의 내용중 offset에서부터 byteCount까지를 body에 넣어주니까 offset에서부터 byteCount까지가 content의 length안에 있어야 합니다. 아니면 ArrayIndexOutOfBoundsException을 발생시키는 함수입니다.
  Util.checkOffsetAndCount(content.length, offset, byteCount);
  return new RequestBody() {
    @Override public MediaType contentType() {
      return contentType;
    }

    @Override public long contentLength() {
      return byteCount;
    }

    @Override public void writeTo(BufferedSink sink) throws IOException {
      sink.write(content, offset, byteCount);
    }
  };
}

MediaType은 RFC 2045의 구현입니다. 간단히 설명하면, media type은 type과 subtype과 0개 이상의 옵션 파라메터로 이루어져 있습니다. 예를 들면 text/html; charset=UTF-8 의 경우 type은 text이고 subtype은 html이고 charset=UTF-8은 character encoding을 알려주는 옵션 파라메터입니다. MediaType은 우리가 직접 MediaType을 생성하지 않고 쉽게 생성할 수 있는 static 함수를 제공합니다. 주어진 string을 Pattern을 사용해서 분리해 냅니다.
public static MediaType parse(String string) {
  // string으로부터 type과 subtype을 찾습니다.
  Matcher typeSubtype = TYPE_SUBTYPE.matcher(string);
  // 패턴이 맞지 않으면 null을 리턴합니다.
  if (!typeSubtype.lookingAt()) return null;
  // group은 Pattern에 regular expression을 넘겨줄 때 ()에 의해 둘러싸이는 부분을 의미합니다. TYPE_SUBTYPE의 경우는 앞의 TOKEN이 group(1)이고 뒤의 TOKEN의 group(2)가 되겠네요.
  String type = typeSubtype.group(1).toLowerCase(Locale.US);
  String subtype = typeSubtype.group(2).toLowerCase(Locale.US);

  // 옵션 파라메터에서 charset를 찾습니다.
  String charset = null;
  // group에서 (?는 제외됩니다. 따라서 TOKEN, TOKEN, QUOTED의 순으로 group이 지정되게 됩니다.
  Matcher parameter = PARAMETER.matcher(string);
  for (int s = typeSubtype.end(); s < string.length(); s = parameter.end()) {
    parameter.region(s, string.length());
    if (!parameter.lookingAt()) return null; // This is not a well-formed media type.

    String name = parameter.group(1);
    if (name == null || !name.equalsIgnoreCase("charset")) continue;
    String charsetParameter = parameter.group(2) != null
        ? parameter.group(2)  // Value is a token.
        : parameter.group(3); // Value is a quoted string.
    // charset은 하나만 설정할 수 있습니다. 여러개인 경우 IllegalArgumentException을 발생시킵니다.
    if (charset != null && !charsetParameter.equalsIgnoreCase(charset)) {
      throw new IllegalArgumentException("Multiple different charsets: " + string);
    }
    charset = charsetParameter;
  }

  return new MediaType(string, type, subtype, charset);
}

OkHttp 이해하기 - 2

Headers는 헤더 정보를 name과 value로 담고 있습니다. Request에 의해 사용됩니다.
Headers도 Builder에 의해 편하게 생성될 수 있습니다. Builder는 헤더들을 ArrayList에 name과 value를 순서대로 넣어서 저장하고 있습니다. i번째에 name이 저장되어 있다면 i+1번째에 value가 저장되어 있는 것이지요.
private final List<String> namesAndValues = new ArrayList<>(20);
헤더를 넣기위한 함수를 두 가지를 제공합니다. addLenient와 add입니다. addLenient는 헤더에 대한 검증없이 무조건 저장합니다. 외부로부터 온 헤더거나 캐시되어 있던걸 다시 꺼내오는 경우에 적합한 함수입니다. 이러한 경우는 이미 검증이 된 헤더로 볼 수 있으니까요.
addLenient를 살펴보겠습니다. 헤더는 "name: value"의 형태를 띄고 있지요. 그러니 제일 먼저 첫번째 문자 이후에서 ":"의 유무를 파악합니다. ":"가 있으면 이를 기준으로 둘로 나누어 name과 value로 저장합니다. 만약 ":"가 없으면 ":"가 혹시 첫번째 문자인지 확인합니다. 이 경우는 초기 SPDY에서 발생할 수 있는 상황입니다. 이 경우 name은 비어있는 스트링으로 하고 ":"를 제외한 나머지를 value로 저장합니다. ":"이 전혀 없는 경우라면 그냥 name은 비어있는 스트링으로 하고 주어진 값을 그대로 value로 저장합니다.
Builder addLenient(String line) {
  int index = line.indexOf(":", 1);
  if (index != -1) {
    return addLenient(line.substring(0, index), line.substring(index + 1));
  } else if (line.startsWith(":")) {
    // Work around empty header names and header names that start with a
    // colon (created by old broken SPDY versions of the response cache).
    return addLenient("", line.substring(1)); // Empty header name.
  } else {
    return addLenient("", line); // No header name.
  }
}

앞에서 말했듯이 name을 먼저 저장하고 그 바로 뒤에 value를 저장합니다. value의 경우 trim을 해서 저장하는 것에 유의하세요.
Builder addLenient(String name, String value) {
  namesAndValues.add(name);
  namesAndValues.add(value.trim());
  return this;
}

이제 add함수를 살펴보겠습니다. 이 경우는 ":"가 반드시 있는지 확인합니다. 없으면 IllegalArgumentException을 내보냅니다. 여기서는 name에 trim을 하는 것에 유의하세요.
public Builder add(String line) {
  int index = line.indexOf(":");
  if (index == -1) {
    throw new IllegalArgumentException("Unexpected header: " + line);
  }
  return add(line.substring(0, index).trim(), line.substring(index + 1));
}

name과 value는 null이 아니어야 합니다. 또한 name의 경우는 길이가 0이 아니어야 하죠. 그렇기 때문에 앞에서 보았듯이 name은 저장을 위해 값을 넘겨줄 때 trim을 한 값을 넘겨주고 value는 나중에 최종적으로 저장할 때 trim을 해서 저장하는 형태를 취하고 있는 것입니다. 또한 name과 value 둘다 null value('\0')를 포함하고 있어서는 안됩니다.
public Builder add(String name, String value) {
  if (name == null) throw new IllegalArgumentException("name == null");
  if (value == null) throw new IllegalArgumentException("value == null");
  if (name.length() == 0 || name.indexOf('\0') != -1 || value.indexOf('\0') != -1) {
    throw new IllegalArgumentException("Unexpected header: " + name + ": " + value);
  }
  return addLenient(name, value);
}

특정 name의 헤더를 지우는 함수를 제공하고 있습니다. 앞에서 name과 value가 어떻게 저장되어 있는지 설명했죠? 그에 따라 찾아낸 name의 위치와 바로 그 다음의 위치도 같이 지우면 name과 value가 다 지워지게 됩니다. for문을 보면 2씩 증가시켜가면서 찾는 것을 볼 수 있습니다.
public Builder removeAll(String name) {
  for (int i = 0; i < namesAndValues.size(); i += 2) {
    if (name.equalsIgnoreCase(namesAndValues.get(i))) {
      namesAndValues.remove(i); // name
      namesAndValues.remove(i); // value
      i -= 2;
    }
  }
  return this;
}

위의 add와 addLenient는 name과 value가 하나의 스트링으로 되어 있는 값을 받아서 이를 둘로 나누어서 처리합니다. 만약 name과 value가 이미 나누어져 있는 값을 저장하고 싶으면 아래의 set함수를 사용합니다. 간단하네요. 먼저 name이 있으면 지우기 위해 removeAll을 먼저 호출해 주고 나고 name과 value를 저장합니다.
public Builder set(String name, String value) {
  removeAll(name);
  add(name, value);
  return this;
}

헤더의 name으로 value를 찾는 함수도 제공해주고 있습니다. for문을 2씩 증가시켜가면서 매칭되는 name의 위치를 찾은 후 그 다음 위치의 값을 리턴해 줍니다.
public String get(String name) {
  for (int i = namesAndValues.size() - 2; i >= 0; i -= 2) {
    if (name.equalsIgnoreCase(namesAndValues.get(i))) {
      return namesAndValues.get(i + 1);
    }
  }
  return null;
}

마지막으로 build를 호출하면 이제까지 저장된 헤더들을 Headers에 저장하고 이를 리턴해줍니다.
public Headers build() {
  return new Headers(this);
}

Headers는 name과 value를 String 배열에 저장합니다. 따라서 Builder를 생성자에서 받을 때 builder의 nameAndValues를 배열로 바꾸어서 저장합니다.
private Headers(Builder builder) {
  this.namesAndValues = builder.namesAndValues.toArray(new String[builder.namesAndValues.size()]);
}
Headers는 내부에 저장된 헤더를 참조하기 위한 다양한 함수들을 제공합니다. 이중 간단한 것은 제외하고 몇가지만 살펴보겠습니다.
헤더의 name들만을 모아서 리턴해주는 names()함수가 있습니다. 찾은 name을 TreeSet에 저장하고 값을 변경할 수 없도록하기 위해 Collections.unmodifiableSet으로 리턴합니다.
public Set<String> names() {
  TreeSet<String> result = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
  for (int i = 0, size = size(); i < size; i++) {
    result.add(name(i));
  }
  return Collections.unmodifiableSet(result);
}
특정 헤더 name의 value들 만을 리턴해주는 values 함수가 있습니다. 파라메터로 넘어온 name에 매치되는 위치를 찾아서 그 위치의 value를 List<String>에 저장하고 찾은 값이 있으면 Collections.unmodifiableList를, 없으면 Collections.<String>emptyList를 리턴합니다. 여기서도 value를 변경하지 못하도록 하기 위해 Collections의 함수들을 사용합니다.
public List<String> values(String name) {
  List<String> result = null;
  for (int i = 0, size = size(); i < size; i++) {
    if (name.equalsIgnoreCase(name(i))) {
      if (result == null) result = new ArrayList<>(2);
      result.add(value(i));
    }
  }
  return result != null
      ? Collections.unmodifiableList(result)
      : Collections.<String>emptyList();
}

Header를 다시 Builder로 변환하려면 String 배열을 List로 변환해 주어야 합니다. 이를 위해 Collections.addAll 함수를 사용합니다.
public Builder newBuilder() {
  Builder result = new Builder();
  Collections.addAll(result.namesAndValues, namesAndValues);
  return result;
}

names()와 values()의 혼합이라 할 수 있는 toMultimap() 함수도 제공됩니다. LinkedHashMap에 key를 name으로 해서 이에 속한 value들을 List<String>으로 저장합니다. 이 경우는 modify가 가능하게 리턴하는군요. 왜일까요?
public Map<String, List<String>> toMultimap() {
  Map<String, List<String>> result = new LinkedHashMap<String, List<String>>();
  for (int i = 0, size = size(); i < size; i++) {
    String name = name(i);
    List<String> values = result.get(name);
    if (values == null) {
      values = new ArrayList<>(2);
      result.put(name, values);
    }
    values.add(value(i));
  }
  return result;
}

위 함수들에 추가해서 두 개의 편리한 함수를 제공합니다. 헤더를 Map의 형태로 가지고 있으면 이를 Headers로 변환해주는 of(Map<String, String> headers) 함수와 여러개의 String 헤더를 한번 호출로 Headers로 변환해주는 of(String... namesAndValues)입니다. 이 두 함수의 구현 자체는 특별할 것이 없으므로 구현에 대한 설명은 생략하겠습니다.

앞에서 보았던 Request의 Builder와 패턴이 거의 똑같습니다. 이정도면 Builder를 어떻게 만들고 어떻게 사용하면 되는지도 쉽게 이해할 수 있겠네요.

OkHttp 이해하기 - 1

OkHttp를 사용하여 http 요청을 하려면 가장 먼저 해야할 일이 Request를 만드는 것입니다. 이 Request를 만들기 위해 Request.Builder를 제공합니다.

public final class Request {
  ...
  // Request를 만들기 위한 Builder 제공
  public static class Builder {
    ...
  }
}

그럼 Builder를 살펴봅시다.
Builder를 생성할 때 접속할 URL, 접속할 때 사용할 헤더들, 요청 바디 그리고 나중에 요청을 취소할 때 사용할 tag를 주어서 생성할 수 있습니다. 이것들을 하나하나 알아보겠습니다.
접속할 URL은 HttpUrl이나 String 또는 URL을 통해서 넣어줄 수 있습니다. 구현은 아래와 같습니다.

public Builder url(HttpUrl url) {
  if (url == null) throw new IllegalArgumentException("url == null");
  this.url = url;
  return this;
}
HttpUrl을 쓰는 경우에는 간단하네요. 그냥 내부 변수에 그대로 가지고 있게 됩니다.
url 스트링을 그대로 넘겨주는 경우에는 아래와 같이 HttpUrl로 변환하는 과정을 거치게 됩니다.
public Builder url(String url) {
  if (url == null) throw new IllegalArgumentException("url == null");

  // 웹소켓 url인지를 확인한 후 http url로 변경해 줍니다.
  if (url.regionMatches(true, 0, "ws:", 0, 3)) {
    url = "http:" + url.substring(3);
  } else if (url.regionMatches(true, 0, "wss:", 0, 4)) {
    url = "https:" + url.substring(4);
  }
  // string을 HttpUrl로 변환합니다. HttpUrl이 알아서 변환해줍니다.
  HttpUrl parsed = HttpUrl.parse(url);
  if (parsed == null) throw new IllegalArgumentException("unexpected url: " + url);
  return url(parsed);
}
URL의 HttpUrl로의 변환도 간단하네요. HttpUrl이 알아서 해줍니다.
public Builder url(URL url) {
  if (url == null) throw new IllegalArgumentException("url == null");
  HttpUrl parsed = HttpUrl.get(url);
  if (parsed == null) throw new IllegalArgumentException("unexpected url: " + url);
  return url(parsed);
}

다음으로는 헤더을 포함시킬 수 있습니다.
(헤더는 Headers라는 클래스로 구현되어 있습니다. 이에 대한 내용은 나중에 살펴봅니다.)

헤더가 중복이 되지 않게 추가하고 싶으면 아래의 함수를 사용합니다.
public Builder header(String name, String value) {
  headers.set(name, value);
  return this;
}
기존에 같은 이름의 헤더가 있어도 중복해서 추가하고 싶으면 아래의 함수를 사용합니다.
public Builder addHeader(String name, String value) {
  headers.add(name, value);
  return this;
}
특정 이름의 헤더를 지우고 싶으면 아래의 함수를 사용합니다.
public Builder removeHeader(String name) {
  headers.removeAll(name);
  return this;
}
기존의 모든 헤더를 지우고 새로운 헤더의 집합으로 바꾸고 싶으면 아래의 함수를 사용합니다.
public Builder headers(Headers headers) {
  this.headers = headers.newBuilder();
  return this;
}
헤더에 "Cache-Control"을 위한 값을 설정할 수 있는 함수는 따로 있습니다.
만약 cacheControl에 비어있는 스트링을 주면 기존의 "Cache-Control"값을 제거하고 값이 있으면 그 값을 설정하는 함수입니다.
public Builder cacheControl(CacheControl cacheControl) {
  String value = cacheControl.toString();
  if (value.isEmpty()) return removeHeader("Cache-Control");
  return header("Cache-Control", value);
}

Builder의 기본 method는 "GET"으로 되어 있습니다. 이를 바꾸고 싶으면 다음의 함수들을 사용합니다.
public Builder get() {
  return method("GET", null);
}

public Builder head() {
  return method("HEAD", null);
}

public Builder post(RequestBody body) {
  return method("POST", body);
}

public Builder delete(RequestBody body) {
  return method("DELETE", body);
}

public Builder delete() {
  return delete(RequestBody.create(null, new byte[0]));
}

public Builder put(RequestBody body) {
  return method("PUT", body);
}

public Builder patch(RequestBody body) {
  return method("PATCH", body);
}

이들이 호출해주는 method함수는 다음과 같습니다. 설정한 method 값에 따라 body가 반드시 있어야 하는지 아니면 반드시 없어야 하는지 등을 확인하고 문제가 없으면 method와 body를 설정을 해줍니다.
public Builder method(String method, RequestBody body) {
  if (method == null || method.length() == 0) {
    throw new IllegalArgumentException("method == null || method.length() == 0");
  }
  if (body != null && !HttpMethod.permitsRequestBody(method)) {
    throw new IllegalArgumentException("method " + method + " must not have a request body.");
  }
  if (body == null && HttpMethod.requiresRequestBody(method)) {
    throw new IllegalArgumentException("method " + method + " must have a request body.");
  }
  this.method = method;
  this.body = body;
  return this;
}
method와 body와의 관계는 아래와 같이 확인합니다.
POST와 PUT과 PATCH는 body가 반드시 있어야 하구요.
public static boolean requiresRequestBody(String method) {
  return method.equals("POST")
      || method.equals("PUT")
      || method.equals("PATCH");
}
POST와 PUT과 PATCH와 DELETE를 제외한 나머지는 body가 없어야 합니다. DELETE는 body가 있을 수도 있고 없을 수도 있
public static boolean permitsRequestBody(String method) {
  return requiresRequestBody(method)
      || method.equals("DELETE"); // Permitted as spec is ambiguous.
}

tag 설정은 아래와 같이 간단합니다.
public Builder tag(Object tag) {
  this.tag = tag;
  return this;
}
앞에서 tag는 요청을 취소할 때 사용하기 위한 값이라고 했는데요. 만약에 이 설정을 안해주면 Request 자체를 사용해서 요청을 취소할 수도 있습니다.

이와같이 Request에 필요한 값들을 설정한 뒤에는 build() 함수를 호출해 주면 됩니다.
public Request build() {
  if (url == null) throw new IllegalStateException("url == null");
  return new Request(this);
}
Request class는 이 Builder를 받아서 필요한 값들을 설정해하고 나중에 필요할 때 이 값들을 꺼낼 수 있는 getter를 제공합니다. 이 함수들은 간단하니 따로 살펴보지는 않겠습니다.
Request에서는 HttpUrl의 형태로 url을 저장하고 있는데요. 이것을 URL이나 URI로 내보내주는 함수를 제공하고 있습니다. 모두 처음 요청시 url로부터 값을 만들어서 저장해놓고 두번째 요청부터는 저장해 놓은 값을 사용하는 lazily initialized의 형태를 취하고 있습니다.
public URL url() {
  URL result = javaNetUrl;
  return result != null ? result : (javaNetUrl = url.url());
}

public URI uri() throws IOException {
  try {
    URI result = javaNetUri;
    return result != null ? result : (javaNetUri = url.uri());
  } catch (IllegalStateException e) {
    throw new IOException(e.getMessage());
  }
}
cache control의 경우도 마찬가지로 구현되어 있는 것을 아래의 함수로 보실 수 있습니다.
public CacheControl cacheControl() {
  CacheControl result = cacheControl;
  return result != null ? result : (cacheControl = CacheControl.parse(headers));
}
여기에 추가로 Request class는 거꾸로 Builder로 내보낼 수 있는 함수를 제공합니다. 이를 통해 Request와 Builder간에 서로 편하게 변환이 가능하게 됩니다.
public Builder newBuilder() {
  return new Builder(this);
}

마지막으로 알아야 할 것은 Builder를 만들때 잘못된 값을 넣어주는 경우에는 모두 IllegalArgumentException을 발생하게 되어 있습니다. 이에 대한 catch가 필요할 수도 있겠네요.

정리해보면 Builder를 통해 HTTP 요청에 필요한 Request를 만들어 줍니다. Builder에 설정해주는 값은 url, method, headers, body, tag의 다섯가지가 있습니다.
headers의 타입인 Headers와 body의 타입인 RequestBody는 다음에 살펴보겠습니다.

Building asynchronous views in SwiftUI 정리

Handling loading states within SwiftUI views self loading views View model 사용하기 Combine을 사용한 AnyPublisher Making SwiftUI views refreshable r...