SpringCloud?Gateway實現(xiàn)API接口加解密
接口范圍
所有GET請求 白名單除外
body 體 是 application_json 和 application_json_utf8 的 POST請求 白名單除外
POST url傳參也支持 白名單除外
啟用禁用/版本
后端提供獨立接口(或者現(xiàn)有接口)查詢是否需要啟用加密功能(如果后端啟用了,前端請求被攔截修改為為啟用,接口也無法訪問回報解密錯誤),此接口明文傳輸
請求頭增加一個加密版本字段,標(biāo)識當(dāng)前的加密算法版本:crypto-version: 1.0.0
加密算法
考慮到全局加密,使用AES加密方式性能更高
加密字符串:原始數(shù)據(jù) > AES加密后的字節(jié)數(shù)組 > Base64編碼處理
解密字符串:Base64密文 > AES密文 -> 原始字符串
AES加密細節(jié):
aesKey:32/16 位由后端同一生成
iv:aesKey
mode:CBC
padding:pkcs7
js例子
//加密
static encryptAES(data, key) {
const dataBytes = CryptoJS.enc.Utf8.parse(data);
const keyBytes = CryptoJS.enc.Utf8.parse(key);
const encrypted = CryptoJS.AES.encrypt(dataBytes, keyBytes, {
iv: keyBytes,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
});
return CryptoJS.enc.Base64.stringify(encrypted.ciphertext);
}報文格式
GET
url:/app/xx/xx?xx=1
加密處理
秘鑰:xxxxxxxxxxxxxxxx
加密文本:{"xx":1}
密文:xq4YR89LgUs4V5N5juKgW5hIsiOsCxBOwzX632S8NV4=
加密后的請求
/app/xx/xx?data=xq4YR89LgUs4V5N5juKgW5hIsiOsCxBOwzX632S8NV4=
POST
url:/app/xx/xx/xxx
json body:
{"xxx1":"111","xxx2":"huawei","xxx3":"789","xxx4":101,"xxx5":2}
加密處理
秘鑰:xxxxxxxxxxxxxxxx
加密文本:
{"xxx1":"111","xxx2":"huawei","xxx3":"789","xxx4":101,"xxx5":2}
密文:1oUTYvWfyaeTJ5/wJTVBqUv0Dz0IAUQTZtxSKY9WLZZl8pILP2Sozk5yOYg9I1WTvzgbbGRDGcWV1ASpYykyS1Fq5cT8s3aLXQ6NMo0AaMOC9L0aVpR863qWso5O8aG3
加密后的請求*
json body:
{
"data": "1oUTYvWfyaeTJ5/wJTVBqUv0Dz0IAUQTZtxSKY9WLZZl8pILP2Sozk5yOYg9I1WTvzgbbGRDGcWV1ASpYykyS1Fq5cT8s3aLXQ6NMo0AaMOjt4G9dK0WwhMGZofYuBKmdF27R8Qkr3VtZvjadtvBazJurITyE7hFcr43nlHSL5E="
}
POST url傳參 和GET格式一致
網(wǎng)關(guān)實現(xiàn)細節(jié)代碼
基于GlobalFilter 接口包裝請求request和響應(yīng)response,先列出關(guān)鍵代碼,完整代碼見文末
filter過濾器請求配置和請求方式分發(fā)
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
if (!cryptoProperties.isEnabled()) {
return chain.filter(exchange);
}
ServerHttpRequest request = exchange.getRequest();
//校驗請求路徑跳過加密
String originalRequestUrl = RequestProvider.getOriginalRequestUrl(exchange);
String path = exchange.getRequest().getURI().getPath();
if (isSkip(path) || isSkip(originalRequestUrl)) {
return chain.filter(exchange);
}
HttpHeaders headers = request.getHeaders();
MediaType contentType = headers.getContentType();
//后期算法升級擴展,暫時只判斷是否相等
if (!cryptoProperties.getCryptoVersion().equals(headers.getFirst(cryptoProperties.getCryptoVersionHeader()))) {
return Mono.error(new CryptoException("加密版本不支持"));
}
if (request.getMethod() == HttpMethod.GET) {
return this.handleGetReq(exchange, chain);
} else if (request.getMethod() == HttpMethod.POST &&
(contentType == null ||
MediaType.APPLICATION_JSON.equals(contentType) ||
MediaType.APPLICATION_JSON_UTF8.equals(contentType))) {
return this.handlePostReq(exchange, chain);
} else {
return chain.filter(exchange);
}
}Get請求參數(shù)解密包裝 ServerHttpRequestDecorator
//構(gòu)造查詢參數(shù)Map
MultiValueMap<String, String> map = buildMultiValueMap(dataJson);
//新的解密后的uri
ServerHttpRequest newHttpRequest = this.buildNewServerHttpRequest(request, map);
//新的解密后的uri request
ServerHttpRequestDecorator decorator = new ServerHttpRequestDecorator(newHttpRequest) {
@Override
public MultiValueMap<String, String> getQueryParams() {
return map;
}
};post請求參數(shù)解密包裝 ServerHttpRequestDecorator
//構(gòu)造一個請求包裝
final MultiValueMap<String, String> finalQueryParamMap = new LinkedMultiValueMap<>(queryParamMap);
ServerHttpRequestDecorator decorator = new ServerHttpRequestDecorator(request) {
@Override
public HttpHeaders getHeaders() {
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.putAll(super.getHeaders());
httpHeaders.remove(HttpHeaders.CONTENT_LENGTH);
httpHeaders.set(HttpHeaders.TRANSFER_ENCODING, "chunked");
return httpHeaders;
}
//處理post url傳參解密
@Override
public MultiValueMap<String, String> getQueryParams() {
if (queryParamsDecrypt) {
return finalQueryParamMap;
}
return super.getQueryParams();
}
@Override
public Flux<DataBuffer> getBody() {
//注意: 這里需要buffer一下,拿到完整報文后再map解密
return super.getBody().buffer().map(buffer -> {
DataBuffer joinDataBuffer = dataBufferFactory.join(buffer);
byte[] content = new byte[joinDataBuffer.readableByteCount()];
joinDataBuffer.read(content);
DataBufferUtils.release(joinDataBuffer);
String decryptData = new String(content, StandardCharsets.UTF_8);
log.info("post decryptData: {}", decryptData);
if (!queryParamsDecrypt && StringUtils.isEmpty(decryptData)) {
throw new CryptoException("參數(shù)格式錯誤");
} else {
JSONObject dataJsonObj = JSON.parseObject(decryptData);
if (!queryParamsDecrypt && !dataJsonObj.containsKey(cryptoProperties.getParamName())) {
throw new CryptoException("參數(shù)格式錯誤");
}
byte[] bytes = AesUtil.decryptFormBase64(dataJsonObj.getString(cryptoProperties.getParamName()), cryptoProperties.getAesKey());
return dataBufferFactory.wrap(Objects.requireNonNull(bytes));
}
});GET/POST返回值加密處理CryptoServerHttpResponseDecorator
class CryptoServerHttpResponseDecorator extends ServerHttpResponseDecorator {
final DataBufferFactory bufferFactory;
boolean isPass = false;
public CryptoServerHttpResponseDecorator(ServerHttpResponse delegate) {
super(delegate);
bufferFactory = delegate.bufferFactory();
}
@Override
public HttpHeaders getHeaders() {
HttpHeaders headers = super.getHeaders();
//同一個請求此處有可能調(diào)用多次,先重置為false
isPass = false;
if (headers.getContentType() != null &&
!MediaType.APPLICATION_JSON.equals(headers.getContentType()) &&
!MediaType.APPLICATION_JSON_UTF8.equals(headers.getContentType())) {
//相應(yīng)體ContentType只處理json
isPass = true;
} else if (!headers.containsKey(cryptoProperties.getCryptoVersionHeader())) {
//添加version響應(yīng)頭
headers.add(cryptoProperties.getCryptoVersionHeader(), cryptoProperties.getCryptoVersion());
}
return headers;
}
//調(diào)用 writeWith 和 writeAndFlushWith 判斷: NettyWriteResponseFilter
// application/json;charset=UTF-8 走這里
@Override
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
if (body instanceof Flux && !isPass) {
Flux<? extends DataBuffer> fluxBody = (Flux<? extends DataBuffer>) body;
return super.writeWith(fluxBody.buffer().map(dataBuffer -> {
DataBuffer joinDataBuffer = bufferFactory.join(dataBuffer);
byte[] content = new byte[joinDataBuffer.readableByteCount()];
joinDataBuffer.read(content);
DataBufferUtils.release(joinDataBuffer);
Map<String, String> data = new HashMap<>(1);
data.put(cryptoProperties.getParamName(), AesUtil.encryptToBase64(content, cryptoProperties.getAesKey()));
return bufferFactory.wrap(JSON.toJSONString(data).getBytes(StandardCharsets.UTF_8));
}));
}
return super.writeWith(body);
}
// StreamingMediaType類型:application/stream 和 application/stream+json 走這里
@Override
public Mono<Void> writeAndFlushWith(Publisher<? extends Publisher<? extends DataBuffer>> body) {
return super.writeAndFlushWith(body);
}
}完整CryptoFilter實現(xiàn)
package org.xx.xx.gateway.filter;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.google.common.collect.Lists;
import io.netty.buffer.ByteBufAllocator;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.reactivestreams.Publisher;
import org.xx.xx.gateway.props.CryptoProperties;
import org.xx.xx.gateway.provider.RequestProvider;
import org.xx.xx.gateway.provider.ResponseProvider;
import org.xx.xx.gateway.util.AesUtil;
import org.xx.xx.gateway.util.StringPool;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.NettyDataBufferFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
/**
* CryptoFilter
*
* @author lizheng
* @version 1.0
* @date 2022/3/11 上午10:57
*/
@Slf4j
@RequiredArgsConstructor
@Configuration
@ConditionalOnProperty(value = "gateway.crypto.enabled", havingValue = "true", matchIfMissing = true)
public class CryptoFilter implements GlobalFilter, Ordered {
private final AntPathMatcher antPathMatcher = new AntPathMatcher();
private final DataBufferFactory dataBufferFactory = new NettyDataBufferFactory(ByteBufAllocator.DEFAULT);
private final CryptoProperties cryptoProperties;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
if (!cryptoProperties.isEnabled()) {
return chain.filter(exchange);
}
ServerHttpRequest request = exchange.getRequest();
//校驗請求路徑跳過加密
String originalRequestUrl = RequestProvider.getOriginalRequestUrl(exchange);
String path = exchange.getRequest().getURI().getPath();
if (isSkip(path) || isSkip(originalRequestUrl)) {
return chain.filter(exchange);
}
HttpHeaders headers = request.getHeaders();
MediaType contentType = headers.getContentType();
//后期算法升級擴展,暫時只判斷是否相等
if (!cryptoProperties.getCryptoVersion().equals(headers.getFirst(cryptoProperties.getCryptoVersionHeader()))) {
return Mono.error(new CryptoException("加密版本不支持"));
}
if (request.getMethod() == HttpMethod.GET) {
return this.handleGetReq(exchange, chain);
} else if (request.getMethod() == HttpMethod.POST &&
(contentType == null ||
MediaType.APPLICATION_JSON.equals(contentType) ||
MediaType.APPLICATION_JSON_UTF8.equals(contentType))) {
return this.handlePostReq(exchange, chain);
} else {
return chain.filter(exchange);
}
}
class CryptoServerHttpResponseDecorator extends ServerHttpResponseDecorator {
final DataBufferFactory bufferFactory;
boolean isPass = false;
public CryptoServerHttpResponseDecorator(ServerHttpResponse delegate) {
super(delegate);
bufferFactory = delegate.bufferFactory();
}
@Override
public HttpHeaders getHeaders() {
HttpHeaders headers = super.getHeaders();
//同一個請求此處有可能調(diào)用多次,先重置為false
isPass = false;
if (headers.getContentType() != null &&
!MediaType.APPLICATION_JSON.equals(headers.getContentType()) &&
!MediaType.APPLICATION_JSON_UTF8.equals(headers.getContentType())) {
//相應(yīng)體ContentType只處理json
isPass = true;
} else if (!headers.containsKey(cryptoProperties.getCryptoVersionHeader())) {
//添加version響應(yīng)頭
headers.add(cryptoProperties.getCryptoVersionHeader(), cryptoProperties.getCryptoVersion());
}
return headers;
}
//調(diào)用 writeWith 和 writeAndFlushWith 判斷: NettyWriteResponseFilter
// application/json;charset=UTF-8 走這里
@Override
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
if (body instanceof Flux && !isPass) {
Flux<? extends DataBuffer> fluxBody = (Flux<? extends DataBuffer>) body;
return super.writeWith(fluxBody.buffer().map(dataBuffer -> {
DataBuffer joinDataBuffer = bufferFactory.join(dataBuffer);
byte[] content = new byte[joinDataBuffer.readableByteCount()];
joinDataBuffer.read(content);
DataBufferUtils.release(joinDataBuffer);
Map<String, String> data = new HashMap<>(1);
data.put(cryptoProperties.getParamName(), AesUtil.encryptToBase64(content, cryptoProperties.getAesKey()));
return bufferFactory.wrap(JSON.toJSONString(data).getBytes(StandardCharsets.UTF_8));
}));
}
return super.writeWith(body);
}
// StreamingMediaType類型:application/stream 和 application/stream+json 走這里
@Override
public Mono<Void> writeAndFlushWith(Publisher<? extends Publisher<? extends DataBuffer>> body) {
return super.writeAndFlushWith(body);
}
}
@SneakyThrows
private Mono<Void> handlePostReq(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String paramData = request.getQueryParams().getFirst(cryptoProperties.getParamName());
MultiValueMap<String, String> queryParamMap = new LinkedMultiValueMap<>();
final boolean queryParamsDecrypt = !StringUtils.isEmpty(paramData);
if (queryParamsDecrypt) {
String dataJson;
try {
//AES解密
dataJson = AesUtil.decryptFormBase64ToString(paramData, cryptoProperties.getAesKey());
} catch (Exception e) {
log.error("請求參數(shù)解密異常: ", e);
return cryptoError(exchange.getResponse(), "請求參數(shù)解密異常");
}
//構(gòu)造查詢參數(shù)Map
queryParamMap = buildMultiValueMap(dataJson);
//新的解密后的uri request
request = this.buildNewServerHttpRequest(request, queryParamMap);
}
//構(gòu)造一個請求包裝
final MultiValueMap<String, String> finalQueryParamMap = new LinkedMultiValueMap<>(queryParamMap);
ServerHttpRequestDecorator decorator = new ServerHttpRequestDecorator(request) {
@Override
public HttpHeaders getHeaders() {
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.putAll(super.getHeaders());
httpHeaders.remove(HttpHeaders.CONTENT_LENGTH);
httpHeaders.set(HttpHeaders.TRANSFER_ENCODING, "chunked");
return httpHeaders;
}
@Override
public MultiValueMap<String, String> getQueryParams() {
if (queryParamsDecrypt) {
return finalQueryParamMap;
}
return super.getQueryParams();
}
@Override
public Flux<DataBuffer> getBody() {
//注意: 這里需要buffer,拿到完整報文后再map解密
return super.getBody().buffer().map(buffer -> {
DataBuffer joinDataBuffer = dataBufferFactory.join(buffer);
byte[] content = new byte[joinDataBuffer.readableByteCount()];
joinDataBuffer.read(content);
DataBufferUtils.release(joinDataBuffer);
String decryptData = new String(content, StandardCharsets.UTF_8);
log.info("post decryptData: {}", decryptData);
if (!queryParamsDecrypt && StringUtils.isEmpty(decryptData)) {
throw new CryptoException("參數(shù)格式錯誤");
} else {
JSONObject dataJsonObj = JSON.parseObject(decryptData);
if (!queryParamsDecrypt && !dataJsonObj.containsKey(cryptoProperties.getParamName())) {
throw new CryptoException("參數(shù)格式錯誤");
}
byte[] bytes = AesUtil.decryptFormBase64(dataJsonObj.getString(cryptoProperties.getParamName()), cryptoProperties.getAesKey());
return dataBufferFactory.wrap(Objects.requireNonNull(bytes));
}
});
}
};
return chain.filter(exchange.mutate()
.request(decorator)
.response(new CryptoServerHttpResponseDecorator(exchange.getResponse()))
.build());
}
@SneakyThrows
private Mono<Void> handleGetReq(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
if (request.getQueryParams().isEmpty()) {
// get無參數(shù) 不走參數(shù)解密
return chain.filter(exchange.mutate()
.request(request)
.response(new CryptoServerHttpResponseDecorator(exchange.getResponse()))
.build());
}
String paramData = request.getQueryParams().getFirst(cryptoProperties.getParamName());
if (StringUtils.isEmpty(paramData)) {
//有參數(shù)但是密文字段不存在
throw new CryptoException("參數(shù)格式錯誤");
}
String dataJson;
try {
//AES解密
dataJson = AesUtil.decryptFormBase64ToString(paramData, cryptoProperties.getAesKey());
} catch (Exception e) {
log.error("請求參數(shù)解密異常: ", e);
return cryptoError(exchange.getResponse(), "請求參數(shù)解密異常");
}
//構(gòu)造查詢參數(shù)Map
MultiValueMap<String, String> map = buildMultiValueMap(dataJson);
//新的解密后的uri
ServerHttpRequest newHttpRequest = this.buildNewServerHttpRequest(request, map);
//新的解密后的uri request
ServerHttpRequestDecorator decorator = new ServerHttpRequestDecorator(newHttpRequest) {
@Override
public MultiValueMap<String, String> getQueryParams() {
return map;
}
};
return chain.filter(exchange.mutate()
.request(decorator)
.response(new CryptoServerHttpResponseDecorator(exchange.getResponse()))
.build());
}
private MultiValueMap<String, String> buildMultiValueMap(String dataJson) {
JSONObject jsonObject = JSON.parseObject(dataJson);
MultiValueMap<String, String> map = new LinkedMultiValueMap<>(jsonObject.size());
for (String key : jsonObject.keySet()) {
map.put(key, Lists.newArrayList(jsonObject.getString(key)));
}
return map;
}
private ServerHttpRequest buildNewServerHttpRequest(ServerHttpRequest request, MultiValueMap<String, String> params) throws URISyntaxException {
StringBuilder queryBuilder = new StringBuilder();
for (String key : params.keySet()) {
queryBuilder.append(key);
queryBuilder.append(StringPool.EQUALS);
queryBuilder.append(params.getFirst(key));
queryBuilder.append(StringPool.AMPERSAND);
}
queryBuilder.deleteCharAt(queryBuilder.length() - 1);
//經(jīng)過測試只覆蓋 ServerHttpRequest的getQueryParams路由分發(fā)之后,無法攜帶過去新的參數(shù),所以這里需要構(gòu)造一個新的解密后的uri
URI uri = request.getURI();
URI newUri = new URI(uri.getScheme(),
uri.getUserInfo(),
uri.getHost(),
uri.getPort(),
uri.getPath(),
queryBuilder.toString(),
uri.getFragment());
//構(gòu)造一個新的ServerHttpRequest
return request.mutate().uri(newUri).build();
}
private boolean isSkip(String path) {
for (String pattern : cryptoProperties.getSkipPathPattern()) {
if (antPathMatcher.match(pattern, path)) {
return true;
}
}
return false;
}
private Mono<Void> cryptoError(ServerHttpResponse resp, String msg) {
resp.setStatusCode(HttpStatus.UNAUTHORIZED);
resp.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
String result = JSON.toJSONString(ResponseProvider.unAuth(msg));
DataBuffer buffer = resp.bufferFactory().wrap(result.getBytes(StandardCharsets.UTF_8));
return resp.writeWith(Flux.just(buffer));
}
@Override
public int getOrder() {
return -200;
}
}以上就是SpringCloud Gateway實現(xiàn)API接口加解密的詳細內(nèi)容,更多關(guān)于SpringCloud Gateway接口加解密的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Java中的CyclicBarrier循環(huán)柵欄詳解
這篇文章主要介紹了Java中的CyclicBarrier循環(huán)柵欄詳解,CyclicBarrier循環(huán)柵欄是用來進行線程協(xié)作,等待線程滿足某個計數(shù),構(gòu)造時設(shè)置計數(shù)個數(shù),每個線程執(zhí)行到某個需要“同步”的時刻調(diào)用 await()方法進行等待,當(dāng)?shù)却木€程數(shù)滿足計數(shù)個數(shù)時,繼續(xù)執(zhí)行,需要的朋友可以參考下2023-12-12
Mybatis批量插入大量數(shù)據(jù)的最優(yōu)方式總結(jié)
批量插入功能是我們?nèi)粘9ぷ髦斜容^常見的業(yè)務(wù)功能之一,下面這篇文章主要給大家總結(jié)介紹了關(guān)于Mybatis批量插入大量數(shù)據(jù)的幾種最優(yōu)方式,文中通過實例代碼介紹的非常詳細,需要的朋友可以參考下2023-03-03
解決try-catch捕獲異常信息后Spring事務(wù)失效的問題
這篇文章主要介紹了解決try-catch捕獲異常信息后Spring事務(wù)失效的問題,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2021-06-06
Mybatis與Jpa的區(qū)別和性能對比總結(jié)
mybatis和jpa兩個持久層框架,從底層到用法都不同,但是實現(xiàn)的功能是一樣的,所以說一直以來頗有爭議,所以下面這篇文章主要給大家介紹了關(guān)于Mybatis與Jpa的區(qū)別和性能對比的相關(guān)資料,需要的朋友可以參考下2021-06-06
mybatis insert foreach循環(huán)插入方式
這篇文章主要介紹了mybatis insert foreach循環(huán)插入方式,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2021-07-07
SpringBoot定時監(jiān)聽RocketMQ的NameServer問題及解決方案
這篇文章主要介紹了SpringBoot定時監(jiān)聽RocketMQ的NameServer問題及解決方案,本文給大家介紹的非常詳細,對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友參考下吧2023-12-12
SpringMVC結(jié)合天氣api實現(xiàn)天氣查詢
這篇文章主要為大家詳細介紹了SpringMVC結(jié)合天氣api實現(xiàn)天氣查詢,具有一定的參考價值,感興趣的小伙伴們可以參考一下2017-05-05
一起來學(xué)習(xí)Java IO的轉(zhuǎn)化流
這篇文章主要為大家詳細介紹了Java IO的轉(zhuǎn)化流,文中示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下,希望能夠給你帶來幫助2022-03-03

