当前位置: 首页 > news >正文

在两个bean之间进行数据传递的解决方案

简介

在日常开发中,在两个bean之间进行数据传递是常见的操作,例如在日常开发中,将数据从VO类转移到DO类等。在两个bean之间进行数据传递,最常见的解决方案,就是手动复制,但是它比较繁琐,充斥着大量的样板代码,这种高度规则化的代码很容易想到使用工具来解决。java中确实有不少框架来解决这个问题,这里统一做个对比。

解决方案

需求:在两个bean之间进行数据传递

// 第一个类:vo类
public class PersonVO {private Integer id;private String username;private String birthday;// getter、setter
}// 第二个类:领域模型
public class Person {private Integer id;private String name;private Date birthday;// getter、setter
}

方案1:手动转换

案例:手动编写vo类和领域模型之间的转换逻辑

public static PersonVO convertPersonToVO(Person person) {PersonVO vo = new PersonVO();vo.setId(person.getId());vo.setUsername(person.getName());vo.setBirthday(DateTimeUtil.convertDateWithDefaultPattern(person.getBirthday()));return vo;
}

这是最原始的方式,它最简单、最稳定,接下来的几种方式都是对于这种方式的优化

方案2:spring提供的BeanUtils

案例:

第一步:添加依赖

<dependency><groupId>org.springframework</groupId><artifactId>spring-beans</artifactId><version>5.2.20.RELEASE</version>
</dependency>

第二步:编写转换逻辑

public static PersonVO convertPersonToVO(Person person) {PersonVO vo = new PersonVO();// spring提供的工具类,它可以在名称相同、类型匹配的属性上进行值传递,如果找不到匹配的属性,也不会报错,// 随后需要用户手动处理无法匹配的属性。BeanUtils.copyProperties(person, vo);// 手动处理无法匹配的属性vo.setUsername(person.getName());vo.setBirthday(DateTimeUtil.convertDateWithDefaultPattern(person.getBirthday()));return vo;
}

spring提供的工具类,在实际开发中是用的比较多的

方案3:apache提供的BeanUtils

案例:

第一步:添加依赖

<dependency><groupId>commons-beanutils</groupId><artifactId>commons-beanutils</artifactId><version>1.9.4</version>
</dependency>

第二步:编写转换逻辑

public static PersonVO convertPersonToVO(Person person) {PersonVO vo = new PersonVO();// apache提供的工具类,和spring提供的几乎一样try {BeanUtils.copyProperties(vo, person);} catch (Exception e) {throw new RuntimeException(e);}vo.setUsername(person.getName());vo.setBirthday(DateTimeUtil.convertDateWithDefaultPattern(person.getBirthday()));return vo;
}

apache提供的工具类,也是实际开发中用的比较多的

方案4:mapstruct

一个框架,用户可以通过注解来指定数据传递的逻辑。它基于java提供的注解处理器API,可以在编译时按照用户指定的规则,生成在两个bean之间进行数据传递的代码。因为是在编译时动态生成代码,避免了运行时的反射调用,所有它的执行效率和手动编写的转换逻辑几乎没有区别。

案例:

第一步:添加依赖

<!--面向用户的api,指定转换逻辑-->
<dependency><groupId>org.mapstruct</groupId><artifactId>mapstruct</artifactId><version>1.4.1.Final</version>
</dependency>
<!--在编译时动态生成字节码的组件,只需要在编译时使用,所以它的依赖范围是provided-->
<dependency><groupId>org.mapstruct</groupId><artifactId>mapstruct-processor</artifactId><version>1.4.1.Final</version><scope>provided</scope>
</dependency>

第二步:编写转换逻辑

// 指定当前接口是一个用于在两个bean之间进行数据传递的接口,
// mapstruct在编译时会处理这个注解。不要和mybatis中的混淆。
@Mapper  
public interface PersonMapper {// mapstruct会在编译时动态生成当前接口的实现类,这里通过这种方式在运行时获取到实现类的实例,// 方便外部调用PersonMapper INSTANCE = Mappers.getMapper(PersonMapper.class);// 这里指定领域模型到vo类的转换逻辑@Mappings({// 指定两个bean之间类型匹配但是名称不一致的属性@Mapping(source = "name", target = "username"),  // 指定不需要进行数据传递的属性@Mapping(target = "birthday", ignore = true)})PersonVO toPersonVO(Person person);// 用户自定义数据转换的逻辑,如果某些属性需要使用java代码来计算,可以通过这种方式来指定@AfterMappingdefault void toPersonVOAfterMapping(Person person, @MappingTarget PersonVO vo) {vo.setBirthday(formatDate(person.getBirthday()));}default String formatDate(Date date) {return DateTimeUtil.convertDateWithDefaultPattern(date);}
}// 在外部调用上面的mapper接口
public static PersonVO convertPersonToVO(Person person) {return PersonMapper.INSTANCE.toPersonVO(person);
}

第三步:查看编译时生成的代码

public class PersonMapperImpl implements PersonMapper {public PersonMapperImpl() {}// 根据之前的注解,生成转换逻辑public PersonVO toPersonVO(Person person) {if (person == null) {return null;} else {PersonVO personVO = new PersonVO();personVO.setUsername(person.getName());personVO.setId(person.getId());this.toPersonVOAfterMapping(person, personVO);return personVO;}}
}

总结:mapstruct的使用比较复杂,这里只做一个简单的展示,并且不深入讲解它的逻辑,总之,它类似于lombok,在编译时根据注解动态生成字节码,注解中指定了转换逻辑。因为是编译时生成字节码,避免了反射调用,所以它的执行效率和手动编写转换逻辑几乎一致。

其它:dozer、orika、modelMapper等框架

相较于手动编写转换逻辑的代码而言,这些框架的使用都过于复杂了,不推荐使用,这里就不展示它们的使用案例了。

解决方案对比

首先,排除掉dozer、orika等,因为相较于手动编写转换逻辑而言,它们的使用都比较复杂,相当于为了避免一件麻烦事,引入另一件更麻烦的事。

其次,基于反射的工具类也不推荐,因为如果bean中的某个属性转换失败,它们不会报错,而是忽略,如果在开发过程中修改了属性名称,造成的数据传递失败不会在编译时被发现,要到运行时才能看出来。

最后,最推荐的,是手动编写转换逻辑或mapstruct。手动编写转换逻辑是最简单、最安全的方式,推荐把转换逻辑放到一个工具类中,实现代码的复用。mapstruct有一定的复杂度,但是它的功能强大,完全可以满足需求,而且如果修改了属性的名称,在编译时就会报错,而且它是编译时生成代码,效率上也满足要求。

具体使用哪一种,取决于项目组的要求。如果追求稳定,推荐手动编写转换逻辑,如果追求开发速度,推荐引入mapstruct,不推荐使用基于反射的BeanUtils,因为如果在开发中修改了属性名,基于BeanUtils的转换逻辑不会感知到这种修改,可能会导致某些错误要到运行时才能被发现。

不同解决方案的性能对比:之前提到基于反射的转换逻辑,执行起来比较慢,但是性能上具体表现如何,这里对不同解决方案的性能做一个对比,具体方法是,上面案例中每种转换方案都执行几万次,观察执行时间,对比它们的效率。

执行一百万次耗费的时间(毫秒)执行一千万次耗费的时间(毫秒)
手动编写转换逻辑6344768
spring提供的BeanUtils7715674
apache提供的BeanUtils419840389
mapstruct6074807

可以看到,apache提供的工具类明显比spring提供的工具类要慢,手动编写转换逻辑和mapstruct在执行速度上差别不大。

源码分析

这里对上面提到的几种解决方案做一个深入介绍。

spring提供的BeanUtils

整体流程

源码:

// 参数1:源对象
// 参数2:目标对象
// 参数3:目标对象的类对象,可以不传
// 参数4:要被忽略的属性,可以不传
private static void copyProperties(Object source, Object target, @Nullable Class<?> editable,@Nullable String... ignoreProperties) throws BeansException {Assert.notNull(source, "Source must not be null");Assert.notNull arget, "Target must not be null");Class<?> actualEditable = target.getClass();if (editable != null) {if (!editable.isInstance(target)) {throw new IllegalArgumentException("Target class [" + target.getClass().getName() +"] not assignable to Editable class [" + editable.getName() + "]");}actualEditable = editable;}// 获取目标对象的中所有属性,这里的PropertyDescriptor,包含属性名、属性的getter方法/setter方法实例PropertyDescriptor[] targetPds = getPropertyDescriptors(actualEditable);List<String> ignoreList = (ignoreProperties != null ? Arrays.asList(ignoreProperties) : null);for (PropertyDescriptor targetPd : targetPds) {// 获取目标属性的setter方法Method writeMethod = targetPd.getWriteMethod();// 如果目标属性有setter方法并且没有被指定为需要忽略if (writeMethod != null && (ignoreList == null || !ignoreList.contains(targetPd.getName()))) {// 根据目标属性的属性名,获取源对象中指定属性的getter方法PropertyDescriptor sourcePd = getPropertyDescriptor(source.getClass(), targetPd.getName());if (sourcePd != null) {// 如果可以获取到目标属性的getter方法Method readMethod = sourcePd.getReadMethod();if (readMethod != null &&// 这里判断getter方法的返回值和setter方法的参数是否匹配ClassUtils.isAssignable(writeMethod.getParameterTypes()[0], readMethod.getReturnType())) {try {if (!Modifier.isPublic(readMethod.getDeclaringClass().getModifiers())) {readMethod.setAccessible(true);}// 如果匹配,把getter方法的返回值传递给setter方法Object value = readMethod.invoke(source);if (!Modifier.isPublic(writeMethod.getDeclaringClass().getModifiers())) {writeMethod.setAccessible(true);}writeMethod.invoke(target, value);}catch (Throwable ex) {throw new FatalBeanException("Could not copy property '" + targetPd.getName() + "' from source to target", ex);}}}}}
}

总结:BeanUtils的基本逻辑,就是获取目标对象中的所有属性,然后根据属性名获取源对象中的getter方法,如果getter方法的返回值和setter方法的参数相匹配,就进行数据传递。这里也可以解释,为什么属性名不一致或数据类型不一致无法进行属性传递。

深入了解

上面的代码中提到了PropertyDescriptor,它存储了一个属性和它的getter、setter方法,这里深入了解这些信息是如何被解析出来的。

相关组件

PropertyDescriptor:存储了一个属性和它的getter、setter方法,它是java.beans包下的api

// PropertyDescriptor
public class PropertyDescriptor extends FeatureDescriptor {private Reference<? extends Class<?>> propertyTypeRef;// getter方法private final MethodRef readMethodRef = new MethodRef();// setter方法private final MethodRef writeMethodRef = new MethodRef();private Reference<? extends Class<?>> propertyEditorClassRef;private boolean bound;private boolean constrained;// 属性名首字母大写// The base name of the method name which will be prefixed with the// read and write method. If name == "foo" then the baseName is "Foo"private String baseName;private String writeMethodName;private String readMethodName;
}// PropertyDescriptor的父类
public class FeatureDescriptor {// 类对象的引用private Reference<? extends Class<?>> classRef;
}// 方法引用,可以看到,Method对象被放到了软引用之中。
final class MethodRef {private String signature;private SoftReference<Method> methodRef;private WeakReference<Class<?>> typeRef;
}

相关组件和整体流程

整体流程:spring调用java提供的内省器,解析类对象,内省器会缓存解析结果,spring本身也提供了缓存机制。

涉及到的组件:

  • java提供的内省器 Introspector,它负责通过反射解析类对象中的信息,把解析结果存储到BeanInfo、PropertyDescriptor中
  • CachedIntrospectionResults:缓存内省器的解析结果,这个组件是spring提供的。

这里以组件为核心来介绍整体流程。

java提供的内省器 Introspector

Introspector:java提供的内省器,通过反射来解析类对象中的数据。

基本结构:

public class Introspector {// 类对象,一个内省器实例负责解析一个类对象private Class<?> beanClass;// 父类的bean信息private BeanInfo superBeanInfo;// 方法信息,key是方法名,值是解析出的方法信息private Map<String, MethodDescriptor> methods;// 属性信息,key是属性名,值是属性信息,这里还包括属性的getter、setter方法private Map<String, PropertyDescriptor> properties;// 类中方法名的前缀static final String ADD_PREFIX = "add";static final String REMOVE_PREFIX = "remove";static final String GET_PREFIX = "get";static final String SET_PREFIX = "set";static final String IS_PREFIX = "is";
}

整体流程:

1、创建内省器的实例,解析bean信息:

public static BeanInfo getBeanInfo(Class<?> beanClass)throws IntrospectionException
{if (!ReflectUtil.isPackageAccessible(beanClass)) {return (new Introspector(beanClass, null, USE_ALL_BEANINFO)).getBeanInfo();}// 缓存解析结果ThreadGroupContext context = ThreadGroupContext.getContext();BeanInfo beanInfo;synchronized (declaredMethodCache) {beanInfo = context.getBeanInfo(beanClass);}if (beanInfo == null) {// 为指定的类对象创建一个内省器实例,然后解析类对象中的信息beanInfo = new Introspector(beanClass, null, USE_ALL_BEANINFO).getBeanInfo();synchronized (declaredMethodCache) {context.putBeanInfo(beanClass, beanInfo);}}return beanInfo;
}

可以看到,内行器会缓存解析出的结果,把它缓存到ThreadGroupContext中。ThreadGroupContext有两层缓存,第一层缓存,线程组实例和ThreadGroupContext实例,第二层缓存,类对象和BeanInfo,第二层缓存使用WeakHashMap来实现,key是弱引用,垃圾回收运行时会回收弱引用,这里可以理解为缓存的失效机制,保证内存安全。

2、解析类对象中的bean信息:整体流程

private BeanInfo getBeanInfo() throws IntrospectionException {// 解析基本的bean信息BeanDescriptor bd = getTargetBeanDescriptor();// 解析方法信息MethodDescriptor mds[] = getTargetMethodInfo();// 解析事件相关的方法,这个很少用到EventSetDescriptor esds[] = getTargetEventInfo();// 解析属性信息,包括属性的getter、setter方法,这里重点关注PropertyDescriptor pds[] = getTargetPropertyInfo();int defaultEvent = getTargetDefaultEventIndex();int defaultProperty = getTargetDefaultPropertyIndex();return new GenericBeanInfo(bd, esds, defaultEvent, pds,defaultProperty, mds, explicitBeanInfo);}

3、解析属性信息和属性信息对应的getter、setter方法,这是需要重点关注的方法

private PropertyDescriptor[] getTargetPropertyInfo() {// 用户手动指定的bean信息,这个特性已经用的很少了// Check if the bean has its own BeanInfo that will provide// explicit information.PropertyDescriptor[] explicitProperties = null;if (explicitBeanInfo != null) {explicitProperties = getPropertyDescriptors(this.explicitBeanInfo);}if (explicitProperties == null && superBeanInfo != null) {// We have no explicit BeanInfo properties.  Check with our parent.addPropertyDescriptors(getPropertyDescriptors(this.superBeanInfo));}for (int i = 0; i < additionalBeanInfo.length; i++) {addPropertyDescriptors(additionalBeanInfo[i].getPropertyDescriptors());}if (explicitProperties != null) {// Add the explicit BeanInfo data to our results.addPropertyDescriptors(explicitProperties);} else {// Apply some reflection to the current class.// 重点看这里,获取当前类和父类中所有的公共方法// First get an array of all the public methods at this levelMethod methodList[] = getPublicDeclaredMethods(beanClass);// Now analyze each method.for (int i = 0; i < methodList.length; i++) {Method method = methodList[i];if (method == null) {continue;}// 跳过静态方法// skip static methods.int mods = method.getModifiers();if (Modifier.isStatic(mods)) {continue;}// 获取方法的基本信息String name = method.getName();Class<?>[] argTypes = method.getParameterTypes();Class<?> resultType = method.getReturnType();int argCount = argTypes.length;PropertyDescriptor pd = null;if (name.length() <= 3 && !name.startsWith(IS_PREFIX)) {// Optimization. Don't bother with invalid propertyNames.continue;}try {// 这里就是解析getter、setter方法if (argCount == 0) { // 参数个数为0,证明是getter方法if (name.startsWith(GET_PREFIX)) {// 如果方法是get开头pd = new PropertyDescriptor(this.beanClass, name.substring(3), method, null);} else if (resultType == boolean.class && name.startsWith(IS_PREFIX)) {// 如果方法是is开头并且返回值类型是boolean,例如,属性类型是boolean时,getter方法是isXXX// Boolean getterpd = new PropertyDescriptor(this.beanClass, name.substring(2), method, null);}} else if (argCount == 1) {  // 参数个数为1if (int.class.equals(argTypes[0]) && name.startsWith(GET_PREFIX)) {pd = new IndexedPropertyDescriptor(this.beanClass, name.substring(3), null, null, method, null);} else if (void.class.equals(resultType) && name.startsWith(SET_PREFIX)) {// 返回值是void并且方法以set开头,证明是setter方法// Simple setterpd = new PropertyDescriptor(this.beanClass, name.substring(3), null, method);if (throwsException(method, PropertyVetoException.class)) {pd.setConstrained(true);}}} else if (argCount == 2) {  // 参数个数为2,带下标的setter方法if (void.class.equals(resultType) && int.class.equals(argTypes[0]) && name.startsWith(SET_PREFIX)) {pd = new IndexedPropertyDescriptor(this.beanClass, name.substring(3), null, null, null, method);if (throwsException(method, PropertyVetoException.class)) {pd.setConstrained(true);}}}} catch (IntrospectionException ex) {// This happens if a PropertyDescriptor or IndexedPropertyDescriptor// constructor fins that the method violates details of the deisgn// pattern, e.g. by having an empty name, or a getter returning// void , or whatever.pd = null;}if (pd != null) {// If this class or one of its base classes is a PropertyChange// source, then we assume that any properties we discover are "bound".if (propertyChangeSource) {pd.setBound(true);}// 保存解析结果addPropertyDescriptor(pd);}}}// 处理解析好的方法processPropertyDescriptors();// 返回解析结果PropertyDescriptor result[] =properties.values().toArray(new PropertyDescriptor[properties.size()]);// Set the default index.if (defaultPropertyName != null) {for (int i = 0; i < result.length; i++) {if (defaultPropertyName.equals(result[i].getName())) {defaultPropertyIndex = i;}}}return result;
}

这段代码看起来很多,但是有许多特性已经被淘汰了,几乎不使用,例如,方法开头的explicitBeanInfo,它是用户手动指定的bean信息,这个特性几乎不会被用到,以及解析方法的过程中遇到的带下标信息的getter、setter方法,这些也不会被用到。

上面的逻辑只展示了内省器是如何解析属性对应的方法,它会遍历所有的方法,根据方法的名称、参数,来决定方法是哪个属性的getter或setter方法,然后把属性名和方法放到一个map中,key是属性名,value是一个list,存储了一个属性的getter、setter方法。随后会处理属性的getter、setter方法,把它们放到一个PropertyDescriptor中。

要注意,内省器中处理方法的核心原则是遍历所有的方法,它不会根据属性名来拼接方法名,然后直接获取方法的实例,这可能是为了兼容一些以前的特性,例如带下标的getter、setter方法,这些特性现在已经不用了,但是内省器中还是会处理这些方法。

缓存内行器执行结果的组件 CachedIntrospectionResults

CachedIntrospectionResults:缓存内省器的解析结果

基本结构:

public final class CachedIntrospectionResults {// 基于强引用的缓存,注意,缓存中的value也是当前类的实例。static final ConcurrentMap<Class<?>, CachedIntrospectionResults> strongClassCache =new ConcurrentHashMap<>(64);// 基于弱引用的缓存static final ConcurrentMap<Class<?>, CachedIntrospectionResults> softClassCache =new ConcurrentReferenceHashMap<>(64);// 类对象的解析结果private final BeanInfo beanInfo;private final Map<String, PropertyDescriptor> propertyDescriptors;}

整体流程:

1、调用内省器并缓存它的执行结果

static CachedIntrospectionResults forClass(Class<?> beanClass) throws BeansException {// 从缓存中获取数据CachedIntrospectionResults results = strongClassCache.get(beanClass);  // 从强缓存中获取if (results != null) {return results;}results = softClassCache.get(beanClass);  // 从弱缓存中获取if (results != null) {return results;}// 这里会调用内省器来解析类对象,解析结果存放到当前类的实例中,然后把当前类的实例放到缓存中results = new CachedIntrospectionResults(beanClass);ConcurrentMap<Class<?>, CachedIntrospectionResults> classCacheToUse;// 这里的判断,决定使用强缓存还是弱缓存。// 如果要被解析的类的类加载器,和CachedIntrospectionResults的累加器是同一个,使用强缓存if (ClassUtils.isCacheSafe(beanClass, CachedIntrospectionResults.class.getClassLoader()) ||isClassLoaderAccepted(beanClass.getClassLoader())) {classCacheToUse = strongClassCache;}else {if (logger.isDebugEnabled()) {logger.debug("Not strongly caching class [" + beanClass.getName() + "] because it is not cache-safe");}classCacheToUse = softClassCache;}// 把解析结果存入缓存CachedIntrospectionResults existing = classCacheToUse.putIfAbsent(beanClass, results);return (existing != null ? existing : results);
}

2、缓存的清除机制:清除指定类加载器下的所有解析结果

public static void clearClassLoader(@Nullable ClassLoader classLoader) {acceptedClassLoaders.removeIf(registeredLoader ->isUnderneathClassLoader(registeredLoader, classLoader));// 清除强引用缓存strongClassCache.keySet().removeIf(beanClass ->isUnderneathClassLoader(beanClass.getClassLoader(), classLoader));// 清除弱引用缓存softClassCache.keySet().removeIf(beanClass ->isUnderneathClassLoader(beanClass.getClassLoader(), classLoader));
}

总结:CachedIntrospectionResults是一个工厂类,它负责管理缓存,并且它管理的缓存就是它自己的实例,每一个实例对应一个类对象的解析结果。

问题:为什么Introspector中提供了缓存机制,但是spring还要额外提供一个CachedIntrospectionResults来缓存解析结果?spring提供的缓存性能更高,Introspector中使用的缓存通过synchronized关键字保证线程安全,spring提供的缓存通过分段锁和cas操作来保证线程安全,性能更高。

总结

上面就是spring的BeanUtils是如何获取PropertyDescriptor实例,它主要是通过java提供的内省器来完成的,同时spring提供了缓存机制。

apache提供的BeanUtils

相较于spring提供的BeanUtils,它的功能更强大,但是执行效率也更低,它的内部同样是基于java提供的内省器,同时,它还提供了多种转换器,用于对数据格式进行转换。

mapstruct

基本原理

mapstruct是为java语言开发的代码生成器,可以通过注解的方式自动生成java bean之间的转换代码

mapstruct类似于lombok,基于java提供的注解处理器技术,来扫描和处理指定注解,在编译时生成转换代码。由于它是在编译时生成代码,不需要在运行时特殊处理或者反射,所以它的执行效率和原生代码一样。

java代码的编译过程,大致可以分为三个阶段:

  • 将源代码转换为抽象语法树(Abstract Syntax Tree),抽象语法树用于描述源代码的语法结构,语法树中的每一个节点都代表着源代码中的一个元素,例如包、运算符、返回值、代码注释等。
  • 编译器调用注解处理器,注解处理器会找到自己要处理的注解,然后修改语法树,生成新的代码
  • 编译器使用修改后的语法树来生成字节码文件。

抽象语法树:编译器在编译过程中生成的,用于表示源码的语法结构

注解处理器:编译器提供的扩展,用户可以编写自己的注解处理器,在编译时调用,处理特定的注解。

JSR-269规范:定义了java编译时的注解处理器(Pluggable Annotation Processing 插件式注解处理器),提供了一种标准化的方式让开发者在编译时处理java代码中的注解。

注解处理器的使用步骤:

  • 自定义注解
  • 继承一个特定的抽象类 AbstractProcessor,实现自己处理注解的逻辑
  • 基于SPI机制,注册自己的注解处理器,在META-INF/services目录下创建一个特定的文件,文件中是自定义注解处理器的全限定名。
  • 完成,此时,在编译源代码时,就会调用注解处理器

在mapstruct的核心原理中,注解处理器的定义和代码的生成是最核心的两部分,mapstruct使用freemarker模板引擎来生成java代码,用户还可以尝试自定义注解处理器来了解注解处理器的工作机制。

优缺点

类似于lombok,这种基于注解处理器的框架可以生成一些样板代码,减少代码量,但是它会增加源码的复杂度,而且不方便调试。相较于手动编写转换逻辑,是不推荐使用的。

相关文章:

  • 【五一培训】Day 4
  • Nginx核心功能 02
  • 《Vue3学习手记8》
  • P1603 斯诺登密码详解
  • C与指针——结构与联合
  • NPP库中libnppist模块介绍
  • Kubernetes 安装 kubectl
  • profile软件开发中的性能剖析与内存分析
  • 牛客周赛91 D题(数组4.0) 题解
  • RPG8.增加武器
  • 什么是右值引用和移动语义?大白话解释
  • Vue 虚拟DOM和DIff算法
  • 学习Linux的第一天
  • 初试C++报错并解决记录
  • 栈Stack
  • Javascript学习笔记1——数据类型
  • 第20节:深度学习基础-反向传播算法详解
  • Linux的时间同步服务器
  • Python 中的 collections 库:高效数据结构的利器
  • node核心学习
  • 降雪致长白山天池景区关闭,有游客在户外等待一小时,景区回应
  • 市值增22倍,巴菲特30年重仓股盘点
  • 巴菲特宣布将于年底退休,“接班人”什么来头?
  • 三亚回应“买水果9斤变6斤”:反映属实,拟对流动摊贩罚款5万元
  • “矿茅”国际化才刚开始?紫金矿业拟分拆境外黄金矿山资产于港交所上市
  • 陈逸飞《黄河颂》人物造型与借鉴影像意义