okhttp3基础使用全解析(配合servlet) - Go语言中文社区

okhttp3基础使用全解析(配合servlet)


依赖

compile 'com.squareup.okhttp3:okhttp:3.10.0'

OkHttpClient
核心类, 内部封装请求响应业务逻辑,由于体积庞大所以只生成一个对象。
RequestBody
post请求所用到的类,用于打包上传的数据,为了简化操作又分为两个子类。
FormBody
RequestBody的子类之一,只能用于打包键值对,默认编码方式为URL的utf-8。
MultipartBody
RequestBody另一个子类,同时打包多个类型数据。
MediaType
解析上传,接收文件类型,最后将信息封装在请求头中。
Headers
封装头文件所有信息,本质是一个String数组。
Response
无论是get或者Post请求最后都会从服务器得到一个响应,而Respones就是包装了这个响应的内容。

创建一个OkHttpClient

使用默认配置参数

OkHttpClient client=new OkHttpClient();

自定义参数

OkHttpClient client=new OkHttpClient.Builder()
        .connectTimeout(10, TimeUnit.SECONDS)       //设置连接超时
        .readTimeout(10, TimeUnit.SECONDS)          //设置读超时
        .writeTimeout(10,TimeUnit.SECONDS)          //设置写超时
        .retryOnConnectionFailure(true)             //是否自动重连
        .build();                                   //构建OkHttpClient对象

参数10指的是10秒,为默认值。

Headers详解

在HttpURLConnection中,头文件信息都是通过connection.addRequestProperty()方法添加的,整体代码耦合度较高,而okhttp完美解决这个问题,将头文件封装成一个对象最后统一处理,而这个对象就是Headers,headers的创建有两种方式;

方式一:建造者模式

Headers headers = new Headers.Builder().add("Height","5").add("Width","5").build();

这种方式方便在于直接通过add()方法以键值对形式保存头文件信息。

方式二:静态方法创建

Map<String, String> maps = new HashMap<>();
Headers headers = Headers.of(maps);

当我们拥有一个map集合时会通过这个方法创建。

两种方法的本质是相同的,看下Headers源码,

public final class Headers {
   
private final String[] namesAndValues;


 Headers(Builder builder) {
   this.namesAndValues = builder.namesAndValues.toArray(new                                     String[builder.namesAndValues.size()]);
	 }

   private Headers(String[] namesAndValues) {
    this.namesAndValues = namesAndValues;
  }	



  public static Headers of(Map<String, String> headers) {
   String[] namesAndValues = new String[headers.size() * 2];
		....
	 return new Headers(namesAndValues)
}



  public Headers build() {
      return new Headers(this);
    }

	
 }

最终都会将键值对转换成一个String数组

产生一个Request随即产生一个Headers对象,并且最终的Headers自带默认的几个头文件信息
笔者发起一个没有自定义Headers参数的get请求,这是servlet部分代码

Enumeration<String> enumeration = request.getHeaderNames();
	
		while(enumeration.hasMoreElements()){
			
			String key=enumeration.nextElement();
			
			System.out.print(key+":");
			
			Enumeration<String> values=request.getHeaders(key);
		
			while(values.hasMoreElements()){
			
				System.out.print(values.nextElement()+";");
			}	
		
			System.out.println();
		}

打印结果

host:***.***.***.***:8080;
connection:Keep-Alive;
accept-encoding:gzip;
user-agent:okhttp/3.10.0;

确实是这样,这些是信息是在 OkHttpClient里生成的,看下源码:

static {
    Internal.instance = new Internal() {
      @Override public void addLenient(Headers.Builder builder, String line) {
        builder.addLenient(line);
      }

      @Override public void addLenient(Headers.Builder builder, String name, String value) {
        builder.addLenient(name, value);
      }
}

OkHttpClient静态域提供这些方法,我们可以看到有个参数为Headers.Builder,所以猜想在最后数据包装时将产生新的一个Headers

而post请求由于需要提交上传数据类型,所以构建RequestBody时需要我们为其提供一个。

**注意:**okhttp的Headers的键值不能含有中文字符,否则报错。

Request##

request内部封装了请求头,请求行,实体内容

public final class Request {
  final HttpUrl url;
  final String method;
  final Headers headers;
  final @Nullable RequestBody body;
  final Object tag;

  private volatile CacheControl cacheControl; // Lazily initialized.

  Request(Builder builder) {
    this.url = builder.url;
    this.method = builder.method;
    this.headers = builder.headers.build();
    this.body = builder.body;
    this.tag = builder.tag != null ? builder.tag : this;
  }
}

可以看到Request不能直接从外部创建

Request的创建

Request request = new Request.Builder().url("http://192.168.23.1:8080/Get/aa?帅=5&Height=5").post(request).build();

这是一个post请求,它的创建方式与Headers大径相同都是使用建造者模式先构建一个Build对象用于接收各种参数,最后通过build()方法创建一个含有这些参数的Request对象。

发起一个get请求
因为是get请求所以不需要RequestBody。

同步线程阻塞

 OkHttpClient okHttpClient=new OkHttpClient();
 Response response = okHttpClient.newCall(request).execute();

异步


        OkHttpClient okHttpClient=new OkHttpClient();
        okHttpClient.newCall(request).enqueue(new Callback() {
            @Override
            public void onFailure(Call call, IOException e) {
                //接收异常(例如URL错误)
            }

            @Override
            public void onResponse(Call call, Response response) throws IOException {
				//正常拿到响应
            }
        });

Request使用的是上面创建好的,看下服务器端怎么接收

Enumeration<String> enumeration = request.getParameterNames();
	
		while(enumeration.hasMoreElements()){
			
			String key=enumeration.nextElement();
			
			System.out.print(key+":");
			
			String[] values=request.getParameterValues(key);
			
			for(String val : values)
			
				System.out.print(val+";");
			}	
			
			System.out.println();
		}

接收结果

帅:5;Height:5;

并没有出现乱码的情况,由于okhttp的URL字符串向字节转换使用utf-8编码,而apache对应的也是utf-8解码。

RequestBody

RequestBody
打包数据的抽象基类,内部有自己的实现

public static RequestBody create(@Nullable MediaType contentType, String content) {}

public static RequestBody create(final @Nullable MediaType contentType, final byte[] content) {}

public static RequestBody create(final @Nullable MediaType contentType, final File file) {}

主要提供了创建自己的三个方法,根据参数可知道分为 String,byte,File,且每次只能打包其中之一, MediaType 参数是我们需要自定义的这个数据的类型。

MediaType
MediaType其实就是一个内容说明,会出现在请求的实体以及接收的实体中。

  private final String mediaType;
  private final String type;
  private final String subtype;
  private final @Nullable String charset;

MediaType是一个存放了已经解析的对象,如何解析?

 MediaType mediaType = MediaType.parse("application/json;charset=utf-8");

综上所述,mediaType值为application/json;charset=utf-8,type为application,subtype为json,charset为utf-8,这样服务器便得到了数据的具体格式进行相关解码操作。具体见 Media Type以及 MIME参考手册

  • json : application/json
  • xml : application/xml
  • png : image/png
  • jpg : image/jpeg
  • gif : imge/gif

这是比较常用的几个。

最后在post请求里OkhttpClient还要将解析的内容拼装在Headers中一并发送。

 MediaType mediaType = MediaType.parse("application/json;charset=GBK");
        RequestBody requestBody=RequestBody.create(mediaType, "夜的第七章/周杰伦");
        Request request =new Request.Builder().post(requestBody).url("http://192.168.23.1:8080/Get/aa").build();


用RequestBody发起的一个post请求。注意最后的charset=GBK,这样okhttp就会用GBK形式编码。

服务器端:

	InputStreamReader inputStreamReader=new InputStreamReader(request.getInputStream(),"UTF-8");
	
		BufferedReader bufferedReader=new BufferedReader(inputStreamReader);
		
		System.out.print(bufferedReader.readLine());

用InputStream直接接收实体内容,我们先用UTF-8解码,测试一下传来的数据是否是GBK形式的。

????????/?????

最后服务端表示无法解释,更改GBK后。

夜的第七章/周杰伦

得到了正确的结果,其实这种传输方式就是直接将String转换成byte看源码:

byte[] bytes = content.getBytes(charset);
    return create(contentType, bytes);

直接按照MediaType传入的编码方式,只进行了一次编码。

byte,File传输方式相同,找到合适MediaType即可。

FormBody##

formbody作为RequestBody子类之一在某些功能上肯定做了改变,它只能传输键值对数据。

formbody的创建

 FormBody formBody=new FormBody.Builder("GBK").add("以父之名","周杰伦")
                .add("蒲公英的约定","周杰伦")
                .add("不能说的秘密","周杰伦")
                .build();

okhttp几乎我们操作的类都是用的建造者模式创建,很方便。这个formBody直接作为RequestBody使用,那么MediaType去哪了?

大概了解一下FormBody的Field

public final class FormBody extends RequestBody {
  private static final MediaType CONTENT_TYPE =
      MediaType.parse("application/x-www-form-urlencoded");

  private final List<String> encodedNames;
  private final List<String> encodedValues;

}

清晰看到其实FormBody自带MediaType的,由于规定它只能传输键值对数据那也就没必要我们再去为它设置MediaType了。为什么这个MediaType没有charset?

接收FormBody数据
先使用上面的代码接收,看看FormBody打包过来的数据完整格式。

%E4%BB%A5%E7%88%B6%E4%B9%8B%E5%90%8D=%E5%91%A8%E6%9D%B0%E4%BC%A6&%E8%92%B2%E5%85%AC%E8%8B%B1%E7%9A%84%E7%BA%A6%E5%AE%9A=%E5%91%A8%E6%9D%B0%E4%BC%A6&%E4%B8%8D%E8%83%BD%E8%AF%B4%E7%9A%84%E7%A7%98%E5%AF%86=%E5%91%A8%E6%9D%B0%E4%BC%A6

发现是一串“乱码"!! 难道FormBody内默认UTF-8编码? 于是将服务器又改成UTF-8:

%E4%BB%A5%E7%88%B6%E4%B9%8B%E5%90%8D=%E5%91%A8%E6%9D%B0%E4%BC%A6&%E8%92%B2%E5%85%AC%E8%8B%B1%E7%9A%84%E7%BA%A6%E5%AE%9A=%E5%91%A8%E6%9D%B0%E4%BC%A6&%E4%B8%8D%E8%83%BD%E8%AF%B4%E7%9A%84%E7%A7%98%E5%AF%86=%E5%91%A8%E6%9D%B0%E4%BC%A6

仍是”乱码",但是发现了端倪,“乱码"竟然一模一样, 如果确实通过了UTF-8,GBK编解码那么最后的值肯定是不同的,所以能证明okhttp的FormBody并没有直接向字符编码,但是又必须经历向字符编码。所以可以推断出okhttp先编码成一种格式,这个格式最后向字符转换后无论是GBK还是UTF-8都可以还原出来,其实就是用到了URL编码。

URL编码

就是将某些字符转换成一段通用字符,这些通用字符无论是GBK,UTF-8都可以编解码相同的值,也就是ASCII传输。详细可以百度。

java语言的编解码:

String putStr = "周杰伦";
            String decodeStr = URLDecoder.decode(putStr, "UTF-8");
            String getStr = URLEncoder.encode(decodeStr, "UTF-8");

这样getStr就能得到putStr的值了, URL编解码也是需要字节编解码的。

这样修改后的服务器端:

InputStreamReader inputStreamReader=new InputStreamReader(request.getInputStream(),"gbk");
	
		BufferedReader bufferedReader=new BufferedReader(inputStreamReader);
		
		String str=bufferedReader.readLine();
			
		System.out.println(URLDecoder.decode(str,"UTF-8"));
	}

字节编码无关紧要,因为传输过来的是经过URL编码的字节,都可以转换,要注意的是decode()里的解码为”UTF-8" 对应的是什么呢?

 FormBody formBody = new FormBody.Builder(Charset.forName("UTF-8"))

其实就是这里设定的编码。FormBody里设定的编码是URL编码。
那么我们怎么区分是否URL编码呢? 请求头已经告诉了我们!

content-type:application/x-www-form-urlencoded;

当我们使用FormBody时默认添加这行请求头,当我们使用post请求都会再添加一个请求头,就是MediaType的值, 所以这里并没有具体的charset,当服务器读到这种请求头就知道需要用URL编码了。当然是URL那种形式就要自定义规范了okhttp默认是UTF-8。

一切正常后看下FormBody的完整格式:

以父之名=周杰伦&蒲公英的约定=周杰伦&不能说的秘密=周杰伦

与get请求放在URL后面的格式一模一样,那在服务器端能否通过相同的代码解析呢? 用到get请求里解析参数的代码,在上面…有点多就不用翻了。

Enumeration<String> enumeration = request.getParameterNames();
		
		while(enumeration.hasMoreElements()){
			
			String key=enumeration.nextElement();
			
			System.out.print(key+":");
			
			String[] values=request.getParameterValues(key);
			
			for(String val : values)
			
				System.out.print(val+";");
			}	
			
			System.out.println();

看下解析的值。

????????????:??¨??°???;è?????è?±?????????:??¨??°???;???è??è??????§????:??¨??°???;

发现好像有点东西,看来只是解码方式不对,那怎样修改这种解码方式呢?

request.setCharacterEncoding("utf-8");

在开头加上这行就行了,毕竟我们用的是框架的解析数据方法那自然也需要用到框架提供的修改解码方法了。看下结果:

以父之名:周杰伦;蒲公英的约定:周杰伦;不能说的秘密:周杰伦;

可以清晰的拿到FormBody的键值,这样数据传输就搞定了。

**注意:**这个方法只能针对实体内容,用在get请求里并没有什么用,get请求的参数直接随着URL使用utf-8编码成字节。而apache解析URL也直接用utf-8。

总结: 直接使用RequestBody传输String其编码方式为MediaType里的Charset,使用FormBody传输String编码方式为URL编码,具体形式可以自定义。

MultipartBody##

multipartBody顾名思义可以打包上传多个类型数据,它的本身更像一种容器包含多个RequestBody。

MultipartBody创建

new MultipartBody.Builder()
                .setType(MultipartBody.FORM)
                .addPart()
                .addFormDataPart()
                .build();

还是相同的创建方式,前面我们证述了每个Request都需要提供一个MediaType作为解释内容的请求头成员,当然 MultipartBody也不例外,通过setType():

 public static final MediaType FORM = MediaType.parse("multipart/form-data");

本身是容器所以它的MediaType类型少所以官方就做了统一,我们可以直接选择FORM这个MediaType,表示可以传输二进制流。
有了MediaType就只剩下内容了,正因为是multipartBody所以它的内容是RequestBody,我们可以通过addPart(),addFormDataPart()两种方法来添加RequestBody。

Part
part内部封装了一个RequestBody以及Headers,Headers用于解释这个RequestBody,其实每个RequestBody都需要一个Headers,并且这个Headers里至少有个参数来告诉服务器自己属于什么样数据,该怎样处理(就是上述的MediaType)。

public static final class Part {

    final @Nullable Headers headers;
    final RequestBody body;

}

Part静态类内部提供了创建自己的方法,当然这些方法并不需要我们调用。

addPart
选取一个典型的addPart()方法用于创建一个Part对象,其他大径相同

 public Builder addPart(@Nullable Headers headers, RequestBody body) {
      return addPart(Part.create(headers, body));
    }

最后调用Part的静态方法创建自己 ,

public static Part create(@Nullable Headers headers, RequestBody body) {
      if (body == null) {
        throw new NullPointerException("body == null");
      }
      if (headers != null && headers.get("Content-Type") != null) {
        throw new IllegalArgumentException("Unexpected header: Content-Type");
      }
      if (headers != null && headers.get("Content-Length") != null) {
        throw new IllegalArgumentException("Unexpected header: Content-Length");
      }
      return new Part(headers, body);
    }

当我们提供一个Headers对象时不能包含Content-Type,Content-Length否则会抛出一个异常,因为这两个头文件由外部的multipartBody的MediaType以及Request的Headers提供。那么这个Headers可以放什么参数呢?比方我们上传一个图片:

 addPart(new Headers.Builder().add("Content-Disposition","form-data;name=files;filename=postimage.jpg")
                                .build(),
                        RequestBody.create(MediaType.parse("image/jpg"),file)

Part参数的Headers需要提供Content-Disposition(附加的文件)以及name,filename的values。而第二个参数RequestBody里的MediaType只需提供type即可。当然具体按照上传的需求改变。

addFormDataPart

addFormDataPart是添加Part的另一种形式,当我们不需要额外的为这个RequestBody添加其他解释(Headers)的时候就可以调用它。

public Builder addFormDataPart(String name, @Nullable String filename, RequestBody body) {
      return addPart(Part.createFormData(name, filename, body));
    }

任然是调用了Part的静态方法:

public static Part createFormData(String name, @Nullable String filename, RequestBody body) {
      if (name == null) {
        throw new NullPointerException("name == null");
      }
      StringBuilder disposition = new StringBuilder("form-data; name=");
      appendQuotedString(disposition, name);

      if (filename != null) {
        disposition.append("; filename=");
        appendQuotedString(disposition, filename);
      }

      return create(Headers.of("Content-Disposition", disposition.toString()), body);
    }

可以看到只需我们提供name以及filename参数,它会生成一个只带一个键值对的Headers,其中key为Content-Disposition,正如上述我们上传图片调用addPart一样,后面的values也是相同的。最后传入一个合适的MediaType的RequestBody即可。

addPart与addFormDataPart比较

addPart需要我们自定义一个Headers所以更加灵活可以满足更多的业务需求,而addFormPart只需提供相关参数所以更加简洁方便。

Respones

既然respones包装了响应的内容那么自然可以得到响应头以及实体内容了。

得到响应头:

 Headers headers = response.headers();
                headers.get("");

得到实体内容:

ResponseBody responseBody = response.body();

responseBody便是封装了实体内容的类。

responesBody内容

一般访问服务器目的就是得到两种内容,一种是XML,Json格式的String,另一种自然就是byte了,图片,视频等等…当然String本身也是byte不过ResponsBody已经处理过了。

处理String:

String getStr = responseBody.string();

这里的string()方法是将传来的字节按照utf-8解码的,所以如果服务器并非utf-8编码则会乱码。那么如何知道服务器的编码形式?其实与服务器处理客户端请求步骤一样,在服务器端添加:

response.setHeader("content-type", "application/json;charset=gbk");

然后通过Respones得到这个头文件信息:

 MediaType mediaType = responseBody.contentType();
                String  getCharSet =  mediaType.charset().name();

Respones会自动识别“content-type"这个key并通过MediaType解析values然后将其封装在内,这样就能得到正确的编码方式了。

处理byte:

 InputStream inputStream = responseBody.byteStream();

拿到字节流就可以完成数据传输了。

关于okhttp的重定向

okhttp重定向存在两个缺陷:

1.okhttp处理301,302重定向时,会把请求方式设置为GET
这样会丢失原来Post请求中的参数。

2.okhttp默认不支持跨协议的重定向,比如http重定向到https

所以我们需要添加拦截器,当检测到需要重定向时用拦截器获取重定向的URL然后更改Request即可。

  .addNetworkInterceptor(new Interceptor() {
                    @Override
                    public Response intercept(Chain chain) throws IOException {

                        Request request = chain.request();
                        HttpUrl beforeUrl = request.url();
                        Response response = chain.proceed(request);
                        HttpUrl afterUrl = response.request().url();
                        if (response.isRedirect()) {

                            if (!beforeUrl.scheme().equals(afterUrl.scheme()) || !request.method().equals("GET")) {
                                //重新请求
                                Request newRequest = request.newBuilder().url(response.request().url()).build();
                                response = chain.proceed(newRequest);
                            }
                        }
                        return response;
                    }
                })

对所有request做下判断,其他的重定向交给Okhttp自己处理就行。

关于Cookies

cookies:存放于请求头的一个凭证。
(“Set-Cookie” ,“name = value;name = value”)
(“Cookie” ,“name = value;name = value”)

而okhttp中的Cookie对象用于封装name与value。

private Request networkRequest(Request request) throws IOException {
    Request.Builder result = request.newBuilder();

    //例行省略....
    
    List<Cookie> cookies = client.cookieJar().loadForRequest(request.url());
    if (!cookies.isEmpty()) {
      result.header("Cookie", cookieHeader(cookies));
    }

    //例行省略....

    return result.build();
    }

    private String cookieHeader(List<Cookie> cookies) {
    StringBuilder cookieHeader = new StringBuilder();
    for (int i = 0, size = cookies.size(); i < size; i++) {
      if (i > 0) {
        cookieHeader.append("; ");
      }
      Cookie cookie = cookies.get(i);
      cookieHeader.append(cookie.name()).append('=').append(cookie.value());
    }
    return cookieHeader.toString();
    }

    public void receiveHeaders(Headers headers) throws IOException {
    if (client.cookieJar() == CookieJar.NO_COOKIES) return;

    List<Cookie> cookies = Cookie.parseAll(userRequest.url(), headers);
    if (cookies.isEmpty()) return;

    client.cookieJar().saveFromResponse(userRequest.url(), cookies);
    }

一个内部机制简易源码模型,可以看到最后会生成一个cookie头文件,将所有的Cookie对象有效值取出拼接成一个字符串随着request发送。每次响应检测到Set-Cookie头文件时便会存储这个cookie信息。

okhttp有强大的Cookies管理机制,非常容易实现cookies的动态更新与持久存储。

  .cookieJar(CookieJar cookieJar);

httpClient添加CookieJar即可,当然嫌麻烦也不用我们自己动手实现。
一个用SP实现持久存储Cookies的CookieJar
这样每次请求的时候都会传入我们的Cookie,

关于Https证书

http是超文本传输协议,信息是明文传输,https 则是具有安全性的ssl加密传输协议 http和https使用的是完全不同的连接方式用的端口也不一样:前者是80,后者是443,https协议需要到ca申请证书,所以申请了ca证书的网址通过okhttp可以直接访问,相反对于自定义证书就需要引入到okhttp中这样才能正确访问。

如何引入证书?
需要一个完整的** SSLSocketFactory**

public SSLSocketFactory setCertificates(InputStream... certificates) {
        try {
            CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
            KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
            keyStore.load(null);
            int index = 0;
            for (InputStream certificate : certificates) {
                String certificateAlias = Integer.toString(index++);
                keyStore.setCertificateEntry(certificateAlias, certificateFactory.generateCertificate(certificate));

                try {
                    if (certificate != null)
                        certificate.close();
                } catch (IOException e) {
                }
            }
            SSLContext sslContext = SSLContext.getInstance("TLS");

            TrustManagerFactory trustManagerFactory =
                    TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());

            trustManagerFactory.init(keyStore);
            sslContext.init
                    (
                            null,
                            trustManagerFactory.getTrustManagers(),
                            new SecureRandom()
                    );

            return sslContext.getSocketFactory();

        } catch (Exception e) {
            e.printStackTrace();
        }
        
        return null;
    }

证书文件放在assets文件夹下传入InputStream得到SSLSocketFactory。

.sslSocketFactory(SSLSocketFactory sslSocketFactory)

最后存入okhttp即可。

关于Okhttp缓存

所谓缓存便是在本地可持久存储从服务器获取的数据,那么自然而然关系到Response, Response的表现有两种形式:
在这里插入图片描述
networkResponse,cacheResponse。 我们的请求最终无非就是在这两种之间选择合适的数据部署。 这里关系到具体的两个类。

cache

cache 是来告诉OkhttpClient缓存的位置以及大小。

  Cache cache = new Cache(getActivity().getCacheDir(), 1024 * 1024 * 20); 
  OkHttpClient okHttpClient = new OkHttpClient.Builder().cache(cache).build();

这样便可完成Cache的设定。

CacheControl

我们可以对一个请求进行设定,控制它的Response的缓存策略。

 Request request = new Request.Builder()
                .cacheControl(new CacheControl.Builder().noCache().build())
                .build();

比如可以设定成这种模式,表示缓存但不从缓存加载数据。
可以选择多种策略模式:

在这里插入图片描述

常用如下:
maxAge() : 设置当前缓存有效期,有效期过了就会从服务器请求并更新本地缓存。
noCache() : 缓存但不加载数据。
noStore() : 不缓存
max-stale : 指示客户机可以接收超出超时期间的响应消息。如果指定max-stale消息的值,那么客户机可以接收超出超时期指定值之内的响应消息。

multipartBody的服务端解析好像需要SpringMVC ,java web领域笔者也不太懂,就先到这了。

版权声明:本文来源CSDN,感谢博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/qq_36043263/article/details/79983122
站方申明:本站部分内容来自社区用户分享,若涉及侵权,请联系站方删除。

0 条评论

请先 登录 后评论

官方社群

GO教程

猜你喜欢