[Java/Spring] 深入理解 : SpringBoot PropertyMapper
1 概述: SpringBoot PropertyMapper
简介:PropertyMapper ∈ 对象拷贝与转换工具
PropertyMapper
是Spring提供的一个工具类,主要用于对对象的重新赋值,拷贝、转换等操作。
- 位于:
org.springframework.boot.context.properties.PropertyMapper
辨析: Spring BeanUtils 与 SpringBoot PropertyMapper
- 共同点:
- 对象及属性拷贝工具:BeanUtils 和 PropertyMapper 都是 Spring 框架中用于处理 Java Bean 之间对象属性拷贝的工具。
- 不同点:
- 通用 vs 定制 :
BeanUtils
提供了拷贝属性的通用方法,而PropertyMapper
提供了一种更灵活、可扩展的方式来定制属性的映射逻辑。
- 对于
BeanUtils.copyProperties
来说,你必须保证属性名和类型是相同的,因为它是根据get和set方法来赋值的。
- 浅拷贝 vs 深拷贝:
- BeanUtils :浅拷贝
org.springframework.beans.BeanUtils 工具类中的 copyProperties() 无法实现深拷贝,只能实现浅拷贝
详情参见:
- PropertyMapper : 支持深拷贝,完全取决于应用程序的开发者用户的诉求
- 模块/包
- BeanUtils :
spring-beans
模块
org.springframework.beans.BeanUtils
- PropertyMapper :
spring-boot
模块
org.springframework.boot.context.properties.PropertyMapper
2 应用场景
场景 :2个异构数据对象的拷贝与转换
- 在实际工作中,经常会遇到将数据库的实体类 Entity 转成 DTO 类的操作。通常的方法:
- 手工方法:我们有可以将属性一个个
get
出来,再set
进去。但经常涉及到判空、数据类型的转换等简单的逻辑处理,容易留下一大堆 IF ELSE 的臃肿代码。
- 第三方工具:用
BeanUtils
工具类 将对应类型的属性一个个copy进去。
- 现在还可以尝试使用 SpringBoot 的 PropertyMapper 来做数据对象的转换
案例1:SpringBoot 的 RabbitTemplateConfiguration
- SpringBoot 官方模块
spring-boot-starter-amqp
中RabbitTemplate
的配置实现
org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration.RabbitTemplateConfiguration
@Bean
@ConditionalOnSingleCandidate(ConnectionFactory.class)
@ConditionalOnMissingBean(RabbitOperations.class)
public RabbitTemplate rabbitTemplate(RabbitProperties properties,
ObjectProvider<MessageConverter> messageConverter,
ObjectProvider<RabbitRetryTemplateCustomizer> retryTemplateCustomizers,
ConnectionFactory connectionFactory) {
PropertyMapper map = PropertyMapper.get();
RabbitTemplate template = new RabbitTemplate(connectionFactory);
messageConverter.ifUnique(template::setMessageConverter);
template.setMandatory(determineMandatoryFlag(properties));
RabbitProperties.Template templateProperties = properties.getTemplate();
if (templateProperties.getRetry().isEnabled()) {
template.setRetryTemplate(
new RetryTemplateFactory(retryTemplateCustomizers.orderedStream().collect(Collectors.toList()))
.createRetryTemplate(templateProperties.getRetry(),
RabbitRetryTemplateCustomizer.Target.SENDER));
}
map.from(templateProperties::getReceiveTimeout).whenNonNull().as(Duration::toMillis)
.to(template::setReceiveTimeout);
map.from(templateProperties::getReplyTimeout).whenNonNull().as(Duration::toMillis)
.to(template::setReplyTimeout);
map.from(templateProperties::getExchange).to(template::setExchange);
map.from(templateProperties::getRoutingKey).to(template::setRoutingKey);
map.from(templateProperties::getDefaultReceiveQueue).whenNonNull().to(template::setDefaultReceiveQueue);
return template;
}
案例2:基于 Http11NioProtocol、WebServerFactoryCustomizer 、自定义配置实体,实现 自定义 SpringBoot 的 Tomcat Server 配置
TomcatEmbedServerProperties : 应用程序的自定义配置实体
//import org.springframework.boot.context.properties.ConfigurationProperties;
//import org.springframework.context.annotation.ComponentScan;
//import org.springframework.context.annotation.Configuration;
/**
* @create-time 2023/4/11
* @description ...
*/
//@ComponentScan
//@Configuration
//@ConfigurationProperties(
// prefix="service-config.tomcat-server"
// , ignoreUnknownFields = true
//)
public class TomcatEmbedServerProperties { //应用程序的自定义配置实体
private Integer port;
private Integer minSpareThreads;
private Integer maxThreads;
private Integer acceptCount;
private Integer maxConnections;
private Integer maxKeepAliveRequests;
private Integer keepAliveTimeout;
private Integer connectionTimeout;
public TomcatEmbedServerProperties(Integer port, Integer minSpareThreads, Integer maxThreads, Integer acceptCount, Integer maxConnections, Integer maxKeepAliveRequests, Integer keepAliveTimeout, Integer connectionTimeout) {
this.port = port;
this.minSpareThreads = minSpareThreads;
this.maxThreads = maxThreads;
this.acceptCount = acceptCount;
this.maxConnections = maxConnections;
this.maxKeepAliveRequests = maxKeepAliveRequests;
this.keepAliveTimeout = keepAliveTimeout;
this.connectionTimeout = connectionTimeout;
}
public TomcatEmbedServerProperties() {
}
public Integer getPort() {
return port;
}
public void setPort(Integer port) {
this.port = port;
}
public Integer getMinSpareThreads() {
return minSpareThreads;
}
public void setMinSpareThreads(Integer minSpareThreads) {
this.minSpareThreads = minSpareThreads;
}
public Integer getMaxThreads() {
return maxThreads;
}
public void setMaxThreads(Integer maxThreads) {
this.maxThreads = maxThreads;
}
public Integer getAcceptCount() {
return acceptCount;
}
public void setAcceptCount(Integer acceptCount) {
this.acceptCount = acceptCount;
}
public Integer getMaxConnections() {
return maxConnections;
}
public void setMaxConnections(Integer maxConnections) {
this.maxConnections = maxConnections;
}
public Integer getMaxKeepAliveRequests() {
return maxKeepAliveRequests;
}
public void setMaxKeepAliveRequests(Integer maxKeepAliveRequests) {
this.maxKeepAliveRequests = maxKeepAliveRequests;
}
public Integer getKeepAliveTimeout() {
return keepAliveTimeout;
}
public void setKeepAliveTimeout(Integer keepAliveTimeout) {
this.keepAliveTimeout = keepAliveTimeout;
}
public Integer getConnectionTimeout() {
return connectionTimeout;
}
public void setConnectionTimeout(Integer connectionTimeout) {
this.connectionTimeout = connectionTimeout;
}
private static boolean isPositive(int value) {
return value > 0;
}
@Override
public String toString() {
return "TomcatEmbedServerProperties{" +
"port=" + port +
", minSpareThreads=" + minSpareThreads +
", maxThreads=" + maxThreads +
", acceptCount=" + acceptCount +
", maxConnections=" + maxConnections +
", maxKeepAliveRequests=" + maxKeepAliveRequests +
", keepAliveTimeout=" + keepAliveTimeout +
", connectionTimeout=" + connectionTimeout +
'}';
}
}
WebServerConfiguration : 应用程序的自定义 Tomcat Server 配置 Bean
package xx.xx.biz.common.configuration;
import xx.xx.common.dto.serviceconfig.TomcatEmbedServerProperties;
import org.apache.catalina.Context;
import org.apache.catalina.connector.Connector;
import org.apache.catalina.startup.Tomcat;
import org.apache.coyote.UpgradeProtocol;
import org.apache.coyote.http11.Http11NioProtocol;
import org.apache.tomcat.util.descriptor.web.SecurityCollection;
import org.apache.tomcat.util.descriptor.web.SecurityConstraint;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.autoconfigure.web.embedded.TomcatWebServerFactoryCustomizer;
import org.springframework.boot.autoconfigure.web.servlet.TomcatServletWebServerFactoryCustomizer;
import org.springframework.boot.context.properties.PropertyMapper;
import org.springframework.boot.web.embedded.tomcat.TomcatConnectorCustomizer;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.server.ConfigurableWebServerFactory;
import org.springframework.boot.web.server.WebServerFactory;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
/**
* @create-time 2023/4/3
* @description 内嵌tomcat
* [1] 在 spring-configuration-metadata.json 文件中相关tomcat配置
* server.tomcat.accept-count: 等待队列长度,默认100
* server.tomcat.max-connections: 最大可连接数,默认10000
* server.tomcat.max-threads: 最大工作线程数,默认200
* server.tomcat.min-spare-threads: 最小工作线程数,默认10
* server.tomcat.accesslog.enabled=true 开启access日志
* server.tomcat.accesslog.directory=/var/www//tomcat 日志存放的路径
* server.tomcat.accesslog.pattern=%h %l %u %t "%r" %s %b %D 日志格式为:请求的host主机地址,时间,方式,路径,协议,状态,返回字节数,处理时间
*
* 注意:默认配置下,连接数超过 10000 后出现拒接连接情况;触发的请求超过 200+100 后拒绝
* [2] 定制内嵌 tomcat 开发
* 相关参数:
* keepAliveTimeOut :多少毫秒后不响应的断开keepalive
* maxKeepAliveRequests :多少次请求后keepalive断开失效
* keepAlive 简介:参考这里
* 优点:
* Keep-Alive 功能使客户端到服务器端的连接持续有效,当出现对服务器的后继请求时,Keep-Alive功能避免了建立或者重新建立连接。
* 长连接能够保证服务器和客户端的socket能够高效利用,减少握手等额外的开销。
* 缺点:
* 但是对于负担较重的网站来说,这里存在另外一个问题:
* 虽然为客户保留打开的连接有一定的好处,但它同样影响了性能,因为在处理暂停期间,本来可以释放的资源仍旧被占用。
* 当Web服务器和应用服务器在同一台机器上运行时,Keep-Alive功能对资源利用的影响尤其突出。
* @reference-doc
* [1] springboot内嵌tomcat优化 - 博客园 - https://www.cnblogs.com/hjwucc/p/11425306.html
* [2] 我可以为 Spring Boot 的嵌入式 tomcat 启用 tomcat 管理器应用程序吗? - IT1352 - https://www.it1352.com/2405633.html
* [3] Tomcat卷三:Jasper引擎 - CSDN - https://blog.csdn.net/m0_53157173/article/details/123131713
* [4] Spring Boot 最佳实践(二)集成Jsp与生产环境部署 - 51CTO - https://blog.51cto.com/vipstone/5408719
* [5] 嵌入式 Tomcat (Embedded Tomcat) - 博客园 - https://www.cnblogs.com/develon/p/11602969.html
*
* [6] 【Java基础】-- isAssignableFrom的用法详细解析 - 腾讯云 - https://cloud.tencent.com/developer/article/1754376
* [7] The HTTP Connector - Apache Tomcat(9) - https://tomcat.apache.org/tomcat-9.0-doc/config/http.html
* [8] HTTP/1.1与HTTP/1.0的区别 - CSDN - https://blog.csdn.net/qq_25827845/article/details/80127198
*/
@Configuration
public class WebServerConfiguration {
private static final Logger logger = LoggerFactory.getLogger(WebServerConfiguration.class);
// 在某配置类中添加如下内容
// 监听的http请求的端口,需要在application配置中添加http.port=端口号 如 80
// @Value("${http.port}")
// Integer httpPort;
//正常启用的https端口 如 443
// @Value("${server.port}")
// Integer httpsPort;
@Autowired
Environment environment;
@Autowired
ServerProperties serverProperties;
@Autowired
TomcatEmbedServerProperties tomcatServerProperties;//应用程序的 自定义 Tomcat Server 配置实体
// @Bean
// public TomcatWebServerFactoryCustomizer tomcatWebServerFactoryCustomizer(Environment environment, ServerProperties serverProperties) {
// return new TomcatWebServerFactoryCustomizer(environment, serverProperties);
// }
// TomcatServletWebServerFactory 是 ConfigurableWebServerFactory 接口 的一种实现
@Bean
//public WebServerFactoryCustomizer<ConfigurableWebServerFactory> webServerFactoryCustomizer() {
public WebServerFactoryCustomizer<TomcatServletWebServerFactory> webServerFactoryCustomizer() {
return new WebServerFactoryCustomizer<TomcatServletWebServerFactory>() {
@Override
//public void customize(WebServerFactoryCustomizer factory) {
public void customize(TomcatServletWebServerFactory factory) {
/**
* step1 关闭扫描 manifest 的 JAR 包
* 解决的问题: springboot 项目启动过程中会扫描一些 jar,jar 不存在会提示报错 : "Failed to scan [xxxx.jar] from classloader hierarchy"
*/
factory.addContextCustomizers((context) -> {
((StandardJarScanner)context.getJarScanner()).setScanManifest(false);
});
//step2 使用工厂类定制 tomcat connector
TomcatServletWebServerFactory webServerFactory = ((TomcatServletWebServerFactory)factory);
//webServerFactory.addContextCustomizers((context -> {
// context.addWelcomeFile("/index.html");
// // 使用 Tomcat 的 LegacyCookieProcessor 处理器
// context.setCookieProcessor(new LegacyCookieProcessor())
//}));
//TomcatServletWebServerFactoryCustomizer tomcatServletWebServerFactoryCustomizer = new TomcatServletWebServerFactoryCustomizer(serverProperties);
//tomcatServletWebServerFactoryCustomizer.customize(webServerFactory);
//int order = tomcatServletWebServerFactoryCustomizer.getOrder();
TomcatConnectorCustomizer tomcatConnectorCustomizer = new TomcatConnectorCustomizer() {
@Override
public void customize(Connector connector) {
Http11NioProtocol protocol = (Http11NioProtocol) connector.getProtocolHandler();
PropertyMapper propertyMapper = PropertyMapper.get();
//protocol.setPort(tomcatServerProperties.getPort());
propertyMapper.from(tomcatServerProperties::getPort).whenNonNull().to(protocol::setPort);
/** Tomcat 9.0.46 默认配置 **/
/**
* @param 最小备用线程数
* @defaultValue 10
* tomcat启动时的初始化的线程数
*/
//protocol.setMinSpareThreads(10);
propertyMapper.from(tomcatServerProperties::getMinSpareThreads).whenNonNull().to(protocol::setMinSpareThreads);
/**
* @param 最大线程数
* @defaultValue 200
* Tomcat可创建的最大的线程数,每一个线程处理一个请求
* 超过这个请求数后,客户端请求只能排队,等有线程释放才能处理
* 可以在服务器 CPU 核心数的 200~250 倍之间
*/
//protocol.setMaxThreads(200);
propertyMapper.from(tomcatServerProperties::getMaxThreads).whenNonNull().to(protocol::setMaxThreads);
/**
* @param 请求等待队列的容量大小
* @defaultValue 100
* 当tomcat请求处理线程池中的所有线程都处于忙碌状态时,此时新建的链接将会被放入到pending队列
* acceptCount即是此队列的容量,如果队列已满,此后所有的建立链接的请求(accept),都将被拒绝
* 在高并发/短链接较多的环境中,可以适当增大此值;当长链接较多的场景中,可以将此值设置为0
*/
//protocol.setAcceptCount(100);
propertyMapper.from(tomcatServerProperties::getAcceptCount).whenNonNull().to(protocol::setAcceptCount);
/**
* @param 在同一时间,tomcat能够接受的最大连接数
* @defaultValue 8192
* 当达到`max-connections `临界值时,系统可能会基于accept-count继续接受连接
* tomcat允许接收和处理的最大链接数,对于BIO而言此值默认与maxThreads参数一样,对于NIO而言此值默认为10000
* 此值还受限于系统的 ulimit、CPU、内存等配置。
*/
// protocol.setMaxConnections(8192);
propertyMapper.from(tomcatServerProperties::getMaxConnections).whenNonNull().to(protocol::setMaxConnections);
/**
* @param 处于keepAlive状态的请求的个数
* @defaultValue 100
* -1 表示不限制,1表示关闭 keepAlive 机制
* 建议: 此值为 maxThreads * 0.5; 不得大于 maxThreads
*/
//protocol.setMaxKeepAliveRequests(100);
propertyMapper.from(tomcatServerProperties::getMaxKeepAliveRequests).whenNonNull().to(protocol::setMaxKeepAliveRequests);
/**
* @param Tomcat 在关闭连接(Connection)之前,等待另一个请求的时间 (HTTP 1.1 KeepAlive 持久连接)
* @defaultValue 60000
* 此值控制/影响: HTTP响应报文中的 2个Header
* Connection: Keep-Alive
* keep-alive: timeout={KeepAliveTimeout/1000}s
*/
//protocol.setKeepAliveTimeout(60000);
propertyMapper.from(tomcatServerProperties::getKeepAliveTimeout).whenNonNull().to(protocol::setKeepAliveTimeout);
/**
* @param 与客户端建立连接后, Tomcat 等待客户端请求的时间
* @defaultValue 60000
* 如果客户端没有请求进来,等待一段时间后断开连接,释放线程
*/
//protocol.setConnectionTimeout(60000);
propertyMapper.from(tomcatServerProperties::getConnectionTimeout).whenNonNull().to(protocol::setConnectionTimeout);
}
};
webServerFactory.addConnectorCustomizers(tomcatConnectorCustomizer);
webServerFactory.addContextCustomizers();
}
};
}
// springboot2 写法
// @Bean
// public TomcatServletWebServerFactory tomcatServletContainer() {
// TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory() {
// @Override
// protected void postProcessContext(Context context) {
// SecurityConstraint constraint = new SecurityConstraint();
// constraint.setUserConstraint("CONFIDENTIAL");
// SecurityCollection collection = new SecurityCollection();
// collection.addPattern("/*");
// constraint.addCollection(collection);
// context.addConstraint(constraint);
// }
// };
// tomcat.addAdditionalTomcatConnectors(httpConnector());
// tomcat.addConnectorCustomizers();
// return tomcat;
// }
// springboot 1.x
/**
* @reference-doc
* [1] Spring Boot如何控制Tomcat缓存? - codenong - https://www.codenong.com/39146476/
* EmbeddedServletContainerCustomizer / TomcatEmbeddedServletContainerFactory / ConfigurableEmbeddedServletContainer
* @return
*/
//当 Spring 容器中没有 TomcatEmbeddedServletContainerFactory 这个 bean 时,会把此 bean 加载进容器
/* @Bean
public EmbeddedServletContainerFactory servletContainer() {
return new TomcatEmbeddedServletContainerFactory() {
@Override
protected TomcatEmbeddedServletContainer getTomcatEmbeddedServletContainer(
Tomcat tomcat) {
tomcat.addUser("admin", "secret");
tomcat.addRole("admin", "manager-gui");
try {
tomcat.addWebapp("/manager", "/path/to/manager/app");
}
catch (ServletException ex) {
throw new IllegalStateException("Failed to add manager app", ex);
}
return super.getTomcatEmbeddedServletContainer(tomcat);
}
};
}*/
// public Connector httpConnector() {
// Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol");
// connector.setScheme("http");
// //Connector监听的http的端口号
// connector.setPort(httpPort);
// connector.setSecure(false);
// //监听到http的端口号后转向到的https的端口号
// connector.setRedirectPort(httpsPort);
// return connector;
// }
// @Configuration(
// proxyBeanMethods = false
// )
// @ConditionalOnClass({Tomcat.class, UpgradeProtocol.class})
// public static class TomcatWebServerFactoryCustomizerConfiguration {
// public TomcatWebServerFactoryCustomizerConfiguration() {
// }
//
// @Bean
// public TomcatWebServerFactoryCustomizer tomcatWebServerFactoryCustomizer(Environment environment, ServerProperties serverProperties) {
// return new TomcatWebServerFactoryCustomizer(environment, serverProperties);
// }
// }
}
案例3: Order 转 OrderDTO
Order
@Data
public class Order {
private Long id;
private BigDecimal totalAmout;
private Integer status;
private Long userId;
private LocalDateTime createTime;
}
OrderDTO
@Data
public class OrderDTO {
private Long id;
private BigDecimal totalAmout;
private Integer status;
private Long userId;
private String createTime;
}
使用 PropertyMapper 转换
Order order = new Order();
order.setId(1L);
order.setStatus(1);
order.setTotalAmout(BigDecimal.ONE);
order.setUserId(100L);
order.setCreateTime(LocalDateTime.now());
PropertyMapper propertyMapper = PropertyMapper.get();
OrderDTO orderDTO = new OrderDTO();
propertyMapper.from(order::getId).to(orderDTO::setId);
// 如果from获取到的元素不是null,则执行to里面的动作
propertyMapper.from(order::getStatus).whenNonNull().to(orderDTO::setStatus);
propertyMapper.from(order::getUserId).to(orderDTO::setUserId);
propertyMapper.from(order::getTotalAmout).to(orderDTO::setTotalAmout);
// 因为Order里面的createTime是LocalDateTime类型,OrderDTO里面则是String类型,需要转换一下
propertyMapper.from(order::getCreateTime).as(createTime -> {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
return createTime.format(formatter);
}).to(orderDTO::setCreateTime);
这样一来就可以通过 PropertyMapper 将 Order 对象的值 set 到 OrderDTO 对象中。
3 PropertyMapper API
<T> Source<T> from(Supplier<T> supplier)
:提供值的来源,入参为Supplier
<T> Source<T> from(T value)
:一种重载形式,入参可以为一个对象void to(Consumer<T> consumer)
:通过将任何未过滤的值传递给指定的使用者来完成映射<R> R toInstance(Function<T, R> factory)
:通过从未过滤的值创建新实例来完成映射void toCall(Runnable runnable)
:当值还没有时,通过调用指定的方法来完成映射<R> Source<R> as(Function<T, R> adapter)
:将T
类型的入参转成R
类型的出参,类似于Stream
中的map
Source<T> when...
:这一系列方法,都是过滤用的。在from
后面调用,如果满足条件,就直接to
方法static PropertyMapper get()
:提供PropertyMapper
实例
PropertyMapper 类内部维护一个静态实例,我们一开始只能通过获取它得到 PropertyMapper 实例
get()
方法始终返回的是其内部的 PropertyMapper final static 实例, PropertyMapper 对象本身的内存开销不会太大
PropertyMapper alwaysApplying(SourceOperator operator)
:自定义过滤规则,参考代码
alwaysApplying 用于向 PropertyMapper 添加一个 when 条件判定,这个判定在每次 from 方法中都被调用。
alwaysApplyingWhenNonNull
则是对这个方法的包装。
PropertyMapper alwaysApplyingWhenNonNull()
:提供实例时,当前实例就过滤掉from
之后是null
的元素。PropertyMapper.get().alwaysApplyingWhenNonNull();
//使当前 PropertyMapper 只会映射 LocalDateTime 类型的字段
PropertyMapper propertyMapper = PropertyMapper.get()
.alwaysApplying( new PropertyMapper.SourceOperator() {
@Override
public <T> PropertyMapper.Source<T> apply(PropertyMapper.Source<T> source) {
return source.when(t -> t instanceof LocalDateTime);
}
} );
//注意:如果from方法后面有when条件,则 alwaysApplying 中设置的初始化提交将会失效
Y 推荐文献
X 参考文献
本文作者:
千千寰宇
本文链接: https://www.cnblogs.com/johnnyzen
关于博文:评论和私信会在第一时间回复,或直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
日常交流:大数据与软件开发-QQ交流群: 774386015 【入群二维码】参见左下角。您的支持、鼓励是博主技术写作的重要动力!
本文链接: https://www.cnblogs.com/johnnyzen
关于博文:评论和私信会在第一时间回复,或直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
日常交流:大数据与软件开发-QQ交流群: 774386015 【入群二维码】参见左下角。您的支持、鼓励是博主技术写作的重要动力!