模拟spring框架,深入讲解spring的对象的创建

导读

项目源码地址

因为公司使用的是spring框架,spring是什么?它就像包罗万象的容器,我们什么都可以往里面填,比如集合持久层的hibernate或mybatis框架,类似于拦截器的的shiro框架等等。

它的好处是可以自动创建对象。以前,在没有使用spring框架时,我们必须自己创建对象。但自从有了spring框架后,Java开发就像迎来了春天,一切都变的那么简单。

它有几种自动创建对象的方式,比如构造器创建对象,set创建对象。。。如果想要对其有更多的了解,那么,现在有很多博客,都对其做了详细的介绍。我在这里不必再做详解了。

本项目使用了logback和slf4j记录日志信息,因为它们两个是经常合作的。同时,也使用了lombok框架,这个框架可以自动生成set、get、toString、equals、hashcode方法等。

下面,便详细介绍我的这个项目。


设计模式

本项目采用工厂和建造者设计模式。

  • 工厂设计模式用来加载配置文件。

在没有使用注解的前提下,我们把所有的将要创建对象的信息写进配置文件中,这就是我们常说的依赖注入。而当代码加载时,需要加载这些配置文件。

这里需要两个类来支撑。一个是XmlConfigBean,记录每个xml文件中的bean信息。XmlBeanProperty这里记录每个bean中的属性信息。

加载文件方法中调用了这两个类,当然,我是用了org下的jdom来读取xml文件,正如以下代码所示。

/**
 * Created By zby on 22:57 2019/3/4
 * 加载配置文件
 *
 * @param dirPath 目录的路径
 */
public static LoadConfig loadXmlConFig(String dirPath) {
    if (StringUtils.isEmpty(dirPath)){
        throw new RuntimeException("路径不存在");
    }
    if (null == config) {
        File dir = new File(dirPath);
        List<File> files = FactoryBuilder.createFileFactory().listFile(dir);
        if (CollectionUtil.isEmpty(files)) {
            throw new RuntimeException("没有配置文件files=" + files);
        }
        allXmls = new HashMap<>();
        SAXBuilder saxBuilder = new SAXBuilder();
        Document document = null;
        for (File file : files) {
            try {
                Map<String, XmlConfigBean> beanMaps = new HashMap<>();
                //创建配置文件
                String configFileName = file.getName();
                document = saxBuilder.build(file);
                Element rootEle = document.getRootElement();
                List beans = rootEle.getChildren("bean");
                if (CollectionUtil.isNotEmpty(beans)) {
                    int i = 0;
                    for (Iterator beanIterator = beans.iterator(); beanIterator.hasNext(); i++) {
                        Element bean = (Element) beanIterator.next();
                        XmlConfigBean configBean = new XmlConfigBean();
                        configBean.setId(attributeToConfigBeanProps(file, i, bean, "id"));
                        configBean.setClazz(attributeToConfigBeanProps(file, i, bean, "class"));
                        configBean.setAutowire(attributeToConfigBeanProps(file, i, bean, "autowire"));
                        configBean.setConfigFileName(configFileName);
                        List properties = bean.getChildren();
                        Set<XmlBeanProperty> beanProperties = new LinkedHashSet<>();
                        if (CollectionUtil.isNotEmpty(properties)) {
                            int j = 0;
                            for (Iterator propertyIterator = properties.iterator(); propertyIterator.hasNext(); j++) {
                                Element property = (Element) propertyIterator.next();
                                XmlBeanProperty beanProperty = new XmlBeanProperty();
                                beanProperty.setName(attributeToBeanProperty(file, i, j, property, "name"));
                                beanProperty.setRef(attributeToBeanProperty(file, i, j, property, "ref"));
                                beanProperty.setValue(attributeToBeanProperty(file, i, j, property, "value"));
                                beanProperties.add(beanProperty);
                            }
                            configBean.setProperties(beanProperties);
                        }
                        beanMaps.put(configBean.getId(), configBean);
                    }
                }
                allXmls.put(configFileName, beanMaps);
            } catch (JDOMException e) {
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return new LoadConfig();
    }
    return config;
}

上面使用到了文件工厂设计模式,内部使用深度递归算法。如果初始目录下,仍旧有子目录,调用自身的方法,直到遇见文件,如代码所示:

/**
 * Created By zby on 14:04 2019/2/14
 * 获取文件的集合
 */
private void local(File dir) {
    if (dir == null) {
        logger.error("文件夹为空dir=" + dir);
        throw new RuntimeException("文件夹为空dir=" + dir);
    }
    File[] fies = dir.listFiles();
    if (ArrayUtil.isNotEmpty(fies)) {
        for (File fy : fies) {
            if (fy.isDirectory()) {
                local(fy);
            }
            String fileName = fy.getName();
            boolean isMatch = Pattern.compile(reg).matcher(fileName).matches();
            boolean isContains = ArrayUtil.containsAny(fileName, FilterConstants.FILE_NAMES);
            if (isMatch && !isContains) {
                fileList.add(fy);
            }
        }
    }
}
  • 建造者设计模式

这里用来修饰类信息的。比如,将类名的首字母转化为小写;通过类路径转化为类字面常量,如代码所示:

/**
 * Created By zby on 20:19 2019/2/16
 * 通过类路径转为类字面常量
 *
 * @param classPath 类路径
 */
public static <T> Class<T> classPathToClazz(String classPath) {
    if (StringUtils.isBlank(classPath)) {
        throw new RuntimeException("类路径不存在");
    }
    try {
        return (Class<T>) Class.forName(classPath);
    } catch (ClassNotFoundException e) {
        logger.error("路径" + classPath + "不存在,创建失败e=" + e);
        e.printStackTrace();
    }
    return null;
}

类型转换器

如果不是用户自定义的类型,我们需要使用类型转化器,将配置文件的数据转化为我们Javabean属性的值。因为,从配置文件读取过来的值,都是字符串类型的,加入Javabean的id为long型,因而,我们需要这个类型转换。

/**
 * Created By zby on 22:31 2019/2/25
 * 将bean文件中的value值转化为属性值
 */

public final class Transfomer {

    public final static Integer MAX_BYTE = 127;

    public final static Integer MIN_BYTE = -128;

    public final static Integer MAX_SHORT = 32767;

    public final static Integer MIN_SHORT = -32768;

    public final static String STR_TRUE = "true";

    public final static String STR_FALSE = "false";

    /**
     * Created By zby on 22:32 2019/2/25
     * 数据转化
     *
     * @param typeName 属性类型的名字
     * @param value    值
     */
    public static Object transformerPropertyValue(String typeName, Object value) throws IllegalAccessException {
        if (StringUtils.isBlank(typeName)) {
            throw new RuntimeException("属性的类型不能为空typeName+" + typeName);
        }
        if (typeName.equals(StandardBasicTypes.STRING)) {
            return objToString(value);
        } else if (typeName.equalsIgnoreCase(StandardBasicTypes.LONG)) {
            return stringToLong(objToString(value));
        } else if (typeName.equals(StandardBasicTypes.INTEGER) || typeName.equals(StandardBasicTypes.INT)) {
            return stringToInt(objToString(value));
        } else if (typeName.equalsIgnoreCase(StandardBasicTypes.BYTE)) {
            return stringToByte(objToString(value));
        } else if (typeName.equalsIgnoreCase(StandardBasicTypes.SHORT)) {
            return stringToShort(objToString(value));
        } else if (typeName.equalsIgnoreCase(StandardBasicTypes.BOOLEAN)) {
            return stringToBoolean(objToString(value));
        } else if (typeName.equalsIgnoreCase(StandardBasicTypes.DOUBLE)) {
            return stringToDouble(objToString(value));
        } else if (typeName.equalsIgnoreCase(StandardBasicTypes.FLOAT)) {
            return stringToFloat(objToString(value));
        } else if (typeName.equals(StandardBasicTypes.DATE)) {
            return stringToDate(objToString(value));
        } else if (typeName.equals(StandardBasicTypes.BIG_DECIMAL)) {
            return stringToBigDecimal(objToString(value));
        } else {
            return value;
        }
    }


    /**
     * Created By zby on 22:32 2019/2/25
     * 数据转化
     */
    public static void transformerPropertyValue(Object currentObj, Field field, Object value) throws IllegalAccessException {
        if (null == currentObj && field == null) {
            throw new RuntimeException("当前对象或属性为空值");
        }
        String typeName = field.getType().getSimpleName();
        field.setAccessible(true);
        field.set(currentObj, transformerPropertyValue(typeName, value));
    }

    /**
     * Created By zby on 23:29 2019/2/25
     * obj to String
     */
    public static String objToString(Object obj) {
        return null == obj ? null : obj.toString();
    }

    /**
     * Created By zby on 23:54 2019/2/25
     * String to integer
     */
    public static Integer stringToInt(String val) {
        if (StringUtils.isBlank(val)) {
            return 0;
        }
        if (val.charAt(0) == 0) {
            throw new RuntimeException("字符串转为整形失败val=" + val);
        }
        return Integer.valueOf(val);
    }

    /**
     * Created By zby on 23:31 2019/2/25
     * String to Long
     */
    public static Long stringToLong(String val) {
        return Long.valueOf(stringToInt(val));
    }

    /**
     * Created By zby on 23:52 2019/2/26
     * String to byte
     */
    public static Short stringToShort(String val) {
        Integer result = stringToInt(val);
        if (result >= MIN_SHORT && result <= MAX_SHORT) {
            return Short.valueOf(result.toString());
        }
        throw new RuntimeException("数据转化失败result=" + result);
    }

    /**
     * Created By zby on 0:03 2019/2/27
     * String to short
     */
    public static Byte stringToByte(String val) {
        Integer result = stringToInt(val);
        if (result >= MIN_BYTE && result <= MAX_BYTE) {
            return Byte.valueOf(result.toString());
        }
        throw new RuntimeException("数据转化失败result=" + result);
    }

    /**
     * Created By zby on 0:20 2019/2/27
     * string to double
     */
    public static Double stringToDouble(String val) {
        if (StringUtils.isBlank(val)) {
            throw new RuntimeException("数据为空,转换失败");
        }
        return Double.valueOf(val);
    }

    /**
     * Created By zby on 0:23 2019/2/27
     * string to float
     */
    public static Float stringToFloat(String val) {
        if (StringUtils.isBlank(val)) {
            throw new RuntimeException("数据为空,转换失败");
        }
        return Float.valueOf(val);
    }

    /**
     * Created By zby on 0:19 2019/2/27
     * string to boolean
     */
    public static boolean stringToBoolean(String val) {
        if (StringUtils.isBlank(val)) {
            throw new RuntimeException("数据为空,转换失败val=" + val);
        }
        if (val.equals(STR_TRUE)) {
            return true;
        }
        if (val.equals(STR_FALSE)) {
            return false;
        }
        byte result = stringToByte(val);
        if (0 == result) {
            return false;
        }
        if (1 == result) {
            return true;
        }
        throw new RuntimeException("数据转换失败val=" + val);
    }


    /**
     * Created By zby on 0:24 2019/2/27
     * string to Date
     */
    public static Date stringToDate(String val) {
        if (StringUtils.isBlank(val)) {
            throw new RuntimeException("数据为空,转换失败val=" + val);
        }
        SimpleDateFormat format = new SimpleDateFormat();
        try {
            return format.parse(val);
        } catch (ParseException e) {
            throw new RuntimeException("字符串转为时间失败val=" + val);
        }
    }

    /**
     * Created By zby on 0:31 2019/2/27
     * string to big decimal
     */
    public static BigDecimal stringToBigDecimal(String val) {
        if (StringUtils.isBlank(val)) {
            throw new RuntimeException("数据为空,转换失败val=" + val);
        }
        return new BigDecimal(stringToDouble(val));
    }

}

常量类型

  • 自动装配类型
/**
 * Created By zby on 13:50 2019/2/23
 * 装配类型
 */
public class AutowireType {
    
    /**
     * 缺省情况向,一般通过ref来自动(手动)装配对象
     */
    public static final String NONE = null;

    /**
     * 根据属性名事项自动装配,
     * 如果一个bean的名称和其他bean属性的名称是一样的,将会自装配它。
     */
    public static final String BY_NAME = "byName";

    /**
     * 根据类型来装配
     * 如果一个bean的数据类型是用其它bean属性的数据类型,兼容并自动装配它。
     */
    public static final String BY_TYPE = "byType";

    /**
     * 根据构造器constructor创建对象
     */
    public static final String CONSTRUCTOR = "constructor";

    /**
     * autodetect – 如果找到默认的构造函数,使用“自动装配用构造”; 否则,使用“按类型自动装配”。
     */
    public static final String AUTODETECT = "autodetect";
    
}
  • 属性类型常量池
/**
 * Created By zby on 22:44 2019/2/25
 * 类型常量池
 */
public class StandardBasicTypes {

    public static final String STRING = "String";

    public static final String LONG = "Long";

    public static final String INTEGER = "Integer";

    public static final String INT = "int";

    public static final String BYTE = "Byte";

    public static final String SHORT = "Short";

    public static final String BOOLEAN = "Boolean";

    public static final String DOUBLE = "double";

    public static final String FLOAT = "float";

    public static final String DATE = "Date";

    public static final String TIMESTAMP = "Timestamp";

    public static final String BIG_DECIMAL = "BigDecimal";

    public static final String BIG_INTEGER = "BigInteger";

}

getBean加载上下文文件

首先需要一个构造器,形参时文件的名字;getBean方法,形参是某个bean的id名字,这样,根据当前bean的自动装配类型,来调用相关的方法。

/**
 * Created By zby on 11:17 2019/2/14
 * 类的上下文加载顺序
 */
public class ClassPathXmlApplicationContext {

private static Logger logger = LoggerFactory.getLogger(ClassPathXmlApplicationContext.class.getName());

private String configXml;

public ClassPathXmlApplicationContext(String configXml) {
    this.configXml = configXml;
}

/**
 * Created By zby on 18:38 2019/2/24
 * bean对应的id的名称
 */
public Object getBean(String name) {
    String dirPath="../simulaspring/src/main/resources/";
    Map<String, Map<String, XmlConfigBean>> allXmls = LoadConfig.loadXmlConFig(dirPath).getAllXmls();
    boolean contaninsKey = MapUtil.findKey(allXmls, configXml);
    if (!contaninsKey) {
        throw new RuntimeException("配置文件不存在" + configXml);
    }
    Map<String, XmlConfigBean> beans = allXmls.get(configXml);
    contaninsKey = MapUtil.findKey(beans, name);
    if (!contaninsKey) {
        throw new RuntimeException("id为" + name + "bean不存在");
    }
    XmlConfigBean configFile = beans.get(name);
    if (null == configFile) {
        throw new RuntimeException("id为" + name + "bean不存在");
    }
    String classPath = configFile.getClazz();
    if (StringUtils.isBlank(classPath)) {
        throw new RuntimeException("id为" + name + "类型不存在");
    }
    String autowire = configFile.getAutowire();
    if (StringUtils.isBlank(autowire)) {
        return getBeanWithoutArgs(beans, classPath, configFile);
    } else {
        switch (autowire) {
            case AutowireType.BY_NAME:
                return getBeanByName(beans, classPath, configFile);
            case AutowireType.CONSTRUCTOR:
                return getBeanByConstruct(classPath, configFile);
            case AutowireType.AUTODETECT:
                return getByAutodetect(beans, classPath, configFile);
            case AutowireType.BY_TYPE:
                return getByType(beans, classPath, configFile);
        }
    }
    return null;
  }
}

下面主要讲解默认自动装配、属性自动装配、构造器自动装配

默认自动装配

如果我们没有填写自动装配的类型,其就采用ref来自动(手动)装配对象。

/**
 * Created By zby on 18:33 2019/2/24
 * 在没有设置自动装配时,通过ref对象
 */
private Object getBeanWithoutArgs(Map<String, XmlConfigBean> beans, String classPath, XmlConfigBean configFile) {
//属性名称
String proName = null;
try {
    Class currentClass = Class.forName(classPath);
    //通过引用 ref 创建对象
    Set<XmlBeanProperty> properties = configFile.getProperties();
    //如果没有属性,就返回,便于下面的递归操作
    if (CollectionUtil.isEmpty(properties)) {
        return currentClass.newInstance();
    }
    Class<?> superClass = currentClass.getSuperclass();
    //TODO 父类的集合
//            List<Class> superClasses = null;
    //在创建子类构造器之前,创建父类构造器,
    // 父类构造器的参数子类构造器的参数
    Object currentObj = null;
    //当前构造器
    Object consArgsObj = null;
    String consArgsName = null;
    boolean hasSuperClass = (null != superClass && !superClass.getSimpleName().equals("Object"));
    if (hasSuperClass) {
        Constructor[] constructors = currentClass.getDeclaredConstructors();
        ArrayUtil.validateArray(superClass, constructors);
        Parameter[] parameters = constructors[0].getParameters();
        if (parameters == null || parameters.length == 0) {
            consArgsObj = constructors[0].newInstance();
        } else {
            ArrayUtil.validateArray(superClass, parameters);
            consArgsName = parameters[0].getType().getSimpleName();
            //配置文件大类型,与参数构造器的类型是否相同
            for (XmlBeanProperty property : properties) {
                String ref = property.getRef();
                if (StringUtils.isNotBlank(ref) && ref.equalsIgnoreCase(consArgsName)) {
                    classPath = beans.get(ref).getClazz();
                    Class<?> clazz = Class.forName(classPath);
                    consArgsObj = clazz.newInstance();
                }
            }
            currentObj = constructors[0].newInstance(consArgsObj);
        }
    } else {
        currentObj = currentClass.newInstance();
    }
    for (XmlBeanProperty property : properties) {
        //这里适合用递归,无限调用自身
        //通过name找到属性,配置文件中是否有该属性,通过ref找到其对应的bean文件
        proName = property.getName();
        Field field = currentClass.getDeclaredField(proName);
        if (null != field) {
            String ref = property.getRef();
            Object value = property.getValue();
            //如果没有赋初值,就通过类型创建
            if (null == value && StringUtils.isNotBlank(ref)) {
                boolean flag = StringUtils.isNotBlank(consArgsName) && null != consArgsObj && consArgsName.equalsIgnoreCase(ref);
                //递归调用获取属性对象
                value = flag ? consArgsObj : getBean(ref);
            }
            field.setAccessible(true);
            Transfomer.transformerPropertyValue(currentObj, field, value);
        }
    }
    return currentObj;
} catch (ClassNotFoundException e) {
    logger.error("名为" + classPath + "类不存在");
    e.printStackTrace();
} catch (InstantiationException e) {
    e.printStackTrace();
} catch (IllegalAccessException e) {
    e.printStackTrace();
} catch (InvocationTargetException e) {
    e.printStackTrace();
} catch (NoSuchFieldException e) {
    logger.error(classPath + "类的属性" + proName + "不存在");
    throw new RuntimeException(classPath + "类的属性" + proName + "不存在");
}
return null;
}

构造器创建对象

根据构造器constructor创建对象

/**
 * Created By zby on 23:06 2019/3/2
 *
 * @param classPath  类路径
 * @param configFile 配置文件
 */
private Object getBeanByConstruct(String classPath, XmlConfigBean configFile) {
    try {
        Class currentClass = Class.forName(classPath);
        Set<XmlBeanProperty> properties = configFile.getProperties();
        if (CollectionUtil.isEmpty(properties)) {
            return currentClass.newInstance();
        }
        ///构造器参数类型和构造器对象集合
        Object[] objects = new Object[properties.size()];
        Class<?>[] paramType = new Class[properties.size()];
        Field[] fields = currentClass.getDeclaredFields();
        int i = 0;
        for (Iterator iterator = properties.iterator(); iterator.hasNext(); i++) {
            XmlBeanProperty property = (XmlBeanProperty) iterator.next();
            String proName = property.getName();
            String ref = property.getRef();
            Object value = property.getValue();
            for (Field field : fields) {
                Class<?> type = field.getType();
                String typeName = type.getSimpleName();
                String paramName = field.getName();
                if (paramName.equals(proName) && ObjectUtil.isNotNull(value) && StringUtils.isBlank(ref)) {
                    objects[i] = Transfomer.transformerPropertyValue(typeName, value);
                    paramType[i] = type;
                    break;
                } else if (paramName.equals(proName) && StringUtils.isNotBlank(ref) && ObjectUtil.isNull(value)) {
                    objects[i] = getBean(ref);
                    paramType[i] = type;
                    break;
                }
            }
        }
        return currentClass.getConstructor(paramType).newInstance(objects);
    } catch (ClassNotFoundException e) {
        logger.error("名为" + classPath + "类不存在");
        e.printStackTrace();
    } catch (InstantiationException e) {
        e.printStackTrace();
    } catch (IllegalAccessException e) {
        e.printStackTrace();
    } catch (InvocationTargetException e) {
        e.printStackTrace();
    } catch (NoSuchMethodException e) {
        e.printStackTrace();
    }
    return null;
}

属性自动装配

根据属性名事项自动装配,如果一个bean的名称和其他bean属性的名称是一样的,将会自装配它。

/**
 * Created By zby on 21:16 2019/3/1
 * 根据属性名事项自动装配,
 * @param classPath 类路径
 * @param configFile  配置文件
 */
private Object getBeanByName( String classPath, XmlConfigBean configFile) {
    String proName = null;
    try {
        Class currentClass = Class.forName(classPath);
        Class superclass = currentClass.getSuperclass();
        Method[] methods = currentClass.getDeclaredMethods();
        List<Method> methodList = MethodHelper.filterSetMethods(methods);
        Object currentObj = currentClass.newInstance();
        Set<XmlBeanProperty> properties = configFile.getProperties();
        //配置文件中,但是有父类,
        if (CollectionUtil.isEmpty(properties)) {
            boolean isExit = null != superclass && !superclass.getSimpleName().equals("Object");
            if (isExit) {
                Field[] parentFields = superclass.getDeclaredFields();
                if (ArrayUtil.isNotEmpty(parentFields)) {
                    if (CollectionUtil.isNotEmpty(methodList)) {
                        for (Field parentField : parentFields) {
                            for (Method method : methodList) {
                                if (MethodHelper.methodNameToProName(method.getName()).equals(parentField.getName())) {
                                    //如果有泛型的话
                                    Type genericType = currentClass.getGenericSuperclass();
                                    if (null != genericType) {
                                        String genericName = genericType.getTypeName();
                                        genericName = StringUtils.substring(genericName, genericName.indexOf("<") + 1, genericName.indexOf(">"));
                                        Class genericClass = Class.forName(genericName);
                                        method.setAccessible(true);
                                        method.invoke(currentObj, genericClass);
                                    }
                                    break;
                                }
                            }
                            break;
                        }
                    }
                }

            }
            return currentObj;
        }
        //传递给父级对象 service -- 》value
        List<Method> tmpList = new ArrayList<>();
        Map<String, Object> map = new HashMap<>();
        Object value = null;
        for (XmlBeanProperty property : properties) {
            proName = property.getName();
            if (ArrayUtil.isNotEmpty(methods)) {
                String ref = property.getRef();
                value = property.getValue();
                for (Method method : methodList) {
                    String methodName = MethodHelper.methodNameToProName(method.getName());
                    Field field = currentClass.getDeclaredField(methodName);
                    if (methodName.equals(proName) && null != field) {
                        if (null == value && StringUtils.isNotBlank(ref)) {
                            value = getBean(ref);
                        } else if (value != null && StringUtils.isBlank(ref)) {
                            value = Transfomer.transformerPropertyValue(field.getType().getSimpleName(), value);
                        }
                        method.setAccessible(true);
                        method.invoke(currentObj, value);
                        map.put(proName, value);
                        tmpList.add(method);
                        break;
                    }
                }
            }
        }
        tmpList = MethodHelper.removeMethod(methodList, tmpList);
        for (Method method : tmpList) {
            Class<?>[] type = method.getParameterTypes();
            if (ArrayUtil.isEmpty(type)) {
                throw new RuntimeException("传递给父级对象的参数为空type=" + type);
            }
            for (Class<?> aClass : type) {
                String superName = ClassHelper.classNameToProName(aClass.getSimpleName());
                value = map.get(superName);
                method.setAccessible(true);
                method.invoke(currentObj, value);
            }
        }
        return currentObj;
    } catch (ClassNotFoundException e) {
        logger.error("名为" + classPath + "类不存在");
        e.printStackTrace();
    } catch (InstantiationException e) {
        e.printStackTrace();
    } catch (IllegalAccessException e) {
        e.printStackTrace();
    } catch (InvocationTargetException e) {
        e.printStackTrace();
    } catch (NoSuchFieldException e) {
        logger.error("类" + classPath + "属性" + proName + "不存在");
        e.printStackTrace();
    }
    return null;
}

总结

这里没有使用注解,但我们也可以使用注解的方式实现自动装配,但这并不是spring的核心,应该是spring的美化,核心值如何实现自动装配。

相关推荐