accessToken refreshToken簡單使用原始碼demo,雙token重新整理及有效時間設定
最後會附上原始碼
這篇介紹了一個專案中使用的雙token登入認證重新整理的demo,如需移植到生產專案中,需要根據實際情況做修改。
有個地方需要注意: 我這裡重新整理產生新的refreshToken時 舊的refreshToken並沒有失效,如果不是特別敏感這點的話可以不計較,若是在意的話,那需要自己處理:比如用快取記錄失效的token每次token認證判斷是否是失效的token ,如果是的話就返回驗證失敗。
下面程式碼中會用到本地redis快取,如果沒有安裝或不會用,看下我之前的博文
win7_64 redis下載安裝啟動服務
springboot整合redis,redis工具類
spingboot redis demo原始碼
如果對token還不是很瞭解 , 那麼建議先看個我之前寫的用java標準處理token的庫jwt 實現的一個簡單token的生成、認證過程 demo:
基於JWT規範的JWS實現token認證過程,採用JWT庫jose4j,附springboot專案 demo原始碼下載
如果對雙token accessToken refreshToken時長設定以及重新整理問題 不清楚的可以看下
雙token重新整理、續期,access_token和refresh_token實效如何設定
demo目錄結構及程式碼
java jwt庫 jose4j的依賴
<!-- https://mvnrepository.com/artifact/org.bitbucket.b_c/jose4j -->
<dependency>
<groupId>org.bitbucket.b_c</groupId>
<artifactId>jose4j</artifactId>
<version>0.6.5</version>
</dependency>
- 1
- 2
- 3
- 4
- 5
- 6
redis 依賴
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
- 1
- 2
- 3
- 4
實體類 User
package com.example.token_demo.entity;
/**
* @author:
* @create: 2019-10-16 09:59
**/
public class User {
public String account;
public String password;
public String lastLoginTime;
public User(){
}
public User(String account, String password) {
this.account = account;
this.password = password;
}
public String getAccount() {
return account;
}
public void setAccount(String account) {
this.account = account;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getLastLoginTime() {
return lastLoginTime;
}
public void setLastLoginTime(String lastLoginTime) {
this.lastLoginTime = lastLoginTime;
}
@Override
public String toString() {
return "User{" +
"account='" + account + '\'' +
", lastLoginTime='" + lastLoginTime + '\'' +
'}';
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
雙token使用web類 AccountController
package com.example.token_demo.controller;
import com.example.token_demo.entity.User;
import com.example.token_demo.redis.RedisUtil;
import com.example.token_demo.service.AuthorizationService;
import org.jose4j.json.internal.json_simple.JSONObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Date;
/**
* @author:
* @create: 2019-10-16 10:02
**/
@RestController
public class AccountController {
@Autowired
AuthorizationService authorizationService;
@Autowired
RedisUtil cache;
/**
* 註冊
* @param account
* @param password
* @return
*/
@GetMapping("/register")
public String register(@RequestParam(name = "account") String account,
@RequestParam(name = "password") String password){
JSONObject ret = new JSONObject();
//簡單校驗下非空
if(account == null || account.equals("") || password == null || password.equals("")){
ret.put("code","1");
ret.put("desc","account | password can not empty");
return ret.toString();
}
//註冊成功,這裡沒有判斷要註冊的使用者是否存在
//...
//寫入資料庫成功後快取(資料庫操作這裡就忽略了)
cache.set("user"+account,new User(account,password));
//response
ret.put("code","0");
ret.put("desc","ok");
return ret.toString();
}
/**
* 登入
* @param account
* @param password
* @return
*/
@GetMapping("/login")
public String login(@RequestParam(name = "account") String account,
@RequestParam(name = "password") String password){
JSONObject ret = new JSONObject();
User user = cache.get("user"+account);
//如果快取account獲取user為null,從資料庫獲取user
//...
if(user != null && password != null && password.equals(user.getPassword())){
//登入成功
//建立token
String accessToken = authorizationService.createAccessIdToken(account);
String refreshToken = authorizationService.createRefreshIdToken(account);
if(accessToken == null || refreshToken == null){
ret.put("code","1");
ret.put("desc","failed");
return ret.toString();
}
//快取當前登入使用者 refreshToken 建立的起始時間,這個會在重新整理accessToken方法中 判斷是否要重新生成(重新整理)refreshToken時用到
cache.set("id_refreshTokenStartTime"+account,System.currentTimeMillis(),(int)AuthorizationService.refreshTokenExpirationTime);
//更新使用者最近登入時間
user.setLastLoginTime(new Date().toLocaleString());
//更新user到資料庫成功後快取(資料庫操作這裡就忽略了)
cache.set("user"+account,user);
//response
ret.put("code","0");
ret.put("desc","ok");
ret.put("accessToken",accessToken);
ret.put("refreshToken",refreshToken);
return ret.toString();
}
ret.put("code","1");
ret.put("desc","failed");
return ret.toString();
}
/**
* 用 refreshToken 來重新整理 accessToken
* @param refreshToken refreshToken
* @return
*/
@GetMapping("/accessToken/refresh/{refreshToken}")
public String accessTokenRefresh(@PathVariable("refreshToken") String refreshToken){
String account = authorizationService.verifyToken(refreshToken);
JSONObject ret = new JSONObject();
if(account == null){
//通過返回碼 告訴客戶端 refreshToken過期了,需要客戶端就得跳轉登入介面
ret.put("code","3");//我這裡只是演示,返回3表示 refreshToken過期
ret.put("desc","failed");
return ret.toString();
}
//建立新的accessToken
String accessToken = authorizationService.createAccessIdToken(account);
//下面判斷是否重新整理 refreshToken,如果refreshToken 快過期了 需要重新生成一個替換掉
long minTimeOfRefreshToken = 2*AuthorizationService.accessTokenExpirationTime;//refreshToken 有效時長是應該為accessToken有效時長的2倍 (我在博文裡有介紹)
Long refreshTokenStartTime = cache.get("id_refreshTokenStartTime"+account);//refreshToken建立的起始時間點
//(refreshToken上次建立的時間點 + refreshToken的有效時長 - 當前時間點) 表示refreshToken還剩餘的有效時長,如果小於2倍accessToken時長 ,則重新整理 refreshToken
if(refreshTokenStartTime == null || (refreshTokenStartTime + AuthorizationService.refreshTokenExpirationTime*1000) - System.currentTimeMillis() <= minTimeOfRefreshToken*1000){
//重新整理refreshToken
refreshToken = authorizationService.createRefreshIdToken(account);
cache.set("id_refreshTokenStartTime"+account,System.currentTimeMillis(),(int)AuthorizationService.refreshTokenExpirationTime);
}
//response
ret.put("code","0");
ret.put("desc","ok");
ret.put("accessToken",accessToken);
ret.put("refreshToken",refreshToken);
return ret.toString();
}
/**
* 查詢使用者資訊
* @param accessToken accessToken
* @return
*/
@GetMapping("/user/{accessToken}")
public String userInfo(@PathVariable("accessToken") String accessToken){
String account = authorizationService.verifyToken(accessToken);
System.out.println("account="+account);
JSONObject ret = new JSONObject();
if(account == null){
//通過返回碼 告訴客戶端 accessToken過期了,需要呼叫重新整理accessToken的介面
ret.put("code","2");//我這裡只是演示,返回2表示 accessToken過期
ret.put("desc","accessToken expire");
return ret.toString();
}
User user = cache.get("user"+account);
//如果快取的user為null 從資料庫查詢...
//...
//response
ret.put("code","0");
ret.put("desc","ok");
ret.put("user",user);
return ret.toJSONString();
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
- 87
- 88
- 89
- 90
- 91
- 92
- 93
- 94
- 95
- 96
- 97
- 98
- 99
- 100
- 101
- 102
- 103
- 104
- 105
- 106
- 107
- 108
- 109
- 110
- 111
- 112
- 113
- 114
- 115
- 116
- 117
- 118
- 119
- 120
- 121
- 122
- 123
- 124
- 125
- 126
- 127
- 128
- 129
- 130
- 131
- 132
- 133
- 134
- 135
- 136
- 137
- 138
- 139
- 140
- 141
- 142
- 143
- 144
- 145
- 146
- 147
- 148
- 149
- 150
- 151
- 152
- 153
- 154
- 155
- 156
- 157
- 158
- 159
- 160
- 161
- 162
- 163
- 164
- 165
- 166
keyPair生成,token建立、認證服務類 AuthorizationService
package com.example.token_demo.service;
import org.jose4j.json.JsonUtil;
import org.jose4j.jwk.RsaJsonWebKey;
import org.jose4j.jwk.RsaJwkGenerator;
import org.jose4j.jws.AlgorithmIdentifiers;
import org.jose4j.jws.JsonWebSignature;
import org.jose4j.jwt.JwtClaims;
import org.jose4j.jwt.NumericDate;
import org.jose4j.jwt.consumer.InvalidJwtException;
import org.jose4j.jwt.consumer.JwtConsumer;
import org.jose4j.jwt.consumer.JwtConsumerBuilder;
import org.jose4j.lang.JoseException;
import org.springframework.stereotype.Service;
import java.security.PrivateKey;
import java.util.UUID;
/**
* @author:
* @create: 2019-10-16 09:36
**/
@Service
public class AuthorizationService {
/**
* keyId,公鑰,私鑰 都是用 createKeyPair 方法生成,在本地執行當前類main方法,然後將生成的keyPair 賦值給privateKeyStr和publicKeyStr
*/
private static String keyId = "0fb0e71291734057be4bd506b5aeb855";
private static String privateKeyStr = "{\"kty\":\"RSA\",\"kid\":\"0fb0e71291734057be4bd506b5aeb855\",\"alg\":\"ES256\",\"n\":\"sMEUzPmJQD2QR30PZxBSyutWLQ_HZpekDlJCjw-FUNVJa2LOOEz2Gs2pyPg7GHyBhJSFefdGh4LciCxNuE387unTFYt-kLaSEzPKHLyCI8vwMFCSasPIlqbtlCjSm_R4A-C6gcXrPvFOoCk7dOFM9z5yJXGcwhMq7ZBSDVSoYsio9bmfyNQgdueWeibDL4_YrOhW8PLcDKbNJ7a9CPJRyEp-gRbHABDKVva_Tt98O_KPDNVApzhVbk7dhC2OEvKSRAqiPQT1VVYiOTUY0QOky-52hnYrYKHEoyJzymsrcraQxA-mLk80icBY7WwPupQF9v-3pgN8AmKrs-dgHoXN8Q\",\"e\":\"AQAB\",\"d\":\"Prw5Qstq4Kc5N3Z26hDMIgPHcXUBRDOcYgzmXNqYaelaBshqA2eljjvjAFbCut0uJz2D5pdSrDRRS-_Vog3kMXRCnIoHYRu72x7tpKdv1X7EAJIIdeaJopcbChQ3NG1fz5iK-haieZOyYXxhAwoYhETgxNN_XQ7qlKk9xkd_AJg6LVV5qh4Ackm51yGf8PdpOkU38suIzxRu_-Ra6-CVr5oeTzLowPisQrvpc3poQNwSY-5mBE_l0sGTIocGCG7xuMdbvACACXoTZ1cJigL_6Cvvd8HpL2gMsJVI_KM6ZKO681ZKay_f_ielJL7wxpkfVIFlJtqDYVjybGqYByWlQQ\",\"p\":\"7IwwVhBhCCr_noJkAK5xwllD05nU_I7zpfE-mzST6Q-7a1dScWb_ZmDM2O1py_IYaB7V42zZzoXrBMG4ZYwO_dfKhBxOKqwohPQY53d5Y8wGzAqoMz6zgjz4P0x_hPzYKIjV6-l-NDgrkbjIaL8RCnP_0fxpxMV6jebaC-QOPNk\",\"q\":\"v0oeiMD15o8rjWFz8sHWVwVVUJJSOxKJQRJ8_NZdwOgEVwUVQjftpFQa6IiTMowUd7t8AKcMyYzXbZvFe0tqTtZ8ccbn_GrMKVrfzoIn1QtaOgV1MCtWfej88afyxT6QhoHB5BLZFp2W5iB8uEyc1dUz-qiJXrX5oPYJWeNZytk\",\"dp\":\"Cvu7ZtOd3cY5Vj_RquJur8p7RsD2zb9JeuQHtycq0wCDAEnurwtMQpGuEUh8yBZ2oacE4Wl1d4xqTC8-g6CMNacmZRn3Wy3hN8MpwN2gSkz359N62d5IcXypPi8sIJ2o38DyxeBylrQg-cQtsgdlICogr7xboOJWfW5Bo5m0O4k\",\"dq\":\"SVZ7WmbQX_Kn-e5Q69NQ_8_1o4xVpnw2zxHthWoSS7EoaMx0GA0lOQldv6UM-iYmerkQk5d4GZW7yjQchGanfU5SK7TcoDO5zmkewSe5ab6Oeww4n50d7evzfhqrwt93vXnmAjEPtdH5VoVCC86jmn_BC-qtr_gImqN5dlLpzBE\",\"qi\":\"YZAFG3X3qrMz_wPFwPJu2CA36rvd1hxv6-tYVWneOlAQaDsRHlmfN4IXdYLsljtDi_Qp-I-Z0LPnZFMgiTUPQ8yzWUPJ31YpZePDxMtc5esI0M19ktofqVrH-NxPKvGLfPBsD5p_u5OtKkztr_Vq6yXhj3tSO8TPSXheUILxmdE\"}";
private static String publicKeyStr = "{\"kty\":\"RSA\",\"kid\":\"0fb0e71291734057be4bd506b5aeb855\",\"alg\":\"ES256\",\"n\":\"sMEUzPmJQD2QR30PZxBSyutWLQ_HZpekDlJCjw-FUNVJa2LOOEz2Gs2pyPg7GHyBhJSFefdGh4LciCxNuE387unTFYt-kLaSEzPKHLyCI8vwMFCSasPIlqbtlCjSm_R4A-C6gcXrPvFOoCk7dOFM9z5yJXGcwhMq7ZBSDVSoYsio9bmfyNQgdueWeibDL4_YrOhW8PLcDKbNJ7a9CPJRyEp-gRbHABDKVva_Tt98O_KPDNVApzhVbk7dhC2OEvKSRAqiPQT1VVYiOTUY0QOky-52hnYrYKHEoyJzymsrcraQxA-mLk80icBY7WwPupQF9v-3pgN8AmKrs-dgHoXN8Q\",\"e\":\"AQAB\"}";
public static long accessTokenExpirationTime = 60;
public static long refreshTokenExpirationTime = 60*3;
public String createAccessIdToken(String userId) {
return createIdToken(userId,accessTokenExpirationTime);
}
public String createRefreshIdToken(String userId) {
return createIdToken(userId,refreshTokenExpirationTime);
}
public String createIdToken(String account,long expirationTime) {
try {
//Claims
JwtClaims claims = new JwtClaims();
claims.setGeneratedJwtId();
claims.setIssuedAtToNow();
//expire time
NumericDate date = NumericDate.now();
date.addSeconds(expirationTime);
claims.setExpirationTime(date);
claims.setNotBeforeMinutesInThePast(1);
claims.setSubject("YOUR_SUBJECT");
claims.setAudience("YOUR_AUDIENCE");
//新增自定義引數,必須是字串型別
claims.setClaim("account", account);
//jws
JsonWebSignature jws = new JsonWebSignature();
jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256);
jws.setKeyIdHeaderValue(keyId);
jws.setPayload(claims.toJson());
PrivateKey privateKey = new RsaJsonWebKey(JsonUtil.parseJson(privateKeyStr)).getPrivateKey();
jws.setKey(privateKey);
//get token
String idToken = jws.getCompactSerialization();
return idToken;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* jws校驗token
*
* @param token
* @return 返回 使用者賬號
* @throws JoseException
*/
public String verifyToken(String token) {
try {
JwtConsumer consumer = new JwtConsumerBuilder()
.setRequireExpirationTime()
.setMaxFutureValidityInMinutes(5256000)
//allow some leeway in validating time based claims to account for clock skew
//過期後30秒內還能解析成功
.setAllowedClockSkewInSeconds(30)
.setRequireSubject()
//.setExpectedIssuer("")
.setExpectedAudience("YOUR_AUDIENCE")
/*
RsaJsonWebKey jwk = null;
try {
jwk = RsaJwkGenerator.generateJwk(2048);
} catch (JoseException e) {
e.printStackTrace();
}
jwk.setKeyId(keyId); */
//.setVerificationKey(jwk.getPublicKey())
.setVerificationKey(new RsaJsonWebKey(JsonUtil.parseJson(publicKeyStr)).getPublicKey())
.build();
JwtClaims claims = consumer.processToClaims(token);
if (claims != null) {
String account = (String) claims.getClaimValue("account");
System.out.println("認證通過!token payload攜帶的自定義內容:使用者賬號account=" + account);
return account;
}
} catch (JoseException e) {
e.printStackTrace();
} catch (InvalidJwtException e) {
e.printStackTrace();
}catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* 建立jwk keyId , 公鑰 ,祕鑰
*/
public static void createKeyPair(){
String keyId = UUID.randomUUID().toString().replaceAll("-", "");
RsaJsonWebKey jwk = null;
try {
jwk = RsaJwkGenerator.generateJwk(2048);
} catch (JoseException e) {
e.printStackTrace();
}
jwk.setKeyId(keyId);
jwk.setAlgorithm(AlgorithmIdentifiers.ECDSA_USING_P256_CURVE_AND_SHA256);
String publicKey = jwk.toJson(RsaJsonWebKey.OutputControlLevel.PUBLIC_ONLY);
String privateKey = jwk.toJson(RsaJsonWebKey.OutputControlLevel.INCLUDE_PRIVATE);
System.out.println("keyId="+keyId);
System.out.println();
System.out.println("公鑰 publicKeyStr="+publicKey);
System.out.println();
System.out.println("私鑰 privateKeyStr="+privateKey);
}
public static void main(String[] args){
//create key pair
createKeyPair();
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
- 87
- 88
- 89
- 90
- 91
- 92
- 93
- 94
- 95
- 96
- 97
- 98
- 99
- 100
- 101
- 102
- 103
- 104
- 105
- 106
- 107
- 108
- 109
- 110
- 111
- 112
- 113
- 114
- 115
- 116
- 117
- 118
- 119
- 120
- 121
- 122
- 123
- 124
- 125
- 126
- 127
- 128
- 129
- 130
- 131
- 132
- 133
- 134
- 135
- 136
- 137
- 138
- 139
- 140
- 141
- 142
- 143
- 144
- 145
- 146
- 147
- 148
- 149
- 150
本地生成keyPair,本地執行AuthorizationService main方法生成keyPair 覆蓋程式碼中的keyId、privateKeyStr和publicKeyStr。 如果不是洩漏了不用更換.
啟動專案
1 先啟動本地 redis 服務
2 啟動token demo程式
測試
測試工具 Postman
Postman 新建訪問 http://127.0.0.1:8081/register?account=zhangsan&password=123456
Postman GET方式 訪問 http://127.0.0.1:8081/login?account=zhangsan&password=123456
注意 ,為了快速測試, 我特意將 accessToken 過期時間設定1分鐘,refreshToken過期時間3分鐘
Postman GET方式 訪問 http://127.0.0.1:8081/user/{accessToken}
-
登入後1分鐘內 查詢user資訊
-
登入後1分鐘40秒 查詢user資訊 (1分30內 accessToken都是有效的,原因解析token設定了 setAllowedClockSkewInSeconds(30),token過期了還能延長30秒有效 )
返回提示 accessToken過期,這時客戶端需要 呼叫重新整理accessToken介面(不要跳轉登入頁面)
4 重新整理accessToken (用refreshToken)
Postman GET方式 訪問 http://127.0.0.1:8081/accessToken/refresh/{refreshToken}
-
登入後 2分鐘之前 重新整理accessToken
只有 accessToken 會被重新整理,refreshToken雖然我返回了 但是值還是登入返回的refreshToken值 ,如下: -
登入後 2分鐘 到 3分鐘 內 重新整理accessToken
accessToken 和 refreshToken 都會被重新整理,如下: -
登入後 3分40秒 重新整理accessToken
報錯返回提示 refreshToken 過期,如下:
這時客戶端 需要跳轉到 登入頁面重新登入
原始碼網盤下載
連結:https://pan.baidu.com/s/15oPRcDtjcdXUmDxqji0Jog
提取碼:gx9p
相關文章
- jwt_token的有效時間和重新整理時間JWT
- LiveGBS國標GB/T28181流媒體服務如何設定TOKEN有效時間介面呼叫token的有效時長
- 【Python】Python 使用http時間同步設定系統時間原始碼PythonHTTP原始碼
- 介面測試:生成Token,使用手機did生成accessToken,再用accessToken生成token 例項
- 使用django 的cache設定token的有效期Django
- 程式碼段——Newtonsoft簡單設定序列化的時間格式
- VUE簡單的定時器實時重新整理Vue定時器
- AccessToken、for_user、get_token
- 手機直播原始碼,每日定時重新整理使用者任務原始碼
- 影片直播系統原始碼,例項原始碼系列-設定系統時間原始碼
- 聊天平臺原始碼,簡單使用 禁止滑動和設定滑動方向原始碼
- Gin使用及原始碼簡析原始碼
- 請求時token過期自動重新整理token
- oracle使用者密碼有效期設定Oracle密碼
- dubbo 超時設定和原始碼分析原始碼
- joda-time的簡單使用及mysql時間函式的使用(今天,本週,本月)MySql函式
- Passport 設定token 過期時間盡然不生效!這是為什麼?Passport
- Windows ntp時間同步設定(bat指令碼)WindowsBAT指令碼
- 前後端實現雙Token無感重新整理使用者認證後端
- 使用 GPU 進行 Lightmap 烘焙 - 簡單 demoGPU
- goc 學習:原始碼部署和簡單使用Go原始碼
- 簡單的websocket demoWeb
- FFmpeg轉碼音影片時間戳設定分析時間戳
- 直播平臺原始碼,簡訊驗證碼傳送demo原始碼
- alpine 映象設定時區的有效辦法
- MediaScanner原始碼簡單分析原始碼
- prometheus 簡單使用及簡單 middleware 開發Prometheus
- 一款簡單的UILabel,可設定字間距,行間距等UI
- 企業如何有效防止原始碼洩露及篡改?原始碼
- Linux時間設定系統時間、硬體時間和時間服務Linux
- [從原始碼學設計]螞蟻金服SOFARegistry之時間輪的使用原始碼
- Java NIO的簡單demoJava
- 人臉融合簡單demo
- token響應式設定
- WinAppDriver 等待時間設定技巧APP
- localStorage設定過期時間
- linux系統時間設定Linux
- js時間顯示設定JS