SpringBoot執(zhí)行Lua腳本
之前基本上完成了Lua腳本在Redis中使用的常用方式以及常用指令颠悬,在項目使用Lua腳本時矮燎,可以直接使用原始的指令,只是過程較為復(fù)雜赔癌。每種語言在實現(xiàn)Redis客戶端時诞外,基本上都會再次封裝,盡量為用戶提供更方便的調(diào)用灾票,目前團隊使用的SpringCloud架構(gòu)峡谊,因此這里主要介紹一下SpringBoot執(zhí)行Lua腳本的方法。
對Redis的一些封裝
對Redis的操作,主要使用了SpringBoot的RedisTemplate模版,該模板是對各種客戶端的一個抽象,無論使用Jedis、Lettuce等都無需關(guān)注底層的一些細節(jié)。另外為了更為簡單的操作Redis蜈块,又對RedisTemplate進行了一層工具封裝器一,如下示例
@Slf4j
public class RedisUtils {
RedisTemplate<String, Object> redisTemplate;
public boolean expire(String key, long time) {
try {
if (time > 0) {
redisTemplate.expire(key, time, TimeUnit.SECONDS);
}
return true;
} catch (Exception e) {
log.error(e.getMessage(), e);
return false;
}
}
...
}
RedisTemplate序列化请毛,支持多種方式仙蚜,團隊統(tǒng)一采用了FastJson進行序列化操作面徽,這種方式目前也比較普遍霎匈,序列化對象的實現(xiàn)也比較簡單
public class FastJsonRedisSerializer<T> implements RedisSerializer<T> {
/**
* 默認編碼
*/
public static final Charset DEFAULT_CHARSET = Charset.forName("utf-8");
private Class<T> clazz;
public FastJsonRedisSerializer(Class<T> clazz) {
super();
this.clazz = clazz;
}
@Nullable
@Override
public byte[] serialize(@Nullable T t) throws SerializationException {
return Optional.ofNullable(t)
.map(p -> JSON.toJSONString(p, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET))
.orElseGet(() -> new byte[0]);
}
@Nullable
@Override
public T deserialize(@Nullable byte[] bytes) throws SerializationException {
return Optional.ofNullable(bytes)
.map(p -> JSON.parseObject(new String(p, DEFAULT_CHARSET), clazz))
.orElse(null);
}
}
在系統(tǒng)生成RedisTemplate對象時,只需要將FastJsonRedisSerializer對象設(shè)置為value和HashValue的序列化屬性即可
redisTemplate.setValueSerializer(fastJsonRedisSerializer);
redisTemplate.setHashValueSerializer(fastJsonRedisSerializer);
以上這些操作,基本上完成RedisTemplate應(yīng)用的前置配置,后續(xù)的示例都是依賴于上述的基礎(chǔ)。
示例需求及構(gòu)造數(shù)據(jù)
在這個示例中蹄葱,不再使用上一節(jié)的訂單例子竣况。模擬一個新的需求摹恨,主要實現(xiàn)Lua腳本日志記錄、多項數(shù)據(jù)的統(tǒng)計工作较木,這些知識點在前一節(jié)都有描述泳赋,這里主要是組裝整合應(yīng)用千诬,日志記錄也比較重要,可以用于跟蹤BUG毅访。
- 公司銷售團隊會形成一個列表,能夠展示在頁面上玛迄,同時顯示用戶姓名,年齡以及當(dāng)前的銷售額
- 管理團隊需要能夠查知銷售團隊的平均年齡钧栖,以及當(dāng)年的銷售總額,以便對比年任務(wù)差值
在這個示例中,將對返回值進行測試淀弹,因此構(gòu)建了幾個對象:銷售人員忿峻、統(tǒng)計結(jié)果等兩個對象
@Getter
@Setter
@ToString
public class Person {
/**人員ID*/
private Integer id;
private String name;
private Integer age;
/**銷售額*/
private BigDecimal sales;
}
@Getter
@Setter
@ToString
public class Sum {
/**平均年齡*/
private Double avgAge;
/**總銷售額*/
private BigDecimal sumSale;
}
創(chuàng)建幾個測試數(shù)據(jù)
public void inintPersons() {
// 人員列表
String idKeys = "mylua:id";
// 銷售人員詳情
String userkey = "mylua:user";
List<Object> ids = ImmutableList.of(1, 2, 3, 4);
ids.forEach(p -> {
Integer i = (Integer) p;
Person person = new Person();
person.setId(i);
person.setName("user" + i);
person.setAge(30 + i);
person.setSales(new BigDecimal(i * 30000));
redisUtils.hset(userkey, String.valueOf(p), person);
});
redisUtils.lsetlist(idKeys, ids);
}
初始化腳本后,Redis中將包含一組測試數(shù)據(jù)
key=mylua:id
用戶ID列表叔遂,目前采用列表實現(xiàn)嚼吞∑酰可以使用有序集合實現(xiàn)补憾,按用戶銷售額進行排序岩瘦,實現(xiàn)銷售人員列表展示
key=mylua:user
散列表實現(xiàn),item=用戶ID苏遥,存儲用戶詳情田炭,{id: "", name: "", age: 1, sales: 1}
執(zhí)行Lua腳本
根據(jù)以上的需求瞬矩,實現(xiàn)步驟為:
- 讀取銷售人員ID列表
- 循環(huán)列表讀取每個銷售信息盾碗,累加年齡及銷售額
- 計算年齡平均值,返回人均年齡和總銷售
在實現(xiàn)這個腳本時,只需要傳入兩個鍵值唬格,銷售人員ID列表鍵家破,散列表得鍵,用戶ID由中間過程產(chǎn)生购岗;不需要傳遞參數(shù)汰聋。因此其腳本參見下述代碼
-- 調(diào)用方式:2 idlistKey userKey, 返回{avgAge, sumSale}
-- 讀取參數(shù)鍵,分別為id集合key喊积,用戶詳情key
local idsKey = KEYS[1]
local userKey = KEYS[2]
-- 記錄日志烹困,安裝默認的log級別為NOTICE
redis.log(redis.LOG_NOTICE, "Receie the param:" .. idsKey)
-- 獲取所有的id集合, table
local ids = redis.call('lrange', idsKey, 0, -1)
-- 記錄用戶長度
redis.log(redis.LOG_NOTICE, "the user count:" .. #ids)
local sumAge = 0
-- 結(jié)果 銷售總額,人均年齡
local sumSale = 0
local avgAge = 0
if type(ids) == "table" and #ids > 0 then
for _, v in pairs(ids) do
-- 散列表中存儲的是字符串乾吻,因為序列化為JSON髓梅,因此使用cjson將json字符串轉(zhuǎn)為Lua table
local user = cjson.decode(redis.call("hget", userKey, v))
sumAge = sumAge + user.age
sumSale = sumSale + user.sales
end
avgAge = sumAge / #ids
end
local result = {}
result.avgAge = avgAge
result.sumSale = sumSale
-- table返回時轉(zhuǎn)換為JSON
local ret = cjson.encode(result)
redis.log(redis.LOG_NOTICE, "calc result:" .. ret)
return ret
上面的腳本中使用到了CJSON類庫,該類庫由C開發(fā)绎签,在LUA中提供高效的JSON操作枯饿,常用方法有
- cjson.encode({}) 將Lua的table數(shù)據(jù)類型轉(zhuǎn)換為JSON格式字符串
- cjson.decode(jsonString) 將json格式字符串轉(zhuǎn)為Lua的table數(shù)據(jù)
腳本完成之后,添加到SpringBoot項目的resources目錄下诡必,即目錄結(jié)構(gòu)為:src/resources/person.lua
奢方。RedisTemplate提供兩種方法可以執(zhí)行Lua腳本
/**
script為腳本資源,keys為一個數(shù)組形式的Key集合爸舒,最后為可選參數(shù)
*/
<T> T execute(RedisScript<T> script, List<K> keys, Object... args)
/**
和上述中方式一致袱巨,多了兩個參數(shù),分別為參數(shù)序列化方法碳抄、結(jié)果序列化方法愉老,更方便擴展
*/
<T> T execute(RedisScript<T> script, RedisSerializer<?> argsSerializer, RedisSerializer<T> resultSerializer, List<K> keys, Object... args)
RedisTemplate使用Lua腳本時,有一個非常重要的參數(shù)RedisScript剖效,該參數(shù)一方面決定了Lua腳本資源嫉入,另一方面也決定了返回值類型。在實際開發(fā)過程中璧尸,使用的是默認DefaultRedisScript
實現(xiàn)對象咒林,對這個類進行一些查閱,了解以下使用方式
// 這里代碼不全爷光,可以查看org.springframework.data.redis.core.script.DefaultRedisScript
// 三個屬性垫竞,分別表示了腳本資源,腳本sha1,以及結(jié)果類型
private @Nullable ScriptSource scriptSource;
private @Nullable String sha1;
private @Nullable Class<T> resultType;
// 腳本字符串和返回類型構(gòu)造函數(shù),一般不常用
public DefaultRedisScript(String script, @Nullable Class<T> resultType) {
...
}
// 獲取腳本Sha1欢瞪,緩存時使用
public String getSha1() {
...
}
// 設(shè)置返回類型,
public void setResultType(Class<T> resultType) {
...
}
// 設(shè)置腳本資源
public void setLocation(Resource scriptLocation) {
this.scriptSource = new ResourceScriptSource(scriptLocation);
}
// 設(shè)置腳本資源
public void setScriptSource(ScriptSource scriptSource) {
this.scriptSource = scriptSource;
}
上面setResultType方法中活烙,有一個要求,只能設(shè)置類型為:Long遣鼓、布爾啸盏、List或者反序列化類型,設(shè)置為什么類型骑祟,最后就得到什么類型回懦。
The script result type. Should be one of Long, Boolean, List, or deserialized value type
在SpringBoot項目中,腳本一般放在resources中次企,因此很容易獲取到Resource資源(ClassPathResource
)怯晕,因此后面兩個設(shè)置腳本資源的方法是比較方便的。對于兩個執(zhí)行Lua腳本的方法缸棵,分別進行調(diào)用舟茶,看一下實現(xiàn)的方式。
- 直接調(diào)用蛉谜,不提供序列化方法
<T> T execute(RedisScript<T> script, List<K> keys, Object... args)
public void execLuaWithoutSerializer() {
DefaultRedisScript<JSONObject> script = new DefaultRedisScript<>();
script.setResultType(JSONObject.class);
script.setLocation(new ClassPathResource("/person.lua"));
List<String> keys = ImmutableList.of("mylua:id", "mylua:user");
JSONObject sum = redisTemplate.execute(script, keys);
System.out.println(sum.toJSONString());
}
在之前的腳本中,定義了必須傳遞兩個key崇堵,但不需要參數(shù)型诚,因此在調(diào)用時傳入了一個List,存儲兩個key鸳劳。由于腳本之后返回了一個JSON格式字符串狰贯,并且RedisTemplate采用了FastJson序列化,因此返回一個JSONObject赏廓,調(diào)用時設(shè)置的ResultType必須和腳本返回類型一致涵紊,由于沒有指定序列化,會使用默認的序列化工具幔摸,而設(shè)置的默認序列化方法泛型為Object摸柄,因此上述無法直接使用Sum類型,參見第一節(jié)RedisTemplate<String, Object> redisTemplate
既忆。如果有序列化特定類型驱负,還是需要采用更明確第二種方法。執(zhí)行后患雇,其返回的結(jié)果如下
{"sumSale":300000,"avgAge":32.5}
由于團隊采用了RedisTemplate<String, Object>的泛型方案跃脊,雖然能夠處理任意類型,但是對于類型轉(zhuǎn)換確實存在一些不方便之處苛吱,本例中只能只能轉(zhuǎn)換為JSONObject也是基于此酪术,無法直接序列化為Sum對象,只能再次提供一個新的序列化再次進行翠储』嫜悖可以采用RedisTemplate不指定泛型的方式去解決類型轉(zhuǎn)換的問題橡疼,但使用起來也會有一些不變之處。
- 提供序列化方法
<T> T execute(RedisScript<T> script, RedisSerializer<?> argsSerializer, RedisSerializer<T> resultSerializer, List<K> keys, Object... args)
public void execLuaWithSerializer() {
DefaultRedisScript<Sum> script = new DefaultRedisScript<>();
script.setResultType(Sum.class);
script.setLocation(new ClassPathResource("/person.lua"));
List<String> keys = ImmutableList.of("mylua:id", "mylua:user");
Sum sum = redisTemplate.execute(script, new StringRedisSerializer(), new FastJsonRedisSerializer<>(Sum.class), keys);
System.out.println(sum);
}
在這個示例中咧七,期望返回自定義的Sum
類型衰齐,將更方便在程序中的應(yīng)用。主要就是返回結(jié)果的序列化方法继阻,也使用了同一個序列化操作對象耻涛。其執(zhí)行結(jié)果
Sum(avgAge=32.5, sumSale=300000)
執(zhí)行結(jié)果符合預(yù)期。上面兩種方法都可以使用瘟檩,相對來說抹缕,第二種更適合在業(yè)務(wù)開發(fā)中使用。由于業(yè)務(wù)的不同墨辛,因此使用的腳本不同卓研,返回數(shù)據(jù)不同,鍵與參數(shù)都不同睹簇。在使用過程中可以稍微封裝一下奏赘。
SpringBoot執(zhí)行腳本流程分析
在之前介紹執(zhí)行腳本指令時,提到過兩種指令EVAL
太惠、EVALSHA
磨淌,并提供了檢查腳本緩存是否存在的指令SCRIPT EXISTS
,EVAL指令或者SCRIPT LOAD指令都可以將腳本緩存凿渊,EVAL立即執(zhí)行梁只,但不會返回SHA1,SCRIPT LOAD緩存腳本埃脏,但不立即執(zhí)行搪锣,并且返回SHA1值。EVALSHA指令可以直接利用Redis緩存的腳本執(zhí)行彩掐,而不需要每次都傳遞腳本构舟,當(dāng)腳本比較大時,可以節(jié)約網(wǎng)絡(luò)傳輸數(shù)據(jù)量堵幽。
但是在上面SpringBoot執(zhí)行過程中旁壮,并沒有發(fā)現(xiàn)其調(diào)用EVALSHA,也沒有執(zhí)行SCRIPT EXISTS的方法谐檀,這個過程中有沒有利用到SHA(在RedisScript中抡谐,有一個getSha1的方法),需要分析一下其執(zhí)行流程桐猬。
上面的兩種方法最終都調(diào)用到下述方法
// 實現(xiàn)類org.springframework.data.redis.core.script.DefaultScriptExecutor
// 第一個方法調(diào)用該方法麦撵,采用過了RedisTemplate默認提供的序列化對象
public <T> T execute(final RedisScript<T> script, final List<K> keys, final Object... args) {
// use the Template's value serializer for args and result
return execute(script, template.getValueSerializer(), (RedisSerializer<T>) template.getValueSerializer(), keys,
args);
}
// 最終兩個都調(diào)用了該方法
public <T> T execute(final RedisScript<T> script, final RedisSerializer<?> argsSerializer,
final RedisSerializer<T> resultSerializer, final List<K> keys, final Object... args) {
return template.execute((RedisCallback<T>) connection -> {
final ReturnType returnType = ReturnType.fromJavaType(script.getResultType());
final byte[][] keysAndArgs = keysAndArgs(argsSerializer, keys, args);
final int keySize = keys != null ? keys.size() : 0;
if (connection.isPipelined() || connection.isQueueing()) {
connection.eval(scriptBytes(script), returnType, keySize, keysAndArgs);
return null;
}
// 沒有使用管道,調(diào)用該方法
return eval(connection, script, returnType, keySize, keysAndArgs, resultSerializer);
});
}
protected <T> T eval(RedisConnection connection, RedisScript<T> script, ReturnType returnType, int numKeys,
byte[][] keysAndArgs, RedisSerializer<T> resultSerializer) {
Object result;
try {
result = connection.evalSha(script.getSha1(), returnType, numKeys, keysAndArgs);
} catch (Exception e) {
if (!ScriptUtils.exceptionContainsNoScriptError(e)) {
throw e instanceof RuntimeException ? (RuntimeException) e : new RedisSystemException(e.getMessage(), e);
}
result = connection.eval(scriptBytes(script), returnType, numKeys, keysAndArgs);
}
if (script.getResultType() == null) {
return null;
}
return deserializeResult(resultSerializer, result);
}
從上面最后一個方法執(zhí)行中可以看到,SpringBoot先計算了當(dāng)前資源的Sha1免胃,并使用EVALSHA指令嘗試執(zhí)行了一次音五,如果成功,則返回結(jié)果羔沙,如果緩存沒有該腳本躺涝,則進入異常部分,并最終使用了EVAL指令進行執(zhí)行扼雏。
從這里可以看到坚嗜,SpringBoot客戶端已經(jīng)實現(xiàn)了腳本緩存的功能,只不過進行了封裝诗充,并且不對用戶暴露苍蔬。在使用時簡單,傻瓜蝴蜓,并且用起來很舒服碟绑。總結(jié)一下就是:SpringBoot每次都先按EVALSHA執(zhí)行茎匠,沒有緩存腳本格仲,再次執(zhí)行EVAL,得到結(jié)果并緩存腳本诵冒。
注:開發(fā)環(huán)境是Windows凯肋,Redis在Linux上部署,由于編碼以及文件的換行符配置導(dǎo)致Windows下計算的SHA1造烁,與Redis在Linux下緩存的文件SHA1不匹配否过,導(dǎo)致每次都無法命中緩存午笛,此時可以通過IDEA的文件換行設(shè)置惭蟋,調(diào)整腳本文件使用Unix換行符,可以解決不同系統(tǒng)匹配問題药磺。
如上圖告组,最開始默認為Windows換行符,CRLF癌佩,調(diào)整為LF即可解決上述問題木缝。