javascript
webmvcconfigurer配置跨域_为什么加了 Spring Security 会导致 Spring Boot 跨域失效呢?...
點擊上方?IT牧場?,選擇?置頂或者星標
技術干貨每日送達
作者:歐陽我去
鏈接:https://segmentfault.com/a/1190000019485883
作為一個后端開發,我們經常遇到的一個問題就是需要配置?CORS,好讓我們的前端能夠訪問到我們的 API,并且不讓其他人訪問。而在 Spring 中,我們見過很多種?CORS?的配置,很多資料都只是告訴我們可以這樣配置、可以那樣配置,但是這些配置有什么區別?
1、CORS 是什么
首先我們要明確,CORS?是什么,以及規范是如何要求的。這里只是梳理一下流程。
CORS?全稱是?Cross-Origin Resource Sharing,直譯過來就是跨域資源共享。要理解這個概念就需要知道域、資源和同源策略這三個概念。
- 域,指的是一個站點,由?protocal、host?和?port?三部分組成,其中?host?可以是域名,也可以是 ip ;port 如果沒有指明,則是使用?protocal?的默認端口.
- 資源,是指一個?URL?對應的內容,可以是一張圖片、一種字體、一段 HTML 代碼、一份 JSON 數據等等任何形式的任何內容。
- 同源策略,指的是為了防止?XSS,瀏覽器、客戶端應該僅請求與當前頁面來自同一個域的資源,請求其他域的資源需要通過驗證。
了解了這三個概念,我們就能理解為什么有 CORS 規范了:從站點 A 請求站點 B 的資源的時候,由于瀏覽器的同源策略的影響,這樣的跨域請求將被禁止發送;為了讓跨域請求能夠正常發送,我們需要一套機制在不破壞同源策略的安全性的情況下、允許跨域請求正常發送,這樣的機制就是 CORS。
2、預檢請求
在?CORS?中,定義了一種預檢請求,即?preflight request,當實際請求不是一個?簡單請求?時,會發起一次預檢請求。預檢請求是針對實際請求的 URL 發起一次 OPTIONS 請求,并帶上下面三個 headers :
- Origin:值為當前頁面所在的域,用于告訴服務器當前請求的域。如果沒有這個 header,服務器將不會進行 CORS 驗證。
- Access-Control-Request-Method:值為實際請求將會使用的方法。
- Access-Control-Request-Headers:值為實際請求將會使用的 header 集合。
如果服務器端?CORS?驗證失敗,則會返回客戶端錯誤,即 4xx 的狀態碼。
否則,將會請求成功,返回?200?的狀態碼,并帶上下面這些 headers:
- Access-Control-Allow-Origin:允許請求的域,多數情況下,就是預檢請求中的 Origin 的值。
- Access-Control-Allow-Credentials:一個布爾值,表示服務器是否允許使用 cookies。
- Access-Control-Expose-Headers:實際請求中可以出現在響應中的 headers 集合。
- Access-Control-Max-Age:預檢請求返回的規則可以被緩存的最長時間,超過這個時間,需要再次發起預檢請求。
- Access-Control-Allow-Methods:實際請求中可以使用到的方法集合瀏覽器會根據預檢請求的響應,來決定是否發起實際請求。
2.1 小結
到這里, 我們就知道了跨域請求會經歷的故事:
接下來,我們看看在 Spring 中,我們是如何讓 CORS 機制在我們的應用中生效的。
3、三種配置的方式
Spring 提供了多種配置?CORS?的方式,有的方式針對單個 API,有的方式可以針對整個應用;有的方式在一些情況下是等效的,而在另一些情況下卻又出現不同。我們這里例舉幾種典型的方式來看看應該如何配置。
假設我們有一個 API:
@RestControllerclass?HelloController?{
????@GetMapping("hello")
????fun?hello():?String?{
????????return?"Hello,?CORS!"
????}
}
3.1 @CrossOrigin?注解
使用 @CorssOrigin?注解需要引入?Spring Web?的依賴,該注解可以作用于方法或者類,可以針對這個方法或類對應的一個或多個 API 配置 CORS 規則:
@RestControllerclass?HelloController?{
????@GetMapping("hello")
????@CrossOrigin(origins?=?["http://localhost:8080"])
????fun?hello():?String?{
????????return?"Hello,?CORS!"
????}
}
3.2 實現?WebMvcConfigurer.addCorsMappings?方法
WebMvcConfigurer?是一個接口,它同樣來自于 Spring Web。我們可以通過實現它的 addCorsMappings 方法來針對全局 API 配置 CORS 規則:
@Configuration@EnableWebMvc
class?MvcConfig:?WebMvcConfigurer?{
????override?fun?addCorsMappings(registry:?CorsRegistry)?{
????????registry.addMapping("/hello")
????????????????.allowedOrigins("http://localhost:8080")
????}
}
3.3 注入?CorsFilter
CorsFilter?同樣來自于?Spring Web,但是實現?WebMvcConfigurer.addCorsMappings?方法并不會使用到這個類,具體原因我們后面來分析。我們可以通過注入一個 CorsFilter 來使用它:
@Configurationclass?CORSConfiguration?{
????@Bean
????fun?corsFilter():?CorsFilter?{
????????val?configuration?=?CorsConfiguration()
????????configuration.allowedOrigins?=?listOf("http://localhost:8080")
????????val?source?=?UrlBasedCorsConfigurationSource()
????????source.registerCorsConfiguration("/hello",?configuration)
????????return?CorsFilter(source)
????}
}
注入?CorsFilter?不止這一種方式,我們還可以通過注入一個?FilterRegistrationBean?來實現,這里就不給例子了。
在僅僅引入?Spring Web?的情況下,實現?WebMvcConfigurer.addCorsMappings?方法和注入?CorsFilter?這兩種方式可以達到同樣的效果,二選一即可。它們的區別會在引入 Spring Security 之后會展現出來,我們后面再來分析。
4、Spring Security 中的配置
在引入了?Spring Security?之后,我們會發現前面的方法都不能正確的配置?CORS,每次?preflight request?都會得到一個?401?的狀態碼,表示請求沒有被授權。這時,我們需要增加一點配置才能讓 CORS 正常工作:
@Configurationclass?SecurityConfig?:?WebSecurityConfigurerAdapter()?{
????override?fun?configure(http:?HttpSecurity?)?{
????????http?.cors()
????}
}
或者,干脆不實現?WebMvcConfigurer.addCorsMappings?方法或者注入?CorsFilter?,而是注入一個?CorsConfigurationSource?,同樣能與上面的代碼配合,正確的配置 CORS:
@Beanfun?corsConfigurationSource():?CorsConfigurationSource?{
????val?configuration?=?CorsConfiguration()
????configuration.allowedOrigins?=?listOf("http://localhost:8080")
????val?source?=?UrlBasedCorsConfigurationSource()
????source.registerCorsConfiguration("/hello",?configuration)
????return?source
}
到此,我們已經看過了幾種典型的例子了,我們接下來看看 Spring 到底是如何實現 CORS 驗證的。
5、這些配置有什么區別
我們會主要分析實現?WebMvcConfigurer.addCorsMappings?方法和調用?HttpSecurity.cors?方法這兩種方式是如何實現?CORS?的,但在進行之前,我們要先復習一下 Filter 與 Interceptor 的概念。
5.1 Filter 與 Interceptor
上圖很形象的說明了?Filter?與?Interceptor?的區別,一個作用在?DispatcherServlet?調用前,一個作用在調用后。
但實際上,它們本身并沒有任何關系,是完全獨立的概念。
Filter?由?Servlet?標準定義,要求?Filter?需要在?Servlet?被調用之前調用,作用顧名思義,就是用來過濾請求。在 Spring Web 應用中,DispatcherServlet 就是唯一的 Servlet 實現。
Interceptor?由 Spring 自己定義,由?DispatcherServlet?調用,可以定義在 Handler 調用前后的行為。這里的 Handler ,在多數情況下,就是我們的 Controller 中對應的方法。
對于?Filter?和?Interceptor?的復習就到這里,我們只需要知道它們會在什么時候被調用到,就能理解后面的內容了。
5.2 WebMvcConfigurer.addCorsMappings?方法做了什么
我們從?WebMvcConfigurer.addCorsMappings?方法的參數開始,先看看?CORS?配置是如何保存到 Spring 上下文中的,然后在了解一下 Spring 是如何使用的它們。
5.2.1 注入 CORS 配置
5.2.1.1 CorsRegistry 和 CorsRegistration
WebMvcConfigurer.addCorsMappings?方法的參數?CorsRegistry?用于注冊?CORS?配置,它的源碼如下:
public?class?CorsRegistry?{????private?final?List?registrations?=?new?ArrayList<>();public?CorsRegistration?addMapping(String?pathPattern)?{
????????CorsRegistration?registration?=?new?CorsRegistration(pathPattern);this.registrations.add(registration);return?registration;
????}protected?Map?getCorsConfigurations()?{
????????Map?configs?=?new?LinkedHashMap<>(this.registrations.size());for?(CorsRegistration?registration?:?this.registrations)?{
????????????configs.put(registration.getPathPattern(),?registration.getCorsConfiguration());
????????}return?configs;
????}
}
我們發現這個類僅僅有兩個方法:
- addMapping?接收一個?pathPattern,創建一個?CorsRegistration?實例,保存到列表后將其返回。在我們的代碼中,這里的 pathPattern 就是?/hello。
- getCorsConfigurations 方法將保存的 CORS 規則轉換成 Map 后返回。
CorsRegistration?這個類,同樣很簡單,我們看看它的部分源碼:
public?class?CorsRegistration?{????private?final?String?pathPattern;
????private?final?CorsConfiguration?config;
????public?CorsRegistration(String?pathPattern)?{
????????this.pathPattern?=?pathPattern;
????????this.config?=?new?CorsConfiguration().applyPermitDefaultValues();
????}
????public?CorsRegistration?allowedOrigins(String...?origins)?{
????????this.config.setAllowedOrigins(Arrays.asList(origins));
????????return?this;
????}
}
不難發現,這個類僅僅保存了一個?pathPattern?字符串和?CorsConfiguration,很好理解,它保存的是一個 pathPattern 對應的 CORS 規則。
在它的構造函數中,調用的 CorsConfiguration.applyPermitDefaultValues 方法則用于配置默認的 CORS 規則:
- allowedOrigins 默認為所有域。
- allowedMethods 默認為 GET 、HEAD 和 POST。
- allowedHeaders 默認為所有。
- maxAge 默認為 30 分鐘。
- exposedHeaders 默認為 null,也就是不暴露任何 header。
- credentials 默認為 null。
創建?CorsRegistration?后,我們可以通過它的?allowedOrigins、allowedMethods?等方法修改它的?CorsConfiguration,覆蓋掉上面的默認值。
現在,我們已經通過?WebMvcConfigurer.addCorsMappings?方法配置好?CorsRegistry?了,接下來看看這些配置會在什么地方被注入到 Spring 上下文中。
5.2.1.2 WebMvcConfigurationSupport
CorsRegistry.getCorsConfigurations?方法,會被?WebMvcConfigurationSupport.getConfigurations?方法調用,這個方法如下:
protected?final?Map?getCorsConfigurations()?{????if?(this.corsConfigurations?==?null)?{
????????CorsRegistry?registry?=?new?CorsRegistry();
????????addCorsMappings(registry);
????????this.corsConfigurations?=?registry.getCorsConfigurations();
????}
????return?this.corsConfigurations;
}
addCorsMappings(registry)?調用的是自己的方法,由子類?DelegatingWebMvcConfiguration?通過委托的方式調用到?WebMvcConfigurer.addCorsMappings?方法,我們的配置也由此被讀取到。
getCorsConfigurations?是一個?protected?方法,是為了在擴展該類時,仍然能夠直接獲取到 CORS 配置。而這個方法在這個類里被四個地方調用到,這四個調用的地方,都是為了注冊一個 HandlerMapping 到 Spring 容器中。每一個地方都會調用 mapping.setCorsConfigurations 方法來接收 CORS 配置,而這個?setCorsConfigurations?方法,則由?AbstractHandlerMapping?提供,CorsConfigurations 也被保存在這個抽象類中。
到此,我們的?CORS?配置借由?AbstractHandlerMapping?被注入到了多個?HandlerMapping?中,而這些?HandlerMapping?以 Spring 組件的形式被注冊到了 Spring 容器中,當請求來臨時,將會被調用。
5.2.2 獲取 CORS 配置
還記得前面關于 Filter 和 Interceptor 那張圖嗎?當請求來到 Spring Web 時,一定會到達 DispatcherServlet 這個唯一的 Servlet。
在?DispatcherServlet.doDispatch?方法中,會調用所有 HandlerMapping.getHandler 方法。好巧不巧,這個方法又是由 AbstractHandlerMapping 實現的:
@Override@Nullable
public?final?HandlerExecutionChain?getHandler(HttpServletRequest?request)?throws?Exception?{
????//?省略代碼
????if?(CorsUtils.isCorsRequest(request))?{
????????CorsConfiguration?globalConfig?=?this.corsConfigurationSource.getCorsConfiguration(request);
????????CorsConfiguration?handlerConfig?=?getCorsConfiguration(handler,?request);
????????CorsConfiguration?config?=?(globalConfig?!=?null???globalConfig.combine(handlerConfig)?:?handlerConfig);
????????executionChain?=?getCorsHandlerExecutionChain(request,?executionChain,?config);
????}
????return?executionChain;
}
在這個方法中,關于 CORS 的部分都在這個 if 中。我們來看看最后這個 getCorsHandlerExecutionChain 做了什么:
protected?HandlerExecutionChain?getCorsHandlerExecutionChain(HttpServletRequest?request,????????HandlerExecutionChain?chain,?@Nullable?CorsConfiguration?config)?{
????if?(CorsUtils.isPreFlightRequest(request))?{
????????HandlerInterceptor[]?interceptors?=?chain.getInterceptors();
????????chain?=?new?HandlerExecutionChain(new?PreFlightHandler(config),?interceptors);
????}
????else?{
????????chain.addInterceptor(new?CorsInterceptor(config));
????}
????return?chain;
}
可以看到:
- 針對?preflight request,由于不會有對應的?Handler?來處理,所以這里就創建了一個 PreFlightHandler 來作為這次請求的 handler。
- 對于其他的跨域請求,因為會有對應的?handler,所以就在?handlerExecutionChain?中加入一個?CorsInterceptor?來進行?CORS?驗證
這里的?PreFlightHandler?和?CorsInterceptor?都是?AbstractHandlerMapping?的內部類,實現幾乎一致,區別僅僅在于一個是?HttpRequestHandler,一個是 HandlerInterceptor;它們對 CORS 規則的驗證都交由 CorsProcessor 接口完成,這里采用了默認實現 DefaultCorsProcessor。
DefaultCorsProcessor?則是依照?CORS?標準來實現,并在驗證失敗的時候打印 debug 日志并拒絕請求。我們只需要關注一下標準中沒有定義的驗證失敗時的狀態碼:
protected?void?rejectRequest(ServerHttpResponse?response)?throws?IOException?{????response.setStatusCode(HttpStatus.FORBIDDEN);
????response.getBody().write("Invalid?CORS?request".getBytes(StandardCharsets.UTF_8));
}
CORS?驗證失敗時調用這個方法,并設置狀態碼為 403。
5.2.3 小結
通過對源碼的研究,我們發現實現?WebMvcConfigurer.addCorsMappings?方法的方式配置?CORS,會在 Interceptor 或者 Handler 層進行 CORS 驗證。
5.3 HtttpSecurity.cors?方法做了什么
在研究這個方法的行為之前,我們先來回想一下,我們調用這個方法解決的是什么問題。
前面我們通過某種方式配置好?CORS?后,引入?Spring Security,CORS?就失效了,直到調用這個方法后,CORS 規則才重新生效。
下面這些原因,導致了?preflight request?無法通過身份驗證,從而導致 CORS 失效:
接下來我們就來看看 HttpSecurity.cors 方法是如何解決這個問題的。
5.3.1 CorsConfigurer 如何配置 CORS 規則
HttpSecurity.cors 方法中其實只有一行代碼:
public?CorsConfigurer?cors()?throws?Exception?{????return?getOrApply(new?CorsConfigurer<>());
}
這里調用的?getOrApply?方法會將?SecurityConfigurerAdapter?的子類實例加入到它的父類?AbstractConfiguredSecurityBuilder?維護的一個?Map?中,然后一個個的調用 configure 方法。所以,我們來關注一下 CorsConfigurer.configure 方法就好了。
@Overridepublic?void?configure(H?http)?throws?Exception?{
????ApplicationContext?context?=?http.getSharedObject(ApplicationContext.class);
????CorsFilter?corsFilter?=?getCorsFilter(context);
????if?(corsFilter?==?null)?{
????????throw?new?IllegalStateException(
????????????????"Please?configure?either?a?"?+?CORS_FILTER_BEAN_NAME?+?"?bean?or?a?"
????????????????????????+?CORS_CONFIGURATION_SOURCE_BEAN_NAME?+?"bean.");
????}
????http.addFilter(corsFilter);
}
這段代碼很好理解,就是在當前的 Spring Context 中找到一個?CorsFilter,然后將它加入到 http 對象的 filters 中。由上面的 HttpSecurity.cors 方法可知,這里的 http 對象實際類型就是 HttpSecurity。
5.3.2 getCorsFilter 方法做了什么
也許你會好奇,HttpSecurity 要如何保證 CorsFilter 一定在 Spring Security 的 Filters 之前調用。但是在研究這個之前,我們先來看看同樣重要的?getCorsFilter?方法,這里可以解答我們前面的一些疑問。
private?CorsFilter?getCorsFilter(ApplicationContext?context)?{????if?(this.configurationSource?!=?null)?{
????????return?new?CorsFilter(this.configurationSource);
????}
????boolean?containsCorsFilter?=?context
????????????.containsBeanDefinition(CORS_FILTER_BEAN_NAME);
????if?(containsCorsFilter)?{
????????return?context.getBean(CORS_FILTER_BEAN_NAME,?CorsFilter.class);
????}
????boolean?containsCorsSource?=?context
????????????.containsBean(CORS_CONFIGURATION_SOURCE_BEAN_NAME);
????if?(containsCorsSource)?{
????????CorsConfigurationSource?configurationSource?=?context.getBean(
????????????????CORS_CONFIGURATION_SOURCE_BEAN_NAME,?CorsConfigurationSource.class);
????????return?new?CorsFilter(configurationSource);
????}
????boolean?mvcPresent?=?ClassUtils.isPresent(HANDLER_MAPPING_INTROSPECTOR,
????????????context.getClassLoader());
????if?(mvcPresent)?{
????????return?MvcCorsFilter.getMvcCorsFilter(context);
????}
????return?null;
}
這是?CorsConfigurer?尋找?CorsFilter?的全部邏輯,我們用人話來說就是:
上面的第 2、3、4 步能解答我們前面的配置為什么生效,以及它們的區別。
注冊?CorsFilter?的方式,這個 Filter 最終會被直接注冊到 Servlet container 中被使用到。
注冊?CorsConfigurationSource?的方式,會用這個 source 創建一個 CorsFiltet 然后注冊到 Servlet container 中被使用到。
而第四步的情況比較復雜。HandlerMappingIntrospector 是 Spring Web 提供的一個類,實現了?CorsConfigurationSource?接口,所以在?MvcCorsFilter?中,它被直接用于創建 CorsFilter。它實現的 getCorsConfiguration 方法,會經歷:
所以得到的?CorsConfigurationSource?實例,實際上就是前面講到的 CorsInterceptor 或者 PreFlightHandler。
所以第四步實際上匹配的是實現 WebMvcConfigurer.addCorsMappings 方法的方式。
由于在?CorsFilter?中每次處理請求時都會調用?CorsConfigurationSource.getCorsConfiguration?方法,而?DispatcherServlet?中也會每次調用?HandlerMapping.getHandler?方法,再加上這時的?HandlerExecutionChain?中還有?CorsInterceptor,所以使用這個方式相對于其他方式,做了很多重復的工作。所以 WebMvcConfigurer.addCorsMappings + HttpSecurity.cors 的方式降低了我們代碼的效率,也許微乎其微,但能避免的情況下,還是不要使用。
5.3.3 HttpSecurity 中的 filters 屬性
在?CorsConfigurer.configure?方法中調用的?HttpSecurity.addFilter?方法,由它的父類?HttpSecurityBuilder?聲明,并約定了很多 Filter 的順序。然而 CorsFilter 并不在其中。不過在 Spring Security 中,目前還只有?HttpSecurity?這一個實現,所以我們來看看這里的代碼實現就知道 CorsFilter 會排在什么地方了。
public?HttpSecurity?addFilter(Filter?filter)?{????Class?extends?Filter>?filterClass?=?filter.getClass();
????if?(!comparator.isRegistered(filterClass))?{
????????throw?new?IllegalArgumentException("...");
????}
????this.filters.add(filter);
????return?this;
}
我們可以看到,Filter?會被直接加到?List?中,而不是按照一定的順序來加入的。但同時,我們也發現了一個?comparator?對象,并且只有被注冊到了該類的 Filter 才能被加入到 filters 屬性中。這個 comparator 又是用來做什么的呢?
在 Spring Security 創建過程中,會調用到?HttpSeciryt.performBuild?方法,在這里我們可以看到 filters 和 comparator 是如何被使用到的。
protected?DefaultSecurityFilterChain?performBuild()?throws?Exception?{????Collections.sort(filters,?comparator);
????return?new?DefaultSecurityFilterChain(requestMatcher,?filters);
}
可以看到,Spring Security 使用了這個?comparator?在獲取?SecurityFilterChain?的時候來保證?filters?的順序,所以,研究這個 comparator 就能知道在 SecurityFilterChain 中的那些 Filter 的順序是如何的了。
這個?comparator?的類型是?FilterComparator?,從名字就能看出來是專用于?Filter?比較的類,它的實現也并不神秘,從構造函數就能猜到是如何實現的:
FilterComparator()?{????Step?order?=?new?Step(INITIAL_ORDER,?ORDER_STEP);
????put(ChannelProcessingFilter.class,?order.next());
????put(ConcurrentSessionFilter.class,?order.next());
????put(WebAsyncManagerIntegrationFilter.class,?order.next());
????put(SecurityContextPersistenceFilter.class,?order.next());
????put(HeaderWriterFilter.class,?order.next());
????put(CorsFilter.class,?order.next());
??//?省略代碼
}
可以看到?CorsFilter?排在了第六位,在所有的 Security Filter 之前,由此便解決了 preflight request 沒有攜帶認證信息的問題。
5.3.4 小結
引入?Spring Security?之后,我們的?CORS?驗證實際上是依然運行著的,只是因為?preflight request?不會攜帶認證信息,所以無法通過身份驗證。使用 HttpSecurity.cors 方法會幫助我們在當前的 Spring Context 中找到或創建一個 CorsFilter 并安排在身份驗證的 Filter 之前,以保證能對 preflight request 正確處理。
6、總結
研究了 Spring 中 CORS 的代碼,我們了解到了這樣一些知識:
- 實現?WebMvcConfigurer.addCorsMappings?方法來進行的?CORS?配置,最后會在 Spring 的 Interceptor 或 Handler 中生效。
- 注入 CorsFilter 的方式會讓 CORS 驗證在 Filter 中生效。
- 引入?Spring Security?后,需要調用 HttpSecurity.cors 方法以保證 CorsFilter 會在身份驗證相關的 Filter 之前執行。
- HttpSecurity.cors?+?WebMvcConfigurer.addCorsMappings?是一種相對低效的方式,會導致跨域請求分別在 Filter 和 Interceptor 層各經歷一次 CORS 驗證。
- HttpSecurity.cors + 注冊 CorsFilter 與 HttpSecurity.cors + 注冊 CorsConfigurationSource 在運行的時候是等效的。
- 在 Spring 中,沒有通過 CORS 驗證的請求會得到狀態碼為 403 的響應。
干貨分享
最近將個人學習筆記整理成冊,使用PDF分享。關注我,回復如下代碼,即可獲得百度盤地址,無套路領取!
?001:《Java并發與高并發解決方案》學習筆記;?002:《深入JVM內核——原理、診斷與優化》學習筆記;?003:《Java面試寶典》?004:《Docker開源書》?005:《Kubernetes開源書》?006:《DDD速成(領域驅動設計速成)》?007:全部?008:加技術群討論
近期熱文
?LinkedBlockingQueue vs ConcurrentLinkedQueue?解讀Java 8 中為并發而生的 ConcurrentHashMap?Redis性能監控指標匯總?最全的DevOps工具集合,再也不怕選型了!?微服務架構下,解決數據庫跨庫查詢的一些思路?聊聊大廠面試官必問的 MySQL 鎖機制
關注我
喜歡就點個"在看"唄^_^
總結
以上是生活随笔為你收集整理的webmvcconfigurer配置跨域_为什么加了 Spring Security 会导致 Spring Boot 跨域失效呢?...的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: linux 快捷键
- 下一篇: js JSON转Excel并导出