稀土掘金技术社区 2024年12月30日
优雅地使用注解管理 Retrofit 的 baseUrl
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文深入探讨了Retrofit中动态修改baseUrl的挑战与现有方案的不足,如使用多个Retrofit对象、@Url注解以及拦截器+header等。针对这些问题,提出了一个更优雅的解决方案:通过自定义注解@BaseUrl,结合拦截器,实现对baseUrl的灵活管理。该方案不仅解决了@Url注解被拦截器覆盖的问题,还优化了动态域名的配置方式,支持在类或方法级别定义baseUrl,并通过缓存机制提升性能。最终,实现了一个更强大、更易用、更高效的动态baseUrl管理方案。

💡 现有方案的缺陷:使用多个Retrofit对象浪费资源;@Url注解虽可修改baseUrl,但需每个请求函数添加参数,易误传;拦截器+header方式虽好,但@Url全路径地址会被全局域名影响,且header配置域名不够简洁。

🛠️ 核心思路:通过拦截器读取Retrofit的注解信息,包括@Url和自定义的@BaseUrl注解。利用Retrofit的动态代理机制,在OkHttp的Request对象中获取Method对象和参数,从而解析注解信息。

✨ 优化方案:自定义@BaseUrl注解,支持key和value参数配置。key用于动态域名,value用于静态域名。通过拦截器,根据注解和动态域名配置,动态替换baseUrl。同时,考虑了@Url注解的优先级,确保全路径地址优先使用。并加入了缓存机制,避免重复读取注解信息,提高性能。

原创 DylanCai 2024-12-30 08:31 重庆

点击关注公众号,“技术干货” 及时达!

点击关注公众号,“技术干货” 及时达!


前言

众所周知,RetrofitbaseUrl 在创建实例的时候就设定好了,之后不允许直接修改。但是我们实际项目中会存在需要改变 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()

虽然也能用,但是有缺陷:

拦截器 + 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 的注解信息才能做处理。Interceptor 类是在 OkHttp 里的,Retrofit 只是对 OkHttp 进行二次封装,OkHttp 并没有依赖 Retrofit,在 Interceptor 里想读取 Retrofit 注解好像不太可能?其实还是有办法的,这就得了解下 Retrofit 源码。

首先我们在拦截器里只能拿到 OkHttp 发起请求的 Request 对象,到底有没有操作空间,那就要看 Retrofit 是怎么创建 Request 对象发起请求的。我们来简单过下 Retrofit 的实现原理,我们定义了一个接口类并没写具体实现类,但是 Retrofit 却能把接口给实例化出来,因为使用了动态代理。来看 Retrofit#create() 函数的源码:

image.png

调用的 Proxy.newProxyInstance() 函数就是用动态代理将接口实例化。而动态代理之所以能实例化对象,是因为在运行时生成了实现类。理论上类是可以生成,但是生成的实现类要有什么实际的业务逻辑,编译器是没法知道的。所以在 Proxy.newProxyInstance() 函数传入了一个 InvocatonHandler 对象,去执行接口里每个函数的逻辑。

每当我们调用接口对象的某个函数时,InvocatonHandler 重写的 invoke() 函数就会回调,我们通过 methodargs 对象能读取到定义的函数有什么注解和参数,根据不同的函数配置去执行不同的逻辑。Retrofit 就是根据函数的注解和参数的注解得到请求的信息,是 Post 还是 Get 请求,有什么请求投头,有什么 body 数据等。之后就通过 OkHttp 发起网络请求了。

源码里的 invoke() 函数会先判断是不是 Object 的函数或者默认函数,是的话就直接执行,不是的话就调用 loadSericeMethod(method).invoke(args) 函数。我们看一下 loadSericeMethod(method) 返回了什么东西。

image.png

可以看到这里是做了层缓存逻辑,有缓存就直接使用缓存,没缓存的时候才创建。创建对象是调用了 ServiceMethod.parseAnnotations() 函数,我们跟过去看一下。

image.png

到这里终于看到了一个相关的对象,我们是想了解 OkHttpRequest 对象是怎么创建的,RequestFactory 很明显就是 Request 的工厂类,它是通过 RequestFactory.parseAnnotations() 函数创建的,从函数名就能看出是解析 method 对象的注解得到 RequestFactory 对象。

通常工厂类都会有个 create() 函数,我们找一下对应源码。

image.png

可以看到 RequestFactory 会解析注解得到 baseUrl、headers、hasBody 等配置,去创建 Request 对象。在最后一行能看到调用了 Builder#tag() 函数存了一个 Invocation 对象,这用来干嘛的呢?

image.png

原来这个 Invocation 存了调用的函数和该函数的参数。那么在创建 Request 对象的时候设置了 Invocationtag,就能在拦截器里的 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 肯定是优先级最高的,其次动态域名的优先级高于静态域名,函数注解的优先级高于类注解,最后才轮到全局域名。那么我们就能基于此得出以下规则:

image.png

    读取函数上的 @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。比如我们的新 baseUrlhttps://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

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 或者加我微信直接反馈。

已经断更很长一段时间了,看到个评论说 “这逼领证了就不写文章了” 让我很尴尬。主要还是工作比较忙,后面会慢慢恢复更新,分享一些讲解封装思路的文章。

参考资料

[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

点击关注公众号,“技术干货” 及时达!

阅读原文

跳转微信打开

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

Retrofit 动态baseUrl 拦截器 自定义注解 OkHttp
相关文章