zoukankan      html  css  js  c++  java
  • 接口方法上的注解无法被@Aspect声明的切面拦截的原因分析

    前言

    在Spring中使用MyBatis的Mapper接口自动生成时,用一个自定义的注解标记在Mapper接口的方法中,再利用@Aspect定义一个切面,拦截这个注解以记录日志或者执行时长。

    但是惊奇的发现这样做之后,在Spring Boot 1.X(Spring Framework 4.x)中,并不能生效,而在Spring Boot 2.X(Spring Framework 5.X)中却能生效。

    这究竟是为什么呢?Spring做了哪些更新产生了这样的变化?此文将带领你探索这个秘密。

    案例

    核心代码

    @SpringBootApplication
    public class Starter {
      public static void main(String[] args) {
        SpringApplication.run(DynamicApplication.class, args);
      }
    }
    
    @Service
    public class DemoService {
    
        @Autowired
        DemoMapper demoMapper;
    
        public List<Map<String, Object>> selectAll() {
            return demoMapper.selectAll();
        }
    }
    
    /**
     * mapper类
     */
    @Mapper
    public interface DemoMapper {
    
      @Select("SELECT * FROM demo")
      @Demo
      List<Map<String, Object>> selectAll();
    
    }
    
    /**
     * 切入的注解
     */
    @Target({ElementType.METHOD})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface Demo {
      String value() default "";
    }
    
    /**
     * aspect切面,用于测试是否成功切入
     */
    @Aspect
    @Order(-10)
    @Component
    public class DemoAspect {
    
      @Before("@annotation(demo)")
      public void beforeDemo(JoinPoint point, Demo demo) {
        System.out.println("before demo");
      }
    
      @AfterDemo("@annotation(demo)")
      public void afterDemo(JoinPoint point, Demo demo) {
        System.out.println("after demo");
      }
    
    }

    测试类

    @RunWith(SpringRunner.class) 
    @SpringBootTest(classes = Starter.class)
    public class BaseTest {
    
        @Autowired
        DemoService demoService;
    
        @Test
        public void testDemo() {
            demoService.selectAll();
        }
    
    }

    在Spring Boot 1.X中,@Aspect里的两个println都没有正常打印,而在Spring Boot 2.X中,都打印了出来。

    调试研究

    已知@Aspect注解声明的拦截器,会自动切入符合其拦截条件的Bean。这个功能是通过@EnableAspectJAutoProxy注解来启用和配置的(默认是启用的,通过AopAutoConfiguration),由@EnableAspectJAutoProxy中的@Import(AspectJAutoProxyRegistrar.class)可知,@Aspect相关注解自动切入的依赖是AnnotationAwareAspectJAutoProxyCreator这个BeanPostProcessor。在

    这个类的postProcessAfterInitialization方法中打上条件断点:beanName.equals("demoMapper")

    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        if (bean != null) {
            // 缓存中尝试获取,没有则尝试包装
            Object cacheKey = getCacheKey(bean.getClass(), beanName);
            if (!this.earlyProxyReferences.contains(cacheKey)) {
                return wrapIfNecessary(bean, beanName, cacheKey);
            }
        }
        return bean;
    }

    在wrapIfNecessary方法中,有自动包装Proxy的逻辑:

    protected Object wrapIfNecessary(Object bean, String beanName, Object cacheKey) {
        // 如果是声明的需要原始Bean,则直接返回
        if (beanName != null && this.targetSourcedBeans.contains(beanName)) {
            return bean;
        }
        // 如果不需要代理,则直接返回
        if (Boolean.FALSE.equals(this.advisedBeans.get(cacheKey))) {
            return bean;
        }
        // 如果是Proxy的基础组件如Advice、Pointcut、Advisor、AopInfrastructureBean则跳过
        if (isInfrastructureClass(bean.getClass()) || shouldSkip(bean.getClass(), beanName)) {
            this.advisedBeans.put(cacheKey, Boolean.FALSE);
            return bean;
        }
    
        // Create proxy if we have advice.
        // 根据相关条件,查找interceptor,包括@Aspect生成的相关Interceptor。
        // 这里是问题的关键点,Spring Boot 1.X中这里返回为空,而Spring Boot 2.X中,则不是空
        Object[] specificInterceptors = getAdvicesAndAdvisorsForBean(bean.getClass(), beanName, null);
        if (specificInterceptors != DO_NOT_PROXY) {
            // 返回不是null,则需要代理
            this.advisedBeans.put(cacheKey, Boolean.TRUE);
            // 放入缓存
            Object proxy = createProxy(
                    bean.getClass(), beanName, specificInterceptors, new SingletonTargetSource(bean));
            // 自动生成代理实例
            this.proxyTypes.put(cacheKey, proxy.getClass());
            return proxy;
        }
    
        this.advisedBeans.put(cacheKey, Boolean.FALSE);
        return bean;
    }

    调试发现,Spring Boot 1.X中specificInterceptors返回为空,而Spring Boot 2.X中则不是空,那么这里就是问题的核心点了,查看源码:

    protected Object[] getAdvicesAndAdvisorsForBean(Class<?> beanClass, String beanName, TargetSource targetSource) {
        List<Advisor> advisors = findEligibleAdvisors(beanClass, beanName);
        if (advisors.isEmpty()) {
            // 如果是空,则不代理
            return DO_NOT_PROXY;
        }
        return advisors.toArray();
    }
    protected List<Advisor> findEligibleAdvisors(Class<?> beanClass, String beanName) {
        // 找到当前BeanFactory中的Advisor
        List<Advisor> candidateAdvisors = findCandidateAdvisors();
        // 遍历Advisor,根据Advisor中的PointCut判断,返回所有合适的Advisor
        List<Advisor> eligibleAdvisors = findAdvisorsThatCanApply(candidateAdvisors, beanClass, beanName);
        // 扩展advisor列表,这里会默认加入一个ExposeInvocationInterceptor用于暴露动态代理对象,之前文章有解释过
        extendAdvisors(eligibleAdvisors);
        if (!eligibleAdvisors.isEmpty()) {
            // 根据@Order或者接口Ordered排序
            eligibleAdvisors = sortAdvisors(eligibleAdvisors);
        }
        return eligibleAdvisors;
    }
    protected List<Advisor> findAdvisorsThatCanApply(
            List<Advisor> candidateAdvisors, Class<?> beanClass, String beanName) {
        ProxyCreationContext.setCurrentProxiedBeanName(beanName);
        try {
            // 真正的查找方法    
            return AopUtils.findAdvisorsThatCanApply(candidateAdvisors, beanClass);
        }
        finally {
            ProxyCreationContext.setCurrentProxiedBeanName(null);
        }
    }

    这里的核心问题在于AopUtils.findAdvisorsThatCanApply方法,这里的返回在两个版本是不一样的,由于这里代码过多就不贴上来了,说明下核心问题代码是这段:

    // AopProxyUtils.java
    public static List<Advisor> findAdvisorsThatCanApply(List<Advisor> candidateAdvisors, Class<?> clazz) {
        // ... 省略
        for (Advisor candidate : candidateAdvisors) {
            if (canApply(candidate, clazz, hasIntroductions)) {
                eligibleAdvisors.add(candidate);
            }
        }
        // ... 省略
    }
    public static boolean canApply(Advisor advisor, Class<?> targetClass, boolean hasIntroductions) {
        if (advisor instanceof IntroductionAdvisor) {
            return ((IntroductionAdvisor) advisor).getClassFilter().matches(targetClass);
        }
        else if (advisor instanceof PointcutAdvisor) {
            // 对于@Aspect的切面,是这段代码在生效
            PointcutAdvisor pca = (PointcutAdvisor) advisor;
            return canApply(pca.getPointcut(), targetClass, hasIntroductions);
        }
        else {
            // It doesn't have a pointcut so we assume it applies.
            return true;
        }
    }

    基本定位了问题点,看下最终调用的canApply方法。

    1.Spring Boot 1.X中源码,即Spring AOP 4.X中源码

    /**
     * targetClass是com.sun.proxy.$Proxy??即JDK动态代理生成的类
     * hasIntroductions是false,先不管
     */
    public static boolean canApply(Pointcut pc, Class<?> targetClass, boolean hasIntroductions) {
        Assert.notNull(pc, "Pointcut must not be null");
        // 先判断class,这里两个版本都为true
        if (!pc.getClassFilter().matches(targetClass)) {
            return false;
        }
    
        MethodMatcher methodMatcher = pc.getMethodMatcher();
        // 如果method是固定true,即拦截所有method,则返回true。这里当然为false
        if (methodMatcher == MethodMatcher.TRUE) {
            // No need to iterate the methods if we're matching any method anyway...
            return true;
        }
    
        // 特殊类型,做下转换,Aspect生成的属于这个类型
        IntroductionAwareMethodMatcher introductionAwareMethodMatcher = null;
        if (methodMatcher instanceof IntroductionAwareMethodMatcher) {
            introductionAwareMethodMatcher = (IntroductionAwareMethodMatcher) methodMatcher;
        }
    
        // 取到目标class的所有接口
        Set<Class<?>> classes = new LinkedHashSet<Class<?>>(ClassUtils.getAllInterfacesForClassAsSet(targetClass));
        // 再把目标calss加入遍历列表
        classes.add(targetClass);
        for (Class<?> clazz : classes) {
            Method[] methods = ReflectionUtils.getAllDeclaredMethods(clazz);
            // 遍历每个类的每个方法,尝试判断是否match
            for (Method method : methods) {
                if ((introductionAwareMethodMatcher != null &&
                        introductionAwareMethodMatcher.matches(method, targetClass, hasIntroductions)) ||
                        methodMatcher.matches(method, targetClass)) {
                    return true;
                }
            }
        }
    
        return false;
    }

    2.Spring Boot 2.X中源码,即Spring AOP 5.X中源码

     

    public static boolean canApply(Pointcut pc, Class<?> targetClass, boolean hasIntroductions) {
        Assert.notNull(pc, "Pointcut must not be null");
        if (!pc.getClassFilter().matches(targetClass)) {
            return false;
        }
    
        MethodMatcher methodMatcher = pc.getMethodMatcher();
        if (methodMatcher == MethodMatcher.TRUE) {
            // No need to iterate the methods if we're matching any method anyway...
            return true;
        }
    
        IntroductionAwareMethodMatcher introductionAwareMethodMatcher = null;
        if (methodMatcher instanceof IntroductionAwareMethodMatcher) {
            introductionAwareMethodMatcher = (IntroductionAwareMethodMatcher) methodMatcher;
        }
    
        Set<Class<?>> classes = new LinkedHashSet<>();
        // 这里与1.X版本不同,使用Jdk动态代理Proxy,先判断是否是Proxy,如果不是则加入用户Class,即被动态代理的class,以便查找真正的Class中是否符合判断条件
        // 因为动态代理可能只把被代理类的方法实现了,被代理类的注解之类的没有复制到生成的子类中,故要使用原始的类进行判断
        // JDK动态代理一样不会为动态代理生成类上加入接口的注解
        // 如果是JDK动态代理,不需要把动态代理生成的类方法遍历列表中,因为实现的接口中真实的被代理接口。
        if (!Proxy.isProxyClass(targetClass)) {
            classes.add(ClassUtils.getUserClass(targetClass));
        }
        classes.addAll(ClassUtils.getAllInterfacesForClassAsSet(targetClass));
    
        for (Class<?> clazz : classes) {
            Method[] methods = ReflectionUtils.getAllDeclaredMethods(clazz);
            for (Method method : methods) {
                // 比1.X版本少遍历了Proxy生成的动态代理类,但是遍历内容都包含了真实的接口,其实是相同的,为什么结果不一样呢?
                if ((introductionAwareMethodMatcher != null &&
                        introductionAwareMethodMatcher.matches(method, targetClass, hasIntroductions)) ||
                        methodMatcher.matches(method, targetClass)) {
                    return true;
                }
            }
        }
    
        return false;
    }

    调试信息图

     上面的代码执行结果不同,但是区别只是少个动态代理生成的类进行遍历,为什么少一个遍历内容结果却是true呢?

    肯定是introductionAwareMethodMatcher或者methodMatcher的逻辑有改动,其中methodMatcher和introductionAwareMethodMatcher是同一个对象,两个方法逻辑相同。

    看代码:

    /** AspectJExpressionPointcut.java
     * method是上面接口中遍历的方法,targetClass是目标class,即生成的动态代理class
     */
    public boolean matches(Method method, @Nullable Class<?> targetClass, boolean beanHasIntroductions) {
        obtainPointcutExpression();
        Method targetMethod = AopUtils.getMostSpecificMethod(method, targetClass);
        ShadowMatch shadowMatch = getShadowMatch(targetMethod, method);
    
        // Special handling for this, target, @this, @target, @annotation
        // in Spring - we can optimize since we know we have exactly this class,
        // and there will never be matching subclass at runtime.
        if (shadowMatch.alwaysMatches()) {
            return true;
        }
        else if (shadowMatch.neverMatches()) {
            return false;
        }
        else {
            // the maybe case
            if (beanHasIntroductions) {
                return true;
            }
            // A match test returned maybe - if there are any subtype sensitive variables
            // involved in the test (this, target, at_this, at_target, at_annotation) then
            // we say this is not a match as in Spring there will never be a different
            // runtime subtype.
            RuntimeTestWalker walker = getRuntimeTestWalker(shadowMatch);
            return (!walker.testsSubtypeSensitiveVars() ||
                    (targetClass != null && walker.testTargetInstanceOfResidue(targetClass)));
        }
    }

    这段代码在Spring Boot 1.X和2.X中基本是相同的,但是在AopUtils.getMostSpecificMethod(method, targetClass);这一句的执行结果上,两者是不同的,1.X返回的是动态代理生成的Class中重写的接口中的方法,2.X返回的是原始接口中的方法。

    而在动态代理生成的Class中重写的接口方法里,是不会包含接口中的注解信息的,所以Aspect中条件使用注解在这里是拿不到匹配信息的,所以返回了false。

    而在2.X中,因为返回的是原始接口的方法,故可以成功匹配。

    问题就在于AopUtils.getMostSpecificMethod(method, targetClass)的逻辑:

    // 1.X
    public static Method getMostSpecificMethod(Method method, Class<?> targetClass) {
        // 这里返回了targetClass上的重写的method方法。
        Method resolvedMethod = ClassUtils.getMostSpecificMethod(method, targetClass);
        // If we are dealing with method with generic parameters, find the original method.
        return BridgeMethodResolver.findBridgedMethod(resolvedMethod);
    }
    
    // 2.X
    public static Method getMostSpecificMethod(Method method, @Nullable Class<?> targetClass) {
        // 比1.X多了个逻辑判断,如果是JDK的Proxy,则specificTargetClass为null,否则取被代理的Class。
        Class<?> specificTargetClass = (targetClass != null && !Proxy.isProxyClass(targetClass) ?
                ClassUtils.getUserClass(targetClass) : null);
        // 如果specificTargetClass为空,直接返回原始method。
        // 如果不为空,返回被代理的Class上的方法
        Method resolvedMethod = ClassUtils.getMostSpecificMethod(method, specificTargetClass);
        // If we are dealing with method with generic parameters, find the original method.
        // 获取真实桥接的方法,泛型支持
        return BridgeMethodResolver.findBridgedMethod(resolvedMethod);
    }

    至此原因已经完全明了,Spring在AOP的5.X版本修复了这个问题。

    影响范围

    原因已经查明,那么根据原因我们推算一下影响范围

    • Bean是接口动态代理对象时,且该动态代理对象不是Spring体系生成的,接口中的切面注解无法被拦截

    • Bean是CGLIB动态代理对象时,该动态代理对象不是Spring体系生成的,原始类方法上的切面注解无法被拦截。

    • 可能也影响基于类名和方法名的拦截体系,因为生成的动态代理类路径和类名是不同的。

    如果是Spring体系生成的,之前拿到的都是真实类或者接口,只有在生成动态代理后,才是新的类。所以在创建动态代理时,获取的是真实的类。

    接口动态代理多见于ORM框架的Mapper、RPC框架的SPI等,所以在这两种情况下使用注解要尤为小心。

    有些同学比较关心@Cacheable注解,放在Mapper中是否生效。答案是生效,因为@Cacheable注解中使用的不是@Aspect的PointCut,而是CacheOperationSourcePointcut,其中虽然也使用了getMostSpecificMethod来获取method,但是最终其实又从原始方法上尝试获取了注解:

    // AbstractFallbackCacheOperationSource.computeCacheOperations
    if (specificMethod != method) {
        //  Fallback is to look at the original method
        opDef = findCacheOperations(method);
        if (opDef != null) {
            return opDef;
        }
        // Last fallback is the class of the original method.
        opDef = findCacheOperations(method.getDeclaringClass());
        if (opDef != null && ClassUtils.isUserLevelMethod(method)) {
            return opDef;
        }
    }

    看似不受影响,其实是做了兼容。

    可以参考后面的内容,有提到Spring相关的issue

    解决方案

    如何解决这个问题呢?答案是在Spring Boot 1.X中没有解决方案。。因为这个类太基础了,除非切换版本。

    使用其他Aspect表达式也可以解决此问题,使用注解方式在1.X版本是无解的。

    表达式参考如下链接:

    https://blog.csdn.net/zhengchao1991/article/details/53391244
    https://blog.csdn.net/lang_niu/article/details/51559994

    本来以为在注解Demo中加入@Inherited可解决的,结果发现不行,因为这个@Inherited只在类注解有效,在接口中或者方法上,都是不能被子类或者实现类继承的,看这个@Inherited上面的注释

    /**
     * Indicates that an annotation type is automatically inherited.  If
     * an Inherited meta-annotation is present on an annotation type
     * declaration, and the user queries the annotation type on a class
     * declaration, and the class declaration has no annotation for this type,
     * then the class's superclass will automatically be queried for the
     * annotation type.  This process will be repeated until an annotation for this
     * type is found, or the top of the class hierarchy (Object)
     * is reached.  If no superclass has an annotation for this type, then
     * the query will indicate that the class in question has no such annotation.
     *
     * <p>Note that this meta-annotation type has no effect if the annotated
     * type is used to annotate anything other than a class.  Note also
     * that this meta-annotation only causes annotations to be inherited
     * from superclasses; annotations on implemented interfaces have no
     * effect.
     * 上面这句话说明了只在父类上的注解可被继承,接口上的都是无效的
     *
     * @author  Joshua Bloch
     * @since 1.5
     */
    @Documented
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.ANNOTATION_TYPE)
    public @interface Inherited {
    }

    扩展阅读

    问题及可能的影响范围已经详细分析完了,下面我们好奇一下,这个核心问题类AopUtils.java的提交记录中,作者有写什么吗

    AopUtils.java类GitHub页面

    https://github.com/spring-projects/spring-framework/blob/master/spring-aop/src/main/java/org/springframework/aop/support/AopUtils.java

    查看这个类的历史记录,注意Commits on Apr 3, 2018这个日期的提交,其中提到:

    Consistent treatment of proxy classes and interfaces for introspection
    
    Issue: SPR-16675
    Issue: SPR-16677

    针对proxy classes做了内省配置,相关issue是SPR-16677,我们看下这个issue。

    https://jira.spring.io/browse/SPR-16677

    这个issue详细描述了这次提交的原因及目的。

    读者感兴趣的话可以详细的阅读。

    注意AopUtils.java的最新提交,又做了一些优化,可以研究一下。

    扩展知识

    上面的示例代码依赖于数据库,现做一个模拟Mapper类的改进,可以直接无任何依赖的重现该问题:

    已知Mybatis的Mapper接口是通过JDK动态代理生成的逻辑,而Mapper接口相关的Bean生成,是通过AutoConfiguredMapperScannerRegistrar自动注册到BeanFactory中的,注册进去的是MapperFactoryBean这个工厂Bean类型。

    而MapperFactoryBean的getObject方法,则是通过getSqlSession().getMapper(this.mapperInterface)生成的,mapperInterfact是mapper接口。

    底层是通过Configuration.getMapper生成的,再底层是mapperRegistry.getMapper方法,代码如下

    public <T> T getMapper(Class<T> type, SqlSession sqlSession) {
        final MapperProxyFactory<T> mapperProxyFactory = (MapperProxyFactory<T>) knownMappers.get(type);
        if (mapperProxyFactory == null) {
            throw new BindingException("Type " + type + " is not known to the MapperRegistry.");
        }
        try {
            // 调用下面的方法生成代理实例
            return mapperProxyFactory.newInstance(sqlSession);
        } catch (Exception e) {
            throw new BindingException("Error getting mapper instance. Cause: " + e, e);
        }
    }
    public T newInstance(SqlSession sqlSession) {
        // 创建MapperProxy这个InvocationHandler实例
        final MapperProxy<T> mapperProxy = new MapperProxy<T>(sqlSession, mapperInterface, methodCache);
        return newInstance(mapperProxy);
    }
    protected T newInstance(MapperProxy<T> mapperProxy) {
        // 调用jdk动态代理生成实例,代理的InvocationHandler是MapperProxy
        return (T) Proxy.newProxyInstance(mapperInterface.getClassLoader(), new Class[] { mapperInterface }, mapperProxy);
    }

    可以看到底层是通过JDK动态代理Proxy生成的,InvocationHandler是MapperProxy类。

    清楚原理之后,我们对上面的实例做下改造,把Mybatis的引用简化。

    @Configuration
    public class DemoConfiguraion {
    
        @Bean
        public FactoryBean<DemoMapper> getDemoMapper() {
            return new FactoryBean<DemoMapper>() {
                @Override
                public DemoMapper getObject() throws Exception {
                    InvocationHandler invocationHandler = (proxy, method, args) -> {
                        System.out.println("调用动态代理方法" + method.getName());
                        return Collections.singletonList(new HashMap<String, Object>());
                    };
                    return (DemoMapper) Proxy.newProxyInstance(this.getClass().getClassLoader(), new Class[] {DemoMapper.class}, invocationHandler);
                }
                @Override
                public Class<?> getObjectType() {
                    return DemoMapper.class;
                }
                @Override
                public boolean isSingleton() {
                    return true;
                }
            };
        }
    }

     

    public static boolean canApply(Pointcut pc, Class<?> targetClass, boolean hasIntroductions) {
        Assert.notNull(pc, "Pointcut must not be null");
        if (!pc.getClassFilter().matches(targetClass)) {
            return false;
        }

        MethodMatcher methodMatcher = pc.getMethodMatcher();
        if (methodMatcher == MethodMatcher.TRUE) {
            // No need to iterate the methods if we're matching any method anyway...
            return true;
        }

        IntroductionAwareMethodMatcher introductionAwareMethodMatcher = null;
        if (methodMatcher instanceof IntroductionAwareMethodMatcher) {
            introductionAwareMethodMatcher = (IntroductionAwareMethodMatcher) methodMatcher;
        }

        Set<Class<?>> classes = new LinkedHashSet<>();
        // 这里与1.X版本不同,使用Jdk动态代理Proxy,先判断是否是Proxy,如果不是则加入用户Class,即被动态代理的class,以便查找真正的Class中是否符合判断条件
        // 因为动态代理可能只把被代理类的方法实现了,被代理类的注解之类的没有复制到生成的子类中,故要使用原始的类进行判断
        // JDK动态代理一样不会为动态代理生成类上加入接口的注解
        // 如果是JDK动态代理,不需要把动态代理生成的类方法遍历列表中,因为实现的接口中真实的被代理接口。
        if (!Proxy.isProxyClass(targetClass)) {
            classes.add(ClassUtils.getUserClass(targetClass));
        }
        classes.addAll(ClassUtils.getAllInterfacesForClassAsSet(targetClass));

        for (Class<?> clazz : classes) {
            Method[] methods = ReflectionUtils.getAllDeclaredMethods(clazz);
            for (Method method : methods) {
                // 比1.X版本少遍历了Proxy生成的动态代理类,但是遍历内容都包含了真实的接口,其实是相同的,为什么结果不一样呢?
                if ((introductionAwareMethodMatcher != null &&
                        introductionAwareMethodMatcher.matches(method, targetClass, hasIntroductions)) ||
                        methodMatcher.matches(method, targetClass)) {
                    return true;
                }
            }
        }

        return false;
    }

  • 相关阅读:
    web.xml文件中元素的作用
    spring获取webapplicationcontext,applicationcontext几种方法详解
    web.xml文件中的Listener元素的作用
    IT旅途——程序员面试经验分享
    软件工程师的十个“不职业”行为
    雷军系好生意:小米,不是一家公司在战斗!
    tomcat 5.0 linux 配置
    ubuntu tomcat6.0 JAVA 变量
    初创企业的初创之路
    架构设计和包图
  • 原文地址:https://www.cnblogs.com/sxw123/p/14067084.html
Copyright © 2011-2022 走看看