8個類手擼一個簡易版配置中心!造輪子真得勁啊!!!

語言: CN / TW / HK

你好,我是 Guide。此刻我正在回家的路上,祝大家小年快樂,幸福安康!

今天咱們來動手寫程式碼實現一個簡易的配置中心,這對於我們理解配置中心的原理很有幫助!

下面是正文。

配置中心是我們平常使用微服務架構時重要的一個模組,常用的配置中心元件也比較多,從早期的Spring Cloud Config,到Disconf、Apollo、Nacos等,它們支援的功能、產品的效能以及給使用者的體驗也各有不同。

雖然說功能上有不少差異,但是它們解決的最核心問題,無疑是 配置檔案修改後的實時生效 ,有時候在搬磚之餘我就在好奇實時生效是如何實現的、如果讓我來設計又會怎麼去實現,於是這幾天抽出了點空閒時間,摸魚摸出了個簡易版的單機配置中心,先來看看效果:

之所以說是簡易版本,首先是因為實現的核心功能就只有配置修改後實時生效,並且程式碼的實現也非常簡單,一共只用了8個類就實現了這個核心功能,看一下程式碼的結構,核心類就是 core 包中的這8個類:

看到這是不是有點好奇,雖說是低配版,就憑這麼幾個類也能實現一個配置中心?那麼先看一下總體的設計流程,下面我們再細說程式碼。

程式碼簡要說明

下面對8個核心類進行一下簡要說明並貼出核心程式碼,有的類中程式碼比較長,可能對手機瀏覽的小夥伴不是非常友好,建議收藏後以後電腦瀏覽器開啟( 騙波收藏,計劃通! )。另外,我已經把專案的全部程式碼上傳到了 git ,有需要的小夥伴可以移步文末獲取地址。

1、ScanRunner

ScanRunner 實現了 CommandLineRunner 介面,可以保證它在springboot啟動最後執行,這樣就能確保其他的Bean已經例項化結束並被放入了容器中。至於為什麼起名叫 ScanRunner ,是因為這裡要實現的主要就是掃描類相關功能。先看一下程式碼:

@Component
public class ScanRunner implements CommandLineRunner {
@Override
public void run(String... args) throws Exception {
doScanComponent();
}

private void doScanComponent(){
String rootPath = this.getClass().getResource("/").getPath();
List<String> fileList = FileScanner.findFileByType(rootPath,null,FileScanner.TYPE_CLASS);
doFilter(rootPath,fileList);
EnvInitializer.init();
}

private void doFilter(String rootPath, List<String> fileList) {
rootPath = FileScanner.getRealRootPath(rootPath);
for (String fullPath : fileList) {
String shortName = fullPath.replace(rootPath, "")
.replace(FileScanner.TYPE_CLASS,"");
String packageFileName=shortName.replaceAll(Matcher.quoteReplacement(File.separator),"\\.");

try {
Class clazz = Class.forName(packageFileName);
if (clazz.isAnnotationPresent(Component.class)
|| clazz.isAnnotationPresent(Controller.class)
||clazz.isAnnotationPresent(Service.class))
{
VariablePool.add(clazz);
}
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
}

真正實現檔案掃描功能是呼叫的 FileScanner ,它的實現我們後面具體再說,在功能上它能夠根據檔案字尾名掃描某一目錄下的全部檔案,這裡首先掃描出了 target 目錄下全部以 .class 結尾的檔案:

掃描到全部 class 檔案後,就可以利用類的全限定名獲取到類的 Class 物件,下一步是呼叫 doFilter 方法對類進行過濾。這裡我們暫時僅考慮通過 @Value 註解的方式注入配置檔案中屬性值的方式,那麼下一個問題來了,什麼類中的 @Value 註解會生效呢?答案是通過 @Component@Controller@Service 這些註解交給spring容器管理的類。

綜上,我們通過這些註解再次進行過濾出符合條件的類,找到後交給 VariablePool 對變數進行處理。

2、FileScanner

FileScanner 是掃描檔案的工具類,它可以根據檔案字尾名篩選出需要的某個型別的檔案,除了在 ScanRunner 中用它掃描了class檔案外,在後面的邏輯中還會用它掃描yml檔案。下面,看一下 FileScanner 中實現的檔案掃描的具體程式碼:

public class FileScanner {
public static final String TYPE_CLASS=".class";
public static final String TYPE_YML=".yml";

public static List<String> findFileByType(String rootPath, List<String> fileList,String fileType){
if (fileList==null){
fileList=new ArrayList<>();
}

File rootFile=new File(rootPath);
if (!rootFile.isDirectory()){
addFile(rootFile.getPath(),fileList,fileType);
}else{
String[] subFileList = rootFile.list();
for (String file : subFileList) {
String subFilePath=rootPath + "\\" + file;
File subFile = new File(subFilePath);
if (!subFile.isDirectory()){
addFile(subFile.getPath(),fileList,fileType);
}else{
findFileByType(subFilePath,fileList,fileType);
}
}
}
return fileList;
}

private static void addFile(String fileName,List<String> fileList,String fileType){
if (fileName.endsWith(fileType)){
fileList.add(fileName);
}
}

public static String getRealRootPath(String rootPath){
if (System.getProperty("os.name").startsWith("Windows")
&& rootPath.startsWith("/")){
rootPath = rootPath.substring(1);
rootPath = rootPath.replaceAll("/", Matcher.quoteReplacement(File.separator));
}
return rootPath;
}
}

查詢檔案的邏輯很簡單,就是在給定的根目錄 rootPath 下,迴圈遍歷每一個目錄,對找到的檔案再進行字尾名的比對,如果符合條件就加到返回的檔名列表中。

至於下面的這個 getRealRootPath 方法,是因為在windows環境下,獲取到專案的執行目錄是這樣的:

/F:/Workspace/hermit-purple-config/target/classes/

而class檔名是這樣的:

F:\Workspace\hermit-purple-config\target\classes\com\cn\hermimt\purple\test\service\UserService.class

如果想要獲取一個類的全限定名,那麼首先要去掉執行目錄,再把檔名中的反斜槓 \ 替換成點 . ,這裡就是為了刪掉檔名中的執行路徑提前做好準備。

3、VariablePool

回到上面的主流程中,每個在 ScanRunner 中掃描出的帶有 @Component@Controller@Service 註解的 Class ,都會交給 VariablePool 進行處理。顧名思義, VariablePool 就是變數池的意思,下面會用這個容器封裝所有帶 @Value 註解的屬性。

public class VariablePool {
public static Map<String, Map<Class,String>> pool=new HashMap<>();

private static final String regex="^(\\$\\{)(.)+(\\})$";
private static Pattern pattern;
static{
pattern=Pattern.compile(regex);
}

public static void add(Class clazz){
Field[] fields = clazz.getDeclaredFields();

for (Field field : fields) {
if (field.isAnnotationPresent(Value.class)){
Value annotation = field.getAnnotation(Value.class);
String annoValue = annotation.value();
if (!pattern.matcher(annoValue).matches())
continue;

annoValue=annoValue.replace("${","");
annoValue=annoValue.substring(0,annoValue.length()-1);

Map<Class,String> clazzMap = Optional.ofNullable(pool.get(annoValue))
.orElse(new HashMap<>());
clazzMap.put(clazz,field.getName());
pool.put(annoValue,clazzMap);
}
}
}

public static Map<String, Map<Class,String>> getPool() {
return pool;
}
}

簡單說一下這塊程式碼的設計思路:

  • 通過反射拿到 Class 物件中所有的屬性,並判斷屬性是否加了 @Value 註解
  • @Value
    ${xxx}
    ${xxx:defaultValue}
    ${
    }
    
  • VariablePool 中聲明瞭一個靜態HashMap,用於存放所有 配置檔案中屬性-類-類中屬性 的對映關係,接下來就要把這個關係存放到這個 pool

簡單來說,變數池就是下面這樣的結構:

這裡如果不好理解的話可以看看例子,我們引入兩個測試 Service

@Service
public class UserService {
@Value("${person.name}")
String name;
@Value("${person.age}")
Integer age;
}

@Service
public class UserDeptService {
@Value("${person.name}")
String pname;
}

在所有 Class 執行完 add 方法後,變數池 pool 中的資料是這樣的:

可以看到在 pool 中, person.name 對應的內層Map中包含了兩條資料,分別是 UserService 中的 name 欄位,以及 UserDeptService 中的 pname 欄位。

4、EnvInitializer

VariablePool 封裝完所有變數資料後, ScanRunner 會呼叫 EnvInitializerinit 方法,開始對yml檔案進行解析,完成配置中心環境的初始化。其實說白了,這個環境就是一個靜態的HashMap, key 是屬性名, value 就是屬性的值。

public class EnvInitializer {
private static Map<String,Object> envMap=new HashMap<>();

public static void init(){
String rootPath = EnvInitializer.class.getResource("/").getPath();
List<String> fileList = FileScanner.findFileByType(rootPath,null,FileScanner.TYPE_YML);
for (String ymlFilePath : fileList) {
rootPath = FileScanner.getRealRootPath(rootPath);
ymlFilePath = ymlFilePath.replace(rootPath, "");
YamlMapFactoryBean yamlMapFb = new YamlMapFactoryBean();
yamlMapFb.setResources(new ClassPathResource(ymlFilePath));
Map<String, Object> map = yamlMapFb.getObject();
YamlConverter.doConvert(map,null,envMap);
}
}

public static void setEnvMap(Map<String, Object> envMap) {
EnvInitializer.envMap = envMap;
}
public static Map<String, Object> getEnvMap() {
return envMap;
}
}

首先還是使用 FileScanner 掃描根目錄下所有的 .yml 結尾的檔案,並使用spring自帶的 YamlMapFactoryBean 進行yml檔案的解析。但是這裡有一個問題,所有yml檔案解析後都會生成一個獨立的Map,需要進行Map的合併,生成一份配置資訊表。至於這一塊具體的操作,都交給了下面的 YamlConverter 進行處理。

我們先進行一下演示,準備兩個yml檔案,配置檔案一: application.yml

spring:
application:
name: hermit-purple
server:
port: 6879
person:
name: Hydra
age: 18

配置檔案二: config/test.yml

my:
name: John
friend:
name: Jay
sex: male
run: yeah

先來看一看環境完成初始化後,生成的資料格式是這樣的:

5、YamlConverter

YamlConverter 主要實現的方法有三個:

  • doConvert() :將 EnvInitializer 中提供的多個Map合併成一個單層Map
  • monoToMultiLayer() :將單層Map轉換為多層Map(為了生成yml格式字串)
  • convert() :yml格式的字串解析為Map(為了判斷屬性是否發生變化)

由於後面兩個功能暫時還沒有涉及,我們先看第一段程式碼:

public class YamlConverter {
public static void doConvert(Map<String,Object> map,String parentKey,Map<String,Object> propertiesMap){
String prefix=(Objects.isNull(parentKey))?"":parentKey+".";
map.forEach((key,value)->{
if (value instanceof Map){
doConvert((Map)value,prefix+key,propertiesMap);
}else{
propertiesMap.put(prefix+key,value);
}
});
}
//...
}

邏輯也很簡單,通過迴圈遍歷的方式,將多個Map最終都合併到了目的 envMap 中,並且如果遇到多層Map巢狀的情況,那麼將多層Map的key通過點 . 進行了連線,最終得到了上面那張圖中樣式的單層Map。

其餘兩個方法,我們在下面使用到的場景再說。

6、ConfigController

ConfigController 作為控制器,用於和前端進行互動,只有兩個介面 saveget ,下面分別介紹。

get

前端頁面在開啟時會呼叫 ConfigController 中的 get 介面,填充到 textArea 中。先看一下 get 方法的實現:

@GetMapping("get")
public String get(){
ObjectMapper objectMapper = new ObjectMapper(new YAMLFactory());
String yamlContent = null;
try {
Map<String, Object> envMap = EnvInitializer.getEnvMap();
Map<String, Object> map = YamlConverter.monoToMultiLayer(envMap, null);
yamlContent = objectMapper.writeValueAsString(map);
} catch (Exception e) {
e.printStackTrace();
}
return yamlContent;
}

之前在專案啟動時,就已經把配置檔案屬性封裝到了 EnvInitializerenvMap 中,並且這個 envMap 是一個單層的Map,不存在巢狀關係。但是我們這裡要使用 jackson 生成標準格式的yml文件,這種格式不符合要求,需要將它還原成一個具有層級關係的多層Map,就需要呼叫 YamlConvertermonoToMultiLayer() 方法。

monoToMultiLayer() 方法的程式碼有點長,就不貼在這裡了,主要是根據key中的 . 進行拆分並不斷建立子級的Map,轉換完成後得到的多層Map資料如下:

在獲得這種格式後的Map後,就可以呼叫 jackson 中的方法將Map轉換為yml格式的字串傳遞給前端了,看一下處理完成後返回給前端的字串:

save

在前端頁面修改了yml內容後點擊儲存時,會呼叫 save 方法儲存並更新配置,方法的實現如下:

@PostMapping("save")
public String save(@RequestBody Map<String,Object> newValue) {
String ymlContent =(String) newValue.get("yml");
PropertyTrigger.change(ymlContent);
return "success";
}

在拿到前端傳過來的yml字串後,呼叫 PropertyTriggerchange 方法,實現後續的更改邏輯。

7、PropertyTrigger

在呼叫 change 方法後,主要做的事情有兩件:

  • 修改 EnvInitializer 中的環境 envMap ,用於前端頁面重新整理時返回新的資料,以及下一次屬性改變時進行對比使用
  • 修改bean中屬性的值,這也是整個配置中心最重要的功能

先看一下程式碼:

public class PropertyTrigger {
public static void change(String ymlContent) {
Map<String, Object> newMap = YamlConverter.convert(ymlContent);
Map<String, Object> oldMap = EnvInitializer.getEnvMap();

oldMap.keySet().stream()
.filter(key->newMap.containsKey(key))
.filter(key->!newMap.get(key).equals(oldMap.get(key)))
.forEach(key->{
System.out.println(key);
Object newVal = newMap.get(key);
oldMap.put(key, newVal);
doChange(key,newVal);
});
EnvInitializer.setEnvMap(oldMap);
}

private static void doChange(String propertyName, Object newValue) {
System.out.println("newValue:"+newValue);
Map<String, Map<Class, String>> pool = VariablePool.getPool();
Map<Class, String> classProMap = pool.get(propertyName);

classProMap.forEach((clazzName,realPropertyName)->{
try {
Object bean = SpringContextUtil.getBean(clazzName);
Field field = clazzName.getDeclaredField(realPropertyName);
field.setAccessible(true);
field.set(bean, newValue);
} catch (NoSuchFieldException | IllegalAccessException e) {
e.printStackTrace();
}
});
}
}

前面鋪墊了那麼多,其實就是為了實現這段程式碼中的功能,具體邏輯如下:

  • YamlConverter
    convert
    EnvInitializer
    envMap
    
  • 遍歷舊的 envMap ,檢視其中的key在新的Map中對應的屬性值是否發生了改變,如果沒有改變則不做之後的任何操作
  • 如果發生改變,用新的值替換 envMap 中的舊值
  • VariablePool
    Class
    Field
    SpringContextUtil
    
  • 將修改後的Map寫回 EnvInitializer 中的 envMap

到這裡,就實現了全部的功能。

8、SpringContextUtil

SpringContextUtil 通過實現 ApplicationContextAware 介面獲得了spring容器,而通過容器的 getBean() 方法就可以容易的拿到spring中的bean,方便進行後續的更改操作。

@Component
public class SpringContextUtil implements ApplicationContextAware {
private static ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
public static <T> T getBean(Class<T> t) {
return applicationContext.getBean(t);
}
}

9、前端程式碼

至於前端程式碼,就是一個非常簡單的表單,程式碼的話可以移步 git 檢視。

最後

到這裡全部的程式碼介紹完了,最後做一個簡要的總結吧,雖然通過這幾個類能夠實現一個簡易版的配置中心功能,但是還有不少的缺陷,例如:

  • 沒有處理 @ConfigurationProperties 註解
  • 只處理了yml檔案,沒有處理properties檔案

  • 目前處理的bean都是基於 singleton 模式,如果作用域為 prototype ,也會存在問題
  • 反射效能低,如果某個屬性涉及的類很多會影響效能

  • 目前只能程式碼嵌入到專案中使用,還不支援獨立部署及遠端註冊功能

  • ……

總的來說,後續需要完善的點還有不少,真是感覺任重道遠。

最後再聊聊專案的名稱,為什麼取名叫 hermit-purple 呢,來源是jojo中二喬的替身 隱者之紫 ,感覺這個替身的能力和配置中心的感知功能還是蠻搭配的,所以就用了這個哈哈。

專案git地址:

https://github.com/trunks2008/hermit-purple-config

··········  END  ··············

也許你還想看

   |   官宣!我升級了!!!

   |   IntelliJ IDEA 老司機,還沒用過這 5 個 Intellij IDEA 除錯魔法?

   |   用 Java 寫個沙盒塔防遊戲!已上架 Steam,Apple Store

   |   幹掉 CRUD!這個開發神器效率爆炸,功能強大

  |   Java 大殺器來了!效能提升一個數量級

  |   豆瓣 9.7!這本技術書籍直接封神了

  |   兩天兩夜,1M圖片優化到100kb!

  |   面試八股文,YYDS!

簡歷指導/Java 學習/面試指導/面試小冊,歡迎 加入 我的 知識 星球( 公眾號後臺回覆“ 星球 ”即可 )。

如果本文對你有幫助的話,歡迎點贊&在看&分享,這對我繼續分享&創作優質文章非常重要。感謝:pray|type_1_2: