• 快速Android开发系列网络篇之Retrofit


    快速Android开发系列网络篇之Retrofit

    Retrofit是一个不错的网络请求库,用官方自己的介绍就是:

    A type-safe REST client for Android and Java

    看官网的介绍用起来很省事,不过如果不了解它是怎么实现的也不太敢用,不然出问题了就不知道怎么办了。这几天比较闲就下下来看了一下,了解一下大概实现方法,细节就不追究了。先来看一个官网的例子,详细说明去网官

    简单示例

    首先定义请求接口,即程序中都需要什么请求操作

    public interface GitHubService {
      @GET("/users/{user}/repos")
      List<Repo> listRepos(@Path("user") String user);
    }

     然后通过RestAdapter生成一个刚才定义的接口的实现类,使用的是动态代理。

    RestAdapter restAdapter = new RestAdapter.Builder()
        .setEndpoint("https://api.github.com")
        .build();
    
    GitHubService service = restAdapter.create(GitHubService.class);

    现在就可以调用接口进行请求了

    List<Repo> repos = service.listRepos("octocat");

    使用就是这么简单,请求时直接调用接口就行了,甚至不用封装参数,因为参数的信息已经在定义接口时通过Annotation定义好了。

    从上面的例子可以看到接口直接返回了需要的Java类型,而不是byte[]或String,解析数据的地方就是Converter,这个是可以自定义的,默认是用Gson解析,也就是说默认认为服务器返回的是Json数据,可以通过指定不同的Convert使用不同的解析方法,如用Jackson解析Json,或自定义XmlConvert解析xml数据。

    Retrofit的使用就是以下几步:

    1. 定义接口,参数声明,Url都通过Annotation指定
    2. 通过RestAdapter生成一个接口的实现类(动态代理)
    3. 调用接口请求数据

    接口的定义要用用Rtrofit定义的一些Annotation,所以先看一下Annotation的。

    Annotation

    以上面的示例中的接口来看

    @GET("/group/{id}/users")
    List<User> groupList(@Path("id") int groupId);

     先看@GET

     
    /** Make a GET request to a REST path relative to base URL. */
    @Documented
    @Target(METHOD)
    @Retention(RUNTIME)
    @RestMethod("GET")
    public @interface GET {
      String value();
    }
     

    @GET本身也被几个Anotation注解,@Target表示@GET注解是用于方法的,value方法就返回这个注解的value值,在上例中就是/group/{id}/users,然后就是@RestMethod

     
    @Documented
    @Target(ANNOTATION_TYPE)
    @Retention(RUNTIME)
    public @interface RestMethod {
      String value();
      boolean hasBody() default false;
    }
     

    RestMethod是一个用于Annotation的Annotation,比如上面的例子中用来注解的@GET,value方法就返回GET,hasBody表示是否有Body,对于POST这个方法就返回true

     
    @Documented
    @Target(METHOD)
    @Retention(RUNTIME)
    @RestMethod(value = "POST", hasBody = true)
    public @interface POST {
      String value();
    }
     

    Retrofit的Annotation包含请求方法相关的@GET、@POST、@HEAD、@PUT、@DELETA、@PATCH,和参数相关的@Path、@Field、@Multipart等。

    定义了Annotation要就有解析它的方法,在Retrofit中解析的位置就是RestMethodInfo,但在这之前需要先看哪里使用了RestMethodInfo,前面说了Retrofit使用了动态代理生成了我们定义的接口的实现类,而这个实现类是通过RestAdapter.create返回的,所以使用动态代理的位置就是RestAdapter,接下来就看一下RestAdapter

    RestAdapter

     
    RestAdapter restAdapter = new RestAdapter.Builder()
        .setEndpoint("https://api.github.com")
        .build();
    
    GitHubService service = restAdapter.create(GitHubService.class);
    
    public RestAdapter build() {
      if (endpoint == null) {
        throw new IllegalArgumentException("Endpoint may not be null.");
      }
      
      ensureSaneDefaults();
      
      return new RestAdapter(endpoint, clientProvider, httpExecutor, callbackExecutor,
          requestInterceptor, converter, profiler, errorHandler, log, logLevel);
    }
     

    setEndPoint就不说了,接口中定义的都是相对Url,EndPoint就是域名,build方法调用ensureSaneDefaults()方法,然后就构造了一个RestAdapter对象,构造函数的参数中传入了EndPoint外的几个对象,这几个对象就是在ensureSaneDefaults()中初始化的。

     
    private void ensureSaneDefaults() {
      if (converter == null) { converter = Platform.get().defaultConverter(); }
      if (clientProvider == null) { clientProvider = Platform.get().defaultClient(); }
      if (httpExecutor == null) { httpExecutor = Platform.get().defaultHttpExecutor(); }
      if (callbackExecutor == null) { callbackExecutor = Platform.get().defaultCallbackExecutor(); }
      if (errorHandler == null) { errorHandler = ErrorHandler.DEFAULT; }
      if (log == null) { log = Platform.get().defaultLog(); }
      if (requestInterceptor == null) { requestInterceptor = RequestInterceptor.NONE; }
    }
     

    ensureSaneDefaults()中初始化了很多成员,errorHandler、log就不看了,其他的除了requestInterceptor都是通过Platform对象获得的,所以要先看下Platform

    Platform

     
    private static final Platform PLATFORM = findPlatform();
      static final boolean HAS_RX_JAVA = hasRxJavaOnClasspath();
    
      static Platform get() {
        return PLATFORM;
      }
    
      private static Platform findPlatform() {
        try {
          Class.forName("android.os.Build");
          if (Build.VERSION.SDK_INT != 0) {
            return new Android();
          }
        } catch (ClassNotFoundException ignored) {
        }
    
        if (System.getProperty("com.google.appengine.runtime.version") != null) {
          return new AppEngine();
        }
    
        return new Base();
      }
     

    使用了单例的PLATFORM,通过findPlatform()初始化实例,如果是Android平台就使用Platform.Android,如果是Google AppEngine就使用Platform.AppEngine,否则使用Platform.Base,这些都是Platform的子类,其中AppEngine又是Base的子类。

    Platform是一个抽象类,定义了以下几个抽象方法,这几个方法的作用就是返回一些RestAdapter中需要要用到成员的默认实现

     
    abstract Converter defaultConverter(); // 默认的Converter,用于将请求结果转化成需要的数据,如GsonConverter将JSON请求结果用Gson解析成Java对象
      abstract Client.Provider defaultClient(); // Http请求类,如果是AppEngine就使用`UrlFetchClient`,否则如果有OKHttp就使用OKHttp,如果是Android,2.3以后使用HttpURLConnection,2.3以前使用HttpClient
      abstract Executor defaultHttpExecutor(); // 用于执行Http请求的Executor
      abstract Executor defaultCallbackExecutor(); // Callback调用中用于执行Callback的Executor(可能是同步的)
      abstract RestAdapter.Log defaultLog(); // Log接口,用于输出Log
     
    看完Platform的接口再看ensureSaneDefaults就清楚了,初始化转化数据的Converter、执行请求的Client、执行请求的Executor、执行Callback的Executor、Log输出类、错误处理类和用于在请求前添加额外处理的拦截请求的Interceptor。

    Converter默认都是用的GsonConverter,就不看了,defaultClient返回执行网络请求的Client

    Platform.Android

     
    @Override Client.Provider defaultClient() {
      final Client client;
      if (hasOkHttpOnClasspath()) {
        client = OkClientInstantiator.instantiate();
      } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.GINGERBREAD) {
        client = new AndroidApacheClient();
      } else {
        client = new UrlConnectionClient();
      }
      return new Client.Provider() {
        @Override public Client get() {
          return client;
        }
      };
    }
     
    Platform.Base
     
    @Override Client.Provider defaultClient() {
      final Client client;
      if (hasOkHttpOnClasspath()) {
        client = OkClientInstantiator.instantiate();
      } else {
        client = new UrlConnectionClient();
      }
      return new Client.Provider() {
        @Override public Client get() {
          return client;
        }
      };
    }
     

     Platform.AppEngine

     
    @Override Client.Provider defaultClient() {
      final UrlFetchClient client = new UrlFetchClient();
      return new Client.Provider() {
        @Override public Client get() {
          return client;
        }
      };
    }
     

    对于Android,优先使用OKHttp,否则2.3以后使用HttpUrlConnection,2.3以前使用HttpClient

    defaultHttpExecutor就是返回一个Executor,执行请求的线程在这个Executor中执行,就做了一件事,把线程设置为后台线程

    defaultCallbackExecutor用于执行Callback类型的请求时,提供一个Executor执行Callback的Runnable

    Platform.Base

    @Override Executor defaultCallbackExecutor() {
        return new Utils.SynchronousExecutor();
    }

    Platform.Android

    @Override Executor defaultCallbackExecutor() {
        return new MainThreadExecutor();
    }

    SynchronousExecutor

    static class SynchronousExecutor implements Executor {
        @Override public void execute(Runnable runnable) {
          runnable.run();
        }
    }
    MainThreadExecutor
     
    public final class MainThreadExecutor implements Executor {
      private final Handler handler = new Handler(Looper.getMainLooper());
    
      @Override public void execute(Runnable r) {
        handler.post(r);
      }
    }
     
    如果是Android,通过Handler将回调发送到主线程执行,如果非Android,直接同步执行。

    Platform看完了,RestAdapter的成员初始化完成,就要看怎么通过RestAdapter.create生成我们定义的接口的实现类了

    RestAdapter.create

     
      public <T> T create(Class<T> service) {
        Utils.validateServiceClass(service);
        return (T) Proxy.newProxyInstance(service.getClassLoader(), new Class<?>[] { service },
            new RestHandler(getMethodInfoCache(service)));
      }
      
      Map<Method, RestMethodInfo> getMethodInfoCache(Class<?> service) {
        synchronized (serviceMethodInfoCache) {
          Map<Method, RestMethodInfo> methodInfoCache = serviceMethodInfoCache.get(service);
          if (methodInfoCache == null) {
            methodInfoCache = new LinkedHashMap<Method, RestMethodInfo>();
            serviceMethodInfoCache.put(service, methodInfoCache);
          }
          return methodInfoCache;
        }
      }
     

    使用了动态代理,InvocationHandlerRestHandlerRestHandler有一个参数,是Method->RestMethodInfo的映射,初始化时这个映射是空的。重点就是这两个了:RestHandlerRestMethodInfo,

     
    @Override public Object invoke(Object proxy, Method method, final Object[] args)
        throws Throwable {
      // If the method is a method from Object then defer to normal invocation.
      if (method.getDeclaringClass() == Object.class) { // 1
        return method.invoke(this, args);
      }
      
      // Load or create the details cache for the current method.
      final RestMethodInfo methodInfo = getMethodInfo(methodDetailsCache, method); // 2
      
      if (methodInfo.isSynchronous) { // 3
        try {
          return invokeRequest(requestInterceptor, methodInfo, args);
        } catch (RetrofitError error) {
          Throwable newError = errorHandler.handleError(error);
          if (newError == null) {
            throw new IllegalStateException("Error handler returned null for wrapped exception.",
                error);
          }
          throw newError;
        }
      }
      
      if (httpExecutor == null || callbackExecutor == null) {
        throw new IllegalStateException("Asynchronous invocation requires calling setExecutors.");
      }
      
      // Apply the interceptor synchronously, recording the interception so we can replay it later.
      // This way we still defer argument serialization to the background thread.
      final RequestInterceptorTape interceptorTape = new RequestInterceptorTape();
      requestInterceptor.intercept(interceptorTape); // 4
      
      if (methodInfo.isObservable) { // 5
        if (rxSupport == null) {
          if (Platform.HAS_RX_JAVA) {
            rxSupport = new RxSupport(httpExecutor, errorHandler);
          } else {
            throw new IllegalStateException("Observable method found but no RxJava on classpath");
          }
        }
        
        return rxSupport.createRequestObservable(new Callable<ResponseWrapper>() {
          @Override public ResponseWrapper call() throws Exception {
            return (ResponseWrapper) invokeRequest(interceptorTape, methodInfo, args);
          }
        });
      }
      
      Callback<?> callback = (Callback<?>) args[args.length - 1]; // 6
      httpExecutor.execute(new CallbackRunnable(callback, callbackExecutor, errorHandler) {
        @Override public ResponseWrapper obtainResponse() {
          return (ResponseWrapper) invokeRequest(interceptorTape, methodInfo, args);
        }
      });
      
      return null; // Asynchronous methods should have return type of void.
    }
     

    执行请求时会调用RestHandlerinvoke方法,如上所示,主要是上面代码中标注有6点

    1. 如果调用的是Object的方法,不做处理直接调用。
    2. 通过getMethodInfo获取调用的Method对应的RestMethodInfo,前面说了,构造RestHandler对象时传进来了一个Method->RestMethodInfo的映射,初始时是空的。
     
      static RestMethodInfo getMethodInfo(Map<Method, RestMethodInfo> cache, Method method) {
        synchronized (cache) {
          RestMethodInfo methodInfo = cache.get(method);
          if (methodInfo == null) {
            methodInfo = new RestMethodInfo(method);
            cache.put(method, methodInfo);
          }
          return methodInfo;
        }
     

    getMethodInfo中判断如果相应的映射不存在,就建立这个映射,并如名字所示缓存起来 
    3. 如果是同步调用(接口中直接返回数据,不通过Callback或Observe),直接调用invokeRequest 
    4. 如果是非同步调用,先通过RequestInterceptorTape记录拦截请求,记录后在后台线程做实际拦截,后面会提到。 
    5. 如果是Observe请求(RxJava),执行第5步,对RxJava不了解,略过 
    6. 如果是Callback形式,交由线程池执行

    接口中的每一个Method有一个对应的RestMethodInfo,关于接口中Annotation信息的处理就都在这里了

    RestMethodInfo

     
    private enum ResponseType {
        VOID,
        OBSERVABLE,
        OBJECT
    }
    RestMethodInfo(Method method) {
        this.method = method;
        responseType = parseResponseType();
        isSynchronous = (responseType == ResponseType.OBJECT);
        isObservable = (responseType == ResponseType.OBSERVABLE);
    }
     

    在构造函数中调用了parseResponseTypeparseResponseType解析了方法签名,根据方法的返回值类型及最后一个参数的类型判断方法的类型是哪种ResponseType

    无论是哪种ResponseType,最终都是调用invokeRequest执行实际的请求,接下来依次看下invokeRequest的执行步骤

    RestAdapter.invokeRequest

    第一步是调用methodInfo.init()解析调用的方法,方法里有做判断,只在第一次调用时解析,因为处一次解析后这个对象就被缓存起来了,下次调同一个方法时可以直接使用

     
      synchronized void init() {
        if (loaded) return;
    
        parseMethodAnnotations();
        parseParameters();
    
        loaded = true;
      }
     

    RestMethodInfo.init中分别调用

    • parseMethodAnnotations():解析所有方法的Annotation
    • parseParameters():解析所有参数的Annotation
     
    for (Annotation methodAnnotation : method.getAnnotations()) {
      Class<? extends Annotation> annotationType = methodAnnotation.annotationType();
      RestMethod methodInfo = null;
      // Look for a @RestMethod annotation on the parameter annotation indicating request method.
      for (Annotation innerAnnotation : annotationType.getAnnotations()) {
        if (RestMethod.class == innerAnnotation.annotationType()) {
          methodInfo = (RestMethod) innerAnnotation;
          break;
        }
      }
      ...
    }
     
    parseMethodAnnotations中,会获取方法所有的Annotation并遍历:
    • 对于每一个Annotation,也会获取它的Annotation,看它是否是被RestMethod注解的Annotation,如果是,说明是@GET,@POST类型的注解,就调用parsePath解析请求的Url,requestParam(URL中问号后的内容)及Url中需要替换的参数名(Url中大括号括起来的部分)
    • 寻找Headers Annotation解析Header参数
    • 解析RequestType:SIMPLE,MULTIPART,FORM_URL_ENCODED

    parseParameters解析请求参数,即参数的Annotation,@PATH@HEADER@FIELD

    第二步是RequestBuilder和Interceptor,这两个是有关联的,所以一起看。

    RequestBuilder requestBuilder = new RequestBuilder(serverUrl, methodInfo, converter);
    requestBuilder.setArguments(args);
    requestInterceptor.intercept(requestBuilder);
    Request request = requestBuilder.build();

    先说RequestInterceptor,作用很明显,当执行请求时拦截请求以做一些特殊处理,比如添加一些额外的请求参数。

     
    /** Intercept every request before it is executed in order to add additional data. */
    public interface RequestInterceptor {
      /** Called for every request. Add data using methods on the supplied {@link RequestFacade}. */
      void intercept(RequestFacade request);
    
      interface RequestFacade {
        void addHeader(String name, String value);
        void addPathParam(String name, String value);
        void addEncodedPathParam(String name, String value);
        void addQueryParam(String name, String value);
        void addEncodedQueryParam(String name, String value);
      }
    
      /** A {@link RequestInterceptor} which does no modification of requests. */
      RequestInterceptor NONE = new RequestInterceptor() {
        @Override public void intercept(RequestFacade request) {
          // Do nothing.
        }
      };
    }
     

    RequestInterceptor只有一个方法intercept,接收一个RequestFacade参数,RequestFacadeRequestInterceptor内部的一个接口,这个接口的方法就是添加请求参数,Query、Header什么的。大概可以看出RequestInterceptor的作用了,如果RequestFacade表示一个请求相关的数据,RequestInteceptor.intercept的作用就是向这个RequestFacade中添加额外Header,Param等参数。

    RequestFacade的一个子类叫RequestBuilder,用来处理Request请求参数,在invokeRequest中会对RequestBuilder调用intercept方法向RequestBuilder添加额外的参数。

    有一个叫RequestInterceptorTape的类,同时实现了RequestFacadeRequestInterceptor,它的作用是:

    • 当作为RequestFacade使用时作为参数传给一个RequestInteceptor,这个RequestInterceptor调用它的addHeader等方法时,它把这些调用及参数记录下来
    • 然后作为RequestInterceptor使用时,将之前记录的方法调用及参数重新应用到它的intercept参数RequestFacade

    RestHandler.invoke中,如果判断方法的调用不是同步调用,就通过下面的两行代码将用户设置的interceptor需要添加的参数记录到RequestInterceptorTape,然后在invokeRequest中再实际执行参数的添加。

    // Apply the interceptor synchronously, recording the interception so we can replay it later.
    // This way we still defer argument serialization to the background thread.
    final RequestInterceptorTape interceptorTape = new RequestInterceptorTape();
    requestInterceptor.intercept(interceptorTape);

     RequestBuilder.setArguments()解析调用接口时的实际参数。然后通过build()方法生成一个Request对象

    第三步执行请求,Response response = clientProvider.get().execute(request);

    第四步就是解析并分发请求结果了,成功请求时返回结果,解析失败调用ErrorHandler给用户一个自定义异常的机会,但最终都是通过异常抛出到invoke()中的,如果是同步调用,直接抛异常,如果是Callback调用,会回调Callback.failure

    CallbackRunnable

    请求类型有同步请求,Callback请求,Observable请求,来看下Callback请求:

    Callback<?> callback = (Callback<?>) args[args.length - 1];
    httpExecutor.execute(new CallbackRunnable(callback, callbackExecutor, errorHandler) {
        @Override public ResponseWrapper obtainResponse() {
          return (ResponseWrapper) invokeRequest(interceptorTape, methodInfo, args);
        }
    });

     Callback请求中函数最后一个参数是一个Callback的实例,httpExecutor是一个Executor,用于执行Runnable请求,我们看到,这里new了一个CallbackRunnable执行,并实现了它的obtainResponse方法,看实现:

     
    abstract class CallbackRunnable<T> implements Runnable {
      private final Callback<T> callback;
      private final Executor callbackExecutor;
      private final ErrorHandler errorHandler;
    
      CallbackRunnable(Callback<T> callback, Executor callbackExecutor, ErrorHandler errorHandler) {
        this.callback = callback;
        this.callbackExecutor = callbackExecutor;
        this.errorHandler = errorHandler;
      }
    
      @SuppressWarnings("unchecked")
      @Override public final void run() {
        try {
          final ResponseWrapper wrapper = obtainResponse();
          callbackExecutor.execute(new Runnable() {
            @Override public void run() {
              callback.success((T) wrapper.responseBody, wrapper.response);
            }
          });
        } catch (RetrofitError e) {
          Throwable cause = errorHandler.handleError(e);
          final RetrofitError handled = cause == e ? e : unexpectedError(e.getUrl(), cause);
          callbackExecutor.execute(new Runnable() {
            @Override public void run() {
              callback.failure(handled);
            }
          });
        }
      }
    
      public abstract ResponseWrapper obtainResponse();
    } 
     
    就是一个普通的Runnable,在run方法中首先执行obtailResponse,从名字可以看到是执行请求返回Response,这个从前面可以看到执行了invokeRequest,和同步调用中一样执行请求。

    紧接着就提交了一个Runnable至callbackExecutor,在看Platform时看到了callbackExecotor是通过Platform.get().defaultCallbackExecutor()返回的,Android中是向主线程的一个Handler发消息

    值得注意的事,对于同步调用,如果遇到错误是直接抛异常,而对于异步调用,是调用Callback.failure()

    Mime

    执行网络请求,需要向服务端发送请求参数,如表单数据,上传的文件等,同样需要解析服务端返回的数据,在Retrofit中对这些做了封装,位于Mime包中,也只有封装了,才好统一由指定的Converter执行数据的转换

    TypedInputTypedOutput表示输入输出的数据,都包含mimeType,并分别支持读入一个InputStream或写到一个OutputStrem

     
    /**
     * Binary data with an associated mime type.
     *
     * @author Jake Wharton (jw@squareup.com)
     */
    public interface TypedInput {
    
      /** Returns the mime type. */
      String mimeType();
    
      /** Length in bytes. Returns {@code -1} if length is unknown. */
      long length();
    
      /**
       * Read bytes as stream. Unless otherwise specified, this method may only be called once. It is
       * the responsibility of the caller to close the stream.
       */
      InputStream in() throws IOException;
    }
    
    /**
     * Binary data with an associated mime type.
     *
     * @author Bob Lee (bob@squareup.com)
     */
    public interface TypedOutput {
      /** Original filename.
       *
       * Used only for multipart requests, may be null. */
      String fileName();
    
      /** Returns the mime type. */
      String mimeType();
    
      /** Length in bytes or -1 if unknown. */
      long length();
    
      /** Writes these bytes to the given output stream. */
      void writeTo(OutputStream out) throws IOException;
    }
     

    TypedByteArray,内部数据是一个Byte数组

     
      private final byte[] bytes;
    
      @Override public long length() {
        return bytes.length;
      }
    
      @Override public void writeTo(OutputStream out) throws IOException {
        out.write(bytes);
      }
    
      @Override public InputStream in() throws IOException {
        return new ByteArrayInputStream(bytes);
      }
     

    TypedString,继承自TypedByteArray,内部表示是一样的

     
    public TypedString(String string) {
        super("text/plain; charset=UTF-8", convertToBytes(string));
      }
    
      private static byte[] convertToBytes(String string) {
        try {
          return string.getBytes("UTF-8");
        } catch (UnsupportedEncodingException e) {
          throw new RuntimeException(e);
        }
      }
     

    其他的也一样,从名字很好理解:TypedFileMultipartTypedOutputFormEncodedTypedOutput

    其他

    Retrofit对输入和输出做了封装,通过TypedOutput向服务器发送数据,通过TypedInput读取服务器返回的数据。

    通过MultipartTypedOutput支持文件上传,读取服务器数据时,如果要求直接返回未解析的Response,Restonse会被转换为TypedByteArray,所以不能是大文件类的

    Retrofit支持不同的Log等级,当为LogLevel.Full时会把Request及Response的Body打印出来,所以如果包含文件就不行了。

    Retrofit默认使用GsonConverter,所以要想获取原始数据不要Retrofit解析,要么自定义Conveter,要么直接返回Response了,返回Response也比较麻烦

    总体来说Retrofit看起来很好用,不过要求服务端返回数据最好要规范,不然如果请求成功返回一种数据结构,请求失败返回另一种数据结构,不好用Converter解析,接口的定义也不好定义,除非都返回Response,或自定义Converter所有接口都返回String

    在Twitter上JakeWharton这么说:

    Gearing up towards a Retrofit 1.6.0 release and then branching 1.x so we can push master towards a 2.0 and fix long-standing design issues.

    要出2.0了,内部API会改,接口应该不怎么变

  • 相关阅读:
    一起来学SpringBoot(十七)优雅的参数校验
    使用JDBC创建出版社和书籍管理系统
    springMvc(初识+操作步骤)
    模拟Java-Sping,实现其IOC和AOP核心
    python多个装饰器的执行顺序
    JAVA——不简单的fianl关键字
    Java HTTP 组件库选型看这篇就够了
    这一次,我连 web.xml 都不要了,纯 Java 搭建 SSM 环境!
    13数据结构与算法分析之---链式栈
    12数据结构与算法分析之---顺序栈
  • 原文地址:https://www.cnblogs.com/android-blogs/p/5391553.html
Copyright © 2020-2023  润新知