前言

本文主要探讨基于 DSL(domain specific language) 之上的插件设计,他们是领域的附属,为领域提供额外的服务,但领域不依赖于他们。

1. 论述

领域应当尽可能地去专注他的核心业务规则,应当尽可能地与其他辅助性的代码解耦,一些通用的功能可以耦合进框架或者设计为中间件;但还存在有一些是与核心功能无关的,且又与业务逻辑密不可分,譬如特定的监控、特定的埋点、为领域定制的稳定性保障等,把他们定义为插件再合适不过,其依赖关系如前言所述。

2. 设计方案

暂不讨论特定的插件要实现哪些特定的能力,后续系列中将逐步展开构建一个完整的 DSL 具体需要哪些插件及其实现方案,这里我想展开思考的是怎样设计一个比较通用的 DSL 插件方案。

论述中对插件的定义与 AOP 的思想相当吻合,也当首选使用 AOP 来实现,但这其中还存在一个问题,我希望插件只专注其自身职责的表达,至于哪些节点需要接入哪些插件应当在 DSL 中配置(即我期望插件与 DSL 之间只存在配置关系),而配置应当支持动态更新,因此这就导致了 AOP 的代理对象事先是不确定的,需要去动态生成。

最后落到实现上,插件这块我需要去攻克两个核心技术点:

1、怎样去更新 AOP 的代理及切点表达式?

2、怎样去更新 IOC 容器?

3. 实现方案

3.1 配置入口

若不考虑动态更新,那么入口要实现的基本功能有两个:1、按需引入,这很简单,用一个 Conditional 即可;2、加载一个表达式类型的通知器,示例如下:

  1. @Configuration
  2. @ConditionalOnBean(DSL.class)
  3. public class PluginConfig {
  4. @Bean
  5. public AspectJExpressionPointcutAdvisor pluginAdvisor() {
  6. AspectJExpressionPointcutAdvisor advisor = new AspectJExpressionPointcutAdvisor();
  7. advisor.setExpression(DSL.getExpression());
  8. advisor.setAdvice(new PluginAdvice());
  9. return advisor;
  10. }
  11. }
  12. public class PluginAdvice implements MethodInterceptor {
  13. @Override
  14. public Object invoke(MethodInvocation invocation) throws Throwable {
  15. System.out.println("do plugin_work start...");
  16. Object resObj = invocation.proceed();
  17. System.out.println("do plugin_work end...");
  18. return resObj;
  19. }
  20. }

测试:

  1. @RunWith(SpringRunner.class)
  2. @SpringBootTest
  3. public class DefaultTest {
  4. @ExtensionNode
  5. private Engine engine;
  6. @Test
  7. public void test() {
  8. DslUtils.setDslA();
  9. engine.launch();
  10. }
  11. }

3.2 监听 DLS 变更

怎么监听配置的更新是所有的配置中心都需要去深入设计的(后续系列中探讨),此处暂用伪代码代替:

  1. @Configuration
  2. public class PluginListenerImpl implements DslListener {
  3. @Override
  4. public void refresh(DslContext dslContext) {
  5. // do something...
  6. }
  7. }

3.3 更新切点表达式

3.1 中我们已经注入了一个表达式通知器的 Bean:AspectJExpressionPointcutAdvisor,因此仅仅更新表达式的字符串非常简单,但查看查看源码会发现起匹配作用的是他的内部对象 AspectJExpressionPointcut,而他在首次执行匹配时会构建一个 PointcutExpression 并保存起来:

  1. private PointcutExpression obtainPointcutExpression() {
  2. if (getExpression() == null) {
  3. throw new IllegalStateException("Must set property 'expression' before attempting to match");
  4. }
  5. if (this.pointcutExpression == null) {
  6. this.pointcutClassLoader = determinePointcutClassLoader();
  7. this.pointcutExpression = buildPointcutExpression(this.pointcutClassLoader);
  8. }
  9. return this.pointcutExpression;
  10. }

因此我们还需要通过反射将这个私有字段置空,让 ClassFilter 重新执行构建,示例如下:

  1. @Configuration
  2. public class PluginListenerImpl implements DslListener {
  3. @Autowired
  4. private AspectJExpressionPointcutAdvisor aspectJExpressionPointcutAdvisor;
  5. @Override
  6. public void refresh(DslContext dslContext) throws NoSuchFieldException, IllegalAccessException {
  7. refreshExpression(dslContext);
  8. // next...
  9. }
  10. private void refreshExpression(DslContext dslContext) throws NoSuchFieldException, IllegalAccessException {
  11. aspectJExpressionPointcutAdvisor.setExpression(dslContext.getExpression());
  12. AspectJExpressionPointcut pointcut = (AspectJExpressionPointcut) aspectJExpressionPointcutAdvisor.getPointcut().getClassFilter();
  13. Field f = AspectJExpressionPointcut.class
  14. .getDeclaredField("pointcutExpression");
  15. f.setAccessible(true);
  16. f.set(pointcut, null);
  17. }
  18. }

3.3 更新动态代理

通过翻阅源码可得出 Spring AOP 主要通过:AbstractAdvisingBeanPostProcessor 、AbstractAutoProxyCreator 这两个 processor 来实现动态代理,其对应的实例为:MethodValidationPostProcessor、AnnotationAwareAspectJAutoProxyCreator,前者用于创建代理对象,后者用于标记切面(即织入代理)。由此,若我们需要去更新动态代理,我想到的最简单的方法就是对指定的节点重新执行以下这两个 processor(原理简单,就是一点点扣源码,麻烦...),其中还有一个小问题,和 3.2 中的一致,代理结果被缓存了,清空再执行即可,示例如下:


  1. @Autowired
  2. private DefaultListableBeanFactory defaultListableBeanFactory;
  3. private void refreshTypes(DslContext dslContext) throws NoSuchFieldException, IllegalAccessException {
  4. List<Class<?>> refreshTypes = dslContext.getRefreshTypes();
  5. for (Class<?> refreshType : refreshTypes) {
  6. String[] beanNames = defaultListableBeanFactory.getBeanNamesForType(refreshType);
  7. for (String beanName : beanNames) {
  8. Object bean = defaultListableBeanFactory.getBean(beanName);
  9. for (BeanPostProcessor processor : defaultListableBeanFactory.getBeanPostProcessors()) {
  10. bean = getProxyBean(bean, beanName, processor);
  11. }
  12. }
  13. }
  14. }
  15. private Object getProxyBean(Object bean, String beanName, BeanPostProcessor processor) throws NoSuchFieldException, IllegalAccessException {
  16. if (processor instanceof MethodValidationPostProcessor
  17. || processor instanceof AnnotationAwareAspectJAutoProxyCreator) {
  18. removeAdvisedBeanCache(processor, bean, beanName);
  19. Object current = processor.postProcessAfterInitialization(bean, beanName);
  20. return current == null ? bean : current;
  21. }
  22. return bean;
  23. }
  24. private void removeAdvisedBeanCache(BeanPostProcessor processor, Object bean, String beanName) throws NoSuchFieldException, IllegalAccessException {
  25. if (processor instanceof AnnotationAwareAspectJAutoProxyCreator) {
  26. AnnotationAwareAspectJAutoProxyCreator annotationAwareAspectJAutoProxyCreator = (AnnotationAwareAspectJAutoProxyCreator) processor;
  27. Field f = AnnotationAwareAspectJAutoProxyCreator.class
  28. .getSuperclass()
  29. .getSuperclass()
  30. .getSuperclass()
  31. .getDeclaredField("advisedBeans");
  32. f.setAccessible(true);
  33. Map<Object, Boolean> advisedBeans = (Map<Object, Boolean>) f.get(annotationAwareAspectJAutoProxyCreator);
  34. Object cacheKey = getCacheKey(bean.getClass(), beanName);
  35. advisedBeans.remove(cacheKey);
  36. }
  37. }
  38. private Object getCacheKey(Class<?> beanClass, @Nullable String beanName) {
  39. if (StringUtils.hasLength(beanName)) {
  40. return (FactoryBean.class.isAssignableFrom(beanClass) ?
  41. BeanFactory.FACTORY_BEAN_PREFIX + beanName : beanName);
  42. } else {
  43. return beanClass;
  44. }
  45. }

到此可以测试以下新生成的代理类:

  1. public class PluginTest {
  2. @Autowired
  3. private BEngine bEngine;
  4. @Autowired
  5. private DslListener dslListener;
  6. @Test
  7. public void test() throws NoSuchFieldException, IllegalAccessException {
  8. System.out.println("--------proxy before-----------");
  9. System.out.println("BEngine.class:" + bEngine.getClass());
  10. bEngine.launch();
  11. DslContext dslContext = new DslContext();
  12. // 初始值为 execution( void com.youclk.et.car.a.AEngine.launch() ),BEngine 并未被代理
  13. dslContext.setExpression("execution( void com.youclk.et.car.b.BEngine.launch() )");
  14. dslContext.setRefreshTypes(Collections.singletonList(BEngine.class));
  15. dslListener.refresh(dslContext);
  16. }
  17. }

结果如下:

通过这种方式更新可以不用担心多次刷新代理对象产生的副作用,因为最终变化的只是代理类所匹配切面通知而已。

3.4 更新 Spring Context

开码之前我一直认为这一步是难点,刷了一遍源码后发觉这一步异常简单(看源码还是很重要...)。DefaultListableBeanFactory 其实有提供 remove 和 register 方法用于更新 Bean,但是这两步的操作我认为太重了,而且在 remove 和 register 之间用到了这个 Bean 怎么办,因此存在极大风险。且看我们上一步做了什么,从 BeanDefinition 这个维度看我们只更新了 classType,其他的都没变,因此我考虑只要更新下 BeanDefinition,并清除对应的缓存即可,示例如下:

  1. private void refreshTypes(DslContext dslContext) throws NoSuchFieldException, IllegalAccessException {
  2. List<Class<?>> refreshTypes = dslContext.getRefreshTypes();
  3. for (Class<?> refreshType : refreshTypes) {
  4. String[] beanNames = defaultListableBeanFactory.getBeanNamesForType(refreshType);
  5. for (String beanName : beanNames) {
  6. Object bean = defaultListableBeanFactory.getBean(beanName);
  7. for (BeanPostProcessor processor : defaultListableBeanFactory.getBeanPostProcessors()) {
  8. bean = getProxyBean(bean, beanName, processor);
  9. }
  10. refreshBeanDefinition(beanName, bean.getClass());
  11. }
  12. }
  13. }
  14. private void refreshBeanDefinition(String beanName, Class<?> classType) throws NoSuchFieldException, IllegalAccessException {
  15. RootBeanDefinition rootBeanDefinition = (RootBeanDefinition) defaultListableBeanFactory.getMergedBeanDefinition(beanName);
  16. rootBeanDefinition.setBeanClass(classType);
  17. ScannedGenericBeanDefinition scannedGenericBeanDefinition = (ScannedGenericBeanDefinition) defaultListableBeanFactory.getBeanDefinition(beanName);
  18. scannedGenericBeanDefinition.setBeanClass(classType);
  19. removeBeanDefinitionCache(beanName);
  20. }
  21. private void removeBeanDefinitionCache(String beanName) throws NoSuchFieldException, IllegalAccessException {
  22. Field factoryBeanObjectCache_f = DefaultListableBeanFactory.class
  23. .getSuperclass()
  24. .getSuperclass()
  25. .getSuperclass()
  26. .getDeclaredField("factoryBeanObjectCache");
  27. factoryBeanObjectCache_f.setAccessible(true);
  28. Map<String, Object> factoryBeanObjectCache = (Map<String, Object>) factoryBeanObjectCache_f.get(defaultListableBeanFactory);
  29. factoryBeanObjectCache.remove(beanName);
  30. Field singletonObjects_f = DefaultListableBeanFactory.class
  31. .getSuperclass()
  32. .getSuperclass()
  33. .getSuperclass()
  34. .getSuperclass()
  35. .getDeclaredField("singletonObjects");
  36. singletonObjects_f.setAccessible(true);
  37. Map<String, Object> singletonObjects = (Map<String, Object>) singletonObjects_f.get(defaultListableBeanFactory);
  38. singletonObjects.remove(beanName);
  39. Field singletonFactories_f = DefaultListableBeanFactory.class
  40. .getSuperclass()
  41. .getSuperclass()
  42. .getSuperclass()
  43. .getSuperclass()
  44. .getDeclaredField("singletonFactories");
  45. singletonFactories_f.setAccessible(true);
  46. Map<String, Object> singletonFactories = (Map<String, Object>) singletonFactories_f.get(defaultListableBeanFactory);
  47. singletonFactories.remove(beanName);
  48. Field earlySingletonObjects_f = DefaultListableBeanFactory.class
  49. .getSuperclass()
  50. .getSuperclass()
  51. .getSuperclass()
  52. .getSuperclass()
  53. .getDeclaredField("earlySingletonObjects");
  54. earlySingletonObjects_f.setAccessible(true);
  55. Map<String, Object> earlySingletonObjects = (Map<String, Object>) earlySingletonObjects_f.get(defaultListableBeanFactory);
  56. earlySingletonObjects.remove(beanName);
  57. }

测试下是否完成了我的预期:

  1. @Autowired
  2. private ApplicationContext applicationContext;
  3. @Test
  4. public void testRefreshBeanDefinition() throws NoSuchFieldException, IllegalAccessException {
  5. System.out.println("--------refresh before-----------");
  6. System.out.println("BEngine.class:" + applicationContext.getBean(bEngine.getClass()).getClass());
  7. refresh();
  8. System.out.println("--------refresh after-----------");
  9. System.out.println("BEngine.class:" + applicationContext.getBean(bEngine.getClass()).getClass());
  10. }
  11. private void refresh() throws NoSuchFieldException, IllegalAccessException {
  12. DslContext dslContext = new DslContext();
  13. //初始值为 execution( void com.youclk.et.car.a.AEngine.launch() ),BEngine 并未被代理
  14. dslContext.setExpression("execution( void com.youclk.et.car.b.BEngine.launch() )");
  15. dslContext.setRefreshTypes(Collections.singletonList(BEngine.class));
  16. dslListener.refresh(dslContext);
  17. }

结果如下:

两次获取到的 classType 不同,说明更新成功。

3.5 更新 IOC 容器

这是最关键的一步,从操作数量上来看也是最重的一步,我们来回顾下,到此我们已经刷新了代理、刷新了切面通知、并将变更提交到了 Spring Context 中,我们还缺最后一步:更新目标对象所有的依赖注入。

因为我们需要将修改后的 Bean 重新注入所有依赖他的 Bean 中,这其中可能涉及到众多的修改操作,因此第一步我们要获取所有的依赖注入关系,他们维护在:AutowiredAnnotationBeanPostProcessor.injectionMetadataCache 中;由于一次提交可能涉及到多个目标对象的更新,他们之间又有存在依赖的可能性,因此第二步先把那一堆新的 bean 刷到 metadataCache,最后筛选出所有与更新相关的依赖,重新注入一遍,示例如下:

  1. private AutowiredAnnotationBeanPostProcessor autowiredAnnotationBeanPostProcessor;
  2. private void refreshTypes(DslContext dslContext) throws Exception {
  3. List<Class<?>> refreshTypes = dslContext.getRefreshTypes();
  4. HashMap<String, String> refreshBeans = new HashMap<>();
  5. for (Class<?> refreshType : refreshTypes) {
  6. String[] beanNames = defaultListableBeanFactory.getBeanNamesForType(refreshType);
  7. for (String beanName : beanNames) {
  8. Object bean = defaultListableBeanFactory.getBean(beanName);
  9. for (BeanPostProcessor processor : defaultListableBeanFactory.getBeanPostProcessors()) {
  10. if (processor instanceof AutowiredAnnotationBeanPostProcessor) {
  11. autowiredAnnotationBeanPostProcessor = (AutowiredAnnotationBeanPostProcessor) processor;
  12. continue;
  13. }
  14. bean = getProxyBean(bean, beanName, processor);
  15. }
  16. refreshBeanDefinition(beanName, bean.getClass());
  17. refreshBeans.put(beanName, getRealName(bean.getClass().getName()));
  18. }
  19. }
  20. refreshIoc(refreshBeans);
  21. }
  22. private void refreshIoc(HashMap<String, String> refreshBeans) throws Exception {
  23. for (String refreshBeanName : refreshBeans.keySet()) {
  24. resetInjectionMetadataCache(refreshBeanName);
  25. }
  26. Set<Object> beans = getReInjectionBeans(refreshBeans);
  27. for (Object bean : beans) {
  28. defaultListableBeanFactory.autowireBeanProperties(bean, 0, false);
  29. }
  30. }
  31. private void resetInjectionMetadataCache(String refreshBeanName) {
  32. autowiredAnnotationBeanPostProcessor.resetBeanDefinition(refreshBeanName);
  33. autowiredAnnotationBeanPostProcessor.determineCandidateConstructors(refreshBeanName.getClass(), refreshBeanName);
  34. RootBeanDefinition rootBeanDefinition = (RootBeanDefinition) defaultListableBeanFactory.getMergedBeanDefinition(refreshBeanName);
  35. Object bean = defaultListableBeanFactory.getBean(refreshBeanName);
  36. autowiredAnnotationBeanPostProcessor.postProcessMergedBeanDefinition(rootBeanDefinition, bean.getClass(), refreshBeanName);
  37. }
  38. private Set<Object> getReInjectionBeans(HashMap<String, String> refreshBeans) throws Exception {
  39. Field injectionMetadataCache_f = AutowiredAnnotationBeanPostProcessor.class.getDeclaredField("injectionMetadataCache");
  40. injectionMetadataCache_f.setAccessible(true);
  41. Map<String, InjectionMetadata> factoryBeanObjectCache = (Map<String, InjectionMetadata>) injectionMetadataCache_f.get(autowiredAnnotationBeanPostProcessor);
  42. Set<Object> injectedBeanNames = new HashSet<>();
  43. for (String beanName : factoryBeanObjectCache.keySet()) {
  44. Collection<InjectionMetadata.InjectedElement> injectedElements = getInjectedElements(factoryBeanObjectCache.get(beanName));
  45. if (injectedElements == null) {
  46. continue;
  47. }
  48. for (InjectionMetadata.InjectedElement injectedElement : injectedElements) {
  49. if (refreshBeans.values().contains(getRealName(getResourceType(injectedElement).getName()))) {
  50. injectedBeanNames.add(defaultListableBeanFactory.getBean(beanName));
  51. }
  52. }
  53. }
  54. return injectedBeanNames;
  55. }
  56. private Collection<InjectionMetadata.InjectedElement> getInjectedElements(InjectionMetadata injectionMetadata) throws Exception {
  57. Field injectedElements_f = InjectionMetadata.class.getDeclaredField("injectedElements");
  58. injectedElements_f.setAccessible(true);
  59. Collection<InjectionMetadata.InjectedElement> injectedElements = (Collection<InjectionMetadata.InjectedElement>) injectedElements_f.get(injectionMetadata);
  60. return injectedElements;
  61. }
  62. private Class<?> getResourceType(InjectionMetadata.InjectedElement injectedElement) throws Exception {
  63. Method getResourceType_m = InjectionMetadata.InjectedElement.class.getDeclaredMethod("getResourceType");
  64. getResourceType_m.setAccessible(true);
  65. return (Class<?>) getResourceType_m.invoke(injectedElement);
  66. }
  67. private String getRealName(String instanceName) {
  68. int index = instanceName.indexOf("$");
  69. if (index > 0) {
  70. instanceName = instanceName.substring(0, index);
  71. }
  72. return instanceName;
  73. }

最后再来测试一波:

  1. @Test
  2. public void test() throws Exception {
  3. bEngine.launch();
  4. refresh();
  5. bEngine.launch();
  6. }

正如预期效果:

结语

灵明无著,物来顺应,未来不迎,当下不杂,既过不恋~ 请关注公众号:

DSL 系列(2) - 插件的论述与实现的更多相关文章

  1. ThreeJS系列1_CinematicCameraJS插件详解

    ThreeJS系列1_CinematicCameraJS插件详解 接着上篇 ThreeJS系列1_CinematicCameraJS插件介绍 看属性的来龙去脉 看方法作用 通过调整属性查看效果 总结 ...

  2. ThreeJS系列2_effect插件集简介( 3d, vr等 )

    ThreeJS系列2_effect插件集简介( 3d, vr等 ) ThreeJS 官方案例中有一些 js库 可以替代 render 将场景中的物质变换为其他效果的物质 目录 ThreeJS系列2_e ...

  3. DSL 系列(1) - 扩展点的论述与实现

    前言 DSL 全称为 domain-specific language(领域特定语言),本系列应当会很长,其中包含些许不成熟的想法,欢迎私信指正. 1. DSL 简述 我理解的 DSL 的主要职能是 ...

  4. jenkins系列之插件配置(二)

    第一步:下面来安装nodejs插件 第二步:可以看到,Jenkins提供了丰富的插件供开发者使用,找到需要的[NodeJS Plugin],勾选后点击安装即可 我的是已经安装了 第三步: 安装完毕后, ...

  5. mybatis学习系列五--插件及类型处理器

    2 插件编写(80-81) 单个插件编写 2.1实现interceptor接口(ibatis) invocation.proceed()方法执行必须要有,否则不会无法实现拦截作用 2.2 使用@int ...

  6. 码云平台IDEA系列的插件使用

    一.IDEA插件安装 file -- setting --  Plugins -- 搜索gitee --  Search in repositories 安装后重启编译器 二.登录并拉取项目 file ...

  7. WorldWind源码剖析系列:插件列表视图类PluginListView和插件列表视图项类PluginListItem

    WorldWind中的插件类是个庞大的类,可以说从软件设计层面上统筹可扩展的插件体系的设计思想是WorldWind中的精华,值得学习和借鉴.插件体系中的所用到的类可以分为两大类,一类是插件类Plugi ...

  8. WorldWind源码剖析系列:插件类Plugin、插件信息类PluginInfo和插件编译器类PluginCompiler

    插件类Plugin是所有由插件编译器加载的插件子类的抽象父类,提供对插件的轻量级的访问控制功能. 插件信息类PluginInfo用来存储关于某个插件的信息的类,可以理解为对插件类Plugin类的进一步 ...

  9. mybatis generator插件系列--分页插件

    1.首先定义分页插件 MysqlPagePlugin.java package com.demo.mybatis.plugin; import org.mybatis.generator.api.Co ...

随机推荐

  1. codeforces 735C Tennis Championship(贪心+递推)

    Tennis Championship 题目链接:http://codeforces.com/problemset/problem/735/C ——每天在线,欢迎留言谈论. 题目大意: 给你一个 n ...

  2. PHP断言(ASSERT)的用法

    简述 编写代码时,我们总是会做出一些假设,断言就是用于在代码中捕捉这些假设,可以将断言看作是异常处理的一种高级形式.程序员断言在程序中的某个特定点该的表达式值为真.如果该表达式为假,就中断操作. 可以 ...

  3. mssql sqlserver 下文分享一种新颖的字符串截取方法

    原文地址:http://www.maomao365.com/?p=7307 摘要:    以前分割字符串时,都使用类似split函数的方式处理,下文分享一种对有规律的字符串的分隔方式,    即:1. ...

  4. linux 命令之netstat

    转自:http://www.maomao365.com/?p=699 linux 命令之netstat 在linux中netstat命令的作用是查看TCP/IP网络当前所开放端口,所对应的本地和外地端 ...

  5. 洗礼灵魂,修炼python(81)--全栈项目实战篇(9)—— 购物商城登录验证系统

    都在线购物过吧?那么你应该体验过,当没有登录账户时,点开购物车,个人中心,收藏物品等的操作时,都会直接跳转到登录账户的界面,然后如果登录一次后就不用再登录,直到用户登出. 是的,本次项目就是做一个登录 ...

  6. 信号量的基本概念与使用semget,semop

    1.信号量的基本概念 信号量是一个计数器,常用于处理进程或线程的同步问题,特别是对临界资源的同步访问. 临界资源可以简单的理解为在某一时刻只能由一个进程或线程进行操作的资源,这里的资源 可以是一段代码 ...

  7. 对于coursera上三门北大网课的评测

    今年暑假开始就选了coursera上三门北大的网课——C++程序设计.算法基础.数据结构基础,它们属于一个项目的,上的话每个月249块钱,项目里包括这三门一共有七门课.因为一开始是三门课同时上的,数据 ...

  8. ORM版学员管理系统3

    老师信息管理 思考 三种方式创建多对多外键方式及其优缺点. 通过外键创建 class Class(models.Model): id = models.AutoField(primary_key=Tr ...

  9. Teradata超长数据会截断

    1.数据库版本 Teradata 15.10 2.测试案例: create multiset table test_stg ( col1 ) CHARACTER SET LATIN not null ...

  10. dp Surf

    题目:https://vj.69fa.cn/1fc993e7e0e1e6fa7ce4640b8d46ef8d?v=1552762626 这个题目和尼克的任务这个题目很像,这个题目因为同一时刻具有选择性 ...