Mastruct使用总结

MapStruct

介绍

官方文档

MapStruct is a Java annotation processor for the generation of type-safe bean mapping classes
MapStruct是一个Java注释处理器,用于生成类型安全的bean映射类

为什么要使用

创建由多个层组成的大型 Java 应用程序需要使用多种领域模型,如持久化模型、领域模型或者所谓的 DTO。为不同的应用程序层使用多个模型将要求我们提供 bean 之间的映射方法。手动执行此操作可以快速创建大量样板代码并消耗大量时间。

原理

对象拷贝工具实现上一般分为2种

(1)在运行时,通过反射调用set/get方法或者直接对成员变量进行赋值。

(2)在编译期,动态生成调用get/set方法赋值的代码,直接生成对应的class文件。

MapStruct属于第二种,在编译期间消耗少许的时间,换取运行时的高性能

比较

对比市面上几种常见的Bean映射框架

Dozer

Dozer 是一个映射框架,它使用递归将数据从一个对象复制到另一个对象。框架不仅能够在 bean 之间复制属性,还能够在不同类型之间自动转换。

<dependency>
    <groupId>net.sf.dozer</groupId>
    <artifactId>dozer</artifactId>
    <version>5.5.1</version>
</dependency>

Orika

Orika 是一个 bean 到 bean 的映射框架,它递归地将数据从一个对象复制到另一个对象。

Orika 的工作原理与 Dozer 相似。两者之间的主要区别是 Orika 使用字节码生成。这允许以最小的开销生成更快的映射器。

<dependency>
    <groupId>ma.glasnost.orika</groupId>
    <artifactId>orika-core</artifactId>
    <version>1.5.2</version>
</dependency>

MapStruct

MapStruct 是一个自动生成 bean mapper 类的代码生成器。MapStruct 还能够在不同的数据类型之间进行转换。

<dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct-processor</artifactId>
    <version>1.2.0.Final</version>
</dependency>

ModelMapper

ModelMapper 是一个旨在简化对象映射的框架,它根据约定确定对象之间的映射方式。它提供了类型安全的和重构安全的 API。

<dependency>
  <groupId>org.modelmapper</groupId>
  <artifactId>modelmapper</artifactId>
  <version>1.1.0</version>
</dependency>

JMapper

JMapper 是一个映射框架,旨在提供易于使用的、高性能的 Java bean 之间的映射。该框架旨在使用注释和关系映射应用 DRY 原则。该框架允许不同的配置方式:基于注释、XML 或基于 api。

<dependency>
    <groupId>com.googlecode.jmapper-framework</groupId>
    <artifactId>jmapper-core</artifactId>
    <version>1.6.0.1</version>
</dependency>

性能测试

基于以下几个维度进行测试性能

平均时间

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nLdtmsbh-1608866793069)(C:\Users\hujingyi\Desktop\技术分享\MapStruct\平均时间.png)]

吞吐量

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Er0HgQTJ-1608866793071)(C:\Users\hujingyi\Desktop\技术分享\MapStruct\吞吐量.png)]

SingleShotTime

单个操作从开始到结束的时间

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-m3t2GgV5-1608866793073)(C:\Users\hujingyi\Desktop\技术分享\MapStruct\SingleShotTime.png)]

结论

综合各方面来看,MapStruct和JMapper相差无几,但是在实际使用上来看,MapStruct的使用更为简单,它完全基于代码生成,而JMapper,则需要做更多的工作,所以选择MapStruct。

使用

下面中的代码示例只是包含了bean和mapper两部分,对于是怎么使用的以及自动生成的实现类是什么样的,并未做具体展示,

完整代码均可在 https://github.com/Venustar-ZL/mapstruct 中找到,不足之处,欢迎指正

注意:以下涉及的功能在MapStruct的低版本中可能会存在问题,建议提升至1.4.0.Final版本

1、了解@Mapper注解

注意此@Mapper注解不同于Mybatis中的@Mapper注解(org.apache.ibatis.annotations.Mapper)

@Mapper注解中的属性 componentModel

componentModel 属性用于指定自动生成的接口实现类的组件类型,这个属性支持四个值:

  • default: 这是默认的情况,MapStruct 不使用任何组件类型, 可以通过Mappers.getMapper(Class)方式获取自动生成的实例对象。
  • cdi: the generated mapper is an application-scoped CDI bean and can be retrieved via @Inject(Contexts and Dependency Injection 上下文依赖注入)
  • spring: 生成的实现类上面会自动添加一个@Component注解,可以通过Spring的 @Autowired方式进行注入
  • jsr330: 生成的实现类上会添加@javax.inject.Named 和@Singleton注解,可以通过 @Inject注解获取

@Mapper注解中的属性 uses

用于自定义映射器的导入

@Mapper注解中的属性 imports

用于所需类的导入

2、@Mapper实例

2.1、使用Mappers工厂获取

@Mapper
public interface TestMapper {
    //使用工厂方法获取Mapper实例
    TestMapper INSTANCE = Mappers.getMapper(TestMapper.class);
}

public class Test {
   TestMapper testMapper = TestMapper.INSTANCE;
}

2.2、通过依赖注入的方式获取

目前支持spring和cdi

@Mapper(componentModel = "spring")
public interface TestMapper {
}

public class Test {
    @Autowired
    private TestMapper testMapper;
}
3、简单映射

对于同名同属性的字段,无需特别声明指定,自动转换。

对于不同名相同属性的字段,可以使用@Mapping注解指定。

Bean

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Product {

    private String productId;
    private String name;

}

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ProductDTO {

    private String productId;
    private String productName;

}

Mapper

@Mapper
public interface SimpleMapper {

    SimpleMapper INSTANCE = Mappers.getMapper(SimpleMapper.class);

    /**
     * 对于同名同属性的字段,无需特别声明指定,自动转换。
     * 对于不同名相同属性的字段,可以使用@Mapping注解指定。
     * @param product
     * @return
     */
    @Mappings({
            @Mapping(source = "name", target = "productName")
    })
    ProductDTO toDto(Product product);
}

也可绑定多个对象的属性值到目标对象中,如下

@Mappings({
            @Mapping(source = "user.id", target = "userId"), // 把user中的id绑定到目标对象的userId属性中
            @Mapping(source = "user.username", target = "name"), // 把user中的username绑定到目标对象的name属性中
            @Mapping(source = "role.roleName", target = "roleName") // 把role对象的roleName属性值绑定到目标对象的roleName中
    })
    UserRoleDto toUserRoleDto(User user, Role role);

注意:maven插件要使用3.6.0版本以上、lombok使用1.16.16版本以上,否则会出现这个错误

No property named "aaa" exists in source parameter(s). Did you mean "null"?
4、数据类型转换
4.1、基本数据类型转换

对于基本的数据类型会进行自动隐式的转换,如int、long、String、Integer等

Bean

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Product {

    private String productId;
    private Long price;

}

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ProductDTO {

    private Integer productId;
    private String price;

}

Mapper

@Mapper
public interface DataTypeMapper {

    DataTypeMapper INSTANCE = Mappers.getMapper(DataTypeMapper.class);

    ProductDTO toDto(Product product);
}

Impl

@Component
public class DataTypeMapperImpl implements DataTypeMapper {
    public DataTypeMapperImpl() {
    }

    public ProductDTO toDto(Product product) {
        if (product == null) {
            return null;
        } else {
            ProductDTO productDTO = new ProductDTO();
            if (product.getProductId() != null) {
                productDTO.setProductId(Integer.parseInt(product.getProductId()));
            }

            if (product.getPrice() != null) {
                productDTO.setPrice(String.valueOf(product.getPrice()));
            }

            return productDTO;
        }
    }
}

由自动生成的实现类也可看出,对于基本数据类型做了转换

4.2、指定转换格式

(1)对于基本数据类型与String之间的转换,可以使用 numberFormat 指定转换格式

(2)Date和String之间的转换,可以通过dateFormat指定转换格式

Bean

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Product {

    private String productId;
    private BigDecimal price;
    private String stock;
    private Date saleTime;
    private String validTime;

}

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ProductDTO {

    private String productId;
    private String price;
    private Integer stock;
    private String saleTime;
    private Date validTime;

}

Mapper

@Mapper
public interface DataFormatMapper {

    DataFormatMapper INSTANCE = Mappers.getMapper(DataFormatMapper.class);

    /**
     * numberFormat指定基本数据类型与String之间的转换
     * dateFormat指定Date和String之间的转换
     * @param product
     * @return
     */
    @Mappings({
            @Mapping(source = "price", target = "price", numberFormat = "#.00元"),
            @Mapping(source = "stock", target = "stock", numberFormat = "#个"),
            @Mapping(target = "saleTime", dateFormat = "yyyy-MM-dd HH:mm:ss"),
            @Mapping(target = "validTime", dateFormat = "yyyy-MM-dd HH:mm")
    })
    ProductDTO toDto(Product product);;

}

Impl

public class DataFormatMapperImpl implements DataFormatMapper {
    public DataFormatMapperImpl() {
    }

    public ProductDTO toDto(Product product) {
        if (product == null) {
            return null;
        } else {
            ProductDTO productDTO = new ProductDTO();

            try {
                if (product.getStock() != null) {
                    productDTO.setStock((new DecimalFormat("#个")).parse(product.getStock()).intValue());
                }
            } catch (ParseException var5) {
                throw new RuntimeException(var5);
            }

            if (product.getPrice() != null) {
                productDTO.setPrice(this.createDecimalFormat("#.00元").format(product.getPrice()));
            }

            productDTO.setProductId(product.getProductId());
            if (product.getSaleTime() != null) {
                productDTO.setSaleTime((new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")).format(product.getSaleTime()));
            }

            try {
                if (product.getValidTime() != null) {
                    productDTO.setValidTime((new SimpleDateFormat("yyyy-MM-dd HH:mm")).parse(product.getValidTime()));
                }

                return productDTO;
            } catch (ParseException var4) {
                throw new RuntimeException(var4);
            }
        }
    }

    private DecimalFormat createDecimalFormat(String numberFormat) {
        DecimalFormat df = new DecimalFormat(numberFormat);
        df.setParseBigDecimal(true);
        return df;
    }
}

4.3、对象引用映射

(1)对应是相同类型的对象引用,直接简单的对引用进行拷贝

Bean

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Product {

    private String productId;
    private ProductDetail productDetail;

}

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ProductDTO {

    private String productId;
    private ProductDetail productDetail;

}

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ProductDetail {

    private String detail;

}

Mapper

@Mapper
public interface ObjectSingleMapper {

    ObjectSingleMapper INSTANCE = Mappers.getMapper(ObjectSingleMapper.class);

    /**
     * 相同类型的对象引用,直接简单的对引用进行拷贝
     * @param product
     * @return
     */
    ProductDTO toDto(Product product);

}

(2)如果类型相同,但是是集合类的引用,会创建一个新的集合,集合里面的所有引用进行拷贝

Bean

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Product {

    private String productId;
    private List<ProductDetail> productDetail;

}

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ProductDTO {

    private String productId;
    private List<ProductDetail> productDetail;

}

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ProductDetail {

    private String detail;

}

Mapper

@Mapper
public interface ObjectMultiMapper {

    ObjectMultiMapper INSTANCE = Mappers.getMapper(ObjectMultiMapper.class);

    /**
     * 集合里面的所有引用进行拷贝
     * @param product
     * @return
     */
    ProductDTO toDto(Product product);

}

(3)对象的类型不同,会检查是否存在对应的映射方法或者默认的类型转换器,否则会尝试自动创建子映射方法

Bean

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Product {

    private String productId;

    private ProductDetail productDetail;

}

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ProductDetail {

    private String productDetail;

}

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ProductDTO {

    private String productDtoId;

    private ProductDTODetail productDTODetail;

}

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ProductDTODetail {

    private String productDtoDetail;

}

Mapper

在此演示两种做法

1、自定义映射方法并导入

@Mapper(uses = DetailMapper.class)
public interface ObjectTypeMapper {

    ObjectTypeMapper INSTANCE = Mappers.getMapper(ObjectTypeMapper.class);

    @Mappings({
            @Mapping(source = "productId", target = "productDtoId"),
            @Mapping(source = "productDetail", target = "productDTODetail")
    })
    ProductDTO toDto(Product product);

}

2、可以使用@Mapping声明嵌套bean转换的规则,mapstruct生成子映射方法时,会使用者声明的规则。同时支持跨层级的属性转换。

@Mapper
public interface ObjectTypeMapper {

    ObjectTypeMapper INSTANCE = Mappers.getMapper(ObjectTypeMapper.class);

    @Mappings({
            @Mapping(source = "productId", target = "productDtoId"),
            // 嵌套类型转换
            @Mapping(source = "productDetail.productDetail", target = "productDTODetail.productDtoDetail")
    })
    ProductDTO toDto(Product product);

}

@Mapper
public interface DetailMapper {

    DetailMapper INSTANCE = Mappers.getMapper(DetailMapper.class);

    @Mapping(source = "productDetail", target = "productDtoDetail")
    ProductDTODetail toDetail(ProductDetail productDetail);

}

第一种在自动生成的实现类中,由MapStruct自己生成的子映射方法来完成转换,而第二种则使用了我们导入的子映射方法来完成转换

4.4、自定义映射器

MapStatuct支持自定义映射器,实现自定义类型之间的转换。

一个自定义映射器可以定义多个映射方法,匹配时,是以方法的入参和出参进行匹配的。如果绑定的映射中,存在多个相同的入参和出参方法,将会报错。

如果多个入参或者出参方法存在继承关系,将会匹配最具体的那一个方法。

Bean

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Product {

    private String productId;
    private Boolean isDone;

}

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ProductDTO {

    private String productId;
    private String isDone;

}

Mapper

public class DoneFormater {

    public String toStr(Boolean isDone) {
        if (isDone) {
            return "已完成";
        } else {
            return "未完成";
        }
    }
    public Boolean toBoolean(String str) {
        if (str.equals("已完成")) {
            return true;
        } else {
            return false;
        }
    }

}

@Mapper( uses = {DoneFormater.class})
public interface ObjectCustomizeMapper {

    ObjectCustomizeMapper INSTANCE = Mappers.getMapper(ObjectCustomizeMapper.class);

    ProductDTO toDto(Product product);

}
4.5、 使用限定符限定使用映射方法

自定义映射器时,存在多个相同入参和出参的方法,报错是因为MapStruct无法选择使用哪个映射方法。但有时确实有这样的场景,这时可以使用限定符绑定每个属性转换时使用的映射方法。

(1)限定符使用自定义注解实现(较为复杂),使用@Mapping注解中的qualifiedBy

(2)基于@Named注解实现(推荐),使用@Mapping注解中的qualifiedByName

在此演示基于@Named注解的实现

Bean

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Product {

    private String productId;
    private Boolean isDone;

}

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ProductDTO {

    private String productId;
    private String isDone;

}

Mapper

@Named("DoneFormater")
public class DoneFormater {

    @Named("DoneFormater")
    public String toStr(Boolean isDone) {
        if (isDone) {
            return "已完成";
        } else {
            return "未完成";
        }
    }

    @Named("DoneDetailFormater")
    public String toDetail(Boolean isDone) {
        if (isDone) {
            return "该产品已完成";
        } else {
            return "该产品未完成";
        }
    }

    public Boolean toBoolean(String str) {
        if (str.equals("已完成")) {
            return true;
        } else {
            return false;
        }
    }

}


@Mapper( uses = {DoneFormater.class})
public interface ObjectQualiferMapper {

    ObjectQualiferMapper INSTANCE = Mappers.getMapper(ObjectQualiferMapper.class);

    @Mapping(source = "isDone", target = "isDone", qualifiedByName = "DoneDetailFormater")
    ProductDTO toDto(Product product);

}
5、Map映射

可以使用@MapMapping实现对key和value的分别映射

@Mapper
public interface MapMapper {

    MapMapper INSTANCE = Mappers.getMapper(MapMapper.class);

    @MapMapping(valueDateFormat = "yyyy-MM-dd HH:mm:ss")
    Map<String, String> toDTO(Map<Long, Date> map);

}
6、枚举值映射

MapStruct可以在多个枚举值之间转换,使用@ValueMapping注解

Bean

public enum E1 {

    E1_1,
    E1_2,
    E1_3

}

public enum E2 {

    E2_1,
    E2_2,
    E2_3

}

Mapper

@Mapper
public interface DataEnumMapper {

    DataEnumMapper INSTANCE = Mappers.getMapper(DataEnumMapper.class);

    @ValueMappings({
            @ValueMapping(target = "E1_1", source = "E2_1"),
            @ValueMapping(target = "E1_2", source = "E2_2"),
            @ValueMapping(target = MappingConstants.NULL, source = "E2_3") //转换成null
    })
    E1 toDTO(E2 e2);

}
7、定制Bean生成或者更新Bean
7.1、对象工厂

使用对象工厂来定制Bean的生成

Bean

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Product {

    private String productId;

    private Integer stock;

}

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ProductDTO {

    private String productId;

    private String detail;

    private Integer stock;

}

public class DTOFactory {

    public ProductDTO createDTO() {
        ProductDTO productDTO = new ProductDTO();
        productDTO.setStock(0);
        productDTO.setDetail("productDTO");
        return productDTO;
    }

}

Mapper

@Mapper(uses = DTOFactory.class)
public interface ObjectFactoryMapper {

    ObjectFactoryMapper INSTANCE = Mappers.getMapper(ObjectFactoryMapper.class);

    ProductDTO toDTO(Product product);

}
7.2、更新Bean

某些场景下,我们只是需要对对象进行更新,可以把需要更新的对象作为方法参数传入,并且使用@MappingTarget指定。

Bean

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Product {

    private String productId;
    private String price;
    private Integer stock;

}

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ProductDTO {

    private String productId;
    private String price;
    private Integer stock;

}

Mapper

@Mapper
public interface ObjectUpdateMapper {

    ObjectUpdateMapper INSTANCE = Mappers.getMapper(ObjectUpdateMapper.class);

    void updateDTO(Product product, @MappingTarget ProductDTO productDTO);

}
8、表达式映射

对于复杂的映射,允许使用java表达式实现字段的映射。

注意要导入使用到的类。

Bean

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Product {

    private String productId;
    private Integer price1;
    private Integer price2;

}

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ProductDTO {

    private String productId;
    private Integer price;
    private Integer price2;

}

Mapper

@Mapper(imports = MathUtils.class)
public interface ObjectExpressionMapper {

    ObjectExpressionMapper INSTANCE = Mappers.getMapper(ObjectExpressionMapper.class);

    @Mappings({
            @Mapping(target = "price", expression = "java(product.getPrice1() + product.getPrice2())"),//直接相加
            @Mapping(target = "price2", expression = "java(MathUtils.addAndReturn0(product.getPrice1(), product.getPrice2()))") //使用工具类处理
    })
    ProductDTO toDTO(Product product);

}
9、缺省值和常量

MapStruct允许设置缺省值和常量,同时缺省值允许使用表达式。

注意:使用缺省值,源字段必须存在,否则缺省值不生效,否则应该使用常量。

即使用缺省值,source必须指定

Bean

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Product {

    private String productId;
    private String random;
    private Integer stock;
    private String createTime;

}

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ProductDTO {

    private String productId;
    private String random;
    private Integer stock;
    private String createTime;

}

Mapper

下面例子中存在一个问题,在查看的文档中使用的有defaultExpression(缺省值可使用表达式赋值),但是在实际使用中却无,后续探讨

解决方案:提高使用的MapStruct版本,如1.3.1

@Mapper(imports = UuidUtils.class)
public interface ObjectDefaultMapper {

    ObjectDefaultMapper INSTANCE = Mappers.getMapper(ObjectDefaultMapper.class);

    @Mappings({
            @Mapping(target = "productId", source = "productId", defaultValue = "123"), //当product的productId为null,设置为0
            @Mapping(target = "random", source = "random", defaultValue = "java(UuidUtils.getUuid())"), //缺省设置随机数
            @Mapping(target = "stock", constant = "100"), //固定设置为0, 常量
            @Mapping(target = "createTime", dateFormat = "yyyy-MM-dd", constant = "2020-01-01") //固定格式化设置为2020-01-01,
    })
    ProductDTO toDTO(Product product);

}
10、存在继承关系的结果处理

当返回的结果类型存在继承关系时,可以使用 @BeanMapping注解指定真实返回的结果类型。

注意:指定的结果类型在自动生成的实现类中可能没有导入,需在@Mapper注解中显式导入,如下图例子中的@Mapper(imports = Cat.class),如果不导入Cat类,会报错

Bean

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Animal {

    public String id;

}

@Data
public class Cat extends Animal{
    public Cat() {
    }

    public Cat(String id) {
        super(id);
    }
}

@Data
public class Dog extends Animal{
    public Dog(String id) {
        super(id);
    }
}

Mapper

@Mapper(imports = Cat.class)
public interface ResultInheritMapper {

    ResultInheritMapper INSTANCE = Mappers.getMapper(ResultInheritMapper.class);

    @BeanMapping(resultType = Cat.class)//指定返回的结果类型
    Animal to(Dog dog);

}
11、映射关系继承

MapStruct允许对映射关系进行继承,使用@InheritConfiguration标记当前方法继承其他映射方法的映射关系。会自动查找相同类型映射源、映射目标的方法进行继承,如果存在多个相同类型的方法,则需要手工指定。

11.1、正向继承

从源对象对目标对象

11.2、反向继承

从目标对象到源对象

Bean

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Product {

    private String id1;
    private String id2;
    private String detail1;
    private String detail2;

}

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ProductDTO {

    private String productId;
    private String detail;

}

Mapper

@Mapper
public interface RelationInheritMapper {

    RelationInheritMapper INSTANCE = Mappers.getMapper(RelationInheritMapper.class);

    @Mapping(target = "productId", source = "id1")
    @Mapping(target = "detail", source = "detail1")
    ProductDTO toDTO(Product product);

    @Mapping(target = "productId", source = "id2")
    @Mapping(target = "detail", source = "detail2")
    ProductDTO toDTO2(Product product);

    @InheritConfiguration(name = "toDTO") //对toDTO的映射关系进行继承
    @Mapping(target = "detail", source = "detail2") //对继承的关系进行重写
    void update(@MappingTarget ProductDTO productDTO, Product product);

    @InheritInverseConfiguration(name = "toDTO") //对toDTO的映射关系进行逆继承
    @Mapping(target = "detail2", source = "detail") //对逆向继承的关系进行重写
    Product toEntity(ProductDTO dto);

}

参考文档

https://mapstruct.org/documentation/stable/reference/html/

https://www.cnblogs.com/javaguide/p/11861749.html

http://www.mamicode.com/info-detail-3027273.html

https://blog.csdn.net/qq122516902/article/details/87259752

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值