介紹
AOP,面向切面程式設計,作為物件導向的一種補充,將公共邏輯(事務管理、日誌、快取、許可權控制、限流等)封裝成切面,跟業務程式碼進行分離,可以減少系統的重複程式碼和降低模組之間的耦合度。切面就是那些與業務無關,但所有業務模組都會呼叫的公共邏輯。
先看一個例子:如何給如下UserServiceImpl中所有方法新增進入方法的日誌,
public class UserServiceImpl implements IUserService {
@Override
public List<User> findUserList() {
System.out.println("execute method: findUserList");
return Collections.singletonList(new User("seven", 18));
}
@Override
public void addUser() {
System.out.println("execute method: addUser");
// do something
}
}
將記錄日誌功能解耦為日誌切面,它的目標是解耦。進而引出AOP的理念:就是將分散在各個業務邏輯程式碼中相同的程式碼透過橫向切割的方式抽取到一個獨立的模組中!
OOP物件導向程式設計,針對業務處理過程的實體及其屬性和行為進行抽象封裝,以獲得更加清晰高效的邏輯單元劃分。
AOP則是針對業務處理過程中的切面進行提取,它所面對的是處理過程的某個步驟或階段,以獲得邏輯過程的中各部分之間低耦合的隔離效果。這兩種設計思想在目標上有著本質的差異。
AOP相關術語
首先要知道,aop不是spring所特有的,同樣的,這些術語也不是spring所特有的。是由AOP聯盟定義的
切面(Aspect):切面是增強和切點的結合,增強和切點共同定義了切面的全部內容。
多個切面之間的執行順序如何控制?首先要明確,在“進入”連線點的情況下,最高優先順序的增強會先執行;在“退出”連線點的情況下,最高優先順序的增強會最後執行。- 通常使用@Order 註解直接定義切面順序
- 實現Ordered 介面重寫 getOrder 方法。Ordered.getValue()方法返回值(或者註解值)較低的那個有更高的優先順序。
- 連線點(Join point):一般指方法,在Spring AOP中,一個連線點總是代表一個方法的執行。連線點是在應用執行過程中能夠插入切面的一個點。這個點可以是呼叫方法時、丟擲異常時、甚至修改一個欄位時。切面程式碼可以利用這些點插入到應用的正常流程之中,並新增新的行為。當然,連線點也可能是類初始化、方法執行、方法呼叫、欄位呼叫或處理異常等
增強(或稱為通知)(Advice):在AOP術語中,切面的工作被稱為增強。知實際上是程式執行時要透過Spring AOP框架來觸發的程式碼段。
- 前置增強(Before):在目標方法被呼叫之前呼叫增強功能;
- 後置增強(After):在目標方法完成之後呼叫增強,此時不會關心方法的輸出是什麼;
- 返回增強(After-returning ):在目標方法成功執行之後呼叫增強;
- 異常增強(After-throwing):在目標方法丟擲異常後呼叫增強;
- 環繞增強(Around):增強包裹了被增強的方法,在被增強的方法呼叫之前和呼叫之後執行自定義的邏輯
- 切點(Pointcut):切點的定義會匹配增強所要織入的一個或多個連線點。通常使用明確的類和方法名稱,或是利用正規表示式定義所匹配的類和方法名稱來指定這些切點。以AspectJ舉例,說白了就可以理解為是execution表示式
- 引入(Introduction):引入允許我們向現有類新增新方法或屬性。 在AOP中表示為幹什麼(引入什麼);
- 目標物件(Target Object): 被一個或者多個切面(aspect)所增強(advise)的物件。它通常是一個代理物件。
- 織入(Weaving):織入是把切面應用到目標物件並建立新的代理物件的過程。在AOP中表示為怎麼實現的;織入分為編譯期織入、類載入期織入、執行期織入;SpringAOP是在執行期織入
execution表示式格式:
execution(modifiers-pattern? ret-type-pattern declaring-type-pattern? name-pattern(param-pattern) throws-pattern?)
- ret-type-pattern 返回型別模式, name-pattern名字模式和param-pattern引數模式是必選的, 其它部分都是可選的。返回型別模式決定了方法的返回型別必須依次匹配一個連線點。 使用的最頻繁的返回型別模式是
*
,它代表了匹配任意的返回型別。 - declaring-type-pattern, 一個全限定的型別名將只會匹配返回給定型別的方法。
- name-pattern 名字模式匹配的是方法名。 可以使用
*
萬用字元作為所有或者部分命名模式。 - param-pattern 引數模式稍微有點複雜:()匹配了一個不接受任何引數的方法, 而(..)匹配了一個接受任意數量引數的方法(零或者更多)。 模式(
*
)匹配了一個接受一個任何型別的引數的方法。 模式(*,String)匹配了一個接受兩個引數的方法,第一個可以是任意型別, 第二個則必須是String型別。
例如:
execution(* com.seven.springframeworkaopannojdk.service.*.*(..))
Spring AOP和AspectJ的關係
AspectJ是一個java實現的AOP框架,它能夠對java程式碼進行AOP編譯(一般在編譯期進行),讓java程式碼具有AspectJ的AOP功能(當然需要特殊的編譯器)。可以這樣說AspectJ是目前實現AOP框架中最成熟,功能最豐富的語言,更幸運的是,AspectJ與java程式完全相容,幾乎是無縫關聯,因此對於有java程式設計基礎的工程師,上手和使用都非常容易。
- AspectJ是更強的AOP框架,是實際意義的AOP標準;
- Spring為何不寫類似AspectJ的框架? Spring AOP使用純Java實現, 它不需要專門的編譯過程, 它一個重要的原則就是無侵入性(non-invasiveness); Spring 小組完全有能力寫類似的框架,只是Spring AOP從來沒有打算透過提供一種全面的AOP解決方案來與AspectJ競爭。Spring的開發小組相信無論是基於代理(proxy-based)的框架如Spring AOP或者是成熟的框架如AspectJ都是很有價值的,他們之間應該是互補的而不是競爭的關係。
- Spring小組喜歡@AspectJ註解風格更勝於Spring XML配置; 所以在Spring 2.0使用了和AspectJ 5一樣的註解,並使用AspectJ來做切入點解析和匹配。但是,AOP在執行時仍舊是純的Spring AOP,並不依賴於AspectJ的編譯器或者織入器(weaver)。
- Spring 2.5對AspectJ的支援:在一些環境下,增加了對AspectJ的裝載時編織支援,同時提供了一個新的bean切入點。
下表總結了 Spring AOP 和 AspectJ 之間的關鍵區別:
Spring AOP | AspectJ |
---|---|
在純 Java 中實現 | 使用 Java 程式語言的擴充套件實現 |
不需要單獨的編譯過程 | 除非設定 LTW,否則需要 AspectJ 編譯器 (ajc) |
只能使用執行時織入 | 執行時織入不可用。支援編譯時、編譯後和載入時織入 |
功能不強 - 僅支援方法級編織 | 更強大 - 可以編織欄位、方法、建構函式、靜態初始值設定項、最終類/方法等......。 |
只能在由 Spring 容器管理的 bean 上實現 | 可以在所有域物件上實現 |
僅支援方法執行切入點 | 支援所有切入點 |
代理是由目標物件建立的, 並且切面應用在這些代理上 | 在執行應用程式之前 (在執行時) 前, 各方面直接在程式碼中進行織入 |
比 AspectJ 慢多了 | 更好的效能 |
易於學習和應用 | 相對於 Spring AOP 來說更復雜 |
AOP的實現原理
AOP有兩種實現方式:靜態代理和動態代理。
靜態代理
靜態代理分為:編譯時織入(特殊編譯器實現)、類載入時織入(特殊的類載入器實現)。
代理類在編譯階段生成,在編譯階段將增強織入Java位元組碼中,也稱編譯時增強。AspectJ使用的是靜態代理。
缺點:代理物件需要與目標物件實現一樣的介面,並且實現介面的方法,會有冗餘程式碼。同時,一旦介面增加方法,目標物件與代理物件都要維護。
動態代理
動態代理:代理類在程式執行時建立,AOP框架不會去修改位元組碼,而是在記憶體中臨時生成一個代理物件,在執行期間對業務方法進行增強,不會生成新類
Spring的AOP實現原理
而Spring的AOP的實現就是透過動態代理實現的。
如果為Spring的某個bean配置了切面,那麼Spring在建立這個bean的時候,實際上建立的是這個bean的一個代理物件,後續對bean中方法的呼叫,實際上呼叫的是代理類重寫的代理方法。而Spring的AOP使用了兩種動態代理,分別是JDK的動態代理,以及CGLib的動態代理。
- 如果目標類實現了介面,Spring AOP會選擇使用JDK動態代理目標類。代理類根據目標類實現的介面動態生成,不需要自己編寫,生成的動態代理類和目標類都實現相同的介面。JDK動態代理的核心是
InvocationHandler
介面和Proxy
類。 - 如果目標類沒有實現介面,那麼Spring AOP會選擇使用CGLIB來動態代理目標類。CGLIB(Code Generation Library)可以在執行時動態生成類的位元組碼,動態建立目標類的子類物件,在子類物件中增強目標類。CGLIB是透過繼承的方式做的動態代理,因此CGLIB存在的束:類是final的,或是方法是final的,或是方法是private,或是靜態方法,也就是無法被子類實現的方法都無法使用CGLIB實現代理。
那麼什麼時候採用哪種動態代理呢?
- 如果目標物件實現了介面,預設情況下會採用JDK的動態代理實現AOP
- 如果目標物件實現了介面,可以強制使用CGLIB實現AOP
- 如果目標物件沒有實現了介面,必須採用CGLIB庫
AOP的配置方式
基於XML
Spring提供了使用"aop"名稱空間來定義一個切面,我們來看個例子
- 定義目標類
public class AopDemoServiceImpl {
public void doMethod1() {
System.out.println("AopDemoServiceImpl.doMethod1()");
}
public String doMethod2() {
System.out.println("AopDemoServiceImpl.doMethod2()");
return "hello world";
}
public String doMethod3() throws Exception {
System.out.println("AopDemoServiceImpl.doMethod3()");
throw new Exception("some exception");
}
}
- 定義切面類
public class LogAspect {
public Object doAround(ProceedingJoinPoint pjp) throws Throwable {
System.out.println("-----------------------");
System.out.println("環繞通知: 進入方法");
Object o = pjp.proceed();
System.out.println("環繞通知: 退出方法");
return o;
}
public void doBefore() {
System.out.println("前置通知");
}
public void doAfterReturning(String result) {
System.out.println("後置通知, 返回值: " + result);
}
public void doAfterThrowing(Exception e) {
System.out.println("異常通知, 異常: " + e.getMessage());
}
public void doAfter() {
System.out.println("最終通知");
}
}
- XML配置AOP
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:aop="http://www.springframework.org/schema/aop"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/aop
http://www.springframework.org/schema/aop/spring-aop.xsd
http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context.xsd
">
<context:component-scan base-package="com.seven.springframeworkaopxml" />
<aop:aspectj-autoproxy/>
<!-- 目標類 -->
<bean id="demoService" class="com.seven.springframeworkaopxml.service.AopDemoServiceImpl">
<!-- configure properties of bean here as normal -->
</bean>
<!-- 切面 -->
<bean id="logAspect" class="com.seven.springframeworkaopxml.aspect.LogAspect">
<!-- configure properties of aspect here as normal -->
</bean>
<aop:config>
<!-- 配置切面 -->
<aop:aspect ref="logAspect">
<!-- 配置切入點 -->
<aop:pointcut id="pointCutMethod" expression="execution(* com.seven.springframeworkaopxml.service.*.*(..))"/>
<!-- 環繞通知 -->
<aop:around method="doAround" pointcut-ref="pointCutMethod"/>
<!-- 前置通知 -->
<aop:before method="doBefore" pointcut-ref="pointCutMethod"/>
<!-- 後置通知;returning屬性:用於設定後置通知的第二個引數的名稱,型別是Object -->
<aop:after-returning method="doAfterReturning" pointcut-ref="pointCutMethod" returning="result"/>
<!-- 異常通知:如果沒有異常,將不會執行增強;throwing屬性:用於設定通知第二個引數的的名稱、型別-->
<aop:after-throwing method="doAfterThrowing" pointcut-ref="pointCutMethod" throwing="e"/>
<!-- 最終通知 -->
<aop:after method="doAfter" pointcut-ref="pointCutMethod"/>
</aop:aspect>
</aop:config>
</beans>
- 測試類
public static void main(String[] args) {
// create and configure beans
ApplicationContext context = new ClassPathXmlApplicationContext("aspects.xml");
// retrieve configured instance
AopDemoServiceImpl service = context.getBean("demoService", AopDemoServiceImpl.class);
// use configured instance
service.doMethod1();
service.doMethod2();
try {
service.doMethod3();
} catch (Exception e) {
// e.printStackTrace();
}
}
基於AspectJ註解(直接寫表示式)
基於XML的宣告式AspectJ存在一些不足,需要在Spring配置檔案配置大量的程式碼資訊,為了解決這個問題,Spring 使用了@AspectJ框架為AOP的實現提供了一套註解。
註解名稱 | 解釋 |
---|---|
@Aspect | 用來定義一個切面。 |
@pointcut | 用於定義切入點表示式。在使用時還需要定義一個包含名字和任意引數的方法簽名來表示切入點名稱,這個方法簽名就是一個返回值為void,且方法體為空的普通方法。 |
@Before | 用於定義前置通知,相當於BeforeAdvice。在使用時,通常需要指定一個value屬性值,該屬性值用於指定一個切入點表示式(可以是已有的切入點,也可以直接定義切入點表示式)。 |
@AfterReturning | 用於定義後置通知,相當於AfterReturningAdvice。在使用時可以指定pointcut / value和returning屬性,其中pointcut / value這兩個屬性的作用一樣,都用於指定切入點表示式。 |
@Around | 用於定義環繞通知,相當於MethodInterceptor。在使用時需要指定一個value屬性,該屬性用於指定該通知被植入的切入點。 |
@After-Throwing | 用於定義異常通知來處理程式中未處理的異常,相當於ThrowAdvice。在使用時可指定pointcut / value和throwing屬性。其中pointcut/value用於指定切入點表示式,而throwing屬性值用於指定-一個形參名來表示Advice方法中可定義與此同名的形參,該形參可用於訪問目標方法丟擲的異常。 |
@After | 用於定義最終final 通知,不管是否異常,該通知都會執行。使用時需要指定一個value屬性,該屬性用於指定該通知被植入的切入點。 |
@DeclareParents | 用於定義引介通知,相當於IntroductionInterceptor (不要求掌握)。 |
基於JDK動態代理
基於JDK動態代理例子原始碼點這裡
- 定義介面
public interface IJdkProxyService {
void doMethod1();
String doMethod2();
String doMethod3() throws Exception;
}
- 實現類
@Service
public class JdkProxyDemoServiceImpl implements IJdkProxyService {
@Override
public void doMethod1() {
System.out.println("JdkProxyServiceImpl.doMethod1()");
}
@Override
public String doMethod2() {
System.out.println("JdkProxyServiceImpl.doMethod2()");
return "hello world";
}
@Override
public String doMethod3() throws Exception {
System.out.println("JdkProxyServiceImpl.doMethod3()");
throw new Exception("some exception");
}
}
- 定義切面
@EnableAspectJAutoProxy
@Component
@Aspect
public class LogAspect {
/**
* define point cut.
*/
@Pointcut("execution(* com.seven.springframeworkaopannojdk.service.*.*(..))")
private void pointCutMethod() {
}
/**
* 環繞通知.
*
* @param pjp pjp
* @return obj
* @throws Throwable exception
*/
@Around("pointCutMethod()")
public Object doAround(ProceedingJoinPoint pjp) throws Throwable {
System.out.println("-----------------------");
System.out.println("環繞通知: 進入方法");
Object o = pjp.proceed();
System.out.println("環繞通知: 退出方法");
return o;
}
/**
* 前置通知.
*/
@Before("pointCutMethod()")
public void doBefore() {
System.out.println("前置通知");
}
/**
* 後置通知.
*
* @param result return val
*/
@AfterReturning(pointcut = "pointCutMethod()", returning = "result")
public void doAfterReturning(String result) {
System.out.println("後置通知, 返回值: " + result);
}
/**
* 異常通知.
*
* @param e exception
*/
@AfterThrowing(pointcut = "pointCutMethod()", throwing = "e")
public void doAfterThrowing(Exception e) {
System.out.println("異常通知, 異常: " + e.getMessage());
}
/**
* 最終通知.
*/
@After("pointCutMethod()")
public void doAfter() {
System.out.println("最終通知");
}
}
- APP啟動
public class App {
public static void main(String[] args) {
// create and configure beans
ApplicationContext context = new AnnotationConfigApplicationContext("com.seven.springframeworkaopannojdk");
// retrieve configured instance
IJdkProxyService service = context.getBean(IJdkProxyService.class);
// use configured instance
service.doMethod1();
service.doMethod2();
try {
service.doMethod3();
} catch (Exception e) {
// e.printStackTrace();
}
}
}
非介面使用Cglib代理
基於Cglib代理例子原始碼點這裡
- 類定義
@Service
public class CglibProxyDemoServiceImpl {
public void doMethod1() {
System.out.println("CglibProxyDemoServiceImpl.doMethod1()");
}
public String doMethod2() {
System.out.println("CglibProxyDemoServiceImpl.doMethod2()");
return "hello world";
}
public String doMethod3() throws Exception {
System.out.println("CglibProxyDemoServiceImpl.doMethod3()");
throw new Exception("some exception");
}
}
- 切面定義
和上面相同
- APP啟動
public class App {
public static void main(String[] args) {
// create and configure beans
ApplicationContext context = new AnnotationConfigApplicationContext("com.seven.springframeworkaopannocglib");
// cglib proxy demo
CglibProxyDemoServiceImpl service = context.getBean(CglibProxyDemoServiceImpl.class);
service.doMethod1();
service.doMethod2();
try {
service.doMethod3();
} catch (Exception e) {
// e.printStackTrace();
}
}
}
使用註解裝配AOP
上面使用AspectJ的註解,並配合一個複雜的execution(* com.seven.springframeworkaopannojdk.service.*.*(..))
語法來定義應該如何裝配AOP。還有另一種方式,則是使用註解來裝配AOP,這兩者一般存在與不同的應用場景中:
- 對於業務開發來說,一般使用 註解的方式來裝配AOP,因為如果要使用AOP進行增強,業務開發就需要配置註解,業務能夠很好的感知到這個方法(這個類)進行了增強。如果使用 表示式來裝配AOP,當後續新增Bean,如果不清楚現有的AOP裝配規則,容易被強迫裝配,而在開發時未感知到,導致出現線上故障。例如,Spring提供的
@Transactional
就是一個非常好的例子。如果自己寫的Bean希望在一個資料庫事務中被呼叫,就標註上@Transactional
。 - 對於基礎架構開發來說,無需業務感知到增強了什麼方法,則可以使用表示式的方式來裝配AOP。需要記錄所有介面的耗時時長,直接寫表示式,對業務無侵入
- 定義註解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogAspectAnno {
}
- 修改切面類,使用註解的方式定義
@EnableAspectJAutoProxy
@Component
@Aspect
public class LogAspect {
@Around("@annotation(logaspectanno)") //注意,括號裡為logaspectanno,而不是LogAspectAnno
public Object doAround(ProceedingJoinPoint pjp, LogAspectAnno logaspectanno) throws Throwable {
System.out.println("-----------------------");
System.out.println("環繞通知: 進入方法");
Object o = pjp.proceed();
System.out.println("環繞通知: 退出方法");
return o;
}
}
- 修改實現類,這裡只對 doMethod1 方法裝配AOP
@Service
public class CglibProxyDemoServiceImpl {
@LogAspectAnno()
public void doMethod1() {
System.out.println("CglibProxyDemoServiceImpl.doMethod1()");
}
public String doMethod2() {
System.out.println("CglibProxyDemoServiceImpl.doMethod2()");
return "hello world";
}
}
@Service
public class JdkProxyDemoServiceImpl implements IJdkProxyService {
@LogAspectAnno
@Override
public void doMethod1() {
System.out.println("JdkProxyServiceImpl.doMethod1()");
}
@Override
public String doMethod2() {
System.out.println("JdkProxyServiceImpl.doMethod2()");
return "hello world";
}
}
- APP類
// create and configure beans
ApplicationContext context = new AnnotationConfigApplicationContext("com.seven.springframeworkaopannotation");
// cglib proxy demo
CglibProxyDemoServiceImpl service1 = context.getBean(CglibProxyDemoServiceImpl.class);
service1.doMethod1();
service1.doMethod2();
IJdkProxyService service2 = context.getBean(IJdkProxyService.class);
service2.doMethod1();
service2.doMethod2();
- 輸出:
-----------------------
環繞通知: 進入方法
CglibProxyDemoServiceImpl.doMethod1()
環繞通知: 退出方法
CglibProxyDemoServiceImpl.doMethod2()
-----------------------
環繞通知: 進入方法
JdkProxyServiceImpl.doMethod1()
環繞通知: 退出方法
JdkProxyServiceImpl.doMethod2()
可以看到,只有doMethod1方法被增強了,doMethod2沒有被增強,就是因為@LogAspectAnno 只註解了 doMethod1() 方法,從而實現更精細化的控制,是業務感知到這個方法是被增強了。
應用場景
我們知道AO能夠將那些與業務無關,卻為業務模組所共同呼叫的邏輯或責任(例如事務處理、日誌管理、許可權控制等)封裝起來,便於減少系統的重複程式碼,降低模組間的耦合度,提高系統可擴充性和可維護性。
- 基於 AOP 實現統一的日誌管理。
- 基於 Redisson + AOP 實現了介面防刷,一個註解即可限制介面指定時間內單個使用者可以請求的次數。
- 基於 Spring Security 提供的
@PreAuthorize
實現許可權控制,其底層也是基於 AOP。
日誌記錄
利用 AOP 方式記錄日誌,只需要在 Controller
的方法上使用自定義 @Log
日誌註解,就可以將使用者操作記錄到資料庫。
@Log(description = "新增使用者")
@PostMapping(value = "/users")
public ResponseEntity create(@Validated @RequestBody User resources){
checkLevel(resources);
return new ResponseEntity(userService.create(resources),HttpStatus.CREATED);
}
AOP 切面類 LogAspect
用來攔截帶有 @Log
註解的方法並處理:
@Aspect
@Component
public class LogAspect {
private static final Logger logger = LoggerFactory.getLogger(LogAspect.class);
// 定義切點,攔截帶有 @Log 註解的方法
@Pointcut("@annotation(com.example.annotation.Log)") // 這裡需要根據你的實際包名修改
public void logPointcut() {
}
// 環繞通知,用於記錄日誌
@Around("logPointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
//...
}
}
限流
利用 AOP 方式對介面進行限流,只需要在 Controller
的方法上使用自定義的 @RateLimit
限流注解即可。
/**
* 該介面 60 秒內最多隻能訪問 10 次,儲存到 redis 的鍵名為 limit_test,
*/
@RateLimit(key = "test", period = 60, count = 10, name = "testLimit", prefix = "limit")
public int test() {
return ATOMIC_INTEGER.incrementAndGet();
}
AOP 切面類 RateLimitAspect
用來攔截帶有 @RateLimit
註解的方法並處理:
@Slf4j
@Aspect
public class RateLimitAspect {
// 攔截所有帶有 @RateLimit 註解的方法
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
//...
}
}
關於限流實現這裡多說一句,這裡並沒有自己寫 Redis Lua 限流指令碼,而是利用 Redisson 中的 RRateLimiter
來實現分散式限流,其底層實現就是基於 Lua 程式碼+令牌桶演算法。
許可權控制
Spring Security 使用 AOP 進行方法攔截。在實際呼叫 update 方法之前,Spring 會檢查當前使用者的許可權,只有使用者許可權滿足對應的條件才能執行。
@Log(description = "修改選單")
@PutMapping(value = "/menus")
// 使用者擁有 `admin`、`menu:edit` 許可權中的任意一個就能能訪問`update`方法
@PreAuthorize("hasAnyRole('admin','menu:edit')")
public ResponseEntity update(@Validated @RequestBody Menu resources){
//...
}
面試題專欄
Java面試題專欄已上線,歡迎訪問。
- 如果你不知道簡歷怎麼寫,簡歷專案不知道怎麼包裝;
- 如果簡歷中有些內容你不知道該不該寫上去;
- 如果有些綜合性問題你不知道怎麼答;
那麼可以私信我,我會盡我所能幫助你。