SpringBoot源碼系列:Environment機制深入分析(一)

概述

Spring的Environment是一個高度抽象的接口。在我們實際開發中可謂是處處都有它的身影,爲了徹底弄清它的廬山真面 本文將從源碼的角度去分析Environment,讓我們更加深入的認識它。以便在工作中遇到各種環境配置問題都能很快追蹤排查問題。(本文基於springboot 2.1.6版本。由於Environment機制內容比較多所以將其分爲兩個部分。本文所討論的是第一部分:加載配置的流程
下面以一個自定義配置的環境demo進入今天的正文

Springboot 自定義Environment Demo

下面這個自定義Environment主要是加載自定義配置文件。注意這裏沒有任何的實際的意義 僅僅演示。因爲Springboot爲我們提供了更簡單的方式去加載自定義配置文件。


在項目resources目錄下面新建一個customize.yml

	coledy:
	    account: xxxxxx

自定義Environment我們只需要繼承StandardEnvironment或者其子類即可。
由於我們現在是web環境所以我選擇繼承StandardServletEnvironment。

@Slf4j
public class CustomEnvironment extends StandardServletEnvironment {
  @Override
  protected void customizePropertySources(MutablePropertySources propertySources) {
    //這行代碼不能少 父類也需要加載一些環境信息 例如StandardServletEnvironment就需要去加載
    //servletContext參數和servletConfig參數 
    super.customizePropertySources(propertySources);
    //這裏我們使用默認的ResourceLoad去加載類路徑下的配置文件
    Resource resource = new DefaultResourceLoader().getResource("classpath:customize.yml");
    try {
    //這裏使用yml解析器解析 加載的Resource 
      List<PropertySource<?>> customYml = new YamlPropertySourceLoader().load("customize_yml", resource);
      for (PropertySource<?> p : customYml){
        propertySources.addLast(p);
      }
    }catch (IOException e){
        log.error("加載custom.yml配置文件異常!!!");
    }
  }
}

由於是自定義Environment所以我們啓動類需要修改一下

@SpringBootApplication
public class SpringbootStudyApplication{
  public static void main(String[] args) {
    SpringApplication application = new SpringApplicationBuilder(SpringbootStudyApplication.class).application();
    //這裏需要我們手動設置一下自定義變量的實例
    application.setEnvironment(new CustomEnvironment());
    ApplicationContext context = application.run(args);
    ConfigurableEnvironment environment = context.getBean(ConfigurableEnvironment.class);
    System.out.println(environment.getProperty("coledy.account"));
  }
}

我們看到自定義一個Environment流程很簡單。
可是問題是剛剛我們提到的 web環境變量 系統環境變量 命令行參數 默認的application.yml 文件是什麼時候加載的?我們先不着急回答這個問題 先看一下剛剛的 propertySources.addLast 這一行代碼。我們把自定義的 customize.yml手動添加到了一個集合中 結果我們就可以在容器中拿到配置的信息。下面我們看一下 Environment的存儲容器MutablePropertySources

Environment的存儲容器PropertySources

MutablePropertySources實現了PropertySources接口,並且在Springboot中MutablePropertySources目前是PropertySources的唯一實現。首先來看一下下面兩個類的介紹

  • PropertySource:spring存儲環境變量屬性的基類只有name屬性和泛型的source屬性.需要關注的有兩個點
    第一是該對象重寫了hashCode方法和equals方法 並且這兩個方法只是對屬性name做了處理。說明一個PropertySource對象綁定到一個唯一的name屬性上面,這個name有點像hashMap裏面的key值,移除,判斷都是依據這個name。還有一點它有一個抽象方法 Object getProperty(String name) 留給子類實現。
    常見的實現有:MapPropertySourcePropertiesPropertySourceResourcePropertySourceStubPropertySourceComparisonPropertySource

  • PropertySources:是一個接口,並且實現了Iterable。其默認的實現是MutablePropertySources,這個類存放PropertySource,容器內部是一個CopyOnWriteArrayList集合

我們可以簡單理解PropertySource是存儲單元 類似Map中的Entry 而PropertySources是一個容器類似於Map

下面是基本的使用

  //構建了一個MutablePropertySources 用於存儲配置信息 環境變量信息
 MutablePropertySources propertySources = new MutablePropertySources();
  //創建一個默認配置使用MapPropertySource
  Map<String,Object> config = new HashMap<>();
  config.put("name" , "first_demo");
  config.put("port" , 8080);
  MapPropertySource mapPropertySource = new MapPropertySource("default_config" , config);
  //創建一個環境配置使用 PropertiesPropertySource
  Properties properties = new Properties();
  properties.put("jvm.version" , 1.8);
  properties.put("system.kind" , "mac");
  properties.put("var" , "jvm.version");
  properties.put("byt" , new byte[]{12,3,13});
  PropertiesPropertySource propertiesPropertySource = new PropertiesPropertySource("env_config" , properties);
  //將上訴的配置加入到環境變量容器中
  propertySources.addFirst(mapPropertySource);
  propertySources.addFirst(propertiesPropertySource);

我們的Environment就是這樣由一個個的PropertySource構成 而不要把PropertySource理解成了鍵值對 他是由多個鍵值對構成的一個組。

springboot Environmen初始化流程

理解了上面的存儲結構我們就跟着SpringBoot的源碼去深入瞭解一下 Environment是如何自動解析獲取配置文件的。

類的繼承關係


   PropertyResolver //提供方法屬性的功能 只是提供屬性訪問
        ConfigurablePropertyResolver //提供類型轉換佔位符設置  必須屬性設置校驗等功能
        Environment   //spring運行環境的接口
           ConfigurableEnvironment  //爲環境容器提供配置功能
               ConfigurableWebEnvironment   //提供initPropertySources(this.servletContext, (ServletConfig)null)方法 會設置web容器運行參數 該方法是在刷新容器是的prepareRefresh時調用
                   StandardServletEnvironment  
                AbstractEnvironment       //環境變量的核心類 customizePropertySources方法留給子類去填充 運行環境集合propertySources
                    StandardEnvironment  //初始化了系統環境變量,系統參數
                        StandardServletEnvironment //初始化Servlet上下文變量 

乍一看類比較多 但是我們只關心核心的 Environment AbstractEnvironment StandardEnvironment StandardServletEnvironment 這四個類。我們還是一樣 從AbstractEnvironment這個抽象類開始分析。上一節也說過Spring大量的使用的 模板方法的設計模式 所以遇到Abstract開頭的類 我們要足夠重視。


加載系統環境變量 Servlet環境上下文參數

public abstract class AbstractEnvironment implements ConfigurableEnvironment {
    //這個屬性緩存被激活配置文件的列表 僅僅是一個緩存的作用  spring.profiles.active指定的屬性
    private final Set<String> activeProfiles = new LinkedHashSet<>();
    //這個也是一個緩存作用保存當前默認的配置文件
	private final Set<String> defaultProfiles = new LinkedHashSet<>(getReservedDefaultProfiles());
	//這個集合比較重要 保存了所有Springboot上下文的配置信息 關於MutablePropertySources的結構我們上面也有提到
	private final MutablePropertySources propertySources = new MutablePropertySources();
	//初始化解析器 可以看到AbstractEnvironment實現了ConfigurableEnvironment接口
	//而ConfigurableEnvironment又實現了ConfigurablePropertyResolver接口
	//這裏可以看出配置解析器的功能全部交由PropertySourcesPropertyResolver來處理 使用靜態代理模式
	private final ConfigurablePropertyResolver propertyResolver =
			new PropertySourcesPropertyResolver(this.propertySources);
    //這個構造器是核心 這就是爲什麼上面的demo(自定義Environment)重寫了customizePropertySources這個方法就能把我們的配置信息放進環境容器
	public AbstractEnvironment() {
			customizePropertySources(this.propertySources);
		}
	//當前的	customizePropertySources是一個空方法完全交由子類去實現 注意這個方法是一個受保護的方法。
	//自定義Environment就是重寫這個方法  類當前會把 盛放屬性的容器傳遞到子類。子類字需要向MutablePropertySources 添加PropertySource即可
	protected void customizePropertySources(MutablePropertySources propertySources) {
	}	
}

StandardEnvironment 實現了AbstractEnvironment 並且重寫了customizePropertySources 向 MutablePropertySources 容器加入了JVM系統屬性 和系統環境變量

@Override
protected void customizePropertySources(MutablePropertySources propertySources) {
//添加System.getProperties()到環境容器
	propertySources.addLast(
			new PropertiesPropertySource(SYSTEM_PROPERTIES_PROPERTY_SOURCE_NAME, getSystemProperties()));
//添加System.getenv()到環境容器
//這裏還得注意一點  可以看到使用的都是addLast方法加入到環境容器的 所以在檢索屬性的時候 系統屬性的優先級必然高於系統環境變量的優先級。
//也就是說我們在系統屬性中配置 一個屬性 name=coledy  同時也在系統環境變量中配置name=lili name最終獲取name的值是coledy
//後續的添加也是按照這個規律 
	propertySources.addLast(
			new SystemEnvironmentPropertySource(SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, getSystemEnvironment()));
}



StandardServletEnvironment web開發最常用的Environment

//添加了servletContextInitParams和servletConfigInitParams 此時只是佔位 沒有任何屬性添加進去
@Override
protected void customizePropertySources(MutablePropertySources propertySources) {
	propertySources.addLast(new StubPropertySource(SERVLET_CONFIG_PROPERTY_SOURCE_NAME));
	propertySources.addLast(new StubPropertySource(SERVLET_CONTEXT_PROPERTY_SOURCE_NAME));
	if (JndiLocatorDelegate.isDefaultJndiEnvironmentAvailable()) {
		propertySources.addLast(new JndiPropertySource(JNDI_PROPERTY_SOURCE_NAME));
	}
	super.customizePropertySources(propertySources);
}

//該方法會重新填充propertySources會將servletContextInitParams和servletConfigInitParams真正的參數放入propertySources
//調用時機 是在容器刷新之前 準備刷新階段調用   GenericWebApplicationContext的initPropertySources方法調用將會觸發該方法的執行  
//這一塊 我們放在 Servlet容器再來探討
@Override
public void initPropertySources(@Nullable ServletContext servletContext, @Nullable ServletConfig servletConfig) {
	WebApplicationContextUtils.initServletPropertySources(getPropertySources(), servletContext, servletConfig);
}

到這裏 我們的Servlet環境參數 系統環境變量 都已經加入到我們的Environment。但是沒有看到解析配置文件的過程。

解析配置文件

順着Springboot的啓動流程找到初始化環境變量的代碼

SpringApplication的run方法 (省略了不相關的代碼)

public ConfigurableApplicationContext run(String... args) {
	....	
	//將命令行參數封裝成ApplicationArguments   這個類內部其實是由 剛剛我們講到的PropertySource的一個子類實現的
	//SimpleCommandLinePropertySource 這裏不作展開
	ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);
	//準備Environment
	ConfigurableEnvironment environment = prepareEnvironment(listeners, applicationArguments);
	configureIgnoreBeanInfo(environment);
	Banner printedBanner = printBanner(environment);
	context = createApplicationContext();
	....		
}

繼續跟進prepareEnvironment

	private ConfigurableEnvironment prepareEnvironment(SpringApplicationRunListeners listeners,
			ApplicationArguments applicationArguments) {
		//這裏是根據我們當前的運行環境去創建一個環境變量容器 這裏創建的是StandardServletEnvironment
		//指的注意的是 這個方法會判斷當前environment是否爲null 如果爲null 就會創建 由於我們之前已經設置了一個
		//	application.setEnvironment(new CustomEnvironment()); 所以這裏是不會new一個
		//StandardServletEnvironment  當然大多數情況是new StandardServletEnvironment 
		ConfigurableEnvironment environment = getOrCreateEnvironment();
		//配置環境變量 將啓動參數加入到Environment容器中 如果以--開頭將會被解析
		configureEnvironment(environment, applicationArguments.getSourceArgs());
		//向容器中發送ApplicationEnvironmentPreparedEvent事件
		listeners.environmentPrepared(environment);
		//將spring.main綁定到當前對象 Binder是Springboot2新加的 後續我們還會見到它這裏先不做展開
		bindToSpringApplication(environment);
		if (!this.isCustomEnvironment) {
			environment = new EnvironmentConverter(getClassLoader()).convertEnvironmentIfNecessary(environment,
					deduceEnvironmentClass());
		}
		//向Environment的環境變量容器中增加一個configurationProperties屬性  這個屬性對應的
		//類型是ConfigurationPropertySourcesPropertySource並且之前加載的所有配置都放在
		//configurationProperties對應的容器中 這裏我們在後續也會聊到 和本次內容無關 這裏就不展開
		ConfigurationPropertySources.attach(environment);
		return environment;
	}

到這整個Environment的加載已經完成了 可是我們沒有找到解析配置文件的地方。
這一次我們主要關注這裏面的一行代碼listeners.environmentPrepared(environment); 這裏向容器中發送了一個ApplicationEnvironmentPreparedEvent事件。這裏在SimpleApplicationEventMulticaster中斷點查看監聽該事件的監聽器是ConfigFileApplicationListener

下面看一下這個類主要的方法定義


public class ConfigFileApplicationListener implements EnvironmentPostProcessor, SmartApplicationListener, Ordered {
 
   //可以看到這個類對兩個事件做了監聽ApplicationEnvironmentPreparedEvent和ApplicationPreparedEvent 今天我們主要討論第一個事件監聽的邏輯處理
	@Override
	public boolean supportsEventType(Class<? extends ApplicationEvent> eventType) {
		return ApplicationEnvironmentPreparedEvent.class.isAssignableFrom(eventType)
				|| ApplicationPreparedEvent.class.isAssignableFrom(eventType);
	}

	@Override
	public void onApplicationEvent(ApplicationEvent event) {
		if (event instanceof ApplicationEnvironmentPreparedEvent) {
	//當發生ApplicationEnvironmentPreparedEvent事件時會調用ApplicationEnvironmentPreparedEvent方法 繼續進入該方法
	onApplicationEnvironmentPreparedEvent((ApplicationEnvironmentPreparedEvent) event);
		}
		if (event instanceof ApplicationPreparedEvent) {
			onApplicationPreparedEvent(event);
		}
	}

	private void onApplicationEnvironmentPreparedEvent(ApplicationEnvironmentPreparedEvent event) {      
	//這裏找到所有的EnvironmentPostProcessor 執行postProcessEnvironment方法。
	//springboot的慣用手段從spring.factories中找到所有key爲EnvironmentPostProcessor的配置。
	//在springboot的啓動時候加載監聽器的時候也是這樣做的
		List<EnvironmentPostProcessor> postProcessors = loadPostProcessors();
		//這裏將自己也加入這個集合  可以發現 當前這個監聽器也是一個EnvironmentPostProcessor
		postProcessors.add(this);
		AnnotationAwareOrderComparator.sort(postProcessors);
		//循環執行所有的postProcessEnvironment
		for (EnvironmentPostProcessor postProcessor : postProcessors) {
			postProcessor.postProcessEnvironment(event.getEnvironment(), event.getSpringApplication());
		}
	}

	@Override
	public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
		addPropertySources(environment, application.getResourceLoader());
	}

   //最終代碼會執行到這。可以看到最終是委託給了Loader類去加載配置文件
	protected void addPropertySources(ConfigurableEnvironment environment, ResourceLoader resourceLoader) {
	   //這裏向環境容器添加RandomValuePropertySource 這樣我們可以在配置文件中使用隨機數
	   //如${random.value} ${random.int}等
		RandomValuePropertySource.addToEnvironment(environment);
		new Loader(environment, resourceLoader).load();
	}
}

爲了節約篇幅 上面的代碼塊中貼了5個方法 但都是比較簡單的方法 執行順序也是從上往下的順序執行。
我們繼續跟進Loader的load方法

		public void load() {
			this.profiles = new LinkedList<>();
			this.processedProfiles = new LinkedList<>();
			this.activatedProfiles = false;
			this.loaded = new LinkedHashMap<>();
//初始化profile  此時是在沒有加載配置文件之前 但是環境變量中已經加載了系統變量,啓動參數等信息
 //所以如果配置文件中沒有設置profile 默認使用啓動參數裏面的配置 啓動參數裏面配置的優先級大於配置文件
 //該方法會向profiles中加入一個null元素和一個default 用於解析application.yml和application-default.yml
			initializeProfiles();
			while (!this.profiles.isEmpty()) {
				Profile profile = this.profiles.poll();
				if (profile != null && !profile.isDefaultProfile()) {
				  //進入這個分支裏面表示 spring.profiles.active配置過了 檢查Environment裏面是否有當前的profile 如果沒有加入Environment
					addProfileToEnvironment(profile.getName());
				}
		//當profile爲null時 該方法會加載application.yml或application.properties文件
				load(profile, this::getPositiveProfileFilter, addToLoaded(MutablePropertySources::addLast, false));
				this.processedProfiles.add(profile);
			}
		//從processedProfiles集合中過濾掉 profile爲null和default的	 
		//剩下的設置到Environment的activeProfiles中 剩下的就是我們配置的spring.profiles.active/include指定的
			resetEnvironmentProfiles(this.processedProfiles);
	//再一次load profile爲null的配置 和前一次加載 區別是 DocumentFilter的區別 這一部分我們放在下一篇博文中討論		
			load(null, this::getNegativeProfileFilter, addToLoaded(MutablePropertySources::addFirst, true));
			//將加載的所有的配置文件加入到Environment中 因爲加載的文件是暫存在loaded屬性中的
			addLoadedPropertySources();
		}

繼續跟進
load(profile, this::getPositiveProfileFilter,addToLoaded(MutablePropertySources::addLast, false));

private void load(Profile profile, DocumentFilterFactory filterFactory, DocumentConsumer consumer) {
   //這裏查找加載哪個路徑下面的配置文件 先看下面的查找方法
		getSearchLocations().forEach((location) -> {
			boolean isFolder = location.endsWith("/");
			Set<String> names = isFolder ? getSearchNames() : NO_SEARCH_NAMES;
			//這裏就循環遍歷下面getSearchLocations()得到路徑下面的文件。繼而加載
			names.forEach((name) -> load(location, name, profile, filterFactory, consumer));
		});
	}
//查找配置路徑	
private Set<String> getSearchLocations() {
    //如果我們配置了加載路徑  優先使用配置的路徑  (這個配置是指在系統環境變量 啓動命令行參數等,關於配置的優先級我們下次再探討)
	if (this.environment.containsProperty(CONFIG_LOCATION_PROPERTY)) {
		return getSearchLocations(CONFIG_LOCATION_PROPERTY);
	}
	//如果沒有配置 優先使用ConfigFileApplicationListener.this.searchLocations這個屬性的配置。如果都沒有值 默認使用內置的配置
	//classpath:/,classpath:/config/,file:./,file:./config/  優先級是越來越高的。
	Set<String> locations = getSearchLocations(CONFIG_ADDITIONAL_LOCATION_PROPERTY);
	locations.addAll(
			asResolvedSet(ConfigFileApplicationListener.this.searchLocations, DEFAULT_SEARCH_LOCATIONS));
	return locations;
}

繼續進入
load(location, name, profile, filterFactory, consumer));

	private void load(String location, String name, Profile profile, DocumentFilterFactory filterFactory,
				DocumentConsumer consumer) {
	//這裏我以當前name爲application爲例 直接進入下面一個分支			
		if (!StringUtils.hasText(name)) {
			for (PropertySourceLoader loader : this.propertySourceLoaders) {
				if (canLoadFileExtension(loader, location)) {
					load(loader, location, profile, filterFactory.getDocumentFilter(profile), consumer);
					return;
				}
			}
		}
	//存儲已處理的文件類型	
		Set<String> processed = new HashSet<>();
	//這裏的this.propertySourceLoaders 默認有兩個一個是	PropertiesPropertySourceLoader 處理類型爲xml和properties
	//另一種類型爲YamlPropertySourceLoader處理yml和yaml類型的文件
		for (PropertySourceLoader loader : this.propertySourceLoaders) {
		//從下面的循環邏輯可以我們可以看出  是用當前name和解析器支持的文件類型拼接去加載
		//如當前name爲application 分別和xml  properties yml 和yaml做拼接去加載當前文件夾下的文件 如果存在進行加載操作 不存在跳過 
			for (String fileExtension : loader.getFileExtensions()) {
//由上面的分析 我們可以看出processed存在的意義  因爲PropertySourceLoaders是可以動態配置的			
//如果我們自定義的處理類型和也支持yml  那麼造成了重複加載問題。所以這裏add成功就執行加載
				if (processed.add(fileExtension)) {
					loadForFileExtension(loader, location + name, "." + fileExtension, profile, filterFactory,
							consumer);
				}
			}
		}
		}

我們繼續跟進loadForFileExtension方法

private void loadForFileExtension(PropertySourceLoader loader, String prefix, String fileExtension,Profile profile, DocumentFilterFactory filterFactory, DocumentConsumer consumer) {
	DocumentFilter defaultFilter = filterFactory.getDocumentFilter(null);
	DocumentFilter profileFilter = filterFactory.getDocumentFilter(profile);
	//這裏一般是第二次進入 當我們在application文件中配置了spring.profiles.active或者include 在加載完application配置之後繼而會加載 我們配置的文件
	if (profile != null) {
	    //這行代碼會拼接完整的文件名 例如 application-default.yml
		String profileSpecificFile = prefix + "-" + profile + fileExtension;
	//下面load方法是加載active或include進去的配置文件 爲什麼會進行兩次load 這兩次load的
	//唯一區別就是DocumentFilter的區別 一個是defaultFiler 一個是profilerFilter 關於這一部門內容我們也會在下一篇文章中討論	
		load(loader, profileSpecificFile, profile, defaultFilter, consumer);
		load(loader, profileSpecificFile, profile, profileFilter, consumer);
		for (Profile processedProfile : this.processedProfiles) {
			if (processedProfile != null) {
				String previouslyLoaded = prefix + "-" + processedProfile + fileExtension;
				load(loader, previouslyLoaded, profile, profileFilter, consumer);
			}
		}
	}
	//第一次進入 當profile爲空時解析默認文件
	load(loader, prefix + fileExtension, profile, profileFilter, consumer);
}

進入真正解析配置的方法
load(loader, prefix + fileExtension, profile, profileFilter, consumer); 刪除了部分校驗的代碼只保留了核心的內容

private void load(PropertySourceLoader loader, String location, Profile profile, DocumentFilter filter, DocumentConsumer consumer) {
	try {
		//將配置文件加載成Resource
		Resource resource = this.resourceLoader.getResource(location);
		//解析resource 這個解析的過程比較簡單就是利用我們上面講到的兩個解析器解析成
		//List<PropertySource<?>>  然後將PropertySource封裝成Document 感興趣的可以閱讀
		//YamlPropertySourceLoader和PropertiesPropertySourceLoader的load方法
		List<Document> documents = loadDocuments(loader, name, resource);
		List<Document> loaded = new ArrayList<>();
		for (Document document : documents) {
		//這個條件判斷放在下一篇博文中討論
			if (filter.match(document)) {
		//如果application配置文件中配置了spring.profiles.active將加入this.profiles中並且將default 從這個集合中移除  
	    //值得注意的是  我們一開始就是從遍歷這個集合 取出裏面的一個null元素開始到這裏。
				addActiveProfiles(document.getActiveProfiles());
		//向this.profiles加入application配置文件的spring.profiles.include屬性指定的值
				addIncludedProfiles(document.getIncludeProfiles());
	//將加載出來的文檔放入緩存中。等待被放入Environment  可以看到第一次加載流程到這裏就結束了。加載指定的active和include 會進入while的下一次循環 
	//因爲我們解析之後向隊列中加了application配置文件配置的active和include的配置項 所以while會繼續往下循環。如果下一個配置文件也有active和include屬性 也會繼續解析 重複這個流程			
				loaded.add(document);
			}
		}
		Collections.reverse(loaded);
		if (!loaded.isEmpty()) {
			loaded.forEach((document) -> consumer.accept(profile, document));
			if (this.logger.isDebugEnabled()) {
				StringBuilder description = getDescription("Loaded config file ", location, resource, profile);
				this.logger.debug(description);
			}
		}
	}
	catch (Exception ex) {
		throw new IllegalStateException("Failed to load property " + "source from location '" + location + "'",
				ex);
	}
}

到此Environment就加載了配置文件的屬性。整個流程load的重載方法比較多 給人感覺不是那麼清晰。
那麼我們本篇文章中還遺留了幾個問題:springBoot2的Binder使用 資源文件優先級 多文檔塊配置以及和環境變量相關的幾個常用的註解等問題 都會在下一篇文章中討論

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章