Caffeine本地緩存示例詳解
一. 概述
Caffeine是一種高性能的緩存庫(kù),是基于Java 8的最佳(最優(yōu))緩存框架。
基于Google的Guava Cache,Caffeine提供一個(gè)性能卓越的本地緩存(local cache) 實(shí)現(xiàn), 也是SpringBoot內(nèi)置的本地緩存實(shí)現(xiàn)。(Caffeine性能是Guava Cache的6倍)
Caffeine提供了靈活的結(jié)構(gòu)來(lái)創(chuàng)建緩存,并且有以下特性:
- 自動(dòng)加載條目到緩存中,可選異步方式
- 可以基于大小剔除
- 可以設(shè)置過(guò)期時(shí)間,時(shí)間可以從上次訪問(wèn)或上次寫(xiě)入開(kāi)始計(jì)算
- 異步刷新
- keys自動(dòng)包裝在弱引用中
- values自動(dòng)包裝在弱引用或軟引用中
- 條目剔除通知
- 緩存訪問(wèn)統(tǒng)計(jì)
二. 數(shù)據(jù)加載
Caffeine提供以下四種類(lèi)型的加載策略:
1. Manual手動(dòng)
public static void demo(){ Cache<String,String> cache = Caffeine.newBuilder() .expireAfterWrite(20, TimeUnit.SECONDS) .maximumSize(5000) .build(); // 1.Insert or update an entry cache.put("hello","world"); // 2. Lookup an entry, or null if not found String val1 = cache.getIfPresent("hello"); // 3. Lookup and compute an entry if absent, or null if not computable cache.get("msg", k -> createExpensiveGraph(k)); // 4. Remove an entry cache.invalidate("hello"); } private static String createExpensiveGraph(String key){ System.out.println("begin to query db..."+Thread.currentThread().getName()); try { Thread.sleep(2000); } catch (InterruptedException e) { } System.out.println("success to query db..."); return UUID.randomUUID().toString(); }
Cache接口可以顯式地控制檢索、更新和刪除Entry
2. Loading自動(dòng)
private static void demo() { LoadingCache<String, String> cache = Caffeine.newBuilder() .expireAfterWrite(5, TimeUnit.SECONDS) .maximumSize(500) .build(new CacheLoader<String, String>() { @Override public String load(String key) throws Exception { return createExpensiveGraph(key); } @Override public Map<String, String> loadAll(Iterable<? extends String> keys) { System.out.println("build keys"); Map<String,String> map = new HashMap<>(); for(String k : keys){ map.put(k,k+"-val"); } return map; } }); String val1 = cache.get("hello"); Map<String,String> values = cache.getAll(Lists.newArrayList("key1", "key2")); } private static String createExpensiveGraph(String key){ System.out.println("begin to query db..."+Thread.currentThread().getName()); try { Thread.sleep(2000); } catch (InterruptedException e) { } System.out.println("success to query db..."); return UUID.randomUUID().toString(); }
LoadingCache通過(guò)關(guān)聯(lián)一個(gè)CacheLoader來(lái)構(gòu)建Cache, 當(dāng)緩存未命中會(huì)調(diào)用CacheLoader的load方法生成V
還可以通過(guò)LoadingCache的getAll方法批量查詢(xún), 當(dāng)CacheLoader未實(shí)現(xiàn)loadAll方法時(shí), 會(huì)批量調(diào)用load方法聚合會(huì)返回.
當(dāng)CacheLoader實(shí)現(xiàn)loadAll方法時(shí), 則直接調(diào)用loadAll返回.
public interface CacheLoader<K, V>{ V load(@NonNull K var1) throws Exception; Map<K, V> loadAll(@NonNull Iterable<? extends K> keys); }
3. Asynchronous Manual異步手動(dòng)
private static void demo() throws ExecutionException, InterruptedException { AsyncCache<String,String> cache = Caffeine.newBuilder() .maximumSize(500) .expireAfterWrite(10, TimeUnit.SECONDS) .buildAsync(); // Lookup and asynchronously compute an entry if absent CompletableFuture<String> future = cache.get("hello", k -> createExpensiveGraph(k)); System.out.println(future.get()); } private static String createExpensiveGraph(String key){ System.out.println("begin to query db..."+Thread.currentThread().getName()); try { Thread.sleep(2000); } catch (InterruptedException e) { } System.out.println("success to query db..."); return UUID.randomUUID().toString(); }
AsyncCache是另一種Cache,它基于Executor計(jì)算Entry,并返回一個(gè)CompletableFuture
和Cache的區(qū)別是, AsyncCache計(jì)算Entry的線程是ForkJoinPool線程池. 手動(dòng)Cache緩存是調(diào)用線程進(jìn)行計(jì)算
4. Asynchronously Loading異步自動(dòng)
public static void demo() throws ExecutionException, InterruptedException { AsyncLoadingCache<String,String> cache = Caffeine.newBuilder() .expireAfterWrite(10, TimeUnit.SECONDS) .maximumSize(500) .buildAsync(k -> createExpensiveGraph(k)); CompletableFuture<String> future = cache.get("hello"); System.out.println(future.get()); } private static String createExpensiveGraph(String key){ System.out.println("begin to query db..."+Thread.currentThread().getName()); try { Thread.sleep(2000); } catch (InterruptedException e) { } System.out.println("success to query db..."); return UUID.randomUUID().toString(); }
AsyncLoadingCache 是關(guān)聯(lián)了 AsyncCacheLoader 的 AsyncCache
三. 數(shù)據(jù)驅(qū)逐
Caffeine提供以下幾種剔除方式:基于大小、基于權(quán)重、基于時(shí)間、基于引用
1. 基于容量
又包含兩種, 基于size和基于weight權(quán)重
基于size
LoadingCache<String,String> cache = Caffeine.newBuilder() .maximumSize(500) .recordStats() .build( k -> UUID.randomUUID().toString()); for (int i = 0; i < 600; i++) { cache.get(String.valueOf(i)); if(i> 500){ CacheStats stats = cache.stats(); System.out.println("evictionCount:"+stats.evictionCount()); System.out.println("stats:"+stats.toString()); } }
如果緩存的條目數(shù)量不應(yīng)該超過(guò)某個(gè)值,那么可以使用Caffeine.maximumSize(long)。如果超過(guò)這個(gè)值,則會(huì)剔除很久沒(méi)有被訪問(wèn)過(guò)或者不經(jīng)常使用的那個(gè)條目。
上述測(cè)試并不是i=500時(shí), 而是稍微延遲于i的增加, 說(shuō)明驅(qū)逐是另外一個(gè)線程異步進(jìn)行的
基于權(quán)重
LoadingCache<Integer,String> cache = Caffeine.newBuilder() .maximumWeight(300) .recordStats() .weigher((Weigher<Integer, String>) (key, value) -> { if(key % 2 == 0){ return 2; } return 1; }) .build( k -> UUID.randomUUID().toString()); for (int i = 0; i < 300; i++) { cache.get(i); if(i> 200){ System.out.println(cache.stats().toString()); } }
如果,不同的條目有不同的權(quán)重值的話(不同的實(shí)例占用空間大小不一樣),那么你可以用Caffeine.weigher(Weigher)來(lái)指定一個(gè)權(quán)重函數(shù),并且使用Caffeine.maximumWeight(long)來(lái)設(shè)定最大的權(quán)重值。
上述測(cè)試并不是i=200時(shí), 而是稍微延遲于i的增加, 說(shuō)明驅(qū)逐是另外一個(gè)線程異步進(jìn)行的
簡(jiǎn)單的來(lái)說(shuō),要么限制緩存條目的數(shù)量,要么限制緩存條目的權(quán)重值,二者取其一。
2. 基于時(shí)間
基于時(shí)間又分為四種: expireAfterAccess、expireAfterWrite、refreshAfterWrite、expireAfter
expireAfterAccess
超時(shí)未訪問(wèn)則失效: 訪問(wèn)包括讀和寫(xiě)
private static LoadingCache<String,String> cache = Caffeine.newBuilder() .expireAfterAccess(1, TimeUnit.SECONDS) .build(key -> UUID.randomUUID().toString());
特征:
- 訪問(wèn)包括讀和寫(xiě)入
- 數(shù)據(jù)失效后不會(huì)主動(dòng)重新加載, 必須依賴(lài)下一次訪問(wèn). (言外之意: 失效和回源是兩個(gè)動(dòng)作)
- key超時(shí)失效或不存在,若多個(gè)線程并發(fā)訪問(wèn), 只有1個(gè)線程回源數(shù)據(jù),其他線程阻塞等待數(shù)據(jù)返回
- 對(duì)同一數(shù)據(jù)一直訪問(wèn), 且間隔小于失效時(shí)間, 則不會(huì)去load數(shù)據(jù), 一直讀到的是臟數(shù)據(jù)
expireAfterWrite
寫(xiě)后超時(shí)失效
private static LoadingCache<String,String> cache = Caffeine.newBuilder() .expireAfterWrite(1, TimeUnit.SECONDS) .build(key -> UUID.randomUUID().toString());
特征:
數(shù)據(jù)失效后不會(huì)主動(dòng)重新加載, 必須依賴(lài)下一次訪問(wèn). (言外之意: 失效和回源是兩個(gè)動(dòng)作)
key超時(shí)失效或不存在,若多個(gè)線程并發(fā)訪問(wèn), 只有1個(gè)線程回源數(shù)據(jù),其他線程阻塞等待數(shù)據(jù)返回
expire后來(lái)訪問(wèn)一定能保證拿到最新的數(shù)據(jù)
refreshAfterWrite
private static LoadingCache<String,String> cache = Caffeine.newBuilder() .refreshAfterWrite(1, TimeUnit.SECONDS) .build(key -> UUID.randomUUID().toString());
和expireAfterWrite類(lèi)似基于寫(xiě)后超時(shí)驅(qū)逐, 區(qū)別是重新load的操作不一樣.
特征:
- 數(shù)據(jù)失效后不會(huì)主動(dòng)重新加載, 必須依賴(lài)下一次訪問(wèn). (言外之意: 失效和回源是兩個(gè)動(dòng)作)
- 當(dāng)cache命中未命中時(shí), 若多個(gè)線程并發(fā)訪問(wèn)時(shí), 只有1個(gè)線程回源數(shù)據(jù),其他線程阻塞等待數(shù)據(jù)返回
- 當(dāng)cache命中失效數(shù)據(jù)時(shí), 若多個(gè)線程并發(fā)訪問(wèn)時(shí), 第一個(gè)訪問(wèn)的線程提交一個(gè)load數(shù)據(jù)的任務(wù)到公共線程池,然后和所有其他訪問(wèn)線程一樣直接返回舊值
實(shí)際通過(guò)LoadingCache.refresh(K)進(jìn)行異步刷新, 如果想覆蓋默認(rèn)的刷新行為, 可以實(shí)現(xiàn)CacheLoader.reload(K, V)方法
expireAfter
比較少用
public static void demo(){ MyTicker ticker = new MyTicker(); LoadingCache<String,String> cache = Caffeine.newBuilder() .maximumSize(500) .ticker(ticker) //此時(shí)的效果為expireAfterWrite(5,TimeUnit.SECONDS) .expireAfter(new Expiry<String, String>() { //1.如果寫(xiě)入key時(shí)是第一次創(chuàng)建,則調(diào)用該方法返回key剩余的超時(shí)時(shí)間, 單位納秒ns //currentTime為當(dāng)前put時(shí)Ticket的時(shí)間,單位ns @Override public long expireAfterCreate(String key,String value, long currentTime) { System.out.println("write first currentTime:"+currentTime/1_000_000_000L); return 5_000_000_000L;//5s } //2.如果寫(xiě)入key時(shí)已經(jīng)存在即更新key時(shí),則調(diào)用該方法返回key剩余的超時(shí)時(shí)間, 單位納秒ns //currentTime為當(dāng)前put時(shí)Ticket的時(shí)間,單位ns,durationTime為舊值(上次設(shè)置)剩余的存活時(shí)間,單位是ns @Override public long expireAfterUpdate(String key,String value, long currentTime,long durationTime) { System.out.println("update currentTime:"+currentTime/1_000_000_000L+",leftTime:"+durationTime/1_000_000_000L); return 5_000_000_000L;//5s } //3.如果key被訪問(wèn)時(shí),則調(diào)用該方法返回key剩余的超時(shí)時(shí)間, 單位納秒ns //currentTime為read時(shí)Ticket的時(shí)間,單位ns,durationTime為舊值(上次設(shè)置)剩余的存活時(shí)間,單位是ns @Override public long expireAfterRead(String key,String value, long currentTime,long durationTime) { System.out.println("read currentTime:"+currentTime/1_000_000_000L+",leftTime:"+durationTime/1_000_000_000L); return durationTime; } }) .build(k -> UUID.randomUUID().toString()); cache.get("key1");//觸發(fā)expireAfterCreate ticker.advance(1, TimeUnit.SECONDS);//模擬時(shí)間消逝 cache.get("key1");//觸發(fā)expireAfterRead,剩余生存時(shí)間4s ticker.advance(2, TimeUnit.SECONDS);//模擬時(shí)間消逝 cache.put("key1","value1");//觸發(fā)expireAfterUpdate,重置生存時(shí)間為5s ticker.advance(3, TimeUnit.SECONDS);//模擬時(shí)間消逝 cache.get("key1");//觸發(fā)expireAfterCreate,剩余生存時(shí)間為2s } public class MyTicker implements Ticker { private final AtomicLong nanos = new AtomicLong(); //模擬時(shí)間消逝 public void advance(long time, TimeUnit unit) { this.nanos.getAndAdd(unit.toNanos(time)); } @Override public long read() { return this.nanos.get(); } }
上述實(shí)現(xiàn)了Expiry接口, 分別重寫(xiě)了expireAfterCreate、expireAfterUpdate、expireAfterRead方法, 當(dāng)?shù)谝淮螌?xiě)入時(shí)、更新時(shí)、讀訪問(wèn)時(shí)會(huì)分別調(diào)用這三個(gè)方法有機(jī)會(huì)重新設(shè)置剩余的失效時(shí)間, 上述案例模擬了expireAfterWrite(5,TimeUnit.SECONDS)的效果.
注意點(diǎn):
- 以上基于時(shí)間驅(qū)逐, 數(shù)據(jù)超時(shí)失效和回源是兩個(gè)動(dòng)作, 必須依賴(lài)下一次訪問(wèn). 為了避免服務(wù)啟動(dòng)時(shí)大量緩存穿透, 可以通過(guò)提前項(xiàng)目啟動(dòng)時(shí)手動(dòng)預(yù)熱
- 一般expireAfterWrite和refreshAfterWrite結(jié)合使用, expire的時(shí)間t1大于refresh的時(shí)間t2, 在t2~t1內(nèi)數(shù)據(jù)更新允許臟數(shù)據(jù), t1之后必須要重新同步加載新數(shù)據(jù)
3. 基于弱/軟引用
/** * 允許GC時(shí)回收keys或values */ public static void demo(){ LoadingCache<String,String> cache = Caffeine.newBuilder() .maximumSize(500) .expireAfterWrite(10, TimeUnit.SECONDS) .weakKeys() .weakValues() .build(k -> UUID.randomUUID().toString()); }
Caffeine.weakKeys() 使用弱引用存儲(chǔ)key。如果沒(méi)有強(qiáng)引用這個(gè)key,則GC時(shí)允許回收該條目
Caffeine.weakValues() 使用弱引用存儲(chǔ)value。如果沒(méi)有強(qiáng)引用這個(gè)value,則GC時(shí)允許回收該條目
Caffeine.softValues() 使用軟引用存儲(chǔ)value, 如果沒(méi)有強(qiáng)引用這個(gè)value,則GC內(nèi)存不足時(shí)允許回收該條目
public static void demo(){ /** * 使用軟引用存儲(chǔ)value,GC內(nèi)存不夠時(shí)會(huì)回收 */ LoadingCache<String,String> cache = Caffeine.newBuilder() .maximumSize(500) .expireAfterWrite(10, TimeUnit.SECONDS) .softValues()//注意沒(méi)有softKeys方法 .build(k -> UUID.randomUUID().toString()); }
Java4種引用的級(jí)別由高到低依次為:強(qiáng)引用 > 軟引用 > 弱引用 > 虛引用
引用類(lèi)型 | 被垃圾回收時(shí)間 | 用途 | 生存時(shí)間 |
---|---|---|---|
強(qiáng)引用 | 從來(lái)不會(huì) | 對(duì)象的一般狀態(tài) | JVM停止運(yùn)行時(shí)終止 |
軟引用 | 在內(nèi)存不足時(shí) | 對(duì)象緩存 | 內(nèi)存不足時(shí)終止 |
弱引用 | 在垃圾回收時(shí) | 對(duì)象緩存 | gc運(yùn)行后終止 |
虛引用 | Unknown | Unknown | Unknown |
四. 驅(qū)逐監(jiān)聽(tīng)
- eviction 指受策略影響而被刪除
- invalidation 值被調(diào)用者手動(dòng)刪除
- removal 值因eviction或invalidation而發(fā)生的一種行為
1. 手動(dòng)觸發(fā)刪除
// individual key cache.invalidate(key) // bulk keys cache.invalidateAll(keys) // all keys cache.invalidateAll()
2. 被驅(qū)逐的原因
- EXPLICIT:如果原因是這個(gè),那么意味著數(shù)據(jù)被我們手動(dòng)的remove掉了
- REPLACED:就是替換了,也就是put數(shù)據(jù)的時(shí)候舊的數(shù)據(jù)被覆蓋導(dǎo)致的移除
- COLLECTED:這個(gè)有歧義點(diǎn),其實(shí)就是收集,也就是垃圾回收導(dǎo)致的,一般是用弱引用或者軟引用會(huì)導(dǎo)致這個(gè)情況
- EXPIRED:數(shù)據(jù)過(guò)期,無(wú)需解釋的原因。
- SIZE:個(gè)數(shù)超過(guò)限制導(dǎo)致的移除
3. 監(jiān)聽(tīng)器
public static void demo(){ LoadingCache<String,String> cache = Caffeine.newBuilder() .maximumSize(5) .recordStats() .expireAfterWrite(2, TimeUnit.SECONDS) .removalListener((String key, String value, RemovalCause cause) -> { System.out.printf("Key %s was removed (%s)%n", key, cause); }) .build(key -> UUID.randomUUID().toString()); for (int i = 0; i < 15; i++) { cache.get(i+""); try { Thread.sleep(200); } catch (InterruptedException e) { } } //因?yàn)閑vict是異步線程去執(zhí)行,為了看到效果稍微停頓一下 try { Thread.sleep(2000); } catch (InterruptedException e) { } }
日志打印如下:
Key 0 was removed (SIZE)
Key 1 was removed (SIZE)
Key 6 was removed (SIZE)
Key 7 was removed (SIZE)
Key 8 was removed (SIZE)
Key 9 was removed (SIZE)
Key 10 was removed (SIZE)
Key 2 was removed (EXPIRED)
Key 3 was removed (EXPIRED)
Key 4 was removed (EXPIRED)
五. 統(tǒng)計(jì)
public static void demo(){ LoadingCache<Integer,String> cache = Caffeine.newBuilder() .maximumSize(10) .expireAfterWrite(10, TimeUnit.SECONDS) .recordStats() .build(key -> { if(key % 6 == 0 ){ return null; } return UUID.randomUUID().toString(); }); for (int i = 0; i < 20; i++) { cache.get(i); printStats(cache.stats()); } for (int i = 0; i < 10; i++) { cache.get(i); printStats(cache.stats()); } } private static void printStats(CacheStats stats){ System.out.println("---------------------"); System.out.println("stats.hitCount():"+stats.hitCount());//命中次數(shù) System.out.println("stats.hitRate():"+stats.hitRate());//緩存命中率 System.out.println("stats.missCount():"+stats.missCount());//未命中次數(shù) System.out.println("stats.missRate():"+stats.missRate());//未命中率 System.out.println("stats.loadSuccessCount():"+stats.loadSuccessCount());//加載成功的次數(shù) System.out.println("stats.loadFailureCount():"+stats.loadFailureCount());//加載失敗的次數(shù),返回null System.out.println("stats.loadFailureRate():"+stats.loadFailureRate());//加載失敗的百分比 System.out.println("stats.totalLoadTime():"+stats.totalLoadTime());//總加載時(shí)間,單位ns System.out.println("stats.evictionCount():"+stats.evictionCount());//驅(qū)逐次數(shù) System.out.println("stats.evictionWeight():"+stats.evictionWeight());//驅(qū)逐的weight值總和 System.out.println("stats.requestCount():"+stats.requestCount());//請(qǐng)求次數(shù) System.out.println("stats.averageLoadPenalty():"+stats.averageLoadPenalty());//單次load平均耗時(shí) }
六. 其他
1. Ticker
時(shí)鐘, 方便測(cè)試模擬時(shí)間流逝
public static void demo(){ MyTicker ticker = new MyTicker(); LoadingCache<String,String> cache = Caffeine.newBuilder() .maximumSize(500) .ticker(ticker) .expireAfterWrite(1, TimeUnit.SECONDS) .build(k -> UUID.randomUUID().toString()); cache.get("key1");//觸發(fā)expireAfterCreate ticker.advance(1, TimeUnit.SECONDS);//模擬時(shí)間消逝 cache.get("key1");//觸發(fā)expireAfterRead,剩余生存時(shí)間4s ticker.advance(2, TimeUnit.SECONDS);//模擬時(shí)間消逝 cache.put("key1","value1");//觸發(fā)expireAfterUpdate,重置生存時(shí)間為5s } public class MyTicker implements Ticker { private final AtomicLong nanos = new AtomicLong(); //模擬時(shí)間消逝 public void advance(long time, TimeUnit unit) { this.nanos.getAndAdd(unit.toNanos(time)); } @Override public long read() { return this.nanos.get(); } }
2. Scheduler
3. 類(lèi)圖及API
到此這篇關(guān)于Caffeine本地緩存詳解的文章就介紹到這了,更多相關(guān)Caffeine本地緩存內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
詳解Spring中Spel表達(dá)式和el表達(dá)式的區(qū)別
在?Java?開(kāi)發(fā)中,表達(dá)式語(yǔ)言是一種強(qiáng)大的工具,而SpEL?表達(dá)式與EL?表達(dá)式是我們常常遇到兩種表達(dá)式語(yǔ)言,下面我們就來(lái)看看它們的具體使用與區(qū)別吧2023-07-07Spring?Cloud?+?Nacos?+?Seata整合過(guò)程(分布式事務(wù)解決方案)
Seata 是一款開(kāi)源的分布式事務(wù)解決方案,致力于在微服務(wù)架構(gòu)下提供高性能和簡(jiǎn)單易用的分布式事務(wù)服務(wù),這篇文章主要介紹了Spring?Cloud?+?Nacos?+?Seata整合過(guò)程(分布式事務(wù)解決方案),需要的朋友可以參考下2022-03-03Spring Native 基礎(chǔ)環(huán)境搭建過(guò)程
Spring?Native可以通過(guò)GraalVM將Spring應(yīng)用程序編譯成原生鏡像,提供了一種新的方式來(lái)部署Spring應(yīng)用,本文介紹Spring?Native基礎(chǔ)環(huán)境搭建,感興趣的朋友跟隨小編一起看看吧2024-02-02SpringBoot開(kāi)發(fā)中的數(shù)據(jù)源詳解
這篇文章主要介紹了SpringBoot開(kāi)發(fā)中的數(shù)據(jù)源詳解,數(shù)據(jù)源(Data Source)顧名思義,數(shù)據(jù)的來(lái)源,是提供某種所需要數(shù)據(jù)的器件或原始媒體,在數(shù)據(jù)源中存儲(chǔ)了所有建立數(shù)據(jù)庫(kù)連接的信息,需要的朋友可以參考下2023-09-09SpringBoot項(xiàng)目的五種創(chuàng)建方式
這篇文章主要介紹了SpringBoot項(xiàng)目的五種創(chuàng)建方式,文中通過(guò)圖文結(jié)合的方式講解的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作有一定的幫助,需要的朋友可以參考下2024-12-12Java 實(shí)戰(zhàn)項(xiàng)目之家居購(gòu)物商城系統(tǒng)詳解流程
讀萬(wàn)卷書(shū)不如行萬(wàn)里路,只學(xué)書(shū)上的理論是遠(yuǎn)遠(yuǎn)不夠的,只有在實(shí)戰(zhàn)中才能獲得能力的提升,本篇文章手把手帶你用Java實(shí)現(xiàn)一個(gè)家居購(gòu)物商城系統(tǒng),大家可以在過(guò)程中查缺補(bǔ)漏,提升水平2021-11-11詳解spring cloud整合Swagger2構(gòu)建RESTful服務(wù)的APIs
這篇文章主要介紹了詳解spring cloud整合Swagger2構(gòu)建RESTful服務(wù)的APIs,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2018-01-01