原创 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
@JvmOverloads
suspend fun request1(@Url url: String = globalBaseUrl + "/aaa/bbb"): AipResponse<Any?>
@Post
@JvmOverloads
suspend 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
@JvmOverloads
suspend 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? = null
val 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 = false
Java 用法
启用多域名。
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
点击关注公众号,“技术干货” 及时达!