Retrofit source code reading notes (4)
The first article introduces Retrofit
dynamic proxy implementation, method annotation analysis, etc.: Android Retrofit source code reading notes (1) .
The second article introduces Retrofit
how to parse the parameters in the method : Android Retrofit source code reading notes (2) .
The third article introduces Retrofit
how to build Http
the request task: Android Retrofit source code reading notes (3) .
This article is the fourth in a series of articles. Logically speaking, the previous articles have already Retrofit
run through the entire process. There should be nothing more to say in the follow-up. However, Retrofit
there are still two advanced usages in use, that is, automatic Definition CallAdapterFactory
and ConverterFactory
, I will assume that readers have read my previous articles and will not explain these two classes any more. This article will use the source code analysis of Retrofit
the officially defined RxJava3CallAdapterFactory
and MoshiConverterFactory
to help everyone better understand how to customize them. Define a CallAdapterFactory
and ConverterFactory
. Get ready to go.
RxJava3CallAdapterFactory
By default, everyone is familiar with it RxJava
. Students who are not familiar with it can go to the Internet to find information and learn it. I will not introduce it separately here. RxJava3CallAdapterFactory
There are three ways to create: RxJava3CallAdapterFactory#create()
, RxJava3CallAdapterFactory#createSynchronous()
and RxJava3CallAdapterFactory#createWithScheduler()
.
-
create()
CreateOkHttp
an asynchronous requestRxJava3CallAdapterFactory
, and the final request is inOkHttp
theDispatcher
thread pool. -
createSynchronous()
CreateOkHttp
a synchronous requestRxJava3CallAdapterFactory
, and the final request thread isRxJava
determined by. -
createWithScheduler()
When creatingOkHttp
a synchronous requestRxJava3CallAdapterFactory
, the final request thread is determined by the passedScheduler
.
As mentioned in the previous article, the usable ones Retrofit
will CallAdatperFactory#get()
be obtained according to the method CallAdapter
, and CallAdatperFactory
the main judgment basis is to judge whether the current supports this type through adatperType
( ) . If it does not support it, it will return empty directly, and it will try to get it from other Continue to search in . Create a corresponding return if supported.returnType
CallAdapterFactory
Retrofit
CallAdapterFactory
CallAdapter
Monkey, let’s take a look at the implementation of today’s first method RxJava3CallAdapterFactory#get()
:
@Override
public @Nullable CallAdapter<?, ?> get(
Type returnType, Annotation[] annotations, Retrofit retrofit) {
Class<?> rawType = getRawType(returnType);
// 如果 `adapterType` 是 Completable,直接返回 RxJava3CallAdapter 对象
if (rawType == Completable.class) {
// Completable is not parameterized (which is what the rest of this method deals with) so it
// can only be created with a single configuration.
// 如果是 Completable 它的 `responseType` 直接指定为 Void,也就是 Completable 不需要返回值。
return new RxJava3CallAdapter(
Void.class, scheduler, isAsync, false, true, false, false, false, true);
}
boolean isFlowable = rawType == Flowable.class;
boolean isSingle = rawType == Single.class;
boolean isMaybe = rawType == Maybe.class;
// 如果 `adatperType` 不是 Flowable,Single,Maybe,Observable 其中之一,直接返回空,表示当前的 adapterType,不能处理直接返回空
if (rawType != Observable.class && !isFlowable && !isSingle && !isMaybe) {
return null;
}
boolean isResult = false;
boolean isBody = false;
Type responseType;
// adapterType 中必须有详细的泛型信息,否者报错。
if (!(returnType instanceof ParameterizedType)) {
String name =
isFlowable ? "Flowable" : isSingle ? "Single" : isMaybe ? "Maybe" : "Observable";
throw new IllegalStateException(
name
+ " return type must be parameterized"
+ " as "
+ name
+ "<Foo> or "
+ name
+ "<? extends Foo>");
}
// 获取泛型的 type
Type observableType = getParameterUpperBound(0, (ParameterizedType) returnType);
// 获取泛型信息的 Class 对象
Class<?> rawObservableType = getRawType(observableType);
// 判断泛型类型是否是 Response
if (rawObservableType == Response.class) {
if (!(observableType instanceof ParameterizedType)) {
throw new IllegalStateException(
"Response must be parameterized" + " as Response<Foo> or Response<? extends Foo>");
}
// Response 的泛型对象为 responseType
responseType = getParameterUpperBound(0, (ParameterizedType) observableType);
} else if (rawObservableType == Result.class) {
// 判断泛型类型是否为 Result 对象
if (!(observableType instanceof ParameterizedType)) {
throw new IllegalStateException(
"Result must be parameterized" + " as Result<Foo> or Result<? extends Foo>");
}
// Result 对象的泛型类型为 responseType
responseType = getParameterUpperBound(0, (ParameterizedType) observableType);
isResult = true;
} else {
// 其他情况,adapterType 的泛型就是 responseType
responseType = observableType;
isBody = true;
}
// 构建 RxJava3CallAdapter 对象返回。
return new RxJava3CallAdapter(
responseType, scheduler, isAsync, isResult, isBody, isFlowable, isSingle, isMaybe, false);
}
The first ones RxJava3CallAdatperFactory
that can be processed adapterType
are Complateable
, Single
, Maybe
, Observable
and Flowable
.
Here is a brief introduction to the above code:
- If
adapterType
yesComplateable
, directly construct a returnresponseType
for .Void
RxJava3CallAdapter
- If
adapterType
is not one ofSingle
,Maybe
,Observable
andFlowable
, return empty directly, indicating that the currentadapterType
cannot be processed. - Determine the
adapterType
genericClass
object in :
Response
As we mentioned when talking about coroutines, this means that the return value requiresResponse
(Retrofit
),Response
which is the generic typeresponseType
.Result
Result
The object isRxJava3CallAdatperFactory
defined in. It will encapsulate successful and failed requests. Usually an exception will be thrown when a request error occurs. AfterResult
encapsulation, exceptions will not be thrown. Success and exceptions will be encapsulated inResult
the object. I used to still I implemented such a function myself, but I didn’t expect that there is something ready to use.Result
So are the genericsresponseType
.- In other cases
, other requests areadapterType
the generic objects ofresponseType
, which is also the most commonly used case in our development.
- Build
RxJava3CallAdapter
object.
RxJava3CallAdapter
In the previous article, we learned CallAdapter
that the main responsibility of is to create an object instance through adapt()
the method adapterType
and then return it to the proxied method. Let's take a look at RxJava3CallAdapter#adapt()
the implementation of the method:
@Override
public Object adapt(Call<R> call) {
// 根据同步调用和异步调用构建最基础的 Observable 对象,该流处理的对象是 Response
Observable<Response<R>> responseObservable =
isAsync ? new CallEnqueueObservable<>(call) : new CallExecuteObservable<>(call);
Observable<?> observable;
// 判断需要的流处理对象的类型,然后在用其他的 Observable 对象封装。
if (isResult) {
// 如果需要 Result 对象,用 ResultObservable 对元素流再封装
observable = new ResultObservable<>(responseObservable);
} else if (isBody) {
// 如果需要普通 Body,用 BodyObservable 封装
observable = new BodyObservable<>(responseObservable);
} else {
// 如果是需要有 Response,就用基础的 Observable 对象就好了。
observable = responseObservable;
}
// 在 Observable 中添加 scheduler
if (scheduler != null) {
observable = observable.subscribeOn(scheduler);
}
// 将 Observable 转换成需要的流类型。
if (isFlowable) {
return observable.toFlowable(BackpressureStrategy.LATEST);
}
if (isSingle) {
return observable.singleOrError();
}
if (isMaybe) {
return observable.singleElement();
}
if (isCompletable) {
return observable.ignoreElements();
}
return RxJavaPlugins.onAssembly(observable);
}
Explain the above code:
- First, build the foundation based on whether it is an asynchronous call
Observable
. The implementation class of synchronous call isCallExecuteObservable
(analyzed later), the implementation class of asynchronous call isCallEnqueueObservable
(analyzed later), and the implementation class of the parameterCall
is what we talked about in our previous articleOkHttpCall
.Observable
The return data of the basic stream isResponse<T>
. - Determine the type of stream data required externally. If necessary
Result
, use (analyzed later) to re-encapsulateBodyObservable
the basic one ; if it needs to be normal , use the re-encapsulation of the basic one ; if it is needed , then the type returned by the basic one is the same. Just use it directly.Observable
Body
BodyObservable
Observable
Response
Observable
- If there is , add one to
Scheduler
in the previous step , mainly for synchronous calls. The thread for synchronous calls is specified by .Oberverble
Scheduler
Scheduler
- Finally,
Observable
convert into the requiredRxJava
stream type (return directly if no conversion is requiredObservable
), that isSingle
, one ofMaybe
,Flowable
,Completable
.
CallExecuteObservable
First of all, you must understand that the streams mentioned above RxJava
are all cold streams. The method will be called when subscribing Observable#subscribeActual()
, and the real Http
task will be triggered in this method. CallExecuteObservable
is the basic stream for synchronous execution, and the data returned by its stream is the complete source code that Response<T>
we can look at directly :CallExecuteObservable
final class CallExecuteObservable<T> extends Observable<Response<T>> {
private final Call<T> originalCall;
CallExecuteObservable(Call<T> originalCall) {
this.originalCall = originalCall;
}
@Override
protected void subscribeActual(Observer<? super Response<T>> observer) {
// Since Call is a one-shot type, clone it for each new observer.
// 复制 call,因为这个每次流的订阅都会触发新的请求任务,但是一个 Call 只能请求一次,所以这里要复制一个 Call。
Call<T> call = originalCall.clone();
// 添加一个 CallDisposable,用来监听流的状态,如果流中途被取消订阅,那么需要把 Call 也取消。
CallDisposable disposable = new CallDisposable(call);
observer.onSubscribe(disposable);
if (disposable.isDisposed()) {
return;
}
boolean terminated = false;
try {
// 直接同步执行 Http 请求任务。
Response<T> response = call.execute();
// 数据返回后检查流是否还存活然后执行 onNext() 和 onComplete() 方法。
if (!disposable.isDisposed()) {
observer.onNext(response);
}
if (!disposable.isDisposed()) {
terminated = true;
observer.onComplete();
}
} catch (Throwable t) {
Exceptions.throwIfFatal(t);
if (terminated) {
RxJavaPlugins.onError(t);
} else if (!disposable.isDisposed()) {
try {
observer.onError(t);
} catch (Throwable inner) {
Exceptions.throwIfFatal(inner);
RxJavaPlugins.onError(new CompositeException(t, inner));
}
}
}
}
private static final class CallDisposable implements Disposable {
private final Call<?> call;
private volatile boolean disposed;
CallDisposable(Call<?> call) {
this.call = call;
}
@Override
public void dispose() {
// 如果流取消,把 Call 的任务也取消了。
disposed = true;
call.cancel();
}
@Override
public boolean isDisposed() {
return disposed;
}
}
}
subscribeActual()
A brief summary of the method:
- Copy
call
, because each stream subscription will trigger a new request task (the so-called cold stream), but oneCall
can only be requested once, so one needs to be copied hereCall
. - Add
CallDisposable
to monitor the status of the stream. If the stream has been canceled,Call
cancel the task. - Directly call the method to synchronize
Call#execute()
the request, check the status of the stream after completion, and then call theObserver
andonNext()
methodsonComplete()
. If there is an error in the process, callonError()
the method.
CallEnqueueObservable
CallEnqueueObservable
It is the basis of asynchronous calls Observable
, and the data format returned by its stream is also the same Response<T>
. Let’s take a look at its source code:
final class CallEnqueueObservable<T> extends Observable<Response<T>> {
private final Call<T> originalCall;
CallEnqueueObservable(Call<T> originalCall) {
this.originalCall = originalCall;
}
@Override
protected void subscribeActual(Observer<? super Response<T>> observer) {
// Since Call is a one-shot type, clone it for each new observer.
Call<T> call = originalCall.clone();
// 构建 CallBallback 对象来监听 Observer 的订阅状态和监听 Http 请求的异步调用
CallCallback<T> callback = new CallCallback<>(call, observer);
observer.onSubscribe(callback);
if (!callback.isDisposed()) {
// 异步请求
call.enqueue(callback);
}
}
private static final class CallCallback<T> implements Disposable, Callback<T> {
private final Call<?> call;
private final Observer<? super Response<T>> observer;
private volatile boolean disposed;
boolean terminated = false;
CallCallback(Call<?> call, Observer<? super Response<T>> observer) {
this.call = call;
this.observer = observer;
}
@Override
public void onResponse(Call<T> call, Response<T> response) {
// 请求成功,如果流已经取消就直接返回
if (disposed) return;
// 执行 Observer 的 onNext() 和 onComplete() 方法
try {
observer.onNext(response);
if (!disposed) {
terminated = true;
observer.onComplete();
}
} catch (Throwable t) {
Exceptions.throwIfFatal(t);
if (terminated) {
RxJavaPlugins.onError(t);
} else if (!disposed) {
try {
observer.onError(t);
} catch (Throwable inner) {
Exceptions.throwIfFatal(inner);
RxJavaPlugins.onError(new CompositeException(t, inner));
}
}
}
}
@Override
public void onFailure(Call<T> call, Throwable t) {
if (call.isCanceled()) return;
// 请求失败,调用 Observer 的 onError() 方法
try {
observer.onError(t);
} catch (Throwable inner) {
Exceptions.throwIfFatal(inner);
RxJavaPlugins.onError(new CompositeException(t, inner));
}
}
@Override
public void dispose() {
// 任务取消。
disposed = true;
call.cancel();
}
@Override
public boolean isDisposed() {
return disposed;
}
}
}
The above method is very simple. The method asynchronous call task subscribeActual()
is executed in the method of executing the subscription . The callback of the listening request is the object, which is also the object of the listening stream cancellation. In the callback of , it indicates that the request is successful. If the stream is not canceled, the and method of is called accordingly . If an error occurs in the process, the method is called. The callback of Ziah indicates that the request failed, and the method of is called directly .Call#enqueue()
Http
CallCallback
CallCallback#onResponse()
Observer
onNext()
onComplete()
onError()
CallCallback#onFailure()
Observer
onError()
ResultObservable
ResultObservable
It converts the basic return Response<T>
stream Observable
into a return Result<T>
stream. It will never trigger onError()
. Success and failure will be encapsulated in Result
the object. Let's take a look at its implementation:
final class ResultObservable<T> extends Observable<Result<T>> {
private final Observable<Response<T>> upstream;
ResultObservable(Observable<Response<T>> upstream) {
this.upstream = upstream;
}
@Override
protected void subscribeActual(Observer<? super Result<T>> observer) {
// 将上层流传过来的数据用 ResultObserver 对象来监听
upstream.subscribe(new ResultObserver<>(observer));
}
private static class ResultObserver<R> implements Observer<Response<R>> {
private final Observer<? super Result<R>> observer;
ResultObserver(Observer<? super Result<R>> observer) {
this.observer = observer;
}
@Override
public void onSubscribe(Disposable disposable) {
observer.onSubscribe(disposable);
}
@Override
public void onNext(Response<R> response) {
// 请求成功,把 Response 用 Result 封装,然后调用 `Observer#onNext()` 方法传递给下游。
observer.onNext(Result.response(response));
}
@Override
public void onError(Throwable throwable) {
// 请求异常,把 `Throwable` 用 Result 封装,然后调用 `Observer#onNext()` 方法传递给下游
try {
observer.onNext(Result.error(throwable));
} catch (Throwable t) {
try {
observer.onError(t);
} catch (Throwable inner) {
Exceptions.throwIfFatal(inner);
RxJavaPlugins.onError(new CompositeException(t, inner));
}
return;
}
observer.onComplete();
}
@Override
public void onComplete() {
observer.onComplete();
}
}
}
In the method, use the object to monitor subscribeActual()
the data flowing from the upper layer . In the method, it means that the request is successful, encapsulate it with and pass it to the downstream through the method ; in the method, it means that the request failed, encapsulate the object with the method, and pass it to the downstream through the method. downstream .ResultObserver
ResultObserver#onNext()
Response<T>
Result<T>
Observer#onNext()
Observer
ResultObserver#onError()
Throwable
Result<T>
Observer#onNext()
Observer
BodyObservable
BodyObservable
Its job is to simply convert the data in the basic Observerable
stream Response<T>
into ResponseBody
. It works ResultObservable
similarly to , except that it does not intercept exception information:
final class BodyObservable<T> extends Observable<T> {
private final Observable<Response<T>> upstream;
BodyObservable(Observable<Response<T>> upstream) {
this.upstream = upstream;
}
@Override
protected void subscribeActual(Observer<? super T> observer) {
upstream.subscribe(new BodyObserver<>(observer));
}
private static class BodyObserver<R> implements Observer<Response<R>> {
private final Observer<? super R> observer;
private boolean terminated;
BodyObserver(Observer<? super R> observer) {
this.observer = observer;
}
@Override
public void onSubscribe(Disposable disposable) {
observer.onSubscribe(disposable);
}
@Override
public void onNext(Response<R> response) {
if (response.isSuccessful()) {
// 成功直接取 ResponseBody 传递到下游去。
observer.onNext(response.body());
} else {
// 请求失败
terminated = true;
Throwable t = new HttpException(response);
try {
observer.onError(t);
} catch (Throwable inner) {
Exceptions.throwIfFatal(inner);
RxJavaPlugins.onError(new CompositeException(t, inner));
}
}
}
@Override
public void onComplete() {
if (!terminated) {
observer.onComplete();
}
}
@Override
public void onError(Throwable throwable) {
if (!terminated) {
observer.onError(throwable);
} else {
// This should never happen! onNext handles and forwards errors automatically.
Throwable broken =
new AssertionError(
"This should never happen! Report as a bug with the full stacktrace.");
//noinspection UnnecessaryInitCause Two-arg AssertionError constructor is 1.7+ only.
broken.initCause(throwable);
RxJavaPlugins.onError(broken);
}
}
}
}
The above code is ResultObservable
very similar to , so there isn’t much to say.
MoshiConverterFactory
If you are not familiar Moshi
with , you can learn about it. If you use Kotlin
Develop, there are still many advantages Moshi
over the old and frail . It is recommended that students who are still using Develop migrate to .Gson
Gson
Moshi
MoshiConverterFactory
The instance is created through MoshiConverterFactory#create()
the method. You can pass in a custom Moshi
object. If not, MoshiConverterFactory
a default one will be created for you.
Let's take a look at Converter.Factory
the important methods of this abstract class:
abstract class Factory {
public @Nullable Converter<ResponseBody, ?> responseBodyConverter(
Type type, Annotation[] annotations, Retrofit retrofit) {
return null;
}
public @Nullable Converter<?, RequestBody> requestBodyConverter(
Type type,
Annotation[] parameterAnnotations,
Annotation[] methodAnnotations,
Retrofit retrofit) {
return null;
}
public @Nullable Converter<?, String> stringConverter(
Type type, Annotation[] annotations, Retrofit retrofit) {
return null;
}
// ...
}
-
responseBodyConverter()
When building a request task, you need to use this method to obtain the object that will beResponseBody
converted into the target objectConverter
. After the request is successful, it is directly usedConverter
for conversion. -
requestBodyConverter()
When constructing a request, you need to use this method to obtain the target object converted intoRequestBody
(Converter
it will be obtained only when@Body
/ is available). When conversion is needed, use conversion directly.@Part
Converter
-
stringConverter()
Obtain and convert other objects intoString
objectsConverter
. For example, when constructing a request,@Query
the annotation-modified parameters need to be converted intoString
objects, and this method will be used to obtain the corresponding onesConverter
.
MoshiConverterFactory
It implements responseBodyConverter()
the and requestBodyConverter()
methods, which means it does not support conversion from other objects String
.
MoshiRequestBodyConverter
Let’s first look at MoshiConverterFactory#requestBodyConverter()
the implementation of the method:
@Override
public Converter<?, RequestBody> requestBodyConverter(
Type type,
Annotation[] parameterAnnotations,
Annotation[] methodAnnotations,
Retrofit retrofit) {
// 根据输入的 Type 获取 Moshi 的 JsonAdapter
JsonAdapter<?> adapter = moshi.adapter(type, jsonAnnotations(parameterAnnotations));
// 更新 JsonAdapter的 配置
if (lenient) {
adapter = adapter.lenient();
}
if (failOnUnknown) {
adapter = adapter.failOnUnknown();
}
if (serializeNulls) {
adapter = adapter.serializeNulls();
}
// 创建 Converter 对象。
return new MoshiRequestBodyConverter<>(adapter);
}
Let’s briefly talk about it. Converter
Its first paradigm represents the input type, and the second parameter represents the converted type. The conversion is done through convert()
the method. Then look at the code above:
Moshi
Obtain the object in through the input typeJsonAdatper
. Serialization/deserialization all rely on this object.- Updated
JsonAdatper
configuration. - Create
MoshiRequestBodyConverter
object return method.
Let's take a look at MoshiRequestBodyConverter#convert()
how the method is converted:
@Override
public RequestBody convert(T value) throws IOException {
// 创建 OkIo 的 Buffer 对象.
Buffer buffer = new Buffer();
// 以 Buffer 为参数创建一个 JsonWriter 对象。
JsonWriter writer = JsonWriter.of(buffer);
// 将转换后的对象写入到 Buffer 中。
adapter.toJson(writer, value);
// 构建类型为 `application/json; charset=UTF-8` 的 RequestBody
return RequestBody.create(MEDIA_TYPE, buffer.readByteString());
}
The above code is very simple:
- First, an object
OkIo
of is constructedBuffer
, and thenBuffer
an object is created from the objectJsonWriter
. JsonAdapter#toJson()
Write the serialized data into via the methodBuffer
.- Convert
Buffer
the data inString
and then construct a typeapplication/json; charset=UTF-8
ofRequestBody
.
MoshiResponseBodyConverter
Let’s take a look at MoshiConverterFactory#responseBodyConverter()
the implementation of the method:
@Override
public Converter<ResponseBody, ?> responseBodyConverter(
Type type, Annotation[] annotations, Retrofit retrofit) {
JsonAdapter<?> adapter = moshi.adapter(type, jsonAnnotations(annotations));
if (lenient) {
adapter = adapter.lenient();
}
if (failOnUnknown) {
adapter = adapter.failOnUnknown();
}
if (serializeNulls) {
adapter = adapter.serializeNulls();
}
return new MoshiResponseBodyConverter<>(adapter);
}
requestBodyConverter
Constructs one in almost the same way JsonAdapter
as and returns MoshiResponseBodyConverter
the object.
Let’s take a look at MoshiResponseBodyConverter#convert()
the implementation of the method:
@Override
public T convert(ResponseBody value) throws IOException {
// 获取 ResponseBody 中的 Source(OkIo) 对象。
BufferedSource source = value.source();
try {
// Moshi has no document-level API so the responsibility of BOM skipping falls to whatever
// is delegating to it. Since it's a UTF-8-only library as well we only honor the UTF-8 BOM.
// 判断开头的 3 个字节是否是 `0xEFBBBF`,如果是就跳过这几个字节
if (source.rangeEquals(0, UTF8_BOM)) {
source.skip(UTF8_BOM.size());
}
// 构建 JsonReader 对象
JsonReader reader = JsonReader.of(source);
// 读取 JsonReader 中的数据,然后反序列化为目标对象。
T result = adapter.fromJson(reader);
// 检查是否已经读取完成
if (reader.peek() != JsonReader.Token.END_DOCUMENT) {
throw new JsonDataException("JSON document was not fully consumed.");
}
return result;
} finally {
value.close();
}
}
To briefly summarize the above code:
- Get the ( ) object
ResponseBody
in .Source
OkIo
- Determine
Source
whether the first 3 bytes in the object are0xEFBBBF
, and skip if so. Source
ConstructJsonReader
(Moshi
) object via .JsonAdatper#fromJson()
Read the data in the method andJsonReader
then deserialize it into the target object.- Check whether
JsonReader
the inJson
has been read correctly, and finally return the deserialized object.
Summarize
Today, we will use source code analysis Retrofit
of the official implementation of RxJava3CallAdapterFactory
and MoshiConverterFactory
to let everyone know how to customize an excellent CallAdapterFactory
and ConverterFactory
. If you are interested in other CallAdapterFactory
or ConverterFactory
, you can also take a look at their implementation.
at last
If you want to become an architect or want to break through the 20-30K salary range, then don't be limited to coding and business, you must be able to select and expand, and improve your programming thinking. In addition, good career planning is also very important, and learning habits are important, but the most important thing is to be able to persevere. Any plan that cannot be implemented consistently is empty talk.
If you have no direction, here is a set of "Advanced Notes on the Eight Modules of Android" written by a senior architect at Alibaba to help you systematically organize messy, scattered, and fragmented knowledge, so that you can systematically and efficiently Master various knowledge points of Android development.
Compared with the fragmented content we usually read, the knowledge points in this note are more systematic, easier to understand and remember, and are strictly arranged according to the knowledge system.
Welcome everyone to support with one click and three links. If you need the information in the article, just scan the CSDN official certification WeChat card at the end of the article to get it for free↓↓↓ (There is also a small bonus of ChatGPT robot at the end of the article, don’t miss it)