原创 DylanCai 2024-12-30 08:31 重庆
点击关注公众号,“技术干货” 及时达!
点击关注公众号,“技术干货” 及时达!
前言
众所周知,Retrofit 的 baseUrl 在创建实例的时候就设定好了,之后不允许直接修改。但是我们实际项目中会存在需要改变 baseUrl 的情况,比如聚合了多个平台的数据会使用到多个 baseUrl,还有做海外 app 要切换到更近的服务器等场景,这就得动态切换 baseUrl。
目前主要有三种解决方案,但是都存在一些缺陷。
现有方案
使用多个 Retrofit 对象
通过 Retrofit#newBuilder() 函数可以拷贝出一个同样配置的 Retrofit 对象去修改 baseUrl。
val otherRetrofit = retrofit.newBuilder().baseUrl("xxxxx").build()val api = otherRetrofit.create<XXXXApi>()
创建多个仅仅是 baseUrl 不一样的 Retrofit 对象太浪费资源,个人不建议这么来使用。
使用 @Url 注解
官方提供了 @Url 注解,修饰的参数只传入 paths,比如 /xxx/xxxx,就会拼上默认的 baseUrl。 但是如果传入了一个全路径地址的话,就会直接该全路径进行请求,也就修改了 baseUrl。 用这个机制来实现动态改 baseUrl 也是可以的,Kotlin 可以给该参数赋值个默认值,比如:
var globalBaseUrl = ""interface Api {@Post@JvmOverloadssuspend fun request1(@Url url: String = globalBaseUrl + "/aaa/bbb"): AipResponse<Any?>@Post@JvmOverloadssuspend fun request2(@Url url: String = globalBaseUrl + "/ccc/ddd"): AipResponse<Any?>}
globalBaseUrl = "http://www.xxxxx.com"api.request1()
虽然也能用,但是有缺陷:
每个请求的函数都要加个 url 参数和默认值非常繁琐
存在误传多一个 string 参数导致覆盖 url 的隐患
拦截器 + header
这是目前看到的比较好的实现方式,用拦截器来替换 baseUrl。以最出名的库 RetrofitUrlManager 为例,一开始会传入一个 OkHttpClient.Builder 对象,其内部会给该对象添加拦截器来替换 baseUrl。
okHttpClient = RetrofitUrlManager.getInstance().with(new OkHttpClient.Builder()).build();
提供了两种方式修改域名,第一种是设置一个全局的域名,该拦截器就会把 request 的 baseUrl 换成全局域名。
RetrofitUrlManager.getInstance().setGlobalDomain("your BaseUrl");第二种是给请求函数添加了 Domain-Name 的 header,配置一个域名代号。当用 putDomain() 函数给该代号设置域名后,拦截器就会把 request 的 baseUrl 换成对应的域名。
public interface ApiService {@Headers({"Domain-Name: douban"}) // Add the Domain-Name header@GET("/v2/book/{id}")Observable<ResponseBody> getBook(@Path("id") int id);}
RetrofitUrlManager.getInstance().putDomain("douban", "https://api.douban.com");这种拦截器 + header 去修改全局域名或者动态修改局部方法域名的方式,确实很不错。
之前项目中也用了该库,但是实际开发中还是遇到了一个相对小众的问题。由于我们做的是海外的 app,地址会根据选择的国家来变化,就用了该库修改了全局域名。当我们要上传文件时,需要请求服务器给我们一个文件地址,然后再往这个地址上传文件,也就是上传文件的地址是动态获取的,baseUrl 不固定,那就要用 @Url 注解来传个全路径地址。
interface Api {@Post@JvmOverloadssuspend fun upload(@Url url: String, @Part part: MultipartBody.Part): AipResponse<Any?>}api.upload(uploadUrl, file)
让我没想到的是,即使用了 @Url 传了全路径地址,该库的拦截器还是把 baseUrl 给替换了,导致上传的地址并不是后台给的,一直上传失败...
看了下库的源码,确实没对 @Url 修饰的参数做处理,@Url 注解的参数如果是全路径,优先级应该是最高的,不应该会被修改。估计大多数人自行用拦截器实现也不会处理这种情况,毕竟在拦截器里获取注解的方式比较隐蔽,需求也比较小众。
当时研究了下该库其实也有别的办法应对这种情况,就是上传时把地址拦截关了,上传后再恢复。
// 上传前RetrofitUrlManager.getInstance().setRun(false)// 上传后RetrofitUrlManager.getInstance().setRun(true)
虽说也能用,但是要开发组的小伙伴都有共识,一旦漏处理了就会导致功能有问题,存在隐患。所以还是得让拦截器能处理有 @Url 注解的情况,要优化替换 baseUrl 的逻辑。
当然目前拦截器 + header 的实现方案也并不是只有这个问题,总结了下有以下的缺陷:
@Url 注解的全路径地址受到了全局域名的影响
header 配置域名的方式不够简洁
header 只能配置到函数上
接下来会带大家来解决这几个问题。
封装思路
如何在拦截器获得注解信息
我们肯定要先读到 @Url 的注解信息才能做处理。Interceptor 类是在 OkHttp 里的,Retrofit 只是对 OkHttp 进行二次封装,OkHttp 并没有依赖 Retrofit,在 Interceptor 里想读取 Retrofit 注解好像不太可能?其实还是有办法的,这就得了解下 Retrofit 源码。
首先我们在拦截器里只能拿到 OkHttp 发起请求的 Request 对象,到底有没有操作空间,那就要看 Retrofit 是怎么创建 Request 对象发起请求的。我们来简单过下 Retrofit 的实现原理,我们定义了一个接口类并没写具体实现类,但是 Retrofit 却能把接口给实例化出来,因为使用了动态代理。来看 Retrofit#create() 函数的源码:
调用的 Proxy.newProxyInstance() 函数就是用动态代理将接口实例化。而动态代理之所以能实例化对象,是因为在运行时生成了实现类。理论上类是可以生成,但是生成的实现类要有什么实际的业务逻辑,编译器是没法知道的。所以在 Proxy.newProxyInstance() 函数传入了一个 InvocatonHandler 对象,去执行接口里每个函数的逻辑。
每当我们调用接口对象的某个函数时,InvocatonHandler 重写的 invoke() 函数就会回调,我们通过 method 和 args 对象能读取到定义的函数有什么注解和参数,根据不同的函数配置去执行不同的逻辑。Retrofit 就是根据函数的注解和参数的注解得到请求的信息,是 Post 还是 Get 请求,有什么请求投头,有什么 body 数据等。之后就通过 OkHttp 发起网络请求了。
源码里的 invoke() 函数会先判断是不是 Object 的函数或者默认函数,是的话就直接执行,不是的话就调用 loadSericeMethod(method).invoke(args) 函数。我们看一下 loadSericeMethod(method) 返回了什么东西。
可以看到这里是做了层缓存逻辑,有缓存就直接使用缓存,没缓存的时候才创建。创建对象是调用了 ServiceMethod.parseAnnotations() 函数,我们跟过去看一下。
到这里终于看到了一个相关的对象,我们是想了解 OkHttp 的 Request 对象是怎么创建的,RequestFactory 很明显就是 Request 的工厂类,它是通过 RequestFactory.parseAnnotations() 函数创建的,从函数名就能看出是解析 method 对象的注解得到 RequestFactory 对象。
通常工厂类都会有个 create() 函数,我们找一下对应源码。
可以看到 RequestFactory 会解析注解得到 baseUrl、headers、hasBody 等配置,去创建 Request 对象。在最后一行能看到调用了 Builder#tag() 函数存了一个 Invocation 对象,这用来干嘛的呢?
原来这个 Invocation 存了调用的函数和该函数的参数。那么在创建 Request 对象的时候设置了 Invocation 的 tag,就能在拦截器里的 Request 对象读取 tag 得到 Method 对象和参数对象了。
override fun intercept(chain: Chain): Response {val request = chain.request()val invocation: Invocation? = request.tag(Invocation::class.java)if (invocation != null) {System.out.printf("%s.%s %s%n",invocation.method().declaringClass.getSimpleName(),invocation.method().name,invocation.arguments())}return chain.proceed(request)}
设计注解用法
有了 Method 对象,拿到接口函数上的注解就不是什么问题了。不仅能处理有 @Url 注解的情况,还能优化前面动态修改域名的使用方式。回顾一下前面说的动态修改域名有两种方式,一种是修改全局的域名,还有一种是给函数增加 Domain-Name 的请求头,比如:
@Headers(["Domain-Name: douban"])@GET("/v2/book/{id}")fun getBook(@Path("id") id: Int): Observable<ResponseBody>
其实这里只是从请求头得到一个域名的代号,上面示例获得的是 douban。我们可以优化成从一个自定义注解中获取该代号,由于是动态修改域名,可以定义一个 @DynamicUrl 注解,用法就能优化成:
@DynamicUrl("douban")@GET("/v2/book/{id}")fun getBook(@Path("id") id: Int): Observable<ResponseBody>
看似只是用法上做了点小小的优化,只是让代码更简洁了一点。实际上自定义注解和请求头的配置方式还有一个非常大的区别,就是 @Headers 注解是只能用在函数上的,而我们能让 @DynamicUrl 注解在类上使用,这样就能在运行时统一修改这个接口下的所有请求函数的域名。
@DynamicUrl("douban")interface Api {...}dynamicUrls["douban"] = "https://xxxxxxx.com/v2"
其实运行时动态修改域名是一个比较小众的需求,而一个项目中有多个静态域名还是比较常见的,个人还想到了另一个用法,定义一个 @ApiUrl 注解,把接口下的所有请求修改成某个固定的域名。
@ApiUrl("https://www.wanandroid.com")interface WanAndroidApi {...}
这两个注解功能实现起来并不难,不过 @DynamicUrl 注解和 @ApiUrl 注解一起用的时候就感觉有点奇怪了。明明都是 @XXXUrl 的注解,一个是传地址,另一个却不是。
@DynamicUrl("wanandroid")@ApiUrl("https://www.wanandroid.com")interface WanAndroidApi {...}
虽说要求别人就是要这么用也是没问题,但是 @DynamicUrl 容易让人误以为也是要配置个地址,有强迫症的我觉得用法还能再优化。个人想过改成 @DynamicUrlKey,但是觉得单词太长了。
最后个人斟酌了很久后,终于想到了一个完美的解决方案,把两个注解合成一个,提供 key 参数和 value 参数进行配置(使用时 value 可以省去)。 合二为一后,注解名就直接叫 BaseUrl,一看就知道是用来修改域名的。
@BaseUrl("https://xxxxxx.com")interface Api1 {...}@BaseUrl(key = "url1")interface Api2 {...}@BaseUrl(key = "url2", value = "https://xxxxxx.com")interface Api3 {...}dynamicBaseUrls["url1"] = "https://xxxxxxx.com/v2"
OK,这样一来用法就确定下来了,来实现拦截器吧~
实现拦截器
我们先要定义一套切换 baseUrl 的规则,虽然多数情况都是只用到一两个配置地址的注解,但是也不排除会有全部都用到的情况。首先 @Url 肯定是优先级最高的,其次动态域名的优先级高于静态域名,函数注解的优先级高于类注解,最后才轮到全局域名。那么我们就能基于此得出以下规则:
读取函数上的 @Url 注解修饰的参数,如果参数传入的是全路径地址,那就直接使用该地址;
读取函数上的 @BaseUrl 注解,如果有配置 key,并且 dynamicBaseUrls 里有对应的域名,那就使用该域名;
读取类上的 @BaseUrl 注解,如果有配置 key,并且 dynamicBaseUrls 里有对应的域名,那就使用该域名;
读取函数上的 @BaseUrl 注解,如果有配置 value 为一个域名,那就使用该域名;
读取类上的 @BaseUrl 注解,如果有配置 value 为一个域名,那就使用该域名;
读取 globalBaseUrl 变量,如果有配置全局域名,那就使用该域名;
使用 Retrofit 创建时配置的 baseUrl;
规则定义好了,就能开始写代码,首先当然是定义 @BaseUrl 注解。
@Retention(AnnotationRetention.RUNTIME)@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)annotation class BaseUrl(val value: String = "", val key: String = "")
注意把 value 作为第一个参数,如果只是想改静态地址,就能把 value 省去,比如 @BaseUrl("https://www.wanandroid.com")。而想用动态域名时就要使用命名参数 key = "xxx",比如 @BaseUrl(key = "wanandroid")。
给注解声明了 key,后续使用该 key 修改地址也更加合理。
定义动态域名的变量,全局域名和动态域名的键值对。
var globalBaseUrl: String? = nullval dynamicBaseUrls = ConcurrentHashMap<String, String>()
接下来就能写拦截器逻辑了。首先处理一下 @Url 的情况,参数传入的是全路径地址,那就直接使用该地址。
val request = chain.request()val invocation = request.tag(Invocation::class.java)val method = invocation?.method() ?: return chain.proceed(request)// 获取 @Url 注解修饰的参数的索引val urlAnnotationIndex = method.parameterAnnotations.indexOfFirst { annotations -> annotations.any { it is Url } }// 判断该参数是不是一个 http 或 https 的全路径地址,是就直接请求invocation.arguments()?.getOrNull(urlAnnotationIndex)?.toString()?.takeIfValidUrl()?.run { return chain.proceed(request) }
读取动态域名,先看有没有函数的动态域名,再看有没有类的动态域名。
val methodUrlKey = method.getAnnotation(BaseUrl::class.java)?.key?.takeIfNotEmpty()val clazzUrlKey = method.declaringClass?.getAnnotation(BaseUrl::class.java)?.key?.takeIfNotEmpty()val dynamicBaseUrl = methodUrlKey?.let { dynamicBaseUrls[it] }?.takeIfValidUrl()?: clazzUrlKey?.let { dynamicBaseUrls[it] }?.takeIfValidUrl()
读取静态域名,同样先看有没有函数的静态域名,再看有没类有的静态域名。
val apiBaseUrl = method.getAnnotation(BaseUrl::class.java)?.value?.takeIfValidUrl()?: method.declaringClass?.getAnnotation(BaseUrl::class.java)?.value?.takeIfValidUrl()
再判断用哪个新的域名了,动态域名 > 静态域名 > 全局域名,哪个有就改用哪个域名,都没有就不修改域名直接请求。
val newBaseUrl = (dynamicBaseUrl ?: apiBaseUrl ?: globalBaseUrl)?.toHttpUrlOrNull()?: return chain.proceed(request)
如果有新域名就替换掉原来的域名,注意修改 request.url 时还要修改其 pathSegments,这是域名的每一段 path。比如我们的新 baseUrl 是 https://xxxx.com/app/v2/,pathSegments 的内容就是 ["app", "v2"]。要把新地址的 pathSegments 加到原来的请求中,否则不符合预期。
val newFullUrl = request.url.newBuilder().scheme(newBaseUrl.scheme).host(newBaseUrl.host).port(newBaseUrl.port).apply {(0..<request.url.pathSize).forEach { _ ->removePathSegment(0)}(newBaseUrl.encodedPathSegments + request.url.encodedPathSegments).forEach {addEncodedPathSegment(it)}}.build()return chain.proceed(request.newBuilder().url(newFullUrl).build())
至此,我们就把域名拦截替换的功能给全部实现了。但是强迫症的我觉得还有点小瑕疵,@BaseUrl 注解的动态域名和静态域名配置是不变的,我们不需要每一次请求都去读一次,应该缓存下来第二次直接用。
那么如何缓存才比较合适呢?可以参考 Retrofit 源码,记不记得前面读源码的时候是给 ServiceMethod 对象做了缓存,不可能每次请求都去读一遍函数有什么注解。我们可以同样用个 Map 进行缓存,Method 对象作为 key,地址的配置作为 value。
private val urlsConfigCache = ConcurrentHashMap<Method, UrlsConfig>()data class UrlsConfig(val apiBaseUrl: String?,val methodUrlKey: String?,val clazzUrlKey: String?,val urlAnnotationIndex: Int,)
通过 Kotlin 的解构声明和 ConcurrentMap.getOrPut() 扩展函数能快速实现创建缓存和读取配置,这样能改动较少的代码把缓存给加上。
val (apiBaseUrl, methodUrlKey, clazzUrlKey, urlAnnotationIndex) = urlsConfigCache.getOrPut(method) {val apiBaseUrl = method.getAnnotation(BaseUrl::class.java)?.value?.takeIfValidUrl()?: method.declaringClass?.getAnnotation(BaseUrl::class.java)?.value?.takeIfValidUrl()val methodUrlKey = method.getAnnotation(BaseUrl::class.java)?.key?.takeIfNotEmpty()val clazzUrlKey = method.declaringClass?.getAnnotation(BaseUrl::class.java)?.key?.takeIfNotEmpty()val urlAnnotationIndex = method.parameterAnnotations.indexOfFirst { annotations -> annotations.any { it is Url } }UrlsConfig(apiBaseUrl, methodUrlKey, clazzUrlKey, urlAnnotationIndex)}
现在终于是完美地实现了用注解管理 baseUrl 的功能~
最终方案
个人封装好了多域名库 MultiBaseUrls 方便大家使用。如果觉得好用的话,希望能点个 star 支持一下~
Feature
支持多种用@BaseUrl 注解修改 baseUrl 的方式
支持用 globalBaseUrl 修改全局的 baseUrl
优先使用 @Url 修饰的全路径参数的 baseUrl
Gradle
在 settings.gradle 文件的 repositories 结尾处添加:
dependencyResolutionManagement {repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)repositories {mavenCentral()maven { url 'https://www.jitpack.io' }}}
或者在 settings.gradle.ktx 文件的 repositories 结尾处添加:
dependencyResolutionManagement {repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)repositories {mavenCentral()maven { url = uri("https://jitpack.io") }}}
添加依赖:
dependencies {implementation("com.github.DylanCaiCoding:MultiBaseUrls:1.0.0")}
Kotlin 用法
调用 OkHttpClient.Builder#enableMultiBaseUrls() 扩展函数启用多域名。
val okHttpClient = OkHttpClient.Builder().enableMultiBaseUrls()// ....build()
使用 @BaseUrl 注解修改接口类里所有请求的 baseUrl。
@BaseUrl("https://xxxxxx.com/")interface Api {// ...}
如果有运行时动态修改 baseUrl 的需求,可以修改全局的 globalBaseUrl,比如:
globalBaseUrl = "https://xxxxxx.com/v2/"如果是有多个 baseUrl 需要在运行时动态修改,那就用 @BaseUrl 配置一个 key,用 dynamicBaseUrls[key] 动态修改 baseUrl。比如:
@BaseUrl(key = "url1", value = "https://xxxxxx.com/")interface Api {@GET("/aaa/bbb")@BaseUrl(key = "url2")suspend fun request(): String}dynamicBaseUrls[url1] = "https://xxxxxx.com/v2/"dynamicBaseUrls[url2] = "https://xxxxxx.com/v3/"
如果有需要,可以随时关闭多域名的支持,改回创建 Retrofit 时的 baseUrl。
isMultiBaseUrlsEnabled = falseJava 用法
启用多域名。
OkHttpClient okHttpClient = MultiBaseUrls.with(new OkHttpClient.Builder()).build();
使用 @BaseUrl 注解修改接口类里所有请求的 baseUrl。
@BaseUrl("https://xxxxxx.com/")public interface Api {// ...}
如果有运行时动态修改 baseUrl 的需求,可以修改全局的 globalBaseUrl,比如:
MultiBaseUrls.setGlobalBaseUrl("https://api.github.com/");如果是有多个 baseUrl 需要在运行时动态修改,那就用 @BaseUrl 配置一个 key,用 dynamicBaseUrls[key] 动态修改 baseUrl。比如:
@BaseUrl(key = "url1")public interface Api {@GET("/aaa/bbb")@BaseUrl(key = "url2")Single<String> request();}MultiBaseUrls.getDynamicBaseUrls().put("url1", "https://xxxxxx.com/v2/");MultiBaseUrls.getDynamicBaseUrls().put("url2", "https://xxxxxx.com/v3/");
如果有需要,可以随时关闭多域名的支持,改回创建 Retrofit 时的 baseUrl。
MultiBaseUrls.setEnabled(false);总结
本文讲述了现有的三种改 baseUrl 方案的优缺点,目前比较好的拦截器 + header 的修改 baseUrl 方案还是有些瑕疵,没有处理 @Url 全路径的情况,header 要一个个函数去配置非常麻烦。
所以个人提出了用拦截器 + 注解的改进方案,从拦截器中读取注解的方式比较隐蔽。个人带大家读了源码了解了为何能从拦截器中读到注解。然后设计了 @BaseUrl 注解的改进用法,并且考虑了多个 @BaseUrl 注解一起用的情况。
最后分享了个人写好的多域名库 MultiBaseUrls 给大家,如果觉得好用,希望能点个 star 支持一下~
关于我
一个兴趣使然的程序“工匠”。有代码洁癖,喜欢封装,对封装有一定的个人见解,有不少个人独特或原创的封装思路。GitHub 有分享一些帮助搭建开发框架的开源库,推荐大家用一用。有任何使用上的问题或者需求都可以提 issues 或者加我微信直接反馈。
掘金:DylanCai[1]
GitHub:DylanCaiCoding[2]
微信号:DylanCaiCoding
已经断更很长一段时间了,看到个评论说 “这逼领证了就不写文章了” 让我很尴尬。主要还是工作比较忙,后面会慢慢恢复更新,分享一些讲解封装思路的文章。
参考资料
[1]
https://juejin.cn/user/4195392100243000/posts: https://juejin.cn/user/4195392100243000/posts
[2]https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2FDylanCaiCoding: https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2FDylanCaiCoding
点击关注公众号,“技术干货” 及时达!
