Java AOP 介绍:概念、原理与应用 – wiki基地


Java AOP 介绍:概念、原理与应用

引言

在软件开发的漫长旅程中,我们不断追求更高质量、更易维护、更灵活的代码。面向对象编程(OOP)无疑是这一过程中的一个里程碑,它通过封装、继承、多态等机制,极大地提高了代码的模块化和复用性。然而,随着企业级应用的日益复杂,开发者们发现,有些功能(例如日志记录、事务管理、安全控制、性能监控等)往往“散布”在系统的各个模块、各个方法中。这些功能并非业务逻辑的核心,但它们与业务逻辑紧密耦合,形成了所谓的“横切关注点”(Cross-Cutting Concerns)。

传统的 OOP 方式处理横切关注点时,常常会导致代码的重复(Violate DRY – Don’t Repeat Yourself 原则),使得业务逻辑与非业务逻辑混杂不清,降低了代码的可读性和可维护性。当需要修改这些横切关注点时(例如改变日志格式或切换事务管理器),开发者不得不修改大量分散的代码,极易出错,且效率低下。

正是在这样的背景下,面向切面编程(Aspect-Oriented Programming,AOP)应运而生。AOP 是一种补充 OOP 的编程范式,旨在通过将横切关注点从核心业务逻辑中分离出来,从而提高模块化程度。它允许开发者将这些分散的功能集中到一个地方进行管理,然后在程序执行的特定点“织入”这些功能,而无需修改原有的业务代码。本文将深入探讨 Java AOP 的概念、核心原理以及它在实际应用中的价值。

一、什么是 AOP?概念解析

AOP 的全称是 Aspect-Oriented Programming,即面向切面编程。它的核心思想是将那些横跨多个模块的通用功能(横切关注点)从业务逻辑中剥离出来,封装成一个独立的模块,称之为“切面”(Aspect)。然后,通过一种叫做“织入”(Weaving)的机制,在程序运行期间或编译期间,将这些切面的功能“织入”到目标代码的特定位置。

简单来说,AOP 就像是在程序的执行流程中设置了许多“拦截点”或“钩子”,当程序的执行到达这些预设的点时,AOP 框架就会自动执行预先定义好的切面逻辑。这使得开发者可以专注于编写核心业务代码,而将通用的、非核心的功能交给 AOP 来处理,从而实现业务逻辑与通用功能的解耦。

与 OOP 的主要区别在于:
* OOP 强调的是对现实世界事物的建模,通过对象(Object)来组织代码,关注的是“谁”(哪个对象)来执行操作。它解决的是如何在纵向(继承、组合)上对事物进行分解和组织。
* AOP 则关注的是程序中那些“横切”多个对象的行为。它解决的是如何在横向(跨越多个对象、多个方法)上对功能进行分解和组织。

AOP 并不是要取代 OOP,而是作为 OOP 的有力补充,帮助开发者更好地管理复杂系统中的通用功能,提升代码的模块化和可维护性。

二、AOP 的核心概念

理解 AOP 需要掌握一些关键术语:

  1. 横切关注点 (Cross-Cutting Concerns): 指那些遍布于应用程序多个模块中的功能,例如日志、事务、安全、性能监控等。它们不是应用程序的核心业务逻辑,但对系统的正常运行至关重要。AOP 的目标就是将这些关注点从核心业务逻辑中分离出来。

  2. 切面 (Aspect): 是横切关注点的模块化单元。它封装了需要在程序的特定连接点执行的逻辑(即通知)以及定义这些连接点的规则(即切入点)。在 Java 中,一个切面通常是一个类,使用特定的注解(如 Spring AOP 中的 @Aspect)进行标记。

  3. 连接点 (Join Point): 指程序执行过程中的某个特定点,例如方法调用、方法执行、字段设置或获取、异常处理等。在这些点上可以插入切面的逻辑。AOP 框架通常只支持特定类型的连接点,例如 Spring AOP 主要支持方法执行连接点。

  4. 切入点 (Pointcut): 是一个表达式,用于匹配一个或多个连接点。切入点定义了何处的(Where)通知应该被应用。它通过模式匹配的方式来选取符合条件的连接点。例如,一个切入点可以定义为“所有以 Service 结尾的类中的公共方法”。

  5. 通知 (Advice): 是在特定连接点执行的切面代码。它定义了做什么(What)以及何时(When)做。通知是切面的核心逻辑。根据执行的时机,通知可以分为多种类型:

    • 前置通知 (Before Advice): 在连接点执行之前执行的通知。
    • 后置通知 (After (finally) Advice): 在连接点无论如何(正常返回或抛出异常)执行完成后都执行的通知。
    • 返回通知 (After Returning Advice): 在连接点正常执行并返回结果之后执行的通知。
    • 异常通知 (After Throwing Advice): 在连接点抛出异常之后执行的通知。
    • 环绕通知 (Around Advice): 包围连接点的通知。它可以控制连接点是否执行,以及在连接点执行前、执行后或抛出异常时执行自定义逻辑。这是功能最强大、最灵活的通知类型。
  6. 目标对象 (Target Object): 指被一个或多个切面所通知的对象。它就是我们编写的包含业务逻辑的类。

  7. 代理 (Proxy): 在许多 AOP 框架(尤其是 Spring AOP)中,AOP 是通过创建目标对象的代理来实现的。代理对象包裹着目标对象,并在调用目标对象的方法时,根据切入点匹配情况,执行相应的通知逻辑。

  8. 织入 (Weaving): 是将切面应用到目标对象来创建新的代理对象的过程。这个过程可以在不同的时间发生:

    • 编译时织入 (Compile-time Weaving): 在源代码编译成字节码时进行。需要特殊的 AOP 编译器。
    • 类加载时织入 (Load-time Weaving – LTW): 在类加载到 JVM 时进行。需要特殊的 JVM 代理(Agent)。
    • 运行时织入 (Runtime Weaving): 在程序运行时进行。通常通过动态代理(如 JDK 动态代理或 CGLIB 代理)实现。Spring AOP 主要采用这种方式。

三、AOP 的原理:织入的机制

织入是 AOP 实现的关键。它决定了切面代码如何与目标代码结合。不同的 AOP 框架和织入方式有不同的原理。

1. 编译时织入

这是最早期的 AOP 织入方式之一,典型的代表是 AspectJ 的静态织入。
* 原理: AOP 框架提供一个特殊的编译器(如 AspectJ 的 ajc)。这个编译器在编译源代码时,会同时处理 .java 文件和 .aj(AspectJ 特有的切面文件)文件。它会在编译阶段直接修改目标类的字节码,将切面逻辑(通知)插入到切入点匹配的连接点位置。
* 优点: 性能开销最小,因为织入是在编译阶段完成的,运行时没有额外的处理。功能最强大,几乎可以拦截所有类型的连接点(方法调用、字段访问、构造器等)。
* 缺点: 需要使用特定的 AOP 编译器,或者在构建过程中集成 AOP 编译步骤,相对不那么方便。

2. 类加载时织入 (LTW)

  • 原理: 这种方式发生在 JVM 加载类文件到内存时。需要配置一个 JVM 代理(Agent),这个 Agent 在类加载的过程中拦截类的加载,并对类的字节码进行修改,插入切面逻辑。同样,LTW 通常也依赖于 AspectJ 的能力,但可以在标准 Java 编译器下工作。
  • 优点: 无需特殊编译器,可以使用标准的 javac。可以动态地修改已有的类文件。
  • 缺点: 需要 JVM 支持或配置 Agent,部署相对复杂一些。可能会对类加载性能产生一定影响。

3. 运行时织入

这是 Spring AOP 主要采用的方式,通常基于动态代理。
* 原理: AOP 框架(如 Spring)在应用程序运行时,为需要被增强(有切面应用到它上面)的目标对象动态地创建一个代理对象。当外部代码调用目标对象的方法时,实际上是调用了其代理对象的方法。代理对象在将请求转发给真正的目标对象之前或之后(或环绕目标方法),会根据切入点配置执行切面中定义的通知逻辑。
* JDK 动态代理: 如果目标对象实现了接口,Spring 会使用 JDK 的 java.lang.reflect.Proxy 类创建代理。代理对象实现了目标对象的所有接口。
* CGLIB 代理: 如果目标对象没有实现接口,Spring 会使用 CGLIB(Code Generation Library)库创建代理。CGLIB 通过继承目标类来创建代理类,是目标类的子类。
* 优点: 无需特殊编译或 JVM 配置,与标准的 Java 开发流程无缝集成。非常灵活,可以在运行时动态地应用或移除切面。
* 缺点: 只能代理方法调用连接点(Spring AOP 默认不支持字段访问、构造器等)。性能上可能会比编译时织入有轻微开销(因为有代理对象的转发)。只能对 Spring Bean 生效(这是 Spring AOP 的特性)。如果使用 CGLIB 代理,目标类不能是 final 的,被代理的方法也不能是 final 的。

Spring AOP 默认使用的是运行时织入,因为它与 Spring 容器的集成最方便,对开发者最友好。虽然 Spring AOP 可以集成 AspectJ 进行编译时或加载时织入,但运行时代理是更常见和默认的方式。

四、Spring AOP vs. AspectJ

在 Java 领域,最流行的 AOP 实现框架是 AspectJ 和 Spring AOP。虽然 Spring AOP 在内部使用了 AspectJ 的一些概念和表达式语法(比如切入点表达式),但它们在实现原理和能力上有所不同:

  • AspectJ:

    • 是 AOP 的先驱和事实上的标准。
    • 实现方式多样:支持编译时织入、加载时织入、运行时织入(通过 LTW 或特殊的 ClassLoader)。
    • 功能强大:支持更丰富的连接点类型(方法调用、方法执行、构造器、字段访问、异常处理等)。
    • 需要额外的编译器或 JVM 配置。
  • Spring AOP:

    • 是 Spring 框架的一部分,设计目标是简化企业级应用开发。
    • 默认实现方式:运行时织入,基于动态代理(JDK Proxy 或 CGLIB)。
    • 功能限制:主要支持方法执行连接点(Method Execution Join Points)在 Spring 管理的 Bean 上。不支持字段访问等连接点。
    • 易于使用:与 Spring 容器无缝集成,配置简单(基于注解或 XML)。
    • 性能考虑:运行时代理会有一些性能开销,但在大多数企业应用中可以忽略不计。

总结来说:

  • 如果你需要最强大的 AOP 功能,能够拦截各种类型的连接点,并且不介意额外的编译或加载时配置,那么 AspectJ 是更好的选择。
  • 如果你主要关注方法级别的拦截,只在 Spring 管理的 Bean 上应用 AOP,并且希望与 Spring 框架无缝集成,配置简单,那么 Spring AOP 是更实用、更常见的选择。

在实际开发中,许多开发者在使用 Spring 框架时,会选择使用 Spring AOP 提供的功能,并可能利用 AspectJ 的注解 @Aspect@Pointcut@Before 等来定义切面,因为 Spring AOP 能够理解和解析这些 AspectJ 注解,但底层的织入机制仍然是 Spring 的代理机制。这常常被称为 “Spring AOP using AspectJ annotations”。

五、AOP 的实际应用场景

AOP 在企业级应用开发中有着广泛的应用,以下是一些常见的场景:

  1. 日志记录 (Logging): 在方法执行前记录方法调用的信息(参数),在方法执行后记录返回结果或异常信息。通过 AOP,可以将日志逻辑从业务方法中完全剥离,集中管理。

  2. 事务管理 (Transaction Management): 这是 AOP 最经典的也是最重要的应用之一。Spring 的 @Transactional 注解就是通过 AOP 实现的。在一个被 @Transactional 标记的方法执行前,AOP 通知会开启一个事务;方法正常返回时,通知会提交事务;方法抛出异常时,通知会回滚事务。

  3. 安全控制 (Security): 在方法执行前检查用户是否有权限访问该方法。例如,可以使用 AOP 在进入某些敏感方法之前进行权限验证。Spring Security 也大量使用了 AOP。

  4. 缓存 (Caching): 在方法执行前检查缓存中是否存在结果,如果存在则直接返回缓存结果,无需执行目标方法;如果不存在,则执行目标方法并将结果放入缓存,再返回。Spring 的 @Cacheable 注解也是基于 AOP 实现的。

  5. 性能监控 (Performance Monitoring): 记录方法执行的开始和结束时间,计算方法的执行耗时。通过 AOP,可以非侵入式地监控应用程序的性能瓶颈。

  6. 异常处理 (Exception Handling): 在方法抛出特定异常时,通过 AOP 通知进行统一的异常处理,例如记录异常信息、发送告警邮件、转换异常类型等。

  7. 参数校验 (Parameter Validation): 在方法执行前对方法的输入参数进行校验。虽然有其他的参数校验框架,但 AOP 也可以用于实现自定义的参数校验逻辑。

这些应用场景都体现了 AOP 的核心价值:将与业务逻辑正交的、分散的功能集中管理,提高代码的模块化、可维护性和复用性。

六、Spring AOP 实践示例

下面我们通过一个简单的 Spring Boot 示例来演示如何使用 Spring AOP 实现日志记录功能。

项目依赖:

需要 Spring Boot Web 和 Spring AspectJ 的启动器依赖。

xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

spring-boot-starter-aop 依赖会自动引入 spring-aopaspectjweaver

启用 AOP:

在 Spring Boot 应用中,引入 spring-boot-starter-aop 后,通常无需额外配置即可启用基于 AspectJ 注解的 AOP。如果是非 Spring Boot 应用,需要在配置类上添加 @EnableAspectJAutoProxy 注解。

“`java
// Spring Boot application class (usually has @SpringBootApplication)
// No extra @EnableAspectJAutoProxy needed generally
@SpringBootApplication
public class AopDemoApplication {
public static void main(String[] args) {
SpringApplication.run(AopDemoApplication.class, args);
}
}

// Or in a regular Spring application config
// @Configuration
// @EnableAspectJAutoProxy // Needed if not using Spring Boot starter
// public class AppConfig {
// // … bean definitions
// }
“`

定义目标对象 (Service):

这是一个简单的业务服务类,它将是被切面增强的目标对象。

“`java
package com.example.aopdemo.service;

import org.springframework.stereotype.Service;

@Service
public class SimpleService {

public String doSomething(String name, int age) {
    System.out.println("Executing SimpleService.doSomething with name: " + name + ", age: " + age);
    // 模拟一些业务逻辑
    if (age < 18) {
        throw new IllegalArgumentException("Age must be 18 or older.");
    }
    return "Hello, " + name + "! You are " + age + " years old.";
}

public void doAnotherThing() {
    System.out.println("Executing SimpleService.doAnotherThing");
    // ... some other logic
}

}
“`

定义切面 (Aspect):

创建一个类,使用 @Aspect 注解标记它是一个切面。在切面类中,定义切入点和通知。

“`java
package com.example.aopdemo.aspect;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

@Aspect // 标记这是一个切面类
@Component // 让 Spring 扫描并管理这个切面
public class LoggingAspect {

private static final Logger logger = LoggerFactory.getLogger(LoggingAspect.class);

// 定义一个切入点,匹配 com.example.aopdemo.service 包下所有类的所有 public 方法
@Pointcut("execution(public * com.example.aopdemo.service.*.*(..))")
public void serviceMethods() {} // 切入点签名,方法体为空

// 前置通知:在匹配 serviceMethods 切入点的方法执行之前执行
@Before("serviceMethods()")
public void logBefore(JoinPoint joinPoint) {
    logger.info("=====>>> [Before] Executing method: {}", joinPoint.getSignature().toShortString());
    logger.info("=====>>> [Before] Arguments: {}", java.util.Arrays.toString(joinPoint.getArgs()));
}

// 后置通知:在匹配 serviceMethods 切入点的方法无论成功或失败执行完成后都执行
@After("serviceMethods()")
public void logAfter(JoinPoint joinPoint) {
    logger.info("=====>>> [After] Finished executing method: {}", joinPoint.getSignature().toShortString());
}

// 返回通知:在匹配 serviceMethods 切入点的方法成功执行并返回结果之后执行
@AfterReturning(pointcut = "serviceMethods()", returning = "result")
public void logAfterReturning(JoinPoint joinPoint, Object result) {
    logger.info("=====>>> [AfterReturning] Method: {} returned: {}", joinPoint.getSignature().toShortString(), result);
}

// 异常通知:在匹配 serviceMethods 切入点的方法抛出异常之后执行
@AfterThrowing(pointcut = "serviceMethods()", throwing = "exception")
public void logAfterThrowing(JoinPoint joinPoint, Throwable exception) {
    logger.error("=====>>> [AfterThrowing] Method: {} threw exception: {}", joinPoint.getSignature().toShortString(), exception.getMessage());
}

// 环绕通知:包围匹配 serviceMethods 切入点的方法执行
// 环绕通知功能强大,可以控制目标方法的执行,常用于性能监控、事务等
@Around("serviceMethods()")
public Object logAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
    long start = System.currentTimeMillis();
    logger.info("=====>>> [Around] Before method: {}", proceedingJoinPoint.getSignature().toShortString());
    Object result = null;
    try {
        // 执行目标方法
        result = proceedingJoinPoint.proceed();
        logger.info("=====>>> [Around] After method: {} with result: {}", proceedingJoinPoint.getSignature().toShortString(), result);
    } catch (Throwable e) {
        logger.error("=====>>> [Around] After method: {} threw exception: {}", proceedingJoinPoint.getSignature().toShortString(), e.getMessage());
        throw e; // 重新抛出异常
    } finally {
        long end = System.currentTimeMillis();
        logger.info("=====>>> [Around] Method: {} executed in {} ms", proceedingJoinPoint.getSignature().toShortString(), (end - start));
    }
    return result;
}

}
“`

测试:

创建一个简单的 Controller 或测试类来调用 SimpleService 的方法。

“`java
package com.example.aopdemo.controller;

import com.example.aopdemo.service.SimpleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class DemoController {

@Autowired
private SimpleService simpleService;

@GetMapping("/do")
public String doSomething(@RequestParam String name, @RequestParam int age) {
    try {
        return simpleService.doSomething(name, age);
    } catch (IllegalArgumentException e) {
        return "Error: " + e.getMessage();
    }
}

@GetMapping("/another")
public String doAnother() {
    simpleService.doAnotherThing();
    return "Done another thing";
}

}
“`

运行结果:

启动 Spring Boot 应用。访问 http://localhost:8080/do?name=Alice&age=20http://localhost:8080/another。观察控制台输出。你会看到不同通知类型的日志信息按照顺序输出,而 SimpleService 的业务逻辑代码并没有被修改。

例如,访问 /do?name=Alice&age=20 的输出大致会是:

... (其他Spring日志) ...
=====>>> [Around] Before method: SimpleService.doSomething(..)
=====>>> [Before] Executing method: SimpleService.doSomething(..)
=====>>> [Before] Arguments: [Alice, 20]
Executing SimpleService.doSomething with name: Alice, age: 20
=====>>> [Around] After method: SimpleService.doSomething(..) with result: Hello, Alice! You are 20 years old.
=====>>> [AfterReturning] Method: SimpleService.doSomething(..) returned: Hello, Alice! You are 20 years old.
=====>>> [After] Finished executing method: SimpleService.doSomething(..)
=====>>> [Around] Method: SimpleService.doSomething(..) executed in XX ms
...

如果访问 /do?name=Bob&age=15 (触发异常):

...
=====>>> [Around] Before method: SimpleService.doSomething(..)
=====>>> [Before] Executing method: SimpleService.doSomething(..)
=====>>> [Before] Arguments: [Bob, 15]
Executing SimpleService.doSomething with name: Bob, age: 15
=====>>> [Around] After method: SimpleService.doSomething(..) threw exception: Age must be 18 or older.
=====>>> [AfterThrowing] Method: SimpleService.doSomething(..) threw exception: Age must be 18 or older.
=====>>> [After] Finished executing method: SimpleService.doSomething(..)
=====>>> [Around] Method: SimpleService.doSomething(..) executed in XX ms
...

这个例子清晰地展示了 AOP 如何在不修改业务代码的情况下,在方法的不同执行阶段插入额外的逻辑。

七、使用 AOP 的优点与缺点

优点:

  1. 提高模块化程度: 将横切关注点从业务逻辑中分离,使代码结构更清晰,各模块职责更单一。
  2. 增强可维护性: 横切关注点的逻辑集中管理,修改时只需修改切面,无需改动大量业务代码。
  3. 增强可重用性: 同一个切面可以在不同的连接点重复使用。
  4. 减少重复代码 (DRY): 避免将相同的非业务代码复制粘贴到各个地方。
  5. 提高开发效率: 开发者可以专注于核心业务逻辑。
  6. 非侵入性: 通常无需修改目标类的源代码即可应用切面。

缺点:

  1. 增加学习成本: AOP 的概念、原理和配置相对复杂,需要一定的学习曲线。
  2. 增加调试难度: 程序的执行流程变得不那么直观,因为有隐藏的切面逻辑在执行,调试时需要考虑切面的影响。
  3. 潜在的性能开销: 尤其是运行时织入,会引入代理对象和额外的调用栈,虽然通常可以忽略不计,但在对性能要求极致的场景下需要评估。
  4. 可能导致问题定位困难: 当出现问题时,可能是业务代码的问题,也可能是切面代码的问题,或者切面与业务代码交互的问题。
  5. 抽象泄漏 (Abstraction Leakage): 在某些情况下,业务开发者可能需要了解一些 AOP 的底层细节或切面逻辑,以便理解为什么某个方法会表现出额外的行为。

尽管存在一些缺点,但在合理的场景下使用 AOP,其带来的模块化、可维护性等好处通常远远大于其缺点,特别是在大型企业级应用中。

八、使用 AOP 的最佳实践

为了更好地使用 AOP,可以遵循一些最佳实践:

  1. 切面应保持精简和单一职责: 每个切面最好只关注一个横切关注点(如日志、事务或安全)。避免在一个切面中混合处理多种不相关的逻辑。
  2. 切入点应准确和具体: 编写精确的切入点表达式,只匹配你真正想要增强的连接点,避免意外地影响其他代码。
  3. 谨慎使用环绕通知: 环绕通知功能强大但也最复杂,因为它完全控制了目标方法的执行。除非需要控制目标方法的调用(如性能监控计时、缓存逻辑、事务边界),否则优先使用其他类型的通知(前置、后置、返回、异常),它们更简单且不易出错。
  4. 文档化你的切面: 清晰地说明每个切面的作用、它匹配的切入点以及通知执行的逻辑,方便其他开发者理解和维护。
  5. 注意切面的顺序: 当多个切面都匹配同一个连接点时,它们的执行顺序可能很重要。Spring AOP 允许通过 @Order 注解或 Ordered 接口来控制切面的执行顺序。
  6. 理解你的 AOP 实现: 熟悉你使用的 AOP 框架(如 Spring AOP 或 AspectJ)的原理和限制,特别是运行时织入对目标类和方法的限制。
  7. 测试你的切面: 和业务代码一样,切面逻辑也需要进行充分的测试,确保它们在期望的连接点正确执行,并且不会引入副作用。

九、结论

AOP 作为一种强大的编程范式,为处理系统中的横切关注点提供了优雅的解决方案。通过将日志、事务、安全等通用功能从核心业务逻辑中分离出来,AOP 显著提高了代码的模块化、可维护性和可重用性。

Java 生态系统中最主流的 AOP 实现是 AspectJ 和 Spring AOP。Spring AOP 凭借其与 Spring 框架的紧密集成和易用性,成为大多数 Java 企业级应用中实现 AOP 的首选,尽管它主要依赖运行时代理且功能范围相对受限。

理解 AOP 的核心概念(切面、连接点、切入点、通知、织入)及其不同实现原理,是有效应用 AOP 的基础。在实际开发中,合理地利用 AOP,并遵循一些最佳实践,可以极大地简化代码结构,提高开发效率和系统质量。AOP 与 OOP 相辅相成,共同构建出更加健壮、灵活和易于管理的现代软件系统。


发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部