目錄
一. 場景再現、具體分析
二. 常見實現方案及方案分析
2.1 數據庫字段最大存儲理論分析
2.2 最佳實踐方式分析
三. 接口選擇、接口分析
四. 數據庫設計
4.1 接口緩存表設計
4.1.1 建表SQL
4.1.2 查詢接口設計
4.2 調用日志記錄表設計
4.2.1 建表SQL
4.2.2 查詢SQL
五. 項目代碼落地實現
5.1 通用代碼編寫
5.1.1 創建接口請求參數實體類
5.1.2 創建接口響應通用返回結果類
5.1.3 創建接口緩存表實體類
5.1.4 創建接口緩存表Mapper接口
5.1.5 創建接口活動日志表實體類
5.1.6 創建接口活動記錄表Mapper接口
5.1.7 創建 Controller?層控制器類
5.1.8 Service 層方法公共變量和方法創建
5.2 Redis 緩存真實數據業務代碼實現
5.3 Redis 緩存文件指針,通過指針再讀取文件內容業務實現
5.4 二者混合使用
六. 簡要總結
一. 場景再現、具體分析
這里我們來設想以下場景。
假設你的個人項目或公司分給你的項目需求需要查詢公司信息,所以對接了啟信寶、企查查等第三方公司信息查詢接口;此外,為了節省成本,當我們首次查詢一個新公司的時候,如果調用企查查成功,則將企查查返回的數據保存到我們本地的數據庫中,同時將數據庫數據讀到緩存中提高查詢效率,后續我們再次查詢此公司,則不會發起實際調用,而是從緩存獲取數據返回。
對于緩存公司數據的方式,通常我們有以下兩種方案。
方案一:將企查查接口返回的數據報文直接存儲到數據庫,可以使用 VARCHAR(MAX),TEXT,MEDIUMTEXT等字段存儲,根據接口返回數據的大小選擇;然后將接口原文直接緩存到 Redis 中;
方案二:將企查查接口返回的數據報文存儲到文件中(文件可以存儲在服務器上,也可以存儲到 minio,阿里云OSS等云服務器上),然后將文件的指針(文件的地址)存儲到數據庫中,使用 VARCHAR 即可,同時為了提高查詢效率,也將文件的指針緩存到 Redis 中;
再比如,以我們當前的 CSDN 網站為例,用戶編寫的文章,要進行暫存或發布,文章應該如何存儲?是直接以文本格式存儲到數據庫中?還是存儲到文本文件,然后將文件指針存儲到數據中?
這種場景其實并不罕見,那么接下來,我們就來探討一下,這兩種文件存儲方案的優缺點吧!
二. 常見實現方案及方案分析
2.1 數據庫字段最大存儲理論分析
如下表格所示,是MySQL中比較常見的幾個存儲文本的字段類型,目前比較常用的字符集有 utf8mb4 和?utf8mb3,更推薦使用?utf8mb4。
字段類型 | 最大字節數 | 計算基礎 | 理論最大漢字數 (utf8mb3,3字節) | 理論最大漢字數 (utf8mb4,4字節) | 適用場景 | 使用頻率 |
---|---|---|---|---|---|---|
VARCHAR | 65,533 | 65,535 - 2 | 21,844 字 | 16,383 字 | 短文本 | 極高 |
TEXT | 65,535 | 固定限制 | 21,845 字 | 16,383 字 | 普通長文本 | 一般 |
JSON | 1,073,741,823 | LONGTEXT 級 | 357,913,941 字 | 268,435,455 字 | JSON 數據 | 較低 |
MEDIUMTEXT | 16,777,215 | 16MB | 5,592,405 字 | 4,194,303 字 | 大文本 | 較低 |
重點來啦!!!小編這里盡可能簡單的說一下,在數據庫的底層,所有的數據都是存儲在數據頁中的,一張數據頁的大小就是16KB,即16384字節,并且數據庫的 InnoDB 存儲引擎有大數據存儲機制,當一條數據的某個字段,以VARCHAR為例,大于數據頁的一半8K(8192字節)時,則不會將字段值真實存儲在當前數據頁,而是存儲到"溢出頁",然后數據庫底層會把"溢出頁"的指針值存儲到字段值中,當我們讀取數據的時候,數據庫底層讀取到大字段的值之后,會根據指針值進行IO操作,將"溢出頁"的值讀出來然后進行返回;而TEXT、JSON、MEDIUMTEXT其它三者更不必多說,都是將數據存儲在單獨的數據頁,然后記錄中的字段值則是存儲數據頁的指針地址,具體見下表格。
行格式 | 溢出閾值 | VARCHAR(10KB)存儲 | TEXT 存儲 | JSON 存儲 | MEDIUMTEXT 存儲 | 版本建議 |
---|---|---|---|---|---|---|
Redundant | 無 | 完整存行內 | 完整存行內 | 完整存行內 | 完整存行內 | 已淘汰 |
Compact | 768 字節 | 768B 前綴 + 溢出鏈 | 768B 前綴 + 溢出鏈 | 768B 前綴 + 溢出鏈 | 768B 前綴 + 溢出鏈 | 兼容舊系統 |
Dynamic | 8 KB | 20B 指針 + 完整溢出鏈 | 20B 指針 + 完整溢出鏈 | 20B 指針 + 完整溢出鏈 | 20B 指針 + 完整溢出鏈 | MySQL 5.7+默認 |
不難發現,在 MySQL5.7 版本之后,行格式已經做了進一步優化,采用了?Dynamic 動態行格式,可以簡單理解為,當我們存儲的一條數據某個字段大小小于 8K 時,MySQL選擇直接存儲完整數據到行內;但是當字段大小大于 8K時,則會將真實數據存儲到其他數據頁,字段則存儲指針值。當然了,數據庫底層的設計極為精妙,也有可能某個數據頁第一條要存儲的數據就是大數據,此時有16KB的空間,空間足夠,一般是全量存儲;但如果一個數據頁已經存儲了100條數據,剩下6KB的大小,但要存儲一行8KB的大小的記錄,此時就有可能觸發溢出存儲機制,將大數據存儲在"溢出頁"。
所以我們本篇文章就以 MySQL5.7+ 之后的版本為例,可以得出以下結論,一定牢記!!!
當使用 VARCHAR、TEXT、JSON、MEDIUMTEXT 字段存儲數據時,若字段大小大于8K,則數據庫行內字段值只存儲文件指針,真實數據存儲在數據頁;若字段大小小于8K,都會把真實數據存儲在行內;唯一的區別就是它們的最大字節數據不同會導致數據頁的數量不同僅此而已。數據頁數量越多,也就意味著數據庫要進行更多次的隨機 IO 去讀取數據頁,可能會影響數據庫的性能。
2.2 最佳實踐方式分析
通過上面的一頓分析,我們對于這四種存儲字段已經比較清楚了,那么現在,我們就從實用的角度來考慮到底選擇哪種字段?
小編更推薦使用 VARCHAR 類型!
VARCHAR:可控可變長度,并且當數據小不需要額外存儲數據頁時,它直接存儲完整數據,當數據量大需要數據頁時,則存儲文件地址,并且支持數據檢查和內存臨時表,并且是常見的類型,代碼實現難度低,符合大眾思路;
TEXT:相對來說比較好的一個字段,但還是不如 VARCHAR,不支持數據檢查和內存臨時表,另外一點就是,無論它是否使用到了額外的數據頁,都會有一個20字節的文件指針,比較浪費空間,且此類型很少有人用,所以實際開發我們也不追求新鮮感,以通用型普遍性 VARCAHR 類型為主;
JSON:比較好的一個數據類型,但不建議使用。原因是需要使用 JSONObject 類型來接受,且部分開發人員未必對此字段類型熟悉,甚至可能SQL語法也不太清楚,需要額外進行學習,徒增開發壓力;并且更重要的一點是,如果我們要在程序中獲取JSON中的某個 Key 并進行操作,此時就顯得較為麻煩,不如存儲為字符串或文本類型,在經 JSONObject.parse() 轉化;
MEDIUMTEXT:占用數據庫存儲較大,不易于管理,特別是當用戶大量訪問調用企查查接口時,會增加數據庫存儲壓力,導致實際查詢時,數據庫底層會進行多次IO操作,如此一來還會如直接存儲到數據庫外的單獨文件,在將文件指針存儲到數據庫。
總的來說,我們本篇文章著重考慮的就是是否要將真實數據存表,還是存文件指針,通過 MySQL 的動態優化策略,我們不難發現,當數據量大于 8K 時,即使我們不額外存到文件中,數據庫底層還是會將數據存儲到"數據頁"中,在查詢時需要進行額外的IO操作,既然如此,還不如當數據量小的數后,直接使用 VARCAHR 存儲,當數據量大的時候,使用外部文件存儲,然后使用 VARCHAR 存儲外部文件指針。
所以,我們可以簡單地把8K作為一個臨界點,總結為以下表格,各位小伙伴可根據表格自行選擇最優實現方案 (方案永遠沒有最好的,只有最合適的!)
決策因素 | 數據庫字段緩存完整數據 | 數據庫字段緩存文件指針 |
---|---|---|
適用數據大小建議 | < 8KB | ≥ 8KB |
用戶查詢頻率建議 | 高、中、低頻率均可 | 低頻率(< 10次/分鐘) |
Redis 內存環境 | 專屬 Redis/內存充足,內存充足,隨意使用 | 多個項目或大型項目共享 Redis/內存緊張 |
實現復雜度對比 | 簡單(直接緩存完整JSON數據) | 中等(需文件存儲+指針管理) 如果是分布式系統多臺服務器,可能還需考慮文件共享 |
數據庫存儲建議 | 對數據存儲無所謂,可接受大量數據直接存庫 | 對數據庫要求較高,不希望大量公司數據占用數據庫存儲 |
響應時間對比 | 1-5ms | 50-300ms |
程序效率優先級 | ????? (要求越快越好) | ?? (可接受百毫秒級延遲) |
三. 接口選擇、接口分析
這里我們以對接啟信寶第三方接口為例,如下圖所示,可以發現官網對于接口有明確的標注接口ID——1.41 工商照面,查詢公司的基本信息。
從官網可以得出接口的基本信息
接口地址:https://api.qixin.com/APIService/enterprise/getBasicInfo
數據格式:JSON
請求方式:HTTP/HTTPS的GET請求
請求示例:https://api.qixin.com/APIService/enterprise/getBasicInfo?keyword=開平達豐紡織印染服裝有限公司
請求參數:Query,Query 參數內部有一個 keyword 屬性
響應參數:標準的 status 狀態碼參數、message 響應描述、data 數據參數以及一個獨有的數據簽名參數 sign。
綜合上述信息可以得知,要對接這個接口,至少需要創建一個請求參數Query,接口地址靜態變量 private static final url = "",獲取賬號和密鑰。
如果我們要對響應的數據進行操作,最好定義對應的 Java 實體類接收,然后通過 JSONObject.parse 轉化為對應的實體類。
四. 數據庫設計
經過上面一,二的分析,我們已經得出了結論,就是最為關鍵的"接口返回數據" 使用 VARCHAR 字段來存儲,那么我們就開始設計數據庫。
4.1 接口緩存表設計
4.1.1 建表SQL
每個字段的具體用處,小編都在注釋中進行了說明,很好理解。
如果直接存儲真實數據,則需要使用字段 "call_response_context";
如果存儲文件指針,則需要使用字段 "bucket_name" 和 "file_name";
為了方便我都提前定義出來拉!
CREATE TABLE `interface_cache` (`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主鍵自增ID,也可以用UUID,只要保證唯一即可,項目中無實際用處',`consumer_service_name` VARCHAR(50) DEFAULT NULL COMMENT '調用服務名稱,如果用多個服務,方便以后對各個服務的真實調用次數做統計',`interface_id` VARCHAR(20) DEFAULT NULL COMMENT '接口唯一標識(ID),一般情況下官網都會有,這里指上面要對接的工商照面接口ID是1.41',`interface_name` VARCHAR(100) DEFAULT NULL COMMENT '第三方接口名稱,比如查詢公司詳情這里就是上面的"工商照面",可加可不加的字段,加上更易于理解接口名稱',`interface_param` VARCHAR(255) DEFAULT NULL COMMENT '接口入參,默認為公司名稱/公司社會唯一信用碼等,對應上方1.41接口的參數值keyword值',`bucket_name` VARCHAR(50) DEFAULT NULL COMMENT '若文件存儲在本地,則指代文件所在的全路徑名稱;若存儲于minio,則指代桶的名稱(方案二要使用的字段)',`file_name` VARCHAR(50) DEFAULT NULL COMMENT '存儲實際公司數據的文件名稱,建議由UUID工具生成(方案二要使用的字段)',`call_response_context` VARCHAR(8192) DEFAULT NULL COMMENT '接口調用響應報文,因為有些接口不使用data存儲數據而是直接返回,所以我們直接存儲整個響應,對應1.41接口響應的四個參數(方案一使用的字段)',`call_input_time` DATETIME COMMENT '接口調用時間,作為記錄',`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '記錄創建時間,其實和接口調用時間一樣,個人感覺可加可不加',`expired_time` DATETIME COMMENT '數據過期時間(可定期清理過期數據),默認3個月有效期,直接在接口調用時間字段值上+3個月有效期',PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方接口調用返回數據緩存表';
4.1.2 查詢接口設計
后續我們查詢數據庫時,基本是通過 api_id + api_param + expired_time 來確認唯一一條已存在的公司數據。所以我們可以對這三個主要字段添加索引,這里就不詳細展示了。
Mapper 層接口如下所示,這里也可以直接使用實體傳參,我這里分成了三個,怎么寫都行
InterfaceCache findCacheByCondition(@Param("interfaceId") String interfaceId,@Param("interfaceParam") String interfaceParam,@Param("expiredTime") Date expiredTime
);
<select id="findCacheByCondition" resultType="com.test.InterfaceCache">select *from interface_cachewhere interface_id = #{interfaceId}and interface_param = #{interfaceParam}and expired_time > #{expiredTime}</select>
插入SQL就直接繼承 mybatisplus 的單行記錄插入即可,就不多贅述了。
4.2 調用日志記錄表設計
4.2.1 建表SQL
CREATE TABLE `interface_active_record` (`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主鍵自增ID,也可以用UUID,只要保證唯一即可,項目中無實際作用',`consumer_service_name` VARCHAR(50) DEFAULT NULL COMMENT '調用服務名稱,方便以后對各個服務的所有調用次數做統計(含緩存、數據庫調用)',`interface_id` VARCHAR(20) DEFAULT NULL COMMENT '接口唯一標識(ID),一般情況下官網都會有,這里指上面要對接的工商照面接口ID是1.41',`interface_name` VARCHAR(100) DEFAULT NULL COMMENT '第三方接口名稱,比如查詢公司詳情這里就是上面的"工商照面",可加可不加的字段,加上更易于理解接口名稱',`interface_param` VARCHAR(255) DEFAULT NULL COMMENT '接口入參,默認為公司名稱/公司社會唯一信用碼等,對應上方1.41接口的參數值keyword值',`is_actually_call` VARCHAR(10) DEFAULT NULL COMMENT '是否實際調用啟信查詢(N:否,Y:是),也可以用tinyint類型碼值0否,1是表示,看個人習慣',`call_description` VARCHAR(255) DEFAULT NULL COMMENT '接口調用響應描述(查詢緩存返回數據?或查詢數據庫返回數據?或實際調用返回數據?',`call_response_status` VARCHAR(20) DEFAULT NULL COMMENT '接口調用響應狀態碼,例如200,201,404等,對應上方1.41接口的相應參數status的值',`call_response_message` VARCHAR(50) DEFAULT NULL COMMENT '接口調用響應信息,對應上方1.41接口的響應參數message的值',`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '當前日志信息創建(插入)時間',PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方接口調用日志記錄表';
4.2.2 查詢SQL
方便我們后續查詢日志表進行驗證,這里先把日志表查詢SQL寫出來;
# 1. 查詢最近的接口調用記錄
select * from interface_active_record order by id desc;
# 2. 查詢某個公司所有企查查接口的調用記錄
select * from interface_active_record where interface_param = '?' order by id desc;
# 3. 查詢某個公司特定的企查查接口調用記錄
select * from interface_active_record where interface_id = '?'and interface_param = '?' order by id desc;
五. 項目代碼落地實現
對于到底緩存完整公司數據,還是緩存文件指針,這都是我們請求成功后要做的操作,這兩種方案,其實最本質的區別就在于業務層Service方法的處理邏輯略有區別,其它比如控制器層 Controller 類,控制器接口請求參數 Entity 類,控制器返回通用參數類型 CommonResponseDTO 類都是一樣的。所以我們下面先把通用代碼創建出來,在再分別去寫兩種方案的Service業務層方法即可。我們開始吧!
5.1 通用代碼編寫
5.1.1 創建接口請求參數實體類
/*** 1.41 工商照面接口請求參數* */
@Data
public class BusinessDetailDTO {/*** 企業全名/注冊號/統一社會信用代碼* */private String keyword;
}
5.1.2 創建接口響應通用返回結果類
/*** 通用返回結果* */
@Data
public class CommonResponseDTO {// 響應狀態碼,直接獲取接口的響應狀態碼 status 的值private String status;// 響應消息描述,直接獲取接口的響應消息 message 的值private String message;// 響應數據,直接獲取接口的整個響應報文,即 status,message,data,sign 組成的JSON字符串private String context;
}
5.1.3 創建接口緩存表實體類
/*** 第三方接口調用返回數據緩存表*/
@Data
public class InterfaceCache {/*** 主鍵自增ID,也可以用UUID,只要保證唯一即可,項目中無實際用處*/private Long id;/*** 調用服務名稱,如果用多個服務,方便以后對各個服務的真實調用次數做統計*/private String consumerServiceName;/*** 接口唯一標識(ID),一般情況下官網都會有,這里指上面要對接的工商照面接口ID是1.41*/private String interfaceId;/*** 第三方接口名稱,比如查詢公司詳情這里就是上面的"工商照面",可加可不加的字段,加上更易于理解接口名稱*/private String interfaceName;/*** 接口入參,默認為公司名稱/公司社會唯一信用碼等,對應上方1.41接口的參數值keyword值*/private String interfaceParam;/*** 若文件存儲在本地,則指代文件所在的全路徑名稱;若存儲于minio,則指代桶的名稱(方案二要使用的字段)*/private String bucketName;/*** 存儲實際公司數據的文件名稱,建議由UUID工具生成(方案二要使用的字段)*/private String fileName;/*** 接口調用響應報文,因為有些接口不使用data存儲數據而是直接返回,所以我們直接存儲整個響應,對應1.41接口響應的四個參數(方案一使用的字段)*/private String callResponseContext;/*** 接口調用時間,作為記錄*/private Date callInputTime;/*** 記錄創建時間,其實和接口調用時間一樣,個人感覺可加可不加*/private Date createTime;/*** 數據過期時間(可定期清理過期數據),默認3個月有效期,直接在接口調用時間字段值上+3個月有效期*/private Date expiredTime;
}
5.1.4 創建接口緩存表Mapper接口
Mapper 接口不需要自定義
@Mapper
public interface InterfaceCacheMapper2 extends BaseMapper<InterfaceCache> {InterfaceCache findCacheByCondition(@Param("interfaceId") String interfaceId,@Param("interfaceParam") String interfaceParam,@Param("expiredTime") Date expiredTime);
}
5.1.5 創建接口活動日志表實體類
/*** 第三方接口調用日志記錄表*/
@Data
public class InterfaceActiveRecord {/*** 主鍵自增ID,也可以用UUID,只要保證唯一即可,項目中無實際作用*/private Long id;/*** 調用服務名稱,方便以后對各個服務的所有調用次數做統計(含緩存、數據庫調用)*/private String consumerServiceName;/*** 接口唯一標識(ID),一般情況下官網都會有,這里指上面要對接的工商照面接口ID是1.41*/private String interfaceId;/*** 第三方接口名稱,比如查詢公司詳情這里就是上面的"工商照面",可加可不加的字段,加上更易于理解接口名稱*/private String interfaceName;/*** 接口入參,默認為公司名稱/公司社會唯一信用碼等,對應上方1.41接口的參數值keyword值*/private String interfaceParam;/*** 是否實際調用啟信查詢(N:否,Y:是),也可以用tinyint類型碼值0否,1是表示,看個人習慣*/private String isActuallyCall;/*** 接口調用響應描述(查詢緩存返回數據?或查詢數據庫返回數據?或實際調用返回數據?*/private String callDescription;/*** 接口調用響應狀態碼,例如200,201,404等,對應上方1.41接口的相應參數status的值*/private String callResponseStatus;/*** 接口調用響應信息,對應上方1.41接口的響應參數message的值*/private String callResponseMessage;/*** 當前日志信息創建(插入)時間*/private Date createTime;
}
5.1.6 創建接口活動記錄表Mapper接口
@Mapper
public interface InterfaceActiveRecordMapper extends BaseMapper<InterfaceActiveRecord> {
}
5.1.7 創建 Controller?層控制器類
@RestController
@RequestMapping("/qx")
public class QxController {@Autowiredprivate QxService qxService;/*** @param businessDetailDTO 主要負責企業全名/注冊號/統一社會信用代碼* @return CommonResponseDTO 公共響應結果類對象*/@PostMapping("/getCompanyInfo")public CommonResponseDTO getCompanyInfo(@RequestBody BusinessDetailDTO businessDetailDTO,HttpServletRequest request) {return qxService.getCompanyInfo(businessDetailDTO,request);}
}
5.1.8 Service 層方法公共變量和方法創建
@Slf4j
@Service
public class QxService {// 接口活動跟蹤記錄Mapper@Autowiredprivate InterfaceActiveRecordMapper interfaceActiveRecordMapper;// 接口數據緩存Mapper@Autowiredprivate InterfaceCacheMapper interfaceCacheMapper;// redis緩存@Autowiredprivate StringRedisTemplate stringRedisTemplate;// 發送網絡請求的restTemplate@Autowiredprivate RestTemplate restTemplate;// appkey,secret_key正常來講應該定義來yml文件中,這里懶省事,直接定義在業務類中private static final String appkey = "appkey";private static final String secret_key = "secret_key";// 緩存失效時間,默認7天,正常來講也應該定義在 yml 文件中,這里懶省事,直接定義在業務類中private static final int cacheDays = 7;// 下面這幾個靜態常量類正常來講應該定義在Constants常量類中,這里懶省事,直接定義在業務類中private static final String QX_GET_BUSINESS_DETAIL_URL = "https://api.qixin.com/APIService/enterprise/getBasicInfo";private static final String QX_GET_BUSINESS_DETAIL_CACHE_PREFIX = "QX:getBusinessDetail:";private static final String QX_GET_BUSINESS_DETAIL_INTERFACE_ID = "1.41:";private static final String QX_GET_BUSINESS_DETAIL_INTERFACE_NAME = "企業工商照面";/*** MD5加密方法,待會業務方法中會用到,提前定義出來。* 加密規則 :* appkey + timestamp + secret_key 組成的 32 位md5加密的小寫字符串(實際加密不帶入 ‘+’)* */private String getMD5(String appkey, String timestamp, String secret_key) {byte[] digest = null;try {MessageDigest md = MessageDigest.getInstance("MD5");String str = appkey + timestamp + secret_key;md.update(str.getBytes(StandardCharsets.UTF_8));digest = md.digest();StringBuilder hexString = new StringBuilder();for (byte b : digest) {// 保證兩位十六進制hexString.append(String.format("%02x", b));}return hexString.toString();}catch (Exception e){throw new RuntimeException("MD5加密失敗", e);}}/*** RestTemplate 發送請求方法,返回請求結果,待會業務方法中會用到,提前定義出來。* */private String httpsWithRestTemplate(String appkey, String timestamp, String secret_key,String url){// 創建請求頭HttpHeaders headers = new HttpHeaders();headers.set("Auth-Version", "2.0"); // 官網固定傳入2.0headers.set("appkey", appkey);headers.set("timestamp", timestamp);headers.set("sign", getMD5(appkey, timestamp, secret_key));// 封裝請求頭和空請求體HttpEntity<String> requestEntity = new HttpEntity<>(headers);// 發送 GET 請求ResponseEntity<String> responseEntity = restTemplate.exchange(url,HttpMethod.GET,requestEntity,String.class);// 獲取響應體return responseEntity.getBody();}/*** 生成完整的查詢工商照面接口地址方法* 啟信接口 -"查詢工商照面"* 官網接口ID:1.41* 接口地址:https://api.qixin.com/APIService/enterprise/getBasicInfo* 接口參數:keyword - 待查詢的企業名稱* 請求示例:https://api.qixin.com/APIService/enterprise/getBasicInfo?keyword=開平達豐紡織印染服裝有限公司* */private String getBusinessBasicDetailUrl(String keyword) {StringBuffer url = new StringBuffer();if (StringUtils.isNotBlank(keyword)){url.append(QX_GET_BUSINESS_DETAIL_URL).append("?keyword=").append(keyword);}return url.toString();}
}
5.2 Redis 緩存真實數據業務代碼實現
如下所示,就是小編個人編寫的一段緩存接口真實數據業務層代碼,僅供各位參考。
核心邏輯就三點:
第一:先查詢緩存,緩存命中則直接返回;
第二:緩存未命中,查詢數據庫,數據庫命中,回寫緩存并返回;
第三:緩存、數據庫均未命中,則發送網絡請求查詢數據,判斷響應結果,保存至數據庫并回寫緩存;
/*** 啟信接口查詢 - 1.41 工商照面* */
public CommonResponseDTO getCompanyInfo(BusinessDetailDTO businessDetailDTO,HttpServletRequest request) {// 創建方法返回對象CommonResponseDTO commonResponseDTO = new CommonResponseDTO();// 1. 創建接口活動跟蹤記錄對象并設置值InterfaceActiveRecord interfaceActiveRecord = new InterfaceActiveRecord();interfaceActiveRecord.setConsumerServiceName("XXXService"); // 服務名稱,正常情況下應該從request中獲取,這里寫的比較隨意interfaceActiveRecord.setInterfaceId(QX_GET_BUSINESS_DETAIL_INTERFACE_ID);interfaceActiveRecord.setInterfaceName(QX_GET_BUSINESS_DETAIL_INTERFACE_NAME);interfaceActiveRecord.setCreateTime(new Date());// 2. 獲取企業名稱參數,同時賦值給緩存key和接口活動跟蹤對象String cacheKey = businessDetailDTO.getKeyword();interfaceActiveRecord.setInterfaceParam(cacheKey);// 3. 查詢緩存,判斷緩存值是否為空,不為空直接返回結果String cacheCompanyJson = stringRedisTemplate.opsForValue().get(QX_GET_BUSINESS_DETAIL_CACHE_PREFIX + cacheKey);if (StringUtils.isNotBlank(cacheCompanyJson)) {// 返回結果JSONObject companyJSON = JSON.parseObject(cacheCompanyJson);commonResponseDTO.setStatus(companyJSON.getString("status"));commonResponseDTO.setMessage(companyJSON.getString("message"));commonResponseDTO.setContext(cacheCompanyJson);// 接口活動跟蹤記錄對象設置值interfaceActiveRecord.setIsActuallyCall("N");interfaceActiveRecord.setCallDescription("查詢Redis緩存返回公司數據");interfaceActiveRecord.setCallResponseStatus(companyJSON.getString("status"));interfaceActiveRecord.setCallResponseMessage(companyJSON.getString("message"));interfaceActiveRecordMapper.insertWithParam(interfaceActiveRecord);return commonResponseDTO;}// 4. 緩存為空,則查詢數據庫,判斷數據庫數據是否為空InterfaceCache interfaceCache = null;interfaceCache = interfaceCacheMapper.findCacheByCondition(QX_GET_BUSINESS_DETAIL_INTERFACE_ID,cacheKey,new Date());if(interfaceCache != null) {// 寫入緩存stringRedisTemplate.opsForValue().set(QX_GET_BUSINESS_DETAIL_CACHE_PREFIX + cacheKey,JSON.toJSONString(interfaceCache.getCallResponseContext()), cacheDays, TimeUnit.DAYS);// 返回結果JSONObject companyJSON = JSON.parseObject(interfaceCache.getCallResponseContext());commonResponseDTO.setStatus(companyJSON.getString("status"));commonResponseDTO.setMessage(companyJSON.getString("message"));commonResponseDTO.setContext(JSON.toJSONString(interfaceCache.getCallResponseContext()));// 接口活動跟蹤記錄對象設置值interfaceActiveRecord.setIsActuallyCall("N");interfaceActiveRecord.setCallDescription("查詢數據庫緩存返回公司數據");interfaceActiveRecord.setCallResponseStatus(companyJSON.getString("status"));interfaceActiveRecord.setCallResponseMessage(companyJSON.getString("message"));interfaceActiveRecordMapper.insertWithParam(interfaceActiveRecord);return commonResponseDTO;}// 5. 數據庫數據也為空,說明數據已過期或從未查詢過,則調用啟信官方接口查詢數據String response = null;InterfaceCache interfaceCacheInsert = new InterfaceCache();try {log.info("調用啟信查詢1.41 工商照面接口入參\n{}", cacheKey);// 發送網絡請求獲取響應數據response = httpsWithRestTemplate(appkey,String.valueOf(System.currentTimeMillis()),secret_key,getBusinessBasicDetailUrl(cacheKey));log.info("調用啟信查詢1.41 工商照面接口返回數據\n{}", response);// 不管是否成功,都返回接口響應commonResponseDTO.setStatus(JSON.parseObject(response).getString("status"));commonResponseDTO.setStatus(JSON.parseObject(response).getString("message"));commonResponseDTO.setContext(String.valueOf(JSON.parseObject(response)));// 不管是否成功,接口活動跟蹤記錄對象設置值interfaceActiveRecord.setCallResponseStatus(JSON.parseObject(response).getString("status"));interfaceActiveRecord.setCallResponseMessage(JSON.parseObject(response).getString("message"));interfaceActiveRecord.setIsActuallyCall("Y");interfaceActiveRecord.setCallDescription("調用啟信寶接口獲取數據");// 判斷狀態是否為200,只有200寫入數據庫和緩存。因為可能出現201-余額不足;202-查詢無結果等情況......if (response != null && "200".equals(JSON.parseObject(response).getString("status"))) {// 請求成功,將數據寫入緩存stringRedisTemplate.opsForValue().set(QX_GET_BUSINESS_DETAIL_CACHE_PREFIX + cacheKey, response, cacheDays, TimeUnit.DAYS);// 接口緩存對象賦值interfaceCacheInsert.setConsumerServiceName("XXXService");interfaceCacheInsert.setInterfaceId(QX_GET_BUSINESS_DETAIL_INTERFACE_ID);interfaceCacheInsert.setInterfaceName(QX_GET_BUSINESS_DETAIL_INTERFACE_NAME);interfaceCacheInsert.setInterfaceParam(cacheKey);interfaceCacheInsert.setBucketName(null);interfaceCacheInsert.setCallResponseContext(response);interfaceCacheInsert.setCallInputTime(new Date());interfaceCacheInsert.setCreateTime(new Date());interfaceCacheInsert.setExpiredTime(new Date(new Date().getTime() + 30L * 24 * 60 * 60 * 1000));// 將接口返回數據插入數據庫interfaceCacheMapper.insert(interfaceCacheInsert);}} catch (Exception e) {interfaceActiveRecord.setIsActuallyCall("N");interfaceActiveRecord.setCallDescription("調用啟信寶接口獲取數據失敗");interfaceActiveRecord.setCallResponseMessage(e.getMessage());throw new RuntimeException("企業基本信息數據寫入本地緩存發生錯誤",e);} finally {// 6. 插入接口活動跟蹤記錄,不管調用是否成功,都要進行記錄,放到 finally 塊中interfaceActiveRecordMapper.insert(interfaceActiveRecord);}return commonResponseDTO;
}
5.3 Redis 緩存文件指針,通過指針再讀取文件內容業務實現
因為要使用文件緩存接口響應數據,所以我們先寫一個保存數據的方法。注釋很詳細,不過多解釋啦。
這里我定義了一個文件前綴,就把文件保存到我的本地電腦磁盤上了,正常來講公司的生產項目,通常會存儲在運行項目的 Linux 服務器上,或者 minio 存儲中間件上,或者阿里云OSS云存儲等地方,這里就不整那么復雜啦,主要分享一個思路。同學們了解即可
// 緩存文件前綴,公司數據緩存文件存放在/data/gateway-server/cache目錄下。
private static final String CACHE_FILE_PREFIX = "/data/gateway-server/cache";/*** 保存公司數據到文件并返回文件名* @param response: 第三方接口返回的公司數據,返回 json 字符串* return: 文件名和文件桶的Map集合···*/
private Map<String,String> saveDataToFile(String response) throws Exception{Map<String,String> map = new HashMap<>();// 緩存文件日期前綴,精確到月份SimpleDateFormat sdf = new SimpleDateFormat("yyyyMM");String date = sdf.format(new Date());// 隨機生成緩存文件名String fileName = UUID.randomUUID().toString().concat(".json");// 緩存文件保存的目錄String bucketName = CACHE_FILE_PREFIX.concat("/").concat(date).concat("/");// 若不存在則生成文件File dir = new File(bucketName);if(!dir.exists() && !dir.isDirectory()){dir.mkdirs();}// 緩存數據保存到本地磁盤try(// 創建一個`FileWriter`對象,用于向指定文件寫入字符數據FileWriter fileWriter = new FileWriter(bucketName.concat(fileName));// 創建一個`BufferedWriter`對象,包裝`FileWriter`以提高寫入效率,默認緩存大小為8192字節(8K),與我們上面分析的8K大小節點剛好相同BufferedWriter bufferedWriter = new BufferedWriter(fileWriter)){// 執行實際寫入操作bufferedWriter.write(response);} catch (IOException e){log.error("寫入緩存數據失敗:\n{}", e.getMessage());}map.put("fileName",fileName);map.put("bucketName",bucketName);return map;
}
將數據緩存到本地磁盤文件之后,我們再寫一個從磁盤文件讀取數據的的方法。
/*** 通過文件指針獲取公司數據* @param bucketName 存儲桶名稱,或文件存儲路徑* @param fileName 文件名稱* return: 文件內容,公司數據,返回 json 字符串*/
public String getDataByFile(String bucketName, String fileName) {File file = new File(bucketName + fileName);StringBuffer sbf = new StringBuffer();BufferedReader reader = null;String response = "";log.info("緩存json讀取的downloadPath:{}", bucketName + fileName);try {// 讀取文件數據reader = new BufferedReader(new FileReader(file));String tempStr;while ((tempStr = reader.readLine()) != null) {sbf.append(tempStr);}reader.close();response = sbf.toString();return response;} catch (Exception e) {log.error("獲取緩存數據失敗:\n{}", e.getMessage());} finally {if (reader != null) {try {reader.close();} catch (IOException e) {log.error("關閉緩存文件失敗:\n{}", e.getMessage());throw new RuntimeException(e);}}}return response;
}
編寫完畢上面的兩個方法,就可以正式來編寫業務邏輯代碼了,如下所示,大致邏輯其實和上面Redis 的思路一樣的,只是在獲取數據操作上加入了文件操作這一層,要求開發者對Java的IO代碼編寫有一定的基礎。
/*** 啟信接口查詢 - 1.41 工商照面* */
public CommonResponseDTO getCompanyInfo(BusinessDetailDTO businessDetailDTO,HttpServletRequest request) {// 1. 創建方法返回對象CommonResponseDTO commonResponseDTO = new CommonResponseDTO();// 1. 創建接口活動跟蹤記錄對象并設置值InterfaceActiveRecord interfaceActiveRecord = new InterfaceActiveRecord();interfaceActiveRecord.setConsumerServiceName("XXXService"); // 服務名稱,正常情況下應該從request中獲取,這里寫的比較隨意interfaceActiveRecord.setInterfaceId(QX_GET_BUSINESS_DETAIL_INTERFACE_ID);interfaceActiveRecord.setInterfaceName(QX_GET_BUSINESS_DETAIL_INTERFACE_NAME);interfaceActiveRecord.setCreateTime(new Date());// 2. 獲取企業名稱參數,同時賦值給緩存key和接口活動跟蹤對象String cacheKey = businessDetailDTO.getKeyword();interfaceActiveRecord.setInterfaceParam(cacheKey);// 3. 查詢緩存,判斷緩存值是否為空,不為空直接返回結果String interfaceCacheJson = stringRedisTemplate.opsForValue().get(QX_GET_BUSINESS_DETAIL_CACHE_PREFIX + cacheKey);InterfaceCache interfaceCache = null;String response = null;if (StringUtils.isNotBlank(interfaceCacheJson)) {// 緩存不為空,則返回緩存數據,轉化為 InterfaceCache 對象interfaceCache = JSON.parseObject(interfaceCacheJson, InterfaceCache.class);// 獲取桶名和文件名稱,從磁盤讀取文件數據response = getDataByFile(interfaceCache.getBucketName(), interfaceCache.getFileName());// 解析 response 為 json 格式數據JSONObject jsonObject = JSON.parseObject(response);commonResponseDTO.setStatus(jsonObject.getString("status"));commonResponseDTO.setMessage(jsonObject.getString("message"));commonResponseDTO.setContext(response);// 接口活動跟蹤記錄對象設置值interfaceActiveRecord.setIsActuallyCall("N");interfaceActiveRecord.setCallDescription("查詢Redis緩存返回公司數據");interfaceActiveRecord.setCallResponseStatus(jsonObject.getString("status"));interfaceActiveRecord.setCallResponseMessage(jsonObject.getString("message"));interfaceActiveRecordMapper.insertWithParam(interfaceActiveRecord);return commonResponseDTO;}// 4. 緩存為空,則查詢數據庫,判斷數據庫數據是否為空interfaceCache = interfaceCacheMapper.findCacheByCondition(QX_GET_BUSINESS_DETAIL_INTERFACE_ID,cacheKey,new Date());if(interfaceCache != null) {// 數據庫中有數據,根據桶名和文件名獲取數據response = getDataByFile(interfaceCache.getBucketName(), interfaceCache.getFileName());// 返回結果JSONObject companyJSON = JSON.parseObject(interfaceCache.getCallResponseContext());commonResponseDTO.setStatus(companyJSON.getString("status"));commonResponseDTO.setMessage(companyJSON.getString("message"));commonResponseDTO.setContext(response);// 寫入緩存stringRedisTemplate.opsForValue().set(QX_GET_BUSINESS_DETAIL_CACHE_PREFIX + cacheKey,JSON.toJSONString(interfaceCache), cacheDays, TimeUnit.DAYS);// 接口活動跟蹤記錄對象設置值interfaceActiveRecord.setIsActuallyCall("N");interfaceActiveRecord.setCallDescription("查詢數據庫緩存返回公司數據");interfaceActiveRecord.setCallResponseStatus(companyJSON.getString("status"));interfaceActiveRecord.setCallResponseMessage(companyJSON.getString("message"));interfaceActiveRecordMapper.insertWithParam(interfaceActiveRecord);return commonResponseDTO;}// 5. 數據庫數據也為空,說明數據已過期或從未查詢過,則調用啟信官方接口查詢數據InterfaceCache interfaceCacheInsert = new InterfaceCache();try {log.info("調用啟信查詢1.41 工商照面接口入參\n{}", cacheKey);// 發送網絡請求獲取響應數據response = httpsWithRestTemplate(appkey,String.valueOf(System.currentTimeMillis()),secret_key,getBusinessBasicDetailUrl(cacheKey));log.info("調用啟信查詢1.41 工商照面接口返回數據\n{}", response);// 不管是否成功,都返回接口響應commonResponseDTO.setStatus(JSON.parseObject(response).getString("status"));commonResponseDTO.setStatus(JSON.parseObject(response).getString("message"));commonResponseDTO.setContext(String.valueOf(JSON.parseObject(response)));// 不管是否成功,接口活動跟蹤記錄對象設置值interfaceActiveRecord.setCallResponseStatus(JSON.parseObject(response).getString("status"));interfaceActiveRecord.setCallResponseMessage(JSON.parseObject(response).getString("message"));interfaceActiveRecord.setIsActuallyCall("Y");interfaceActiveRecord.setCallDescription("調用啟信寶接口獲取數據");// 但判斷狀態是否為200,只有200寫入數據庫和緩存。因為可能出現201-余額不足;202-查詢無結果等情況......if (response != null && "200".equals(JSON.parseObject(response).getString("status"))) {// 保存接口返回數據到本地文件Map<String, String> map = saveDataToFile(response);// 接口緩存對象賦值interfaceCacheInsert.setConsumerServiceName("XXXService");interfaceCacheInsert.setInterfaceId(QX_GET_BUSINESS_DETAIL_INTERFACE_ID);interfaceCacheInsert.setInterfaceName(QX_GET_BUSINESS_DETAIL_INTERFACE_NAME);interfaceCacheInsert.setInterfaceParam(cacheKey);interfaceCacheInsert.setBucketName(map.get("bucketName"));interfaceCacheInsert.setFileName(map.get("fileName"));interfaceCacheInsert.setCallInputTime(new Date());interfaceCacheInsert.setCreateTime(new Date());interfaceCacheInsert.setExpiredTime(new Date(new Date().getTime() + 30L * 24 * 60 * 60 * 1000));// 將接口返回數據插入數據庫interfaceCacheMapper.insert(interfaceCacheInsert);// 插入數據庫之后,interfaceCacheInsert 對象是一條帶有主鍵ID值的完整數據,存入緩存stringRedisTemplate.opsForValue().set(QX_GET_BUSINESS_DETAIL_CACHE_PREFIX + cacheKey, JSON.toJSONString(interfaceCacheInsert), cacheDays, TimeUnit.DAYS);}} catch (Exception e) {interfaceActiveRecord.setIsActuallyCall("N");interfaceActiveRecord.setCallDescription("調用啟信寶接口獲取數據失敗");interfaceActiveRecord.setCallResponseMessage(e.getMessage());throw new RuntimeException("企業基本信息數據寫入本地緩存發生錯誤",e);} finally {// 6. 插入接口活動跟蹤記錄,不管調用是否成功,都要進行記錄,放到 finally 塊中interfaceActiveRecordMapper.insert(interfaceActiveRecord);}return commonResponseDTO;
}
5.4 二者混合使用
這一種方法,也不失為一種解決思路。
比如我們公司一共要對接10個企查查相關接口,有大接口返回大量數據(10~50K),有小接口返回少量數據(1~3K),此時我們就可以混合上面的兩種方法,大接口采用文件指針的解決思路,小接口采用緩存直接存儲的解決思路,可以達到部分接口提高響應效率,同時大接口又不會過度占用 Redis 內存。
代碼就不詳細舉例了,只是將上面兩種方案的代碼都復制使用即可。不過這種方法,做起來復雜度較高就是了,各位開發者同學可以根據司機項目需求的需要,選擇相對應的解決方案!
六. 簡要總結
綜上所述,可以簡單總結為以下三句話。
(1)?對接第三方SDK接口時,如果響應體較小,且希望提高服務器響應效率,則可以將接口響應整個存儲數據庫和 Redis 緩存,實現復雜度低,響應效率高,缺點是大量請求時,可能導致 Redis 內存占用較高;
(2) 如果響應體較大,建議將響應體數據緩存到磁盤文件或指定存儲服務器,將文件指針作為字段值存入數據庫,讀取文件時,先獲取文件指針,然后通過文件指針讀取文件緩存數據,響應給前端,缺點是因為需要進行文件IO操作,所以響應效率不如直接存儲 Redis;
(3) 也可以結合二者,大接口使用文件指針,小接口直接存儲 Redis,但編碼復雜度較高,后續維護可能會略顯復雜;