SpringBoot整合Sa-Token實現(xiàn)?API?接口簽名安全校驗功能
在涉及跨系統(tǒng)接口調(diào)用時,我們?nèi)菀着龅揭韵掳踩珕栴}:
- 請求身份被偽造
- 請求參數(shù)被篡改
- 請求被抓包,然后重放攻擊
sa-token api-sign 模塊將幫你輕松解決以上難題。(此插件是內(nèi)嵌到 sa-token-core 核心包中的模塊,開發(fā)者無需再次引入其它依賴,插件直接可用)
假設(shè)我們有如下業(yè)務(wù)需求:
用戶在 A 系統(tǒng)參與活動成功后,活動獎勵以余額的形式下發(fā)到 B 系統(tǒng)。
1. 初始方案:直接裸奔
在不考慮安全問題的情況下,我們很容易完成這個需求:
1、在 B 系統(tǒng)開放一個接口
@RestController
@RequestMapping("/sign")
public class SignController {
@PostMapping("/addMoney")
public String addMoney(Long userId, Long money) {
// TODO 處理業(yè)務(wù)...
return "ADD SUCCESS";
}
}2、在 A 系統(tǒng)使用 http 工具類調(diào)用這個接口
@RestController
@RequestMapping("/activity")
public class ActivityController {
@PostMapping("/join")
public String join() {
// 參加完活動后,發(fā)送余額
Long userId = 1L;
Long money = 100L;
Map<String, Object> params = new HashMap<>();
params.put("userId", userId);
params.put("money", money);
String url = "http://localhost:8079/sign/addMoney";
String result = HttpUtil.post(url, params);
return "join";
}
}上述代碼簡單的完成了需求,但是很明顯它有一個安全問題:B 系統(tǒng)開放的接口不僅可以被 A 系統(tǒng)調(diào)用,還可以被其它任何人調(diào)用,甚至別人可以本地跑一個 for 循環(huán)調(diào)用這個接口,為自己無限充值金額
2. 方案升級:增加 secretKey 校驗
為防止 B 系統(tǒng)開放的接口被陌生人任意調(diào)用,我們增加一個 secretKey 參數(shù)
@PostMapping("/addMoney")
public String addMoney(Long userId, Long money, String secretKey) {
// 校驗 secretKey
if (!check(secretKey)) {
throw new RuntimeException("無效 secretKey,無法響應(yīng)請求");
}
// TODO 處理業(yè)務(wù)...
return "ADD SUCCESS";
}由于 A 系統(tǒng)是我們 “自己人”,所以它可以拿著 secretKey 進(jìn)行合法請求:
@PostMapping("/join")
public String join() {
// 參加完活動后,發(fā)送余額
Map<String, Object> params = new HashMap<>();
params.put("userId", userId);
params.put("money", money);
params.put("secretKey", "×××××××××××");
String url = "http://localhost:8079/sign/addMoney";
String result = HttpUtil.post(url, params);
return "join";
}現(xiàn)在,即使 B 系統(tǒng)的接口被暴露了,也不會被陌生人任意調(diào)用了,安全性得到了一定的保證,但是仍然存在一些問題:
- 如果請求被抓包,secretKey 就會泄露,因為每次請求都在 url 中明文傳輸了 secretKey 參數(shù)。
- 如果請求被抓包,請求的其它參數(shù)就可以被任意修改,例如可以將 money 參數(shù)修改為 9999999,B系統(tǒng)無法確定參數(shù)是否被修改過。
3.方案再升級:使用摘要算法生成參數(shù)簽名
首先,在 A 系統(tǒng)不要直接發(fā)起請求,而是先計算一個 sign 參數(shù):
@PostMapping("/join")
public String join() {
// 參加完活動后,發(fā)送余額
Long userId = 1L;
Long money = 100L;
String secretKey = "×××××××××××";
// 計算 sign
String sign = md5("money=" + money + "&userId=" + userId + "&key=" + secretKey);
Map<String, Object> params = new HashMap<>();
params.put("userId", userId);
params.put("money", money);
params.put("sign", sign);
String url = "http://localhost:8079/sign/addMoney";
String result = HttpUtil.post(url, params);
return "join";
}注意:此處計算簽名時,需要將所有參數(shù)按照字典順序依次排列(key除外,掛在最后面)
然后在 B 系統(tǒng)接收請求時,使用同樣的算法、同樣的秘鑰,生成 sign 字符串,與參數(shù)中 sign 值進(jìn)行比較:
@PostMapping("/addMoney")
public String addMoney(Long userId, Long money, String sign) {
// 在 B 系統(tǒng),使用同樣的算法、同樣的密鑰,計算出 sign2,與傳入的 sign 進(jìn)行比對
String sign2 = md5("money=" + money + "&userId=" + userId + "&key=" + secretKey);
if (!sign2.equals(sign)) {
return "無效 sign,無法響應(yīng)請求";
}
// TODO 處理業(yè)務(wù)...
return "ADD SUCCESS";
}因為 sign 的值是由 userId、money、secretKey 三個參數(shù)共同決定的,所以只要有一個參數(shù)不一致,就會造成最終生成 sign 也是不一致的,所以,根據(jù)比對結(jié)果:
- 如果 sign 一致,說明這是個合法請求。
- 如果 sign 不一致,說明發(fā)起請求的客戶端秘鑰不正確,或者請求參數(shù)被篡改過,是個不合法請求。
此方案優(yōu)點:
- 不在 url 中直接傳遞 secretKey 參數(shù)了,避免了泄露風(fēng)險。
- 由于 sign 參數(shù)的限制,請求中的參數(shù)也不可被篡改,B 系統(tǒng)可放心的使用這些參數(shù)。
此方案仍然存在以下缺陷:
- 被抓包后,請求可以被無限重放,B 系統(tǒng)無法判斷請求是真正來自于 A 系統(tǒng)發(fā)出的,還是被抓包后重放的。
@PostMapping("/join")
public String join() {
// 參加完活動后,發(fā)送余額
Long userId = 1L;
Long money = 100L;
String nonce = SaFoxUtil.getRandomString(32); // 隨機(jī)32位字符串
String secretKey = "×××××××××××";
// 計算 sign
String sign = md5("money=" + money + "&nonce=" + nonce + "&userId=" + userId + "&key=" + secretKey);
Map<String, Object> params = new HashMap<>();
params.put("userId", userId);
params.put("money", money);
params.put("nonce", nonce);
params.put("sign", sign);
String url = "http://localhost:8079/sign/addMoney";
String result = HttpUtil.post(url, params);
return "join";
}4. 方案再再升級:追加 nonce 隨機(jī)字符串
首先,在 A 系統(tǒng)發(fā)起調(diào)用前,追加一個 nonce 參數(shù),一起參與到簽名中:
public String join() {
// 參加完活動后,發(fā)送余額
Long userId = 1L;
Long money = 100L;
String nonce = SaFoxUtil.getRandomString(32); // 隨機(jī)32位字符串
String secretKey = "×××××××××××";
// 計算 sign
String sign = md5("money=" + money + "&nonce=" + nonce + "&userId=" + userId + "&key=" + secretKey);
Map<String, Object> params = new HashMap<>();
params.put("userId", userId);
params.put("money", money);
params.put("nonce", nonce);
params.put("sign", sign);
String url = "http://localhost:8079/sign/addMoney";
String result = HttpUtil.post(url, params);
return "join";
}然后在 B 系統(tǒng)接收請求時,也把 nonce 參數(shù)加進(jìn)去生成 sign 字符串,進(jìn)行比較:
public String addMoney(Long userId, Long money, String nonce,String sign) {
// 檢查此 nonce 是否已被使用過了
if (Objects.nonNull(CacheUtil.get("nonce_" + nonce))) {
return "此 nonce 已被使用過了,請求無效";
}
// 在 B 系統(tǒng),使用同樣的算法、同樣的密鑰,計算出 sign2,與傳入的 sign 進(jìn)行比對
String sign2 = md5("money=" + money + "&nonce=" + nonce + "&userId=" + userId + "&key=" + secretKey);
if (!sign2.equals(sign)) {
return "無效 sign,無法響應(yīng)請求";
}
// 存入緩存
CacheUtil.set("nonce_" + nonce, "1");
// TODO 處理業(yè)務(wù)...
return "ADD SUCCESS";
}代碼分析:
- 為方便理解,我們先看第 3 步:此處在校驗簽名成功后,將 nonce 隨機(jī)字符串記入緩存中。
- 再看第 1 步:每次請求進(jìn)來,先查看一下緩存中是否已經(jīng)記錄了這個隨機(jī)字符串,如果是,則立即返回:無效請求。
這兩步的組合,保證了一個 nonce 隨機(jī)字符串只能被使用一次,如果請求被抓包后重放,是無法通過 nonce 校驗的。
至此,問題似乎已被解決了 …… 嗎?
別急,我們還有一個問題沒有考慮:這個 nonce 在字符串在緩存應(yīng)該被保存多久呢?
- 保存 15 分鐘?那抓包的人只需要等待 15 分鐘,你的 nonce 記錄在緩存中消失,請求就可以被重放了。
- 那保存 24 小時?保存一周?保存半個月?好像無論保存多久,都無法從根本上解決這個問題。
你可能會想到,那我永久保存吧。這樣確實能解決問題,但顯然服務(wù)器承載不了這么做,即使再微小的數(shù)據(jù)量,在時間的累加下,也總一天會超出服務(wù)器能夠承載的上限。
5. 方案再再再升級:追加 timestamp 時間戳
我們可以再追加一個 timestamp 時間戳參數(shù),將請求的有效性限定在一個有限時間范圍內(nèi),例如 15分鐘。
首先,在 A 系統(tǒng)追加 timestamp 參數(shù):
public String join() {
// 參加完活動后,發(fā)送余額
Long userId = 1L;
Long money = 100L;
Long timestamp = System.currentTimeMillis();
String nonce = SaFoxUtil.getRandomString(32); // 隨機(jī)32位字符串
String secretKey = "×××××××××××";
// 計算 sign
String sign = md5("money=" + money + "&nonce=" + nonce + "×tamp=" + timestamp + "&userId=" + userId + "&key=" + secretKey);
Map<String, Object> params = new HashMap<>();
params.put("userId", userId);
params.put("money", money);
params.put("nonce", nonce);
params.put("timestamp", timestamp);
params.put("sign", sign);
String url = "http://localhost:8079/sign/addMoney";
String result = HttpUtil.post(url, params);
return "join";
}在 B 系統(tǒng)檢測這個 timestamp 是否超出了允許的范圍
public String addMoney(Long userId, Long money, Long timestamp, String nonce,String sign) {
// 1、檢查 timestamp 是否超出允許的范圍(此處假定最大允許15分鐘差距)
long timestampDisparity = System.currentTimeMillis() - timestamp; // 實際的時間差
if(timestampDisparity > 1000 * 60 * 15) {
return "timestamp 時間差超出允許的范圍,請求無效";
}
// 檢查此 nonce 是否已被使用過了
if (Objects.nonNull(CacheUtil.get("nonce_" + nonce))) {
return "此 nonce 已被使用過了,請求無效";
}
// 在 B 系統(tǒng),使用同樣的算法、同樣的密鑰,計算出 sign2,與傳入的 sign 進(jìn)行比對
String sign2 = md5("money=" + money + "&nonce=" + nonce + "&userId=" + userId + "&key=" + secretKey);
if (!sign2.equals(sign)) {
return "無效 sign,無法響應(yīng)請求";
}
// 將 nonce 記入緩存,ttl 有效期和 allowDisparity 允許時間差一致
CacheUtil.set("nonce_" + nonce, "1", 1000 * 60 * 15);
// TODO 處理業(yè)務(wù)...
return "ADD SUCCESS";
}至此,抓包者:
- 如果在 15 分鐘內(nèi)重放攻擊,nonce 參數(shù)不答應(yīng):緩存中可以查出 nonce 值,直接拒絕響應(yīng)請求。
- 如果在 15 分鐘后重放攻擊,timestamp 參數(shù)不答應(yīng):超出了允許的 timestamp 時間差,直接拒絕響應(yīng)請求。
6. 服務(wù)器的時鐘差異造成安全問題
以上的代碼,均假設(shè) A 系統(tǒng)服務(wù)器與 B 系統(tǒng)服務(wù)器的時鐘一致,才可以正常完成安全校驗,但在實際的開發(fā)場景中,有些服務(wù)器會存在時鐘不準(zhǔn)確的問題。
假設(shè) A 服務(wù)器與 B 服務(wù)器的時鐘差異為 10 分鐘,即:在 A 服務(wù)器為 8:00 的時候,B 服務(wù)器為 7:50。
- A 系統(tǒng)發(fā)起請求,其生成的時間戳也是代表 8:00。
- B 系統(tǒng)接受到請求后,完成業(yè)務(wù)處理,此時 nonce 的 ttl 為 15分鐘,到期時間為 7:50 + 15分 = 8:05。
- 8.05 后,nonce 緩存消失,抓包者重放請求攻擊:
- timestamp 校驗通過:因為時間戳差距僅有 8.05 - 8.00 = 5分鐘,小于 15 分鐘,校驗通過。
- -nonce 校驗通過:因為此時 nonce 緩存已經(jīng)消失,可以通過校驗。
- sign 校驗通過:因為這本來就是由 A 系統(tǒng)構(gòu)建的一個合法簽名。
攻擊完成。
要解決上述問題,有兩種方案:
- 方案一:修改服務(wù)器時鐘,使兩個服務(wù)器時鐘保持一致。
- 方案二:在代碼層面兼容時鐘不一致的場景。
要采用方案一的同學(xué)可自行搜索一下同步時鐘的方法,在此暫不贅述,此處詳細(xì)闡述一下方案二。
我們只需簡單修改一下,B 系統(tǒng)校驗參數(shù)的代碼即可:
public String addMoney(Long userId, Long money, Long timestamp, String nonce,String sign) {
// 1、檢查 timestamp 是否超出允許的范圍 (重點一:此處需要取絕對值)
long timestampDisparity = Math.abs(System.currentTimeMillis() - timestamp);
if(timestampDisparity > 1000 * 60 * 15) {
return "timestamp 時間差超出允許的范圍,請求無效";
}
// 檢查此 nonce 是否已被使用過了
if (Objects.nonNull(CacheUtil.get("nonce_" + nonce))) {
return "此 nonce 已被使用過了,請求無效";
}
// 在 B 系統(tǒng),使用同樣的算法、同樣的密鑰,計算出 sign2,與傳入的 sign 進(jìn)行比對
String sign2 = md5("money=" + money + "&nonce=" + nonce + "&userId=" + userId + "&key=" + secretKey);
if (!sign2.equals(sign)) {
return "無效 sign,無法響應(yīng)請求";
}
// 將 nonce 記入緩存,防止重復(fù)使用(重點二:此處需要將 ttl 設(shè)定為允許 timestamp 時間差的值 x 2 )
CacheUtil.set("nonce_" + nonce, "1", (1000 * 60 * 15) * 2;
// TODO 處理業(yè)務(wù)...
return "ADD SUCCESS";
}7. 使用 Sa-Token 框架完成 API 參數(shù)簽名
接下來步入正題,使用 Sa-Token 內(nèi)置的 sign 模塊,方便的完成 API 簽名創(chuàng)建、校驗等步驟:
- 不限制請求的參數(shù)數(shù)量,方便組織業(yè)務(wù)需求代碼。
- 自動補(bǔ)全 nonce、timestamp 參數(shù),省時省力。
- 自動構(gòu)建簽名,并序列化參數(shù)為字符串。
- 一句代碼完成 nonce、timestamp、sign 的校驗,防偽造請求調(diào)用、防參數(shù)篡改、防重放攻擊。
7.1 引入依賴
api-sign 模塊已內(nèi)嵌到核心包,只需要引入 sa-token 本身依賴即可:(請求發(fā)起端和接收端都需要引入)
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
<version>1.35.0.RC</version>
</dependency>7.2 配置密鑰
請求發(fā)起端和接收端需要配置一個相同的秘鑰,在 application.yml 中配置:
sa-token:
sign:
# API 接口簽名秘鑰 (隨便亂摁幾個字母即可)
secret-key: kQwIOrYvnXmSDkwEiFngrKidMcdrgKor7.3 請求發(fā)起端構(gòu)建簽名
public String join() {
// 參加完活動后,發(fā)送余額
Long userId = 1L;
Long money = 100L;
Map<String, Object> params = new HashMap<>();
params.put("userId", userId);
params.put("money", money);
SaSignUtil.addSignParamsAndJoin(params);
String url = "http://localhost:8079/sign/addMoney";
return HttpUtil.post(url, params);
}7.4 請求接受端校驗簽名
public String addMoney(Long userId, Long money) {
// 1、校驗請求中的簽名
SaSignUtil.checkRequest(SaHolder.getRequest());
// 2、校驗通過,處理業(yè)務(wù)
System.out.println("userId=" + userId);
System.out.println("money=" + money);
return "ADD SUCCESS";
}如上代碼便可簡單方便的完成 API 接口參數(shù)簽名校驗,當(dāng)請求端的秘鑰不對,或者請求參數(shù)被篡改、請求被重放時,均無法通過 SaSignUtil.checkRequest 校驗
7.5 原理分析
7.5.1 構(gòu)建簽名
SaSignUtil#addSignParamsAndJoin(params);:
public static String addSignParamsAndJoin(Map<String, Object> paramsMap) {
return SaManager.getSaSignTemplate().addSignParamsAndJoin(paramsMap);
}會調(diào)用 SaSignTemplate 類中的方法
SaSignTemplate#addSignParamsAndJoin() 方法
public String addSignParamsAndJoin(Map<String, Object> paramsMap) {
// 1.添加參數(shù):timestamp、nonce、sign
paramsMap = this.addSignParams(paramsMap);
// 2.將 map 使用 & 轉(zhuǎn)化為String
return this.joinParams(paramsMap);
}這個方法有兩個邏輯:
- 添加參數(shù):timestamp、nonce、sign
- 將 map 使用 & 轉(zhuǎn)化為String
SaSignTemplate#addSignParams() 方法
public Map<String, Object> addSignParams(Map<String, Object> paramsMap) {
paramsMap.put(timestamp, String.valueOf(System.currentTimeMillis()));
paramsMap.put(nonce, SaFoxUtil.getRandomString(32));
paramsMap.put(sign, this.createSign(paramsMap));
return paramsMap;
}SaSignTemplate#createSign() 方法:生成簽名
public String createSign(Map<String, ?> paramsMap) {
String secretKey = this.getSecretKey();
SaSignException.throwByNull(secretKey, "參與參數(shù)簽名的秘鑰不可為空", 12201);
if (((Map)paramsMap).containsKey(sign)) {
paramsMap = new TreeMap((Map)paramsMap);
((Map)paramsMap).remove(sign);
}
// 按照數(shù)據(jù)字典進(jìn)行排序,并將 map 使用 & 轉(zhuǎn)化為String
String paramsStr = this.joinParamsDictSort((Map)paramsMap);
String fullStr = paramsStr + "&" + key + "=" + secretKey;
// md5
return this.abstractStr(fullStr);
}
public String abstractStr(String fullStr) {
return SaSecureUtil.md5(fullStr);
}這個方法有兩個邏輯:
- 按照數(shù)據(jù)字典進(jìn)行排序,并將 map 使用 & 轉(zhuǎn)化為String
- 使用 md5 摘要算法
7.5.2 驗證簽名
SaSignUtil.checkRequest(SaHolder.getRequest());:
public static void checkRequest(SaRequest request) {
SaManager.getSaSignTemplate().checkRequest(request);
}還是會調(diào)用 SaSignTemplate 類中的方法
SaSignTemplate#checkParamMap() 方法:校驗請求參數(shù)
public void checkRequest(SaRequest request) {
this.checkParamMap(request.getParamMap());
}
public void checkParamMap(Map<String, String> paramMap) {
String timestampValue = (String)paramMap.get(timestamp);
String nonceValue = (String)paramMap.get(nonce);
String signValue = (String)paramMap.get(sign);
// 1.校驗時間戳
this.checkTimestamp(Long.parseLong(timestampValue));
// 2.校驗隨機(jī)數(shù)
if (this.getSignConfigOrGlobal().getIsCheckNonce()) {
this.checkNonce(nonceValue);
}
// 3.校驗簽名
this.checkSign(paramMap, signValue);
}這個方法有三個邏輯:
- 校驗時間戳:判斷是否在時間差范圍內(nèi)
- 校驗隨機(jī)數(shù):判斷此隨機(jī)數(shù)是否已使用
- 校驗簽名:判斷原簽名和現(xiàn)在生成的簽名是否一致
SaSignTemplate#checkNonce() 方法:校驗隨機(jī)數(shù)
public void checkNonce(String nonce) {
if (SaFoxUtil.isEmpty(nonce)) {
throw new SaSignException("nonce 為空,無效");
} else {
String key = this.splicingNonceSaveKey(nonce);
if (SaManager.getSaTokenDao().get(key) != null) {
throw new SaSignException("此 nonce 已被使用過,不可重復(fù)使用:" + nonce);
} else {
SaManager.getSaTokenDao().set(key, nonce, this.getSignConfigOrGlobal().getSaveNonceExpire() * 2L + 2L);
}
}
}SaToken 存儲
SaTokenDao 是存儲接口,默認(rèn)實現(xiàn)是用的是 SaTokenDaoDefaultImpl。SaTokenDaoDefaultImpl 存儲數(shù)據(jù),主要是通過 ConcurrentHashMap 存放在本地內(nèi)存中。
SaManager#getSaTokenDao() 方法:
public static SaTokenDao getSaTokenDao() {
if (saTokenDao == null) {
Class var0 = SaManager.class;
synchronized(SaManager.class) {
if (saTokenDao == null) {
setSaTokenDaoMethod(new SaTokenDaoDefaultImpl());
}
}
}
return saTokenDao;
}SaTokenDaoDefaultImpl :
public class SaTokenDaoDefaultImpl implements SaTokenDao {
// 數(shù)據(jù)集合
public Map<String, Object> dataMap = new ConcurrentHashMap();
// 過期時間集合 (單位: 毫秒) , 記錄所有key的到期時間 [注意不是剩余存活時間]
public Map<String, Long> expireMap = new ConcurrentHashMap();
public Thread refreshThread;
public volatile boolean refreshFlag;
public SaTokenDaoDefaultImpl() {
// 定時清理過期數(shù)據(jù)
this.initRefreshThread();
}
public String get(String key) {
this.clearKeyByTimeout(key);
return (String)this.dataMap.get(key);
}
public void set(String key, String value, long timeout) {
if (timeout != 0L && timeout > -2L) {
this.dataMap.put(key, value);
this.expireMap.put(key, timeout == -1L ? -1L : System.currentTimeMillis() + timeout * 1000L);
}
}
public void initRefreshThread() {
if (SaManager.getConfig().getDataRefreshPeriod() > 0) {
this.refreshFlag = true;
this.refreshThread = new Thread(() -> {
while(true) {
try {
try {
if (!this.refreshFlag) {
return;
}
this.refreshDataMap();
} catch (Exception var2) {
var2.printStackTrace();
}
int dataRefreshPeriod = SaManager.getConfig().getDataRefreshPeriod();
if (dataRefreshPeriod <= 0) {
dataRefreshPeriod = 1;
}
Thread.sleep((long)dataRefreshPeriod * 1000L);
} catch (Exception var3) {
var3.printStackTrace();
}
}
});
this.refreshThread.start();
}
}
}如果僅僅存放在本地內(nèi)存中,涉及到多個項目,可能數(shù)據(jù)無法共享。
引入倉庫 sa-token-dao-redis-jackson
<!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
<version>1.35.0.RC</version>
</dependency>
<!-- 提供Redis連接池 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>SaTokenDaoRedisJackson 使用 Redis 作為存儲數(shù)據(jù)的地方
SaBeanInject#setSaTokenDao,SaBeanInject 是自動配置的。當(dāng)系統(tǒng)中存在 SaTokenDao的 Bean 實例,則設(shè)置SaTokenDao 實例
public class SaBeanInject {
@Autowired(
required = false
)
public void setSaTokenDao(SaTokenDao saTokenDao) {
SaManager.setSaTokenDao(saTokenDao);
}
}參考:
【開源項目】使用Sa-Token框架完成API參數(shù)簽名
到此這篇關(guān)于SpringBoot整合Sa-Token 快速實現(xiàn) API 接口簽名安全校驗的文章就介紹到這了,更多相關(guān)SpringBoot API 接口簽名安全校驗內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Java隨機(jī)數(shù)的5種獲得方法(非常詳細(xì)!)
這篇文章主要給大家介紹了關(guān)于Java隨機(jī)數(shù)的5種獲得方法,在實際開發(fā)中產(chǎn)生隨機(jī)數(shù)的使用是很普遍的,所以在程序中進(jìn)行產(chǎn)生隨機(jī)數(shù)操作很重要,文中通過圖文介紹的非常詳細(xì),需要的朋友可以參考下2023-10-10
ByteArrayInputStream簡介和使用_動力節(jié)點Java學(xué)院整理
ByteArrayInputStream 是字節(jié)數(shù)組輸入流。它繼承于InputStream。這篇文章主要介紹了ByteArrayInputStream簡介和使用_動力節(jié)點Java學(xué)院整理,需要的朋友可以參考下2017-05-05
Java數(shù)據(jù)結(jié)構(gòu)之常見排序算法(下)
這篇文章主要介紹了Java數(shù)據(jù)結(jié)構(gòu)之常見排序算法(下),與之相對有(上),想了解的朋友可以去本網(wǎng)站掃搜,在這兩篇文章里涵蓋關(guān)于八大排序算法的所有內(nèi)容,需要的朋友可以參考下2023-01-01
Java利用apache ftp工具實現(xiàn)文件上傳下載和刪除功能
這篇文章主要為大家詳細(xì)介紹了Java利用apache ftp工具實現(xiàn)文件上傳下載、刪除功能,具有一定的參考價值,感興趣的小伙伴們可以參考一下2018-06-06
Java利用Geotools從DEM數(shù)據(jù)中讀取指定位置的高程信息全過程
Geotools作為一款功能強(qiáng)大且開源的地理工具庫,為地理數(shù)據(jù)的處理和分析提供了豐富的類庫和便捷的接口,能夠很好地滿足從DEM數(shù)據(jù)中讀取高程信息這一實戰(zhàn)需求,本文將深入講解如何利用Geotools從獲取DEM數(shù)據(jù)到成功讀取指定位置高程信息的全過程,需要的朋友可以參考下2025-03-03
Java實現(xiàn)RedisUtils操作五大集合(增刪改查)
本文主要介紹了Java實現(xiàn)RedisUtils操作五大集合,文中通過示例代碼介紹的非常詳細(xì),文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2021-07-07

