背景

ios端在线上遇到了网络请求失败的问题,查到原因是客户的userId里面包含了符号”|”,在网路哦请求的时候需要这个参数了,ios没有对这个符号进行url encode,导致请求失败。

然后我测试和排查到安卓端没有这个问题,发现网络请求框架Retrofit+OkHttp自动对参数做了编码。

现象

传递进去的userId参数是:”哈哈|ABC”
然后编码出来的userId参数是:%E5%93%88%E5%93%88%7CABC

  1. https://a.b.c/ef/v1/werqwe/csdfwef/xcvweg?userId=%E5%93%88%E5%93%88%7CABC&origin=android-SDK

原理

首先,这个url encode编码,也称为percent-encode,即百分号编码。关于编码原理,可以参考这篇:percent-encode 百分号编码
这里能明显看得到,我们传递进去的参数,在框架内部自动做了url encode。
下文则开始分析网络请求框架中是在哪个地方做了这个编码的。

从Retrofit开始

Retrofit请求实例代码

  1. @GET("users/{user}/repos")
  2. suspend fun listReposKt(
  3. @Path("user") user: String,
  4. @Query("uid") uid: String
  5. ): List<GithubUserReposVO>

我们就看GET请求,这里分别用了注解:@GET@Path@Query
@GET表示这是get请求。
@Path用来拼接请求url的路径。
@Query用来设置请求的query参数。

我们测试的情况是对参数做了url encode,那么我们看query注解:

  1. @Documented
  2. @Target(PARAMETER)
  3. @Retention(RUNTIME)
  4. public @interface Query {
  5. /** The query parameter name. */
  6. String value();
  7. /**
  8. * Specifies whether the parameter {@linkplain #value() name} and value are already URL encoded.
  9. */
  10. boolean encoded() default false;
  11. }

我们看到encode变量默认是false,表明这个变量默认是没有提前url编码的,那么后续会由框架内部进行url encode处理。
如果设置成了true,表明开发者已经提前做了自定义的url encode了,框架内部将不对这个参数做url encode处理。

实际上发现只要能够作为标志请求参数的注解,都有一个encoded()方法,包括了query, field, queryMap, fieldMap等。

GET请求

Retrofit内部应该是根据不同的请求类型去处理不同的注解参数的标记的。即只有遇到了GET注解,才会去处理query注解。根据这个猜想,我们看GET注解。(后面发现这个猜想是错的)

  1. //RequestFactory.java
  2. private void parseMethodAnnotation(Annotation annotation) {
  3. if (annotation instanceof DELETE) {
  4. parseHttpMethodAndPath("DELETE", ((DELETE) annotation).value(), false);
  5. } else if (annotation instanceof GET) {
  6. parseHttpMethodAndPath("GET", ((GET) annotation).value(), false);
  7. } else if (annotation instanceof HEAD) {
  8. parseHttpMethodAndPath("HEAD", ((HEAD) annotation).value(), false);
  9. } else if (annotation instanceof PATCH) {
  10. parseHttpMethodAndPath("PATCH", ((PATCH) annotation).value(), true);
  11. } else if (annotation instanceof POST) {
  12. parseHttpMethodAndPath("POST", ((POST) annotation).value(), true);
  13. } else if (annotation instanceof PUT) {
  14. parseHttpMethodAndPath("PUT", ((PUT) annotation).value(), true);
  15. } else if (annotation instanceof OPTIONS) {
  16. parseHttpMethodAndPath("OPTIONS", ((OPTIONS) annotation).value(), false);
  17. } else if (annotation instanceof HTTP) {
  18. HTTP http = (HTTP) annotation;
  19. parseHttpMethodAndPath(http.method(), http.path(), http.hasBody());
  20. } else if (annotation instanceof retrofit2.http.Headers) {
  21. String[] headersToParse = ((retrofit2.http.Headers) annotation).value();
  22. if (headersToParse.length == 0) {
  23. throw methodError(method, "@Headers annotation is empty.");
  24. }
  25. headers = parseHeaders(headersToParse);
  26. } else if (annotation instanceof Multipart) {
  27. if (isFormEncoded) {
  28. throw methodError(method, "Only one encoding annotation is allowed.");
  29. }
  30. isMultipart = true;
  31. } else if (annotation instanceof FormUrlEncoded) {
  32. if (isMultipart) {
  33. throw methodError(method, "Only one encoding annotation is allowed.");
  34. }
  35. isFormEncoded = true;
  36. }

看第7行,这里把注解的值传递进去。一般GET注解的值是拼接一个相对路径的,Retrofit的用法是一开始构造的时候传递一个baseUrl,然后再请求的时候拼接各种相对路径。
继续看:

  1. //RequestFactory.java
  2. private void parseHttpMethodAndPath(String httpMethod, String value, boolean hasBody) {
  3. if (this.httpMethod != null) {
  4. throw methodError(method, "Only one HTTP method is allowed. Found: %s and %s.",
  5. this.httpMethod, httpMethod);
  6. }
  7. this.httpMethod = httpMethod;
  8. this.hasBody = hasBody;
  9. if (value.isEmpty()) {
  10. return;
  11. }
  12. // Get the relative URL path and existing query string, if present.
  13. int question = value.indexOf('?');
  14. if (question != -1 && question < value.length() - 1) {
  15. // Ensure the query string does not have any named parameters.
  16. String queryParams = value.substring(question + 1);
  17. Matcher queryParamMatcher = PARAM_URL_REGEX.matcher(queryParams);
  18. if (queryParamMatcher.find()) {
  19. throw methodError(method, "URL query string \"%s\" must not have replace block. "
  20. + "For dynamic query parameters use @Query.", queryParams);
  21. }
  22. }
  23. this.relativeUrl = value;
  24. this.relativeUrlParamNames = parsePathParameters(value);
  25. }

这里保存了相对路径到变量relativeUrl,然后也解析了相对路径中可能由符号’?’拼接的路径中的请求参数。把他们保存在relativeUrlParamNames容器中。

在这里没有找到query的影子,所以上述的猜想:Retrofit内部应该是根据不同的请求类型去处理不同的注解参数的标记的。即只有遇到了GET注解,才会去处理query注解。
是错误的。

一般带着问题找源码的时候很难去整个源码全局架构去分析,只能通过猜想和代码跳转,直接去看我们想要了解的那部分,这样无法站在全局、架构、设计的角度去吃透源码,但是比较方便快速定位问题的原理,比较节省时间,并且对具体问题的印象更深刻。

那么继续代码跳转query注解:

  1. //RequestFactory.java
  2. @Nullable
  3. private ParameterHandler<?> parseParameterAnnotation(
  4. int p, Type type, Annotation[] annotations, Annotation annotation) {
  5. if (annotation instanceof Url) {
  6. //...
  7. }else if (annotation instanceof Path){
  8. //...
  9. }else if (annotation instanceof Query){
  10. validateResolvableType(p, type);
  11. Query query = (Query) annotation;
  12. String name = query.value();
  13. boolean encoded = query.encoded();
  14. Class<?> rawParameterType = Utils.getRawType(type);
  15. gotQuery = true;
  16. if (Iterable.class.isAssignableFrom(rawParameterType)) {
  17. if (!(type instanceof ParameterizedType)) {
  18. throw parameterError(method, p, rawParameterType.getSimpleName()
  19. + " must include generic type (e.g., "
  20. + rawParameterType.getSimpleName()
  21. + "<String>)");
  22. }
  23. ParameterizedType parameterizedType = (ParameterizedType) type;
  24. Type iterableType = Utils.getParameterUpperBound(0, parameterizedType);
  25. Converter<?, String> converter =
  26. retrofit.stringConverter(iterableType, annotations);
  27. return new ParameterHandler.Query<>(name, converter, encoded).iterable();
  28. } else if (rawParameterType.isArray()) {
  29. Class<?> arrayComponentType = boxIfPrimitive(rawParameterType.getComponentType());
  30. Converter<?, String> converter =
  31. retrofit.stringConverter(arrayComponentType, annotations);
  32. return new ParameterHandler.Query<>(name, converter, encoded).array();
  33. } else {
  34. Converter<?, String> converter =
  35. retrofit.stringConverter(type, annotations);
  36. return new ParameterHandler.Query<>(name, converter, encoded);
  37. }
  38. }else if (annotation instanceof QueryName){
  39. //...
  40. }else if(...){
  41. //...
  42. }
  43. //...
  44. }

parseParameterAnnotation函数中找到了处理query的逻辑,这是一个比较长的函数,达到了400多行。我们只看query的处理。
在14行提取了encoded变量,然后作为参数构造了对象:ParameterHandler.Query。

  1. // ParameterHandler.java
  2. static final class Query<T> extends ParameterHandler<T> {
  3. private final String name;
  4. private final Converter<T, String> valueConverter;
  5. private final boolean encoded;
  6. Query(String name, Converter<T, String> valueConverter, boolean encoded) {
  7. this.name = checkNotNull(name, "name == null");
  8. this.valueConverter = valueConverter;
  9. this.encoded = encoded;
  10. }
  11. @Override void apply(RequestBuilder builder, @Nullable T value) throws IOException {
  12. if (value == null) return; // Skip null values.
  13. String queryValue = valueConverter.convert(value);
  14. if (queryValue == null) return; // Skip converted but null values
  15. builder.addQueryParam(name, queryValue, encoded);
  16. }
  17. }

encoded变量在19行,apply函数中调用,传递到builder.addQueryParam。builder是RequestBuilder。其实就是用来构建OkHttp的Request对象的。
看他的addQueryParam函数:

  1. // RequestBuilder.java
  2. void addQueryParam(String name, @Nullable String value, boolean encoded) {
  3. if (relativeUrl != null) {
  4. // Do a one-time combination of the built relative URL and the base URL.
  5. urlBuilder = baseUrl.newBuilder(relativeUrl);
  6. if (urlBuilder == null) {
  7. throw new IllegalArgumentException(
  8. "Malformed URL. Base: " + baseUrl + ", Relative: " + relativeUrl);
  9. }
  10. relativeUrl = null;
  11. }
  12. if (encoded) {
  13. //noinspection ConstantConditions Checked to be non-null by above 'if' block.
  14. urlBuilder.addEncodedQueryParameter(name, value);
  15. } else {
  16. //noinspection ConstantConditions Checked to be non-null by above 'if' block.
  17. urlBuilder.addQueryParameter(name, value);
  18. }
  19. }

根据encoded变量,分别执行了urlBuilder的addEncodedQueryParameter和addQueryParameter方法。
urlBuilder是HttpUrl.Builder类对象,也就是用来构造url的类。
HttpUrl类型则来自OkHttp,我们需要看OkHttp的内容了。

到OkHttp了

分别看上述的两个方法定义:

  1. //HttpUrl.Builder
  2. /** Encodes the query parameter using UTF-8 and adds it to this URL's query string. */
  3. fun addQueryParameter(name: String, value: String?) = apply {
  4. if (encodedQueryNamesAndValues == null) encodedQueryNamesAndValues = mutableListOf()
  5. encodedQueryNamesAndValues!!.add(name.canonicalize(
  6. encodeSet = QUERY_COMPONENT_ENCODE_SET,
  7. plusIsSpace = true
  8. ))
  9. encodedQueryNamesAndValues!!.add(value?.canonicalize(
  10. encodeSet = QUERY_COMPONENT_ENCODcanonicalE_SET,
  11. plusIsSpace = true
  12. ))
  13. }
  14. /** Adds the pre-encoded query parameter to this URL's query string. */
  15. fun addEncodedQueryParameter(encodedName: String, encodedValue: String?) = apply {
  16. if (encodedQueryNamesAndValues == null) encodedQueryNamesAndValues = mutableListOf()
  17. encodedQueryNamesAndValues!!.add(encodedName.canonicalize(
  18. encodeSet = QUERY_COMPONENT_REENCODE_SET,
  19. alreadyEncoded = true,
  20. plusIsSpace = true
  21. ))
  22. encodedQueryNamesAndValues!!.add(encodedValue?.canonicalize(
  23. encodeSet = QUERY_COMPONENT_REENCODE_SET,
  24. alreadyEncoded = true,
  25. plusIsSpace = true
  26. ))
  27. }

他的逻辑其实就是,向encodedQueryNamesAndValues容器中先添加canonicalize函数处理过的name,再添加canonicalize函数处理过的value。
canonical有规范化的意思,这里把参数的name和value规范化了,难道就是url encode了?继续看下

  1. /**
  2. * Returns a substring of `input` on the range `[pos..limit)` with the following
  3. * transformations:
  4. *
  5. * * Tabs, newlines, form feeds and carriage returns are skipped.
  6. *
  7. * * In queries, ' ' is encoded to '+' and '+' is encoded to "%2B".
  8. *
  9. * * Characters in `encodeSet` are percent-encoded.
  10. *
  11. * * Control characters and non-ASCII characters are percent-encoded.
  12. *
  13. * * All other characters are copied without transformation.
  14. *
  15. * @param alreadyEncoded true to leave '%' as-is; false to convert it to '%25'.
  16. * @param strict true to encode '%' if it is not the prefix of a valid percent encoding.
  17. * @param plusIsSpace true to encode '+' as "%2B" if it is not already encoded.
  18. * @param unicodeAllowed true to leave non-ASCII codepoint unencoded.
  19. * @param charset which charset to use, null equals UTF-8.
  20. */
  21. internal fun String.canonicalize(
  22. pos: Int = 0,
  23. limit: Int = length,
  24. encodeSet: String,
  25. alreadyEncoded: Boolean = false,
  26. strict: Boolean = false,
  27. plusIsSpace: Boolean = false,
  28. unicodeAllowed: Boolean = false,
  29. charset: Charset? = null
  30. ): String {
  31. var codePoint: Int
  32. var i = pos
  33. while (i < limit) {
  34. codePoint = codePointAt(i)
  35. if (codePoint < 0x20 ||
  36. codePoint == 0x7f ||
  37. codePoint >= 0x80 && !unicodeAllowed ||
  38. codePoint.toChar() in encodeSet ||
  39. codePoint == '%'.toInt() &&
  40. (!alreadyEncoded || strict && !isPercentEncoded(i, limit)) ||
  41. codePoint == '+'.toInt() && plusIsSpace) {
  42. // Slow path: the character at i requires encoding!
  43. val out = Buffer()
  44. out.writeUtf8(this, pos, i)
  45. out.writeCanonicalized(
  46. input = this,
  47. pos = i,
  48. limit = limit,
  49. encodeSet = encodeSet,
  50. alreadyEncoded = alreadyEncoded,
  51. strict = strict,
  52. plusIsSpace = plusIsSpace,
  53. unicodeAllowed = unicodeAllowed,
  54. charset = charset
  55. )
  56. return out.readUtf8()
  57. }
  58. i += Character.charCount(codePoint)
  59. }
  60. // Fast path: no characters in [pos..limit) required encoding.
  61. return substring(pos, limit)
  62. }

算是猜对了,这个函数做的事情就是url encode。
函数的具体算法就不看了,可以看到函数的参数有个alreadyEncoded: Boolean,即可以配置是不是已经编码过了。

前面看到所有的url encode后的参数都存在容器encodedQueryNamesAndValues里面,他是怎么被使用的呢?
HttpUrl.Builder是用来buildHttpUrl的,他会把query参数全部拼接好然后给到HttpUrl。

  1. // HttpUrl.Builder
  2. fun build(): HttpUrl {
  3. @Suppress("UNCHECKED_CAST") // percentDecode returns either List<String?> or List<String>.
  4. return HttpUrl(
  5. scheme = scheme ?: throw IllegalStateException("scheme == null"),
  6. username = encodedUsername.percentDecode(),
  7. password = encodedPassword.percentDecode(),
  8. host = host ?: throw IllegalStateException("host == null"),
  9. port = effectivePort(),
  10. pathSegments = encodedPathSegments.percentDecode() as List<String>,
  11. queryNamesAndValues = encodedQueryNamesAndValues?.percentDecode(plusIsSpace = true),
  12. fragment = encodedFragment?.percentDecode(),
  13. url = toString()
  14. )
  15. }

看第13行的toString

  1. override fun toString(): String {
  2. return buildString {
  3. if (scheme != null) {
  4. append(scheme)
  5. append("://")
  6. } else {
  7. append("//")
  8. }
  9. if (encodedUsername.isNotEmpty() || encodedPassword.isNotEmpty()) {
  10. append(encodedUsername)
  11. if (encodedPassword.isNotEmpty()) {
  12. append(':')
  13. append(encodedPassword)
  14. }
  15. append('@')
  16. }
  17. if (host != null) {
  18. if (':' in host!!) {
  19. // Host is an IPv6 address.
  20. append('[')
  21. append(host)
  22. append(']')
  23. } else {
  24. append(host)
  25. }
  26. }
  27. if (port != -1 || scheme != null) {
  28. val effectivePort = effectivePort()
  29. if (scheme == null || effectivePort != defaultPort(scheme!!)) {
  30. append(':')
  31. append(effectivePort)
  32. }
  33. }
  34. encodedPathSegments.toPathString(this)
  35. if (encodedQueryNamesAndValues != null) {
  36. append('?')
  37. encodedQueryNamesAndValues!!.toQueryString(this)
  38. }
  39. if (encodedFragment != null) {
  40. append('#')
  41. append(encodedFragment)
  42. }
  43. }
  44. }

看35到38行,拼接”?”,然后拼接参数:

  1. // HttpUrl.companion object
  2. /** Returns a string for this list of query names and values. */
  3. internal fun List<String?>.toQueryString(out: StringBuilder) {
  4. for (i in 0 until size step 2) {
  5. val name = this[i]
  6. val value = this[i + 1]
  7. if (i > 0) out.append('&')
  8. out.append(name)
  9. if (value != null) {
  10. out.append('=')
  11. out.append(value)
  12. }
  13. }
  14. }

步长为2,将name和value依次拼接。
那这个HttpUrl构造给谁用呢?
内部和外部都在直接用。
内部给底层连接池用,上层拦截器用,外部给Retrofit等第三方库用,或者也可以直接面向客户使用。

总结

okhttp太牛掰了。