Dubbo 在 Proxyless Mesh 模式下的探索与改进
01 背景随着 Docker 和 Kubernetes 的出现,一个庞大的单体应用可以被拆分成多个独立部署的微服务,并被打包运行于对应的容器中。不同应用之间相互通信,以共同完成某一功能模块。微服务架构与容器化部署带来的好处是显而易见的,它降低了服务间的耦合性,利于开发和维护,能更有效地利用计算资源。当然,微服务架构也存在相应的缺点:强依赖于SDK,业务模块与治理模块耦合较为严重。除了相关依赖,往往还需要在业务代码中嵌入SDK代码或配置。统一治理难。每次框架升级都需要修改 SDK 版本,并重新进行回归测试,确认功能正常后再对每一台机器重新部署上线。不同服务引用的 SDK 版本不统一、能力参差不齐,增大了统一治理的难度。缺少一套统一解决方案。目前市场不存在一整套功能完善、无死角的微服务治理与解决方案。在实际生产环境往往还需要引入多个治理组件来完成像灰度发布、故障注入等功能。为解决这些痛点,Service Mesh诞生了。以经典的side car模式为例,它通过在业务 Pod 中注入 Sidecar 容器,对代理流量实施治理和管控,将框架的治理能力下层到 side car 容器中,与业务系统解耦,从而轻松实现多语言、多协议的统一流量管控、监控等需求。通过剥离 SDK 能力并拆解为独立进程,从而解决了强依赖于 SDK 的问题,从而使开发人员可以更加专注于业务本身,实现了基础框架能力的下沉,如下图所示(源自dubbo官网):经典的 Sidecar Mesh 部署架构有很多优势,如减少 SDK 耦合、业务侵入小等,但增加了一层代理,也带来了一些额外的问题,比如:SideCar 代理会损耗一部分性能,当网络结构层级比较复杂时尤其明显,对性能要求很高的业务造成了一定的困扰。架构更加复杂,对运维人员要求高。对部署环境有一定的要求,需要其能支持SideCar代理的运行。为解决这些痛点,Proxyless Service Mesh 模式诞生了。传统服务网格通过代理的方式拦截所有的业务网络流量,代理需要感知到控制平面下发的配置资源,从而按照要求控制网络流量的走向。以istio为例,Proxyless 模式是指应用直接与负责控制平面的istiod进程通信,istiod进程通过监听并获取k8s的资源,例如Service、Endpoint等,并将这些资源统一通过 xds 协议下发到不同的rpc框架,由rpc框架进行请求转发,从而实现服务发现和服务治理等能力。Dubbo社区是国内最早开始对Proxyless Service Mesh模式进行探索的社区,这是由于相比于 Service Mesh,Proxyless模式落地成本较低,对于中小企业来说是一个较好的选择。Dubbo 在3.1 版本中通过对xds协议进行解析,新增了对 Proxyless 的支持。Xds是一类发现服务的总称,应用通过xds api可以动态获取Listener(监听器),Route(路由), Cluster(集群), Endpoint(集群成员)以及Secret(证书)配置。通过 Proxyless 模式,Dubbo 与 Control Plane直接建立通信,进而实现控制面对流量管控、服务治理、可观测性、安全等的统一管控,从而规避 Sidecar 模式带来的性能损耗与部署架构复杂性。02 Dubbo Xds 推送机制详解@startuml
' ========调整样式=============
' 单个状态定义示例:state 未提交 #70CFF5 ##Black
' hide footbox 可关闭时序图下面部分的模块
' autoactivate on 是否自动激活
skinparam sequence {
ArrowColor black
LifeLineBorderColor black
LifeLineBackgroundColor #70CFF5
ParticipantBorderColor #black
ParticipantBackgroundColor #70CFF5
}
' ========定义流程=============
activate ControlPlane
activate DubboRegistry
autonumber 1
ControlPlane <-> DubboRegistry : config pull and push
activate XdsServiceDiscoveryFactory
activate XdsServiceDiscovery
activate PilotExchanger
DubboRegistry -> XdsServiceDiscoveryFactory : request
XdsServiceDiscoveryFactory --> DubboRegistry: get registry configuration
XdsServiceDiscoveryFactory -> XdsChannel: 返回列表信息(若数据没有导入完成,则不可见)
XdsServiceDiscoveryFactory-> XdsServiceDiscovery: init Xds service discovery
XdsServiceDiscovery-> PilotExchanger: init PilotExchanger
alt PilotExchanger
PilotExchanger -> XdsChannel: 初始化XdsChannel
XdsChannel --> PilotExchanger: return
PilotExchanger -> PilotExchanger: get cert pair
PilotExchanger -> PilotExchanger: int ldsProtocol
PilotExchanger -> PilotExchanger: int rdsProtocol
PilotExchanger -> PilotExchanger: int edsProtocol
end
alt PilotExchanger
XdsServiceDiscovery --> XdsServiceDiscovery: 解析Xds协议
XdsServiceDiscovery --> XdsServiceDiscovery: 根据Eds初始化节点信息
XdsServiceDiscovery --> XdsServiceDiscovery: 将Rds、Cds的的负载均衡和路由规则写入结点的运行信息中
XdsServiceDiscovery --> XdsServiceDiscovery: 回传给服务自省框架,构建invoker
end
deactivate ControlPlane
deactivate XdsServiceDiscovery
deactivate XdsServiceDiscoveryFactory
@enduml
从整体上看,istio control plane和dubbo的交互时序图如上。Dubbo 里xds处理的主要逻辑在 PilotExchanger 和各个DS(LDS、RDS、CDS、EDS)的对应协议的具体实现里。PilotExchanger统一负责串联逻辑,主要有三大逻辑:获取授信证书。调用不同 protocol 的 getResource 获取资源。调用不同 protocol 的 observeResource 方法监听资源变更。例如对于lds和rds,PilotExchanger 会调用 lds 的 getResource 方法与 istio 建立通信连接,发送数据并解析来自istio的响应,解析完成后的resource资源会作为rds调用getResource方法的入参,并由rds发送数据给istio。当lds发生变更时,则由lds的observeResource方法去触发自身与 rds 的变更。上述关系对于rds和eds同样如此。现有交互如下,上述过程对应图里红线的流程:在第一次成功获取资源之后,各个 DS 会通过定时任务去不断发送请求给 istio,并解析响应结果和保持与 istio 之间的交互,进而实现控制面对流量管控、服务治理、可观测性方面的管控,其流程对应上图蓝线部分。03 当前 Dubbo Proxyless 实现存在的不足Dubbo Proxyless模式经过验证之后,已经证明了其可靠性。现有dubbo proxyless的实现方案存在以下问题:目前与istio交互的逻辑是推送模式。getResource和observeResource是两条不同的stream流,每次发送新请求都需要重新建立连接。但我们建立的stream流是双向流动的,istio在监听到资源变化后由主动推送即可,LDS、RDS、EDS分别只需要维护一条stream流。Stream流模式改为建立持久化连接之后,需要设计一个本地的缓存池,去存储已经存在的资源。当istio主动推送更新后,需要去刷新缓存池的数据。现有observeResource逻辑是通过定时任务去轮询istio。现在observeResource不再需要定时去轮询,只需要将需要监听的资源加入到缓存池,等istio自动推送即可,且istio推送回来的数据需要按照app切分好,实现多点监听,后续dubbo支持其他DS模式,也可复用相应的逻辑。目前由istio托管的dubbo应用在istio掉线后会抛出异常,断线后无法重新连接,只能重新部署应用,增加了运维和管理的复杂度。我们需增加断线重连的功能,等istio恢复正常后无需重新部署即可重连。改造完成后的交互逻辑:04 Xds 监听模式实现方案4.1 资源缓存池目前Dubbo的资源类型有LDS,RDS,EDS。对于同一个进程,三种资源监听的所有资源都与 istio 对该进程所缓存的资源监听列表一一对应。因此针对这三种资源,我们应该设计分别对应的本地的资源缓存池,dubbo 尝试资源的时候先去缓存池查询,若有结果则直接返回;否则将本地缓存池的资源列表与想要发送的资源聚合后,发送给istio让其更新自身的监听列表。缓存池如下,其中key代表单个资源,T为不同DS的返回结果:protected Map<String, T> resourcesMap = new ConcurrentHashMap<>();有了缓存池我们必须有一个监听缓存池的结构或者容器,在这里我们设计为Map的形式,如下:protected Map<Set<String>, List<Consumer<Map<String, T>>>> consumerObserveMap = new ConcurrentHashMap<>();其中key为想要监听的资源,value为一个List, 之所以设计为List是为了可以支持重复订阅。List存储的item为jdk8中的Consumer类型,它可以用于传递一个函数或者行为,其入参为Map,其key对应所要监听的单个资源,便于从缓存池中获取。如上文所述,PilotExchanger负责串联整个流程,不同DS之间的更新关系可以用Consumer进行传递。以监听LDS observeResource为例, 大致代码如下:// 监听
void observeResource(Set<String> resourceNames, Consumer<Map<String, T>> consumer, boolean isReConnect);
// Observe LDS updated
ldsProtocol.observeResource(ldsResourcesName, (newListener) -> {
// LDS数据不一致
if (!newListener.equals(listenerResult)) {
//更新LDS数据
this.listenerResult = newListener;
// 触发RDS监听
if (isRdsObserve.get()) {
createRouteObserve();
}
}
}, false);Stream流模式改为建立持久化连接之后,我们也需要把这个Consumer的行为存储在本地的缓存池中。Istio收到来自dubbo的推送请求后,刷新自身缓存的资源列表并返回响应。此时istio返回的响应内容是聚合后的结果,Dubbo收到响应后,将响应资源拆分为更小的资源粒度,再推送给对应的 Dubbo应用通知其进行变更。▧踩坑点istio推送的数据可能为空字符串,此时缓存池子无需存储,直接跳过即可。否则dubbo会绕过缓冲池,不断向istio发送请求。考虑以下场景,dubbo应用同时订阅了两个接口,分别由app1和app2提供。为避免监听之间的相互覆盖,因此向istio发送数据时,需要聚合所有监听的资源名一次性发起。4.2 多点独立监听在第一次向istio发送请求时会调用getResource方法先去cache查询,缺失了再聚合数据去istio请求数据,istio再返回相应的结果给dubbo。我们处理istio的响应有两种实现方案:1. 由用户在getResource方案中new 一个completeFuture,由cache分析是否是需要的数据,若确认是新数据则由该future回调传递结果。2. getResource建立资源的监听器consumerObserveMap,定义一个consumer并把取到的数据同步到原来的线程,cache 收到来自istio的推送后会做两件事:将数据推送所有监听器和将数据发送给该资源的监听器。以上两种方案都能实现,但最大的区别就是用户调用onNext发送数据给istio的时候需不需要感知getResource 的存在。综上,最终选择方案2进行实现。具体实现逻辑是让dubbo与istio建立连接后,istio会推送自身监听到资源列表给dubbo,dubbo解析响应,并根据监听的不同app切分数据,并刷新本地缓存池的数据,并发送ACK响应给istio,大致流程如下:@startuml
object Car
object Bus
object Tire
object Engine
object Driver
Car <|- Bus
Car *-down- Tire
Car *-down- Engine
Bus o-down- Driver
@enduml部分关键代码如下:public class ResponseObserver implements XXX {
...
public void onNext(DiscoveryResponse value) {
//接受来自istio的数据并切分
Map<String, T> newResult = decodeDiscoveryResponse(value);
//本地缓存池数据
Map<String, T> oldResource = resourcesMap;
//刷新缓存池数据
discoveryResponseListener(oldResource, newResult);
resourcesMap = newResult;
// for ACK
requestObserver.onNext(buildDiscoveryRequest(Collections.emptySet(), value));
}
...
public void discoveryResponseListener(Map<String, T> oldResult,
Map<String, T> newResult) {
....
}
}
//具体实现交由LDS、RDS、EDS自身
protected abstract Map<String, T> decodeDiscoveryResponse(DiscoveryResponse response){
//比对新数据和缓存池的资源,并将不同时存在于两种池子的资源取出
...
for (Map.Entry<Set<String>, List<Consumer<Map<String, T>>>> entry : consumerObserveMap.entrySet()) {
// 本地缓存池不存在则跳过
...
//聚合资源
Map<String, T> dsResultMap = entry.getKey()
.stream()
.collect(Collectors.toMap(k -> k, v -> newResult.get(v)));
//刷新缓存池数据
entry.getValue().forEach(o -> o.accept(dsResultMap));
}
}
▧踩坑点原本多个stream流的情况下,会用递增的requestId来复用stream流,改成持久化连接之后,一种resource会有多个requestid,可能会相互覆盖,因此必须去掉这个机制。初始实现方案并没有对资源进行切分,而是一把梭,考虑到后续对其他DS的支持,对istio返回的数据进行切分,也导致consumerObserveMap有点奇形怪状。三种DS在发送数据时可以共享同一channel,但监听所用到的必须是同一channel,否则数据变更时istio不会进行推送。建立双向stream流之后,初始方案future为全局共享。但可能有这样的场景:相同的ds两次相邻时间的onNext事件,记为A事件和B事件,可能是A事件先发送,B随后;但可能是B事件的结果先返回,不确定istio推送的时间,因此future必须是局部变量而不是全局共享。4.3 采用读写锁避免并发冲突监听器consumerObserveMap和缓存池resourcesMap均可能产生并发冲突。对于resourcemap,由于put操作都集中在getResource方法,因此可以采用悲观锁就能锁住相应的资源,避免资源的并发监听。对于consumerObserveMap,同时存在put、remove和遍历操作,从时序上,采用读写锁可规避冲突,对于遍历操作加读锁,对于put和remove操作加写锁,即可避免并发冲突。综上,resourcesMap加悲观锁即可,consumerObserveMap涉及的操作场景如下:远程请求istio时候会往consumerObserveMap新增数据,加写锁。CompleteFuture跨线程返回数据后,去掉监听future,加写锁。监听缓存池时会往consumerObserveMap新增监听,加写锁。断线重连时会往consumerObserveMap新增监听,加写锁。解析istio返回的数据,遍历缓存池并刷新数据,加读锁。▧踩坑点由于dubbo和istio建立的是是双向stream流,相同的ds两次相邻时间的onNext事件,记为A事件和B事件,可能是A事件先发送,B随后;但可能是B事件的结果先返回,不确定istio推送的时间。因此需要加锁。4.4 断线重连断线重连只需要用定时任务去定时与istio交互,尝试获取授信证书,证书获取成功即可视为istio成功重新上线,dubbo会聚合本地的资源去istio请求数据,并解析响应和刷新本地缓存池数据,最后再关闭定时任务。▧踩坑点采用全局共享的定时任务池,不能进行关闭,否则会影响其他业务。05 感想与总结在这次功能的改造中,笔者着实掉了一波头发,怎么找bug也找不到的情形不在少数。除了上述提到的坑点之外,其他的坑点包括但不局限于:dubbo在某一次迭代里更改了获取k8s证书的方式,授权失败。原本的功能没问题,merge了下master代码,grpc版本与envoy版本不兼容,各种报错,最后靠降低版本成功解决。原本的功能没问题,merge了下master代码,最新分支代码里metadataservice发成了triple,然而在Proxyless模式下只支持dubbo协议,debug了三四天,最后发现需要增加配置。......但不得不承认,Proxyless Service Mesh确实有它自身的优势和广阔的市场前景。自dubbo3.1.0 release版本之后,dubbo已经实现了Proxyless Service Mesh能力,未来dubbo社区将深度联动业务,解决更多实际生产环境中的痛点,更好地完善service mesh能力。
高德Go生态的服务稳定性建设|性能优化的实战总结
本文共同作者:阳迪、联想、君清前言go语言凭借着优秀的性能,简洁的编码风格,极易使用的协程等优点,逐渐在各大互联网公司中流行起来。而高德业务使用go语言已经有3年时间了,随着高德业务的发展,go语言生态也日趋完善,今后会有越来越多新的go服务出现。在任何时候,保障服务的稳定性都是首要的,go服务也不例外,而性能优化作为保障服务稳定性,降本增效的重要手段之一,在高德go服务日益普及的当下显得愈发重要。此时此刻,我们将过去go服务开发中的性能调优经验进行总结和沉淀,为您呈上这篇精心准备的go性能调优指南。通过本文您将收获以下内容: 从理论的角度,和你一起捋清性能优化的思路,制定最合适的优化方案。推荐几款go语言性能分析利器,与你一起在性能优化的路上披荆斩棘。总结归纳了众多go语言中常用的性能优化小技巧,总有一个你能用上。基于高德go服务百万级QPS实践,分享几个性能优化实战案例,让性能优化不再是纸上谈兵。1. 性能调优-理论篇1.1 衡量指标优化的第一步是先衡量一个应用性能的好坏,性能良好的应用自然不必费心优化,性能较差的应用,则需要从多个方面来考察,找到木桶里的短板,才能对症下药。那么如何衡量一个应用的性能好坏呢?最主要的还是通过观察应用对核心资源的占用情况以及应用的稳定性指标来衡量。所谓核心资源,就是相对稀缺的,并且可能会导致应用无法正常运行的资源,常见的核心资源如下:cpu:对于偏计算型的应用,cpu往往是影响性能好坏的关键,如果代码中存在无限循环,或是频繁的线程上下文切换,亦或是糟糕的垃圾回收策略,都将导致cpu被大量占用,使得应用程序无法获取到足够的cpu资源,从而响应缓慢,性能变差。内存:内存的读写速度非常快,往往不是性能的瓶颈,但是内存相对来说容量有限切价格昂贵,如果应用大量分配内存而不及时回收,就会造成内存溢出或泄漏,应用无法分配新的内存,便无法正常运行,这将导致很严重的事故。带宽:对于偏网络I/O型的应用,例如网关服务,带宽的大小也决定了应用的性能好坏,如果带宽太小,当系统遇到大量并发请求时,带宽不够用,网络延迟就会变高,这个虽然对服务端可能无感知,但是对客户端则是影响甚大。磁盘:相对内存来说,磁盘价格低廉,容量很大,但是读写速度较慢,如果应用频繁的进行磁盘I/O,那性能可想而知也不会太好。以上这些都是系统资源层面用于衡量性能的指标,除此之外还有应用本身的稳定性指标:异常率:也叫错误率,一般分两种,执行超时和应用panic。panic会导致应用不可用,虽然服务通常都会配置相应的重启机制,确保偶然的应用挂掉后能重启再次提供服务,但是经常性的panic,会导致应用频繁的重启,减少了应用正常提供服务的时间,整体性能也就变差了。异常率是非常重要的指标,服务的稳定和可用是一切的前提,如果服务都不可用了,还谈何性能优化。响应时间(RT):包括平均响应时间,百分位(top percentile)响应时间。响应时间是指应用从收到请求到返回结果后的耗时,反应的是应用处理请求的快慢。通常平均响应时间无法反应服务的整体响应情况,响应慢的请求会被响应快的请求平均掉,而响应慢的请求往往会给用户带来糟糕的体验,即所谓的长尾请求,所以我们需要百分位响应时间,例如tp99响应时间,即99%的请求都会在这个时间内返回。吞吐量:主要指应用在一定时间内处理请求/事务的数量,反应的是应用的负载能力。我们当然希望在应用稳定的情况下,能承接的流量越大越好,主要指标包括QPS(每秒处理请求数)和QPM(每分钟处理请求数)。1.2 制定优化方案明确了性能指标以后,我们就可以评估一个应用的性能好坏,同时也能发现其中的短板并对其进行优化。但是做性能优化,有几个点需要提前注意:第一,不要反向优化。比如我们的应用整体占用内存资源较少,但是rt偏高,那我们就针对rt做优化,优化完后,rt下降了30%,但是cpu使用率上升了50%,导致一台机器负载能力下降30%,这便是反向优化。性能优化要从整体考虑,尽量在优化一个方面时,不影响其他方面,或是其他方面略微下降。第二,不要过度优化。如果应用性能已经很好了,优化的空间很小,比如rt的tp99在2ms内,继续尝试优化可能投入产出比就很低了,不如将这些精力放在其他需要优化的地方上。由此可见,在优化之前,明确想要优化的指标,并制定合理的优化方案是很重要的。常见的优化方案有以下几种:优化代码有经验的程序员在编写代码时,会时刻注意减少代码中不必要的性能消耗,比如使用strconv而不是fmt.Sprint进行数字到字符串的转化,在初始化map或slice时指定合理的容量以减少内存分配等。良好的编程习惯不仅能使应用性能良好,同时也能减少故障发生的几率。总结下来,常用的代码优化方向有以下几种:提高复用性,将通用的代码抽象出来,减少重复开发。池化,对象可以池化,减少内存分配;协程可以池化,避免无限制创建协程打满内存。并行化,在合理创建协程数量的前提下,把互不依赖的部分并行处理,减少整体的耗时。异步化,把不需要关心实时结果的请求,用异步的方式处理,不用一直等待结果返回。算法优化,使用时间复杂度更低的算法。使用设计模式设计模式是对代码组织形式的抽象和总结,代码的结构对应用的性能有着重要的影响,结构清晰,层次分明的代码不仅可读性好,扩展性高,还能避免许多潜在的性能问题,帮助开发人员快速找到性能瓶颈,进行专项优化,为服务的稳定性提供保障。常见的对性能有所提升的设计模式例如单例模式,我们可以在应用启动时将需要的外部依赖服务用单例模式先初始化,避免创建太多重复的连接。空间换时间或时间换空间在优化的前期,可能一个小的优化就能达到很好的效果。但是优化的尽头,往往要面临抉择,鱼和熊掌不可兼得。性能优秀的应用往往是多项资源的综合利用最优。为了达到综合平衡,在某些场景下,就需要做出一些调整和牺牲,常用的方法就是空间换时间或时间换空间。比如在响应时间优先的场景下,把需要耗费大量计算时间或是网络i/o时间的中间结果缓存起来,以提升后续相似请求的响应速度,便是空间换时间的一种体现。使用更好的三方库在我们的应用中往往会用到很多开源的第三方库,目前在github上的go开源项目就有173万+。有很多go官方库的性能表现并不佳,比如go官方的日志库性能就一般,下面是zap发布的基准测试信息(记录一条消息和10个字段的性能表现)。PackageTimeTime % to zapObjects Allocated⚡️ zap862 ns/op+0%5 allocs/op⚡️ zap (sugared)1250 ns/op+45%11 allocs/opzerolog4021 ns/op+366%76 allocs/opgo-kit4542 ns/op+427%105 allocs/opapex/log26785 ns/op+3007%115 allocs/oplogrus29501 ns/op+3322%125 allocs/oplog1529906 ns/op+3369%122 allocs/op从上面可以看出zap的性能比同类结构化日志包更好,也比标准库更快,那我们就可以选择更好的三方库。2. 性能调优-工具篇当我们找到应用的性能短板,并针对短板制定相应优化方案,最后按照方案对代码进行优化之后,我们怎么知道优化是有效的呢?直接将代码上线,观察性能指标的变化,风险太大了。此时我们需要有好用的性能分析工具,帮助我们检验优化的效果,下面将为大家介绍几款go语言中性能分析的利器。2.1 benchmarkGo语言标准库内置的 testing 测试框架提供了基准测试(benchmark)的能力,benchmark可以帮助我们评估代码的性能表现,主要方式是通过在一定时间(默认1秒)内重复运行测试代码,然后输出执行次数和内存分配结果。下面我们用一个简单的例子来验证一下,strconv是否真的比fmt.Sprint快。首先我们来编写一段基准测试的代码,如下:package main
import (
"fmt"
"strconv"
"testing"
)
func BenchmarkStrconv(b *testing.B) {
for n := 0; n < b.N; n++ {
strconv.Itoa(n)
}
}
func BenchmarkFmtSprint(b *testing.B) {
for n := 0; n < b.N; n++ {
fmt.Sprint(n)
}
}我们可以用命令行go test -bench . 来运行基准测试,输出结果如下:goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkStrconv-12 41988014 27.41 ns/op
BenchmarkFmtSprint-12 13738172 81.19 ns/op
ok main 7.039s可以看到strconv每次执行只用了27.41纳秒,而fmt.Sprint则是81.19纳秒,strconv的性能是fmt.Sprint的三倍,那为什么strconv要更快呢?会不会是这次运行时间太短呢?为了公平起见,我们决定让他们再比赛一轮,这次我们延长比赛时间,看看结果如何。通过go test -bench . -benchtime=5s 命令,我们可以把测试时间延长到5秒,结果如下:goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkStrconv-12 211533207 31.60 ns/op
BenchmarkFmtSprint-12 69481287 89.58 ns/op
PASS
ok main 18.891s结果有些变化,strconv每次执行的时间上涨了4ns,但变化不大,差距仍有2.9倍。但是我们仍然不死心,我们决定让他们一次跑三轮,每轮5秒,三局两胜。通过go test -bench . -benchtime=5s -count=3 命令,我们可以把测试进行3轮,结果如下:goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkStrconv-12 217894554 31.76 ns/op
BenchmarkStrconv-12 217140132 31.45 ns/op
BenchmarkStrconv-12 219136828 31.79 ns/op
BenchmarkFmtSprint-12 70683580 89.53 ns/op
BenchmarkFmtSprint-12 63881758 82.51 ns/op
BenchmarkFmtSprint-12 64984329 82.04 ns/op
PASS
ok main 54.296s结果变化也不大,看来strconv是真的比fmt.Sprint快很多。那快是快,会不会内存分配上情况就相反呢?通过go test -bench . -benchmem 这个命令我们可以看到两个方法的内存分配情况,结果如下:goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkStrconv-12 43700922 27.46 ns/op 7 B/op 0 allocs/op
BenchmarkFmtSprint-12 143412 80.88 ns/op 16 B/op 2 allocs/op
PASS
ok main 7.031s可以看到strconv在内存分配上是0次,每次运行使用的内存是7字节,只是fmt.Sprint的43.8%,简直是全方面的优于fmt.Sprint啊。那究竟是为什么strconv比fmt.Sprint好这么多呢?通过查看strconv的代码,我们发现,对于小于100的数字,strconv是直接通过digits和smallsString这两个常量进行转换的,而大于等于100的数字,则是通过不断除以100取余,然后再找到余数对应的字符串,把这些余数的结果拼起来进行转换的。const digits = "0123456789abcdefghijklmnopqrstuvwxyz"
const smallsString = "00010203040506070809" +
"10111213141516171819" +
"20212223242526272829" +
"30313233343536373839" +
"40414243444546474849" +
"50515253545556575859" +
"60616263646566676869" +
"70717273747576777879" +
"80818283848586878889" +
"90919293949596979899"
// small returns the string for an i with 0 <= i < nSmalls.
func small(i int) string {
if i < 10 {
return digits[i : i+1]
}
return smallsString[i*2 : i*2+2]
}
func formatBits(dst []byte, u uint64, base int, neg, append_ bool) (d []byte, s string) {
...
for j := 4; j > 0; j-- {
is := us % 100 * 2
us /= 100
i -= 2
a[i+1] = smallsString[is+1]
a[i+0] = smallsString[is+0]
}
...
}而fmt.Sprint则是通过反射来实现这一目的的,fmt.Sprint得先判断入参的类型,在知道参数是int型后,再调用fmt.fmtInteger方法把int转换成string,这多出来的步骤肯定没有直接把int转成string来的高效。// fmtInteger formats signed and unsigned integers.
func (f *fmt) fmtInteger(u uint64, base int, isSigned bool, verb rune, digits string) {
...
switch base {
case 10:
for u >= 10 {
i--
next := u / 10
buf[i] = byte('0' + u - next*10)
u = next
}
...
}benchmark还有很多实用的函数,比如ResetTimer可以重置启动时耗费的准备时间,StopTimer和StartTimer则可以暂停和启动计时,让测试结果更集中在核心逻辑上。2.2 pprof2.2.1 使用介绍pprof是go语言官方提供的profile工具,支持可视化查看性能报告,功能十分强大。pprof基于定时器(10ms/次)对运行的go程序进行采样,搜集程序运行时的堆栈信息,包括CPU时间、内存分配等,最终生成性能报告。pprof有两个标准库,使用的场景不同:runtime/pprof 通过在代码中显式的增加触发和结束埋点来收集指定代码块运行时数据生成性能报告。net/http/pprof 是对runtime/pprof的二次封装,基于web服务运行,通过访问链接触发,采集服务运行时的数据生成性能报告。runtime/pprof的使用方法如下:package main
import (
"os"
"runtime/pprof"
"time"
)
func main() {
w, _ := os.OpenFile("test_cpu", os.O_RDWR | os.O_CREATE | os.O_APPEND, 0644)
pprof.StartCPUProfile(w)
time.Sleep(time.Second)
pprof.StopCPUProfile()
}我们也可以使用另外一种方法,net/http/pprof:package main
import (
"net/http"
_ "net/http/pprof"
)
func main() {
err := http.ListenAndServe(":6060", nil)
if err != nil {
panic(err)
}
}将程序run起来后,我们通过访问http://127.0.0.1:6060/debug/pprof/就可以看到如下页面:点击profile就可以下载cpu profile文件。那我们如何查看我们的性能报告呢? pprof支持两种查看模式,终端和web界面,注意: 想要查看可视化界面需要提前安装graphviz。这里我们以web界面为例,在终端内我们输入如下命令:go tool pprof -http :6060 test_cpu就会在浏览器里打开一个页面,内容如下:从界面左上方VIEW栏下,我们可以看到,pprof支持Flame Graph,dot Graph和Top等多种视图,下面我们将一一介绍如何阅读这些视图。2.2.1 火焰图 Flame Graph如何阅读首先,推荐直接阅读火焰图,在查函数耗时场景,这个比较直观;最简单的:横条越长,资源消耗、占用越多; 注意:每一个function 的横条虽然很长,但可能是他的下层“子调用”耗时产生的,所以一定要关注“下一层子调用”各自的耗时分布;每个横条支持点击下钻能力,可以更详细的分析子层的耗时占比。2.2.2 dot Graph 图如何阅读英文原文在这里:https://github.com/google/pprof/blob/master/doc/README.md节点颜色:红色表示耗时多的节点;绿色表示耗时少的节点;灰色表示耗时几乎可以忽略不计(接近零);节点字体大小 :字体越大,表示占“上层函数调用”比例越大;(其实上层函数自身也有耗时,没包含在此)字体越小,表示占“上层函数调用”比例越小;线条(边)粗细:线条越粗,表示消耗了更多的资源;反之,则越少;线条(边)颜色:颜色越红,表示性能消耗占比越高;颜色越绿,表示性能消耗占比越低;灰色,表示性能消耗几乎可以忽略不计;虚线:表示中间有一些节点被“移除”或者忽略了;(一般是因为耗时较少所以忽略了) 实线:表示节点之间直接调用 内联边标记:被调用函数已经被内联到调用函数中(对于一些代码行比较少的函数,编译器倾向于将它们在编译期展开从而消除函数调用,这种行为就是内联。)2.2.3 TOP 表如何阅读flat:当前函数,运行耗时(不包含内部调用其他函数的耗时)flat%:当前函数,占用的 CPU 运行耗时总比例(不包含外部调用函数)sum%:当前行的flat%与上面所有行的flat%总和。cum:当前函数加上它内部的调用的运行总耗时(包含内部调用其他函数的耗时)cum%:同上的 CPU 运行耗时总比例2.3 tracepprof已经有了对内存和CPU的分析能力,那trace工具有什么不同呢?虽然pprof的CPU分析器,可以告诉你什么函数占用了最多的CPU时间,但它并不能帮助你定位到是什么阻止了goroutine运行,或者在可用的OS线程上如何调度goroutines。这正是trace真正起作用的地方。我们需要更多关于Go应用中各个goroutine的执行情况的更为详细的信息,可以从P(goroutine调度器概念中的processor)和G(goroutine调度器概念中的goroutine)的视角完整的看到每个P和每个G在Tracer开启期间的全部“所作所为”,对Tracer输出数据中的每个P和G的行为分析并结合详细的event数据来辅助问题诊断的。Tracer可以帮助我们记录的详细事件包含有:与goroutine调度有关的事件信息:goroutine的创建、启动和结束;goroutine在同步原语(包括mutex、channel收发操作)上的阻塞与解锁。与网络有关的事件:goroutine在网络I/O上的阻塞和解锁;与系统调用有关的事件:goroutine进入系统调用与从系统调用返回;与垃圾回收器有关的事件:GC的开始/停止,并发标记、清扫的开始/停止。Tracer主要也是用于辅助诊断这三个场景下的具体问题的:并行执行程度不足的问题:比如没有充分利用多核资源等;因GC导致的延迟较大的问题;Goroutine执行情况分析,尝试发现goroutine因各种阻塞(锁竞争、系统调用、调度、辅助GC)而导致的有效运行时间较短或延迟的问题。2.3.1 trace性能报告打开trace性能报告,首页信息包含了多维度数据,如下图:View trace:以图形页面的形式渲染和展示tracer的数据,这也是我们最为关注/最常用的功能Goroutine analysis:以表的形式记录执行同一个函数的多个goroutine的各项trace数据Network blocking profile:用pprof profile形式的调用关系图展示网络I/O阻塞的情况Synchronization blocking profile:用pprof profile形式的调用关系图展示同步阻塞耗时情况Syscall blocking profile:用pprof profile形式的调用关系图展示系统调用阻塞耗时情况Scheduler latency profile:用pprof profile形式的调用关系图展示调度器延迟情况User-defined tasks和User-defined regions:用户自定义trace的task和regionMinimum mutator utilization:分析GC对应用延迟和吞吐影响情况的曲线图通常我们最为关注的是View trace和Goroutine analysis,下面将详细说说这两项的用法。2.3.2 view trace如果Tracer跟踪时间较长,trace会将View trace按时间段进行划分,避免触碰到trace-viewer的限制:View trace使用快捷键来缩放时间线标尺:w键用于放大(从秒向纳秒缩放),s键用于缩小标尺(从纳秒向秒缩放)。我们同样可以通过快捷键在时间线上左右移动:s键用于左移,d键用于右移。(游戏快捷键WASD)采样状态这个区内展示了三个指标:Goroutines、Heap和Threads,某个时间点上的这三个指标的数据是这个时间点上的状态快照采样:Goroutines:某一时间点上应用中启动的goroutine的数量,当我们点击某个时间点上的goroutines采样状态区域时(我们可以用快捷键m来准确标记出那个时间点),事件详情区会显示当前的goroutines指标采样状态:Heap指标则显示了某个时间点上Go应用heap分配情况(包括已经分配的Allocated和下一次GC的目标值NextGC):Threads指标显示了某个时间点上Go应用启动的线程数量情况,事件详情区将显示处于InSyscall(整阻塞在系统调用上)和Running两个状态的线程数量情况:P视角区这里将View trace视图中最大的一块区域称为“P视角区”。这是因为在这个区域,我们能看到Go应用中每个P(Goroutine调度概念中的P)上发生的所有事件,包括:EventProcStart、EventProcStop、EventGoStart、EventGoStop、EventGoPreempt、Goroutine辅助GC的各种事件以及Goroutine的GC阻塞(STW)、系统调用阻塞、网络阻塞以及同步原语阻塞(mutex)等事件。除了每个P上发生的事件,我们还可以看到以单独行显示的GC过程中的所有事件。事件详情区点选某个事件后,关于该事件的详细信息便会在这个区域显示出来,事件详情区可以看到关于该事件的详细信息:Title:事件的可读名称;Start:事件的开始时间,相对于时间线上的起始时间;Wall Duration:这个事件的持续时间,这里表示的是G1在P4上此次持续执行的时间;Start Stack Trace:当P4开始执行G1时G1的调用栈;End Stack Trace:当P4结束执行G1时G1的调用栈;从上面End Stack Trace栈顶的函数为runtime.asyncPreempt来看,该Goroutine G1是被强行抢占了,这样P4才结束了其运行;Incoming flow:触发P4执行G1的事件;Outgoing flow:触发G1结束在P4上执行的事件;Preceding events:与G1这个goroutine相关的之前的所有的事件;Follwing events:与G1这个goroutine相关的之后的所有的事件All connected:与G1这个goroutine相关的所有事件。2.3.3 Goroutine analysisGoroutine analysis提供了从G视角看Go应用执行的图景。与View trace不同,这次页面中最广阔的区域提供的G视角视图,而不再是P视角视图。在这个视图中,每个G都会对应一个单独的条带(和P视角视图一样,每个条带都有两行),通过这一条带可以按时间线看到这个G的全部执行情况。通常仅需在goroutine analysis的表格页面找出执行最快和最慢的两个goroutine,在Go视角视图中沿着时间线对它们进行对比,以试图找出执行慢的goroutine究竟出了什么问题。2.4 后记虽然pprof和trace有着非常强大的profile能力,但在使用过程中,仍存在以下痛点:获取性能报告麻烦:一般大家做压测,为了更接近真实环境性能态,都使用生产环境/pre环境进行。而出于安全考虑,生产环境内网一般和PC办公内网是隔离不通的,需要单独配置通路才可以获得生产环境内网的profile 文件下载到PC办公电脑中,这也有一些额外的成本;查看profile分析报告麻烦:之前大家在本地查看profile 分析报告,一般 go tool pprof -http=":8083" profile 命令在本地PC开启一个web service 查看,并且需要至少安装graphviz 等库。查看trace分析同样麻烦:查看go trace 的profile 信息来分析routine 锁和生命周期时,也需要类似的方式在本地PC执行命令 go tool trace mytrace.profile 分享麻烦:如果我想把自己压测的性能结果内容,分享个另一位同学,那只能把1中获取的性能报告“profile文件”通过钉钉发给被分享人。然而有时候本地profile文件比较多,一不小心就发错了,还不如截图,但是截图又没有了交互放大、缩小、下钻等能力。处处不给力!留存复盘麻烦:系统的性能分析就像一份病历,每每看到阶段性的压测报告,总结或者对照时,不禁要询问,做过了哪些优化和改造,病因病灶是什么,有没有共性,值不值得总结归纳,现在是不是又面临相似的性能问题?那么能不能开发一个平台工具,解决以上的这些痛点呢?目前在阿里集团内部,高德的研发同学已经通过对go官方库的定制开发,实现了go语言性能平台,解决了以上这些痛点,并在内部进行了开源。该平台已面向阿里集团,累计实现性能场景快照数万条的获取和分析,解决了很多的线上服务性能调试和优化问题,这里暂时不展开,后续有机会可以单独分享。3. 性能调优-技巧篇除了前面提到的尽量用strconv而不是fmt.Sprint进行数字到字符串的转化以外,我们还将介绍一些在实际开发中经常会用到的技巧,供各位参考。3.1 字符串拼接拼接字符串为了书写方便快捷,最常用的两个方法是运算符 + 和 fmt.Sprintf()运算符 + 只能简单地完成字符串之间的拼接,fmt.Sprintf() 其底层实现使用了反射,性能上会有所损耗。从性能出发,兼顾易用可读,如果待拼接的变量不涉及类型转换且数量较少(<=5),拼接字符串推荐使用运算符 +,反之使用 fmt.Sprintf()。// 推荐:用+进行字符串拼接
func BenchmarkPlus(b *testing.B) {
for i := 0; i < b.N; i++ {
s := "a" + "b"
_ = s
}
}
// 不推荐:用fmt.Sprintf进行字符串拼接
func BenchmarkFmt(b *testing.B) {
for i := 0; i < b.N; i++ {
s := fmt.Sprintf("%s%s", "a", "b")
_ = s
}
}
goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkPlus-12 1000000000 0.2658 ns/op 0 B/op 0 allocs/op
BenchmarkFmt-12 16559949 70.83 ns/op 2 B/op 1 allocs/op
PASS
ok main 5.908s3.2 提前指定容器容量在初始化slice时,尽量指定容量,这是因为当添加元素时,如果容量的不足,slice会重新申请一个更大容量的容器,然后把原来的元素复制到新的容器中。// 推荐:初始化时指定容量
func BenchmarkGenerateWithCap(b *testing.B) {
nums := make([]int, 0, 10000)
for n := 0; n < b.N; n++ {
for i:=0; i < 10000; i++ {
nums = append(nums, i)
}
}
}
// 不推荐:初始化时不指定容量
func BenchmarkGenerate(b *testing.B) {
nums := make([]int, 0)
for n := 0; n < b.N; n++ {
for i:=0; i < 10000; i++ {
nums = append(nums, i)
}
}
}
goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkGenerateWithCap-12 23508 336485 ns/op 476667 B/op 0 allocs/op
BenchmarkGenerate-12 22620 68747 ns/op 426141 B/op 0 allocs/op
PASS
ok main 16.628s3.3 遍历 []struct{} 使用下标而不是 range常用的遍历方式有两种,一种是for循环下标遍历,一种是for循环range遍历,这两种遍历在性能上是否有差异呢?让我们来一探究竟。针对[]int,我们来看看两种遍历有和差别吧func getIntSlice() []int {
nums := make([]int, 1024, 1024)
for i := 0; i < 1024; i++ {
nums[i] = i
}
return nums
}
// 用下标遍历[]int
func BenchmarkIndexIntSlice(b *testing.B) {
nums := getIntSlice()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var tmp int
for k := 0; k < len(nums); k++ {
tmp = nums[k]
}
_ = tmp
}
}
// 用range遍历[]int元素
func BenchmarkRangeIntSlice(b *testing.B) {
nums := getIntSlice()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var tmp int
for _, num := range nums {
tmp = num
}
_ = tmp
}
}
goos: darwin
goarch: amd64
pkg: demo/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkIndexIntSlice-12 3923230 270.2 ns/op 0 B/op 0 allocs/op
BenchmarkRangeIntSlice-12 4518495 287.8 ns/op 0 B/op 0 allocs/op
PASS
ok demo/test 3.303s可以看到,在遍历[]int时,两种方式并无差别。我们再看看遍历[]struct{}的情况type Item struct {
id int
val [1024]byte
}
// 推荐:用下标遍历[]struct{}
func BenchmarkIndexStructSlice(b *testing.B) {
var items [1024]Item
for i := 0; i < b.N; i++ {
var tmp int
for j := 0; j < len(items); j++ {
tmp = items[j].id
}
_ = tmp
}
}
// 推荐:用range的下标遍历[]struct{}
func BenchmarkRangeIndexStructSlice(b *testing.B) {
var items [1024]Item
for i := 0; i < b.N; i++ {
var tmp int
for k := range items {
tmp = items[k].id
}
_ = tmp
}
}
// 不推荐:用range遍历[]struct{}的元素
func BenchmarkRangeStructSlice(b *testing.B) {
var items [1024]Item
for i := 0; i < b.N; i++ {
var tmp int
for _, item := range items {
tmp = item.id
}
_ = tmp
}
}
goos: darwin
goarch: amd64
pkg: demo/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkIndexStructSlice-12 4413182 266.7 ns/op 0 B/op 0 allocs/op
BenchmarkRangeIndexStructSlice-12 4545476 269.4 ns/op 0 B/op 0 allocs/op
BenchmarkRangeStructSlice-12 33300 35444 ns/op 0 B/op 0 allocs/op
PASS
ok demo/test 5.282s可以看到,用for循环下标的方式性能都差不多,但是用range遍历数组里的元素时,性能则相差很多,前面两种方法是第三种方法的130多倍。主要原因是通过for k, v := range获取到的元素v实际上是原始值的一个拷贝。所以在面对复杂的struct进行遍历的时候,推荐使用下标。但是当遍历对象是复杂结构体的指针([]*struct{})时,用下标还是用range迭代元素的性能就差不多了。3.4 利用unsafe包避开内存copyunsafe包提供了任何类型的指针和 unsafe.Pointer 的相互转换及uintptr 类型和 unsafe.Pointer 可以相互转换,如下图unsafe包指针转换关系依据上述转换关系,其实除了string和[]byte的转换,也可以用于slice、map等的求长度及一些结构体的偏移量获取等,但是这种黑科技在一些情况下会带来一些匪夷所思的诡异问题,官方也不建议用,所以还是慎用,除非你确实很理解各种机制了,这里给出项目中实际用到的常规string和[]byte之间的转换,如下:func Str2bytes(s string) []byte {
x := (*[2]uintptr)(unsafe.Pointer(&s))
h := [3]uintptr{x[0], x[1], x[1]}
return *(*[]byte)(unsafe.Pointer(&h))
}
func Bytes2str(b []byte) string {
return *(*string)(unsafe.Pointer(&b))
}
我们通过benchmark来验证一下是否性能更优:// 推荐:用unsafe.Pointer实现string到bytes
func BenchmarkStr2bytes(b *testing.B) {
s := "testString"
var bs []byte
for n := 0; n < b.N; n++ {
bs = Str2bytes(s)
}
_ = bs
}
// 不推荐:用类型转换实现string到bytes
func BenchmarkStr2bytes2(b *testing.B) {
s := "testString"
var bs []byte
for n := 0; n < b.N; n++ {
bs = []byte(s)
}
_ = bs
}
// 推荐:用unsafe.Pointer实现bytes到string
func BenchmarkBytes2str(b *testing.B) {
bs := Str2bytes("testString")
var s string
b.ResetTimer()
for n := 0; n < b.N; n++ {
s = Bytes2str(bs)
}
_ = s
}
// 不推荐:用类型转换实现bytes到string
func BenchmarkBytes2str2(b *testing.B) {
bs := Str2bytes("testString")
var s string
b.ResetTimer()
for n := 0; n < b.N; n++ {
s = string(bs)
}
_ = s
}
goos: darwin
goarch: amd64
pkg: demo/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkStr2bytes-12 1000000000 0.2938 ns/op 0 B/op 0 allocs/op
BenchmarkStr2bytes2-12 38193139 28.39 ns/op 16 B/op 1 allocs/op
BenchmarkBytes2str-12 1000000000 0.2552 ns/op 0 B/op 0 allocs/op
BenchmarkBytes2str2-12 60836140 19.60 ns/op 16 B/op 1 allocs/op
PASS
ok demo/test 3.301s可以看到使用unsafe.Pointer比强制类型转换性能是要高不少的,从内存分配上也可以看到完全没有新的内存被分配。3.5 协程池go语言最大的特色就是很容易的创建协程,同时go语言的协程调度策略也让go程序可以最大化的利用cpu资源,减少线程切换。但是无限度的创建goroutine,仍然会带来问题。我们知道,一个go协程占用内存大小在2KB左右,无限度的创建协程除了会占用大量的内存空间,同时协程的切换也有不少开销,一次协程切换大概需要100ns,虽然相较于线程毫秒级的切换要优秀很多,但依然存在开销,而且这些协程最后还是需要GC来回收,过多的创建协程,对GC也是很大的压力。所以我们在使用协程时,可以通过协程池来限制goroutine数量,避免无限制的增长。限制协程的方式有很多,比如可以用channel来限制:var wg sync.WaitGroup
ch := make(chan struct{}, 3)
for i := 0; i < 10; i++ {
ch <- struct{}{}
wg.Add(1)
go func(i int) {
defer wg.Done()
log.Println(i)
time.Sleep(time.Second)
<-ch
}(i)
}
wg.Wait()这里通过限制channel长度为3,可以实现最多只有3个协程被创建的效果。当然也可以使用@烟渺实现的errgoup。使用方法如下:func Test_ErrGroupRun(t *testing.T) {
errgroup := WithTimeout(nil, 10*time.Second)
errgroup.SetMaxProcs(4)
for index := 0; index < 10; index++ {
errgroup.Run(nil, index, "test", func(context *gin.Context, i interface{}) (interface{},
error) {
t.Logf("[%s]input:%+v, time:%s", "test", i, time.Now().Format("2006-01-02 15:04:05"))
time.Sleep(2*time.Second)
return i, nil
})
}
errgroup.Wait()
}输出结果如下:=== RUN Test_ErrGroupRun
errgroup_test.go:23: [test]input:0, time:2022-12-04 17:31:29
errgroup_test.go:23: [test]input:3, time:2022-12-04 17:31:29
errgroup_test.go:23: [test]input:1, time:2022-12-04 17:31:29
errgroup_test.go:23: [test]input:2, time:2022-12-04 17:31:29
errgroup_test.go:23: [test]input:4, time:2022-12-04 17:31:31
errgroup_test.go:23: [test]input:5, time:2022-12-04 17:31:31
errgroup_test.go:23: [test]input:6, time:2022-12-04 17:31:31
errgroup_test.go:23: [test]input:7, time:2022-12-04 17:31:31
errgroup_test.go:23: [test]input:8, time:2022-12-04 17:31:33
errgroup_test.go:23: [test]input:9, time:2022-12-04 17:31:33
--- PASS: Test_ErrGroupRun (6.00s)
PASSerrgroup可以通过SetMaxProcs设定协程池的大小,从上面的结果可以看到,最多就4个协程在运行。3.6 sync.Pool 对象复用我们在代码中经常会用到json进行序列化和反序列化,举一个投放活动的例子,一个投放活动会有许多字段会转换为字节数组。type ActTask struct {
Id int64 `ddb:"id"` // 主键id
Status common.Status `ddb:"status"` // 状态 0=初始 1=生效 2=失效 3=过期
BizProd common.BizProd `ddb:"biz_prod"` // 业务类型
Name string `ddb:"name"` // 活动名
Adcode string `ddb:"adcode"` // 城市
RealTimeRuleByte []byte `ddb:"realtime_rule"` // 实时规则json
...
}
type RealTimeRuleStruct struct {
Filter []*struct {
PropertyId int64 `json:"property_id"`
PropertyCode string `json:"property_code"`
Operator string `json:"operator"`
Value []string `json:"value"`
} `json:"filter"`
ExtData [1024]byte `json:"ext_data"`
}
func (at *ActTask) RealTimeRule() *form.RealTimeRule {
if err := json.Unmarshal(at.RealTimeRuleByte, &at.RealTimeRuleStruct); err != nil {
return nil
}
return at.RealTimeRuleStruct
}以这里的实时投放规则为例,我们会将过滤规则反序列化为字节数组。每次json.Unmarshal都会申请一个临时的结构体对象,而这些对象都是分配在堆上的,会给 GC 造成很大压力,严重影响程序的性能。对于需要频繁创建并回收的对象,我们可以使用对象池来提升性能。sync.Pool可以将暂时不用的对象缓存起来,待下次需要的时候直接使用,不用再次经过内存分配,复用对象的内存,减轻 GC 的压力,提升系统的性能。sync.Pool的使用方法很简单,只需要实现 New 函数即可。对象池中没有对象时,将会调用 New 函数创建。var realTimeRulePool = sync.Pool{
New: func() interface{} {
return new(RealTimeRuleStruct)
},
}然后调用 Pool 的 Get() 和 Put() 方法来获取和放回池子中。rule := realTimeRulePool.Get().(*RealTimeRuleStruct)
json.Unmarshal(buf, rule)
realTimeRulePool.Put(rule)Get() 用于从对象池中获取对象,因为返回值是 interface{},因此需要类型转换。Put() 则是在对象使用完毕后,放回到对象池。接下来我们进行性能测试,看看性能如何var realTimeRule = []byte("{\\\"filter\\\":[{\\\"property_id\\\":2,\\\"property_code\\\":\\\"search_poiid_industry\\\",\\\"operator\\\":\\\"in\\\",\\\"value\\\":[\\\"yimei\\\"]},{\\\"property_id\\\":4,\\\"property_code\\\":\\\"request_page_id\\\",\\\"operator\\\":\\\"in\\\",\\\"value\\\":[\\\"all\\\"]}],\\\"white_list\\\":[{\\\"property_id\\\":1,\\\"property_code\\\":\\\"white_list_for_adiu\\\",\\\"operator\\\":\\\"in\\\",\\\"value\\\":[\\\"j838ef77bf227chcl89888f3fb0946\\\",\\\"lb89bea9af558589i55559764bc83e\\\"]}],\\\"ipc_user_tag\\\":[{\\\"property_id\\\":1,\\\"property_code\\\":\\\"ipc_crowd_tag\\\",\\\"operator\\\":\\\"in\\\",\\\"value\\\":[\\\"test_20227041152_mix_ipc_tag\\\"]}],\\\"relation_id\\\":0,\\\"is_copy\\\":true}")
// 推荐:复用一个对象,不用每次都生成新的
func BenchmarkUnmarshalWithPool(b *testing.B) {
for n := 0; n < b.N; n++ {
task := realTimeRulePool.Get().(*RealTimeRuleStruct)
json.Unmarshal(realTimeRule, task)
realTimeRulePool.Put(task)
}
}
// 不推荐:每次都会生成一个新的临时对象
func BenchmarkUnmarshal(b *testing.B) {
for n := 0; n < b.N; n++ {
task := &RealTimeRuleStruct{}
json.Unmarshal(realTimeRule, task)
}
}
goos: darwin
goarch: amd64
pkg: demo/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkUnmarshalWithPool-12 3627546 319.4 ns/op 312 B/op 7 allocs/op
BenchmarkUnmarshal-12 2342208 490.8 ns/op 1464 B/op 8 allocs/op
PASS
ok demo/test 3.525s可以看到,两种方法在时间消耗上差不太多,但是在内存分配上差距明显,使用sync.Pool后内存占用仅为不使用的1/5。3.7 避免系统调用系统调用是一个很耗时的操作,在各种语言中都是,go也不例外,在go的GPM模型中,异步系统调用G会和MP分离,同步系统调用GM会和P分离,不管何种形式除了状态切换及内核态中执行操作耗时外,调度器本身的调度也耗时。所以在可以避免系统调用的地方尽量去避免// 推荐:不使用系统调用
func BenchmarkNoSytemcall(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if configs.PUBLIC_KEY != nil {
}
}
})
}
// 不推荐:使用系统调用
func BenchmarkSytemcall(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if os.Getenv("PUBLIC_KEY") != "" {
}
}
})
}
goos: darwin
goarch: amd64
pkg: demo/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkNoSytemcall-12 1000000000 0.1495 ns/op 0 B/op 0 allocs/op
BenchmarkSytemcall-12 37224988 31.10 ns/op 0 B/op 0 allocs/op
PASS
ok demo/test 1.877s4. 性能调优-实战篇案例1: go协程创建数据库连接不释放导致内存暴涨应用背景感谢@路现提供的案例。遇到的问题及表象特征线上机器偶尔出现内存使用率超过百分之九十报警。分析思路及排查方向在报警触发时,通过直接拉取线上应用的profile文件,查看内存分配情况,我们看到内存分配主要产生在本地缓存的组件上。但是分析代码并没有发现存在内存泄露的情况,看着像是资源一直没有被释放,进一步分析goroutine的profile文件发现存在大量的goroutine未释放,表现在本地缓存击穿后回源数据库,对数据库的查询访问一直不释放。调优手段与效果最终通过排查,发现使用的数据库组件存在bug,在极端情况下会出现死锁的情况,导致数据库访问请求无法返回也无法释放。最终bug修复后升级数据库组件版本解决了问题。案例2: 优惠索引内存分配大,gc 耗时高应用背景感谢@梅东提供的案例。遇到的问题及表象特征接口tp99高,偶尔会有一些特别耗时的请求,导致用户的优惠信息展示不出来分析思路及排查方向通过直接在平台上抓包观察,我们发现使用的分配索引这个方法占用的堆内存特别高,通过 top 可以看到是排在第一位的我们分析代码,可以看到,获取城市索引的地方,每次都是重新申请了内存的,通过改动为返回指针,就不需要每次都单独申请内存了,核心代码改动:调优手段与效果修改后,上线观察,可以看到使用中的内存以及gc耗时都有了明显降低案例3:流量上涨导致cpu异常飙升应用背景感谢@君度提供的案例。遇到的问题及表象特征能量站v2接口和task-home-page接口流量较大时,会造成ab实验策略匹配时cpu飙升分析思路及排查方向调优手段与效果主要优化点如下:1、优化toEntity方法,简化为单独的ID()方法2、优化数组、map初始化结构3、优化adCode转换为string过程4、关闭过多的match log打印优化后profile:优化上线前后CPU的对比案例4: 内存对象未释放导致内存泄漏应用背景感谢@淳深提供的案例,提供案例的服务,日常流量峰值在百万qps左右,是高德内部十分重要的服务。此前该服务是由java实现的,后来用go语言进行重构,在重构完成切全量后,有许多性能优化的优秀案例,这里选取内存泄漏相关的一个案例分享给大家,希望对大家在自己服务进行内存泄漏问题排查时能提供参考和帮助。遇到的问题及表象特征go语言版本全量切流后,每天会对服务各项指标进行详细review,发现每日内存上涨约0.4%,如下图在go版本服务切全量前,从第一张图可以看到整个内存使用是平稳的,无上涨趋势,但是切go版本后,从第二张图可以看到,整个内存曲线呈上升趋势,遂认定内存泄漏,开始排查内存泄漏的“罪魁祸首”。分析思路及排查方向我们先到线上机器抓取当前时间的heap文件,间隔一天后再次抓取heap文件,通过pprof diff对比,我们发现time.NewTicker的内存占用增长了几十MB(由于未保留当时的heap文件,此处没有截图),通过调用栈信息,我们找到了问题的源头,来自中间件vipserver client的SrvHost方法,通过深扒vipserver client代码,我们发现,每个vipserver域名都会有一个对应的协程,这个协程每隔三秒钟就会新建一个ticker对象,且用过的ticker对象没有stop,也就不会释放相应的内存资源。而这个time.NewTicker会创建一个timer对象,这个对象会占用72字节内存。在服务运行一天的情况下,进过计算,该对象累计会在内存中占用约35.6MB,和上述内存每日增长0.4%正好能对上,我们就能断定这个内存泄漏来自这里。调优手段与效果知道是timer对象重复创建的问题后,只需要修改这部分的代码就好了,最新的vipserver client修改了此处的逻辑,如下修改完后,运行一段时间,内存运行状态平稳,已无内存泄漏问题。结语目前go语言不仅在阿里集团内部,在整个互联网行业内也越来越流行,希望本文能为正在使用go语言的同学在性能优化方面带来一些参考价值。在阿里集团内部,高德也是最早规模化使用go语言的团队之一,目前高德线上运行的go服务已经达到近百个,整体qps已突破百万量级。在使用go语言的同时,高德也为集团内go语言生态建设做出了许多贡献,包括开发支持阿里集团常见的中间件(比如配置中心-Diamond、分布式RPC服务框架-HSF、服务发现-Vipserver、消息队列-MetaQ、流量控制-Sentinel、日志追踪-Eagleeye等)go语言版本,并被阿里中间件团队官方收录。但是go语言生态建设仍然有很长的道路要走,希望能有更多对go感兴趣的同学能够加入我们,一起参与阿里的go生态建设,乃至为互联网业界的go生态发展添砖加瓦。
【读书笔记】《Effective C#》50条建议笔记整理
@[TOC]前言《Effective C#》是.NET专家Bill Wanger给出我们50条利用C#优点以及特性来写出健壮的,高效的,易于维护的代码的高效法则;自己在阅读完这本书后对这本书中的50条建议较为精华的结论进行整理,方便自己学习的同时分享出来。第一章、C#语言的编程习惯能用的东西为什么要改?因为改了之后效果更好。开发者换用其他工具或语言来编程也是这个道理,因为换了之后工作效率更高。如果不肯改变现有的习惯,那么就体会不到新技术的好处。如果你是从其他语言转入C#的,那么需要学习C#语言自己的编程习惯,使得这门语言能够促进你的工作,而不是阻碍你的工作。第1条:优先使用隐式类型的局部变量优先使用隐式类型var来声明变量而不指明其类型,这样的好处有可以令开发者把注意力更多地集中在名称上面,从而更好地了解其含义。不用去操心程序中使用了不合适的类型,编译器会自动选择合适的类型。好的变量命名可以提高可读性,合适的推导类型会提高开发效率。var HighestSellingProduct = someObject.DoSomeWork(anotherParameter);var类型不能盲目使用,对于int、float、double等数值型的变量,就应该明确指出其类型。第2条:考虑用readonly代替constC#有两种常量,编译期的常量const和运行期的常量readonly。编译期的常量const只能用来表示内置的整数、浮点数、枚举或字符串。编译期的常量const虽然性能高点,但却远不如运行期的常量readonly来的灵活。const关键字用来声明那些必须在编译期得以确定的值,例如attribute的参数、switch case语句的标签、enum的定义等,偶尔还用来声明那些不会随着版本而变化的值。除此之外的值则应该考虑声明成更加灵活的readonly常量。开发者确实想把某个值在编译期固定下来就使用const类型,否则就使用更灵活,兼容性更好的readonly类型。// 编译时常量:
public const int Millennium = 2000;
// 运行时常量:
public static readonly int ThisYear = 2004;第3条:优先考虑is或as运算符,尽量少用强制类型转换使用面向对象语言来编程序的时候,应该尽量避免类型转换操作,但总有一些场合是必须转换类型的。采用as运算符来实现类型转换更加安全可读性更高,而且在运行的时候也更有效率。尽量采用as来进行类型转换,因为这么做不需要编写额外的try/catch结构来处理异常。如果想判断对象是不是某个具体的类型而不是看它能否从当前类型转换成目标类型,那么可以使用is运算符。t = (MyType)st;
t = st as MyType; 第4条:用内插字符串取代string.Format()内插字符串以$开头,相比较于String.Format()方法的序号数量和参数个数不相等就会出错的情况,内插字符串代码的可读性更高。内插字符串不像传统的格式字符串那样把序号放在一对花括号里面,并用其指代params数组中的对应元素,而是可以直接在花括号里面编写C#表达式。Console.WriteLine($"The customer's name is {c?.Name ?? "Name is missing"}");第5条:用FormattableString取代专门为特定区域而写的字符串如果程序只是针对当前区域而生成文本,那么直接使用内插字符串就够了,这样反而可以避免多余的操作。如果需要针对特定的地区及语言来生成字符串,那么就必须根据内插字符串的解读结果来创建FormattableString,并将其转换成适用于该地区及该语言的字符串。FormattableString second = $"It's the {DateTime.Now.Day} of the {DateTime.Now.Month} month"; 第6条:不要用表示符号名称的硬字符串来调用APInameof()表达式这个关键字可以根据变量来获取包含其名称的字符串,使得开发者不用把变量名直接写成字面量。使用nameof运算符的好处是 ,如果符号改名了,那么用nameof来获取符号名称的地方也会获取到修改之后的新名字。这种写法可以保留较多的符号信息,使得自动化工具能够多发现并多修复一些错误,从而令开发者可以专心解决那些更为困难的问题。如果不这样做,那么有些错误就只能通过自动化测试及人工检查才能寻找出来。在下面的代码中,如果属性名变了,那么用来构造Property-ChangedEventArgs对象的参数也会随之变化。Public String Name
{
get { return name; }
set
{
if (value != name)
{
name = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name)));
}
}
}第7条:用委托表示回调回调就是这样一种由服务端向客户端提供异步反馈的机制,它可能会涉及多线程(multithreading),也有可能只是给同步更新提供入口。C#语言用委托来表示回调。通过委托,可以定义类型安全的回调。最常用到委托的地方是事件处理,然而除此之外,还有很多地方也可以用。委托是一种对象,其中含有指向方法的引用,这个方法既可以是静态方法,又可以是实例方法。开发者可以在程序运行的时候配置一个或多个客户对象,并与之通信。可以直接用lambda表达式来表示委托。此外,.NET Framework库也用Predicate\<T>、Action\<T>及Func\<T>定义了很多常见的委托形式。由于历史原因,所有的委托都是多播委托(multicast delegate),也就是会把添加到委托中的所有目标函数(target function)都视为一个整体去执行。总之,如果要在程序运行的时候执行回调,那么最好的办法就是使用委托,因为客户端只需编写简单的代码,即可实现回调。委托的目标可以在运行的时候指定,并且能够指定多个目标。在.NET程序里面,需要回调客户端的地方应该考虑用委托来做。第8条:用null条件运算符调用事件处理程序比较下面四种代码的写法public void RaiseUpdates()
{
counter++;
Updated(this, counter);
}
public void RaiseUpdates()
{
counter++;
if(Update != null)
Updated(this, counter);
}
public void RaiseUpdates()
{
counter++;
var handler = Updated;
if(handler != null)
Updated(this, counter);
}
public void RaiseUpdates()
{
counter++;
Updated?.Invoke(this, counter);
}第一种写法有个明显的问题:如果在对象上面触发Updated事件时并没有事件处理程序与之相关,那么就会发生NullReferenceException。第二种写法有个隐藏的bug,当程序中的线程执行完那行if语句并发现Updated不等于null之后,可能会有另一个线程打断该线程,并将唯一的那个事件处理程序解除订阅,这样的话,等早前的线程继续执行Updated(this,counter);语句时,事件处理程序就变成了null,调用这样的处理程序会引发NullReferenceException。当然,这种情况较为少见,而且不容易重现。第三种写法没有错,但是.NET开发新手却很难看懂,而且以后凡是要触发事件的地方就都得按这种写法重复一遍才行。第四种写法是最正统的,这段代码采用null条件运算符(也就是?.)安全地调用事件处理程序。该运算符首先判断其左侧的内容,如果发现这个值不是null,那就执行右侧的内容。反之,若为null,则跳过该语句,直接执行下一条语句。有了这种简单而清晰的新写法(第四种)之后,原来的老习惯就需要改一改了。以后在触发事件的时候,都应该采用这种写法。第9条:尽量避免装箱与取消装箱这两种操作装箱的过程是把值类型放在非类型化的引用对象中,使得那些需要使用引用类型的地方也能够使用值类型。取消装箱则是把已经装箱的那个值拷贝一份出来。如果要在只接受System.Object类型或接口类型的地方使用值类型,那就必然涉及装箱及取消装箱。但这两项操作都很影响性能。有的时候还需要为对象创建临时的拷贝,而且容易给程序引入难于查找的bug。值类型可以转换成指向System.Object或其他接口的引用,但由于这种转换是默默发生的,因此一旦出现问题就很难排查。把值类型的值放入集合、用值类型的值做参数来调用参数类型为System.Object的方法以及将这些值转为System.Object等。这些做法都应该尽量避免。第10条:只有在应对新版基类与现有子类之间的冲突时才应该使用new修饰符重新定义非虚方法可能会使程序表现出令人困惑的行为,以导致出现难以排查的bug;虚方法是动态绑定的,也就是说,要到运行的时候才会根据对象的实际类型来决定应该调用哪个版本;应该花时间想想:究竟有哪些方法与属性是应该设置成多态的,然后仅仅把这些内容用virtual加以修饰;唯一一种应该使用new修饰符的情况是:新版的基类里面添加了一个方法,而那个方法与你的子类中已有的方法重名了只有当基类所引入的新成员与子类中的现有成员冲突时,才可以考虑运用该修饰符,但即便在这种特殊的情况下,也得仔细想想使用它所带来的后果。除此之外的其他情况决不应该使用new修饰符。public class BaseWidget
{
public void NormalizerValues()
{
// 省略细节
}
}
public class MyWidget : BaseWidget
{
public new void NormalizerValues()
{
// 省略细节
// 只在(运气)情况下调用基类
// 新方法做同样的操作
base.NormalizerValues();
}
}第二章、.NET的资源管理.NET程序运行在托管环境(managed environment)中,这对C#程序的高效设计方式有很大的影响。开发者必须从.NET CLR(Common Language Runtime,公共语言运行时)的角度来思考,才可以充分发挥这套环境的优势,而不能完全沿用其他开发环境下的想法。第11条:理解并善用.NET的资源管理机制要想写出高效的程序,开发者就需要明白程序所在的这套环境是如何处理内存与其他重要资源的。与资源管理功能较少的环境相比,.NET环境会提供垃圾回收器(GC)来帮助你控制托管内存,这使得开发者无须担心内存泄漏、迷途指针(dangling pointer)、未初始化的指针以及其他很多内存管理问题。为了防止资源泄漏,非内存型的资源(nonmemory resource)必须由开发者释放,于是会促使其创建finalizer来完成该工作。考虑实现并运用IDisposable接口,以便在不给GC增加负担的前提下把这些资源清理干净。第12条:声明字段时,尽量直接为其设定初始值类的构造函数通常不止一个,构造函数变多了之后,开发者就有可能忘记给某些成员变量设定初始值。为了避免这个问题,最好是在声明的时候直接初始化,而不要等实现每个构造函数的时候再去赋值。成员变量的初始化语句可以方便地取代那些本来需要放在构造函数里面的代码,这些语句的执行时机比基类的构造函数更早,它们会按照本类声明相关变量的先后顺序来执行。public class MyClass
{
//声明集合,并初始化它。
private List<string> labels = new List<string>();
}有三种情况是不应该编写初始化语句的:第一种情况:对象初始化为0或null的时候,系统在运行时本身就会初始化逻辑,生成指令会把整块内存全都设置成0,初始化会显得多余和降低性能。第二种情况:如果不同的构造函数需要按照各自的方式来设定某个字段的初始值,那么这种情况下就不应该再编写初始化语句了,因为该语句只适用于那些总是按相同方式来初始化的变量。第三种情况:如果初始化变量的过程中有可能出现异常,那么就不应该使用初始化语句,而是应该把这部分逻辑移动到构造函数里面。第13条:用适当的方式初始化类中的静态成员创建某个类型的实例之前,应该先把静态的成员变量初始化好,这在C#语言中可以通过静态初始化语句及静态构造函数来做。静态构造函数是特殊的函数,会在初次访问该类所定义的其他方法、变量或属性之前执行,可以用来初始化静态变量、实现单例(singleton)模式,或是执行其他一些必要的工作,以便使该类能够正常运作。如果静态字段的初始化工作比较复杂或是开销比较大,那么可以考虑运用Lazy\<T>机制,将初始化工作推迟到首次访问该字段的时候再去执行。若是必须通过复杂的逻辑才能完成初始化,则应考虑创建静态构造函数。public class MySingleton2
{
private static readonly MySingleton2 theOneAndOnly;
static MySingleton2()
{
theOneAndOnly = new MySingleton2();
}
public static MySingleton2 TheOnly
{
get { return theOneAndOnly; }
}
private MySingleton2()
{
}
// 剩余部分省略
}用静态构造函数取代静态初始化语句一般是为了处理异常,因为静态初始化语句无法捕捉异常,而静态构造函数却可以。static MySingleton2()
{
try
{
theOneAndOnly = new MySingleton2();
}
catch
{
// 这里尝试恢复
}
}要想为类中的静态成员设定初始值,最干净、最清晰的办法就是使用静态初始化语句及静态构造函数,因为这两种写法比较好懂,而且不容易出错。第14条:尽量删减重复的初始化逻辑如果这些构造函数都会用到相同的逻辑,那么应该把这套逻辑提取到共用的构造函数中(并且令其他构造函数直接或间接地调用该函数)。这样既可以减少重复的代码,又能够令C#编译器根据这些初始化命令生成更为高效的目标代码。public class MyClass
{
// 收集数据
private List<ImportantData> coll;
// 实例的名称;
private string name;
// 需要满足new()约束;
public MyClass() : this(0, string.Empty)
{
}
public MyClass(int initialCount = 0, string name = "")
{
coll = (initialCount > 0) ?
new List<ImportantData>(initialCount) :
new List<ImportantData>();
this.name = name;
}
}采用默认参数机制来编写构造函数是比较好的做法,但是有些API会使用反射(reflection)来创建对象,它们需要依赖于无参的构造函数,这种函数与那种所有参数都具备默认值的构造函数并不是一回事,因此可能需要单独提供。下面列出构建某个类型的首个实例时系统所执行的操作:把存放静态变量的空间清零。执行静态变量的初始化语句。执行基类的静态构造函数。执行(本类的)静态构造函数。把存放实例变量的空间清零。执行实例变量的初始化语句。适当地执行基类的实例构造函数。执行(本类的)实例构造函数。第15条:不要创建无谓的对象垃圾回收器可以帮你把内存管理好,并高效地移除那些用不到的对象,但这并不是在鼓励你毫无节制地创建对象。因为创建并摧毁一个基于堆(heap-based)的对象无论如何都要比根本不生成这个对象耗费更多的处理器时间。在方法中创建很多局部的引用对象可能会大幅降低程序的性能。如果局部变量是引用类型而非值类型,并且出现在需要频繁运行的例程(routine)中,那就应该将其提升为成员变量;要避免的是频繁创建相同的对象,而不是说必须把每个局部变量都转化为成员变量。这两项技巧都可以令程序在运行过程中尽量少分配一些对象第一项技巧是把经常使用的局部变量提升为成员变量.第二项技巧是采用依赖注入(dependency injection)的办法创建并复用那些经常使用的类实例。如果最终要构建的字符串很复杂,不太方便用内插字符串实现,那么可以考虑改用StringBuilder处理,这是一种可变的字符串,提供了修改其内容的机制,使得开发者能够以此来构建不可变的string对象。string msg = string.Format("Hello,{0}. Today is {1}", thisUser.Name, DateTime.Now.ToString());垃圾回收器能够有效地管理应用程序所使用的内存,但是要注意,在堆上创建并销毁对象仍需耗费一定的时间,因此,不要过多地创建对象,也不要创建那些根本不用去重新构建的对象。第16条:绝对不要在构造函数里面调用虚函数在构建对象的过程中调用虚函数总是有可能令程序中的数据混乱。在(基类的)构造函数里面调用虚函数会令代码严重依赖于派生类的实现细节,而这些细节是无法控制的,因此,这种做法很容易出问题Visual Studio所附带的FxCop及Static Code Analyzer等工具都会将其视为潜在的问题。(这两款插件可能会解决问题)第17条:实现标准的dispose模式前面说过(第11条),如果对象包含非托管资源,那么一定要正确地加以清理。这样做虽然有可能令程序的性能因执行finalizer而下降,但毕竟可以保证垃圾回收器能够把资源回收掉。如果你的类本身不包含非托管资源,那就不用编写finalizer,但若是包含这种资源的话,则必须提供finalizer,因为你不能保证该类的使用者总是会调用Dispose()方法。实现IDisposable.Dispose()方法时,要注意以下四点:把非托管资源全都释放掉。把托管资源全都释放掉(这也包括不再订阅早前关注的那些事件)。设定相关的状态标志,用以表示该对象已经清理过了。阻止垃圾回收器重复清理该对象。这可以通过GC.SuppressFinalize(this)来完成。正确实现IDisposable接口是一举两得的事情,因为它既提供了适当的机制使得托管资源能够及时释放,又令客户端可以通过标准的Dispose()方法来释放非托管型的资源。编写finalizer时,一定要仔细检查代码,而且最好能把Dispose方法的代码也一起检查一遍。如果发现这些代码除了释放资源之外还执行了其他的操作,那就要再考虑考虑了。这些操作以后有可能令程序出bug,最好是现在就把它们从方法中删掉,使得finalizer与Dispose()方法只用来释放资源。public class BadClass
{
//存储全局对象的引用:
private static readonly List<BadClass> finalizedList = new List<BadClass>();
private string msg;
public BadClass(string msg)
{
//捕获引用:
msg = (string)msg.Clone();
}
~BadClass()
{
//将该对象添加到列表
//该对象是可达的
finalizedList.Add(this);
}
}第三章、合理地运用泛型泛型还有很多种用法,例如可以用来编写接口、事件处理程序以及通用的算法,等等。定义泛型类型可能会增加程序的开销,但也有可能给程序带来好处。用泛型来编程有的时候会令程序码更加简洁,有的时候则会令其更加臃肿。泛型类的定义(generic class definition)属于完全编译的MSIL类型,其代码对于任何一种可供使用的类型参数来说都必须完全有效。对于泛型类型来说,若所有的类型参数都已经指明,那么这种泛型类型称为封闭式泛型类型(closed generic type),反之,仅指出了某些参数,则称为开放式泛型结构(open generic type)。与真正的类型相比,IL形式的泛型只是定义好了其中的某一部分而已。必须把里面的占位符替换成具体的内容才能令其成为完备的泛型类型(completed generic type)。第18条:只定义刚好够用的约束条件泛型定义太宽或太严都不合适。你可以用约束来表达自己对泛型类型中的类型参数所提的要求,这些要求对编译器与使用该类的其他开发者都会带来影响。还有一种约束条件需要谨慎地使用,那就是new约束,有的时候可以去掉这条约束,并将代码中的new()改为default()。后者是C#的运算符,用来针对某个类型产生默认值,值类型则为0,引用类型则为null。对于引用类型来说,new()与default()有很大的区别。public static T FirstOrDefault<T>(this IEnumerable<T> sequence, Predicate<T> test)
{
foreach(T value in sequence)
{
if (test(value))
return value;
}
return default(T);
}编译器能够保证使用这个泛型类型的人所提供的类型参数一定会满足这些条件。例如你可以规定类型参数必须是值类型(struct)或必须是引用类型(class),还可以规定它必须实现某些接口或是必须继承自某个基类(这当然意味着它必须首先是个类才行)。如果不采用约束来表达这些要求,那么就得执行大量的强制类型转换操作,并在程序运行的时候做很多测试。第19条:通过运行期类型检查实现特定的泛型算法只需要指定新的类型参数,就可以复用泛型类,这样做会实例化出一个功能相似的新类型。但问题在于,其实很多时候在复用泛型类时会出现功能高度相似的时候,这显得完全没有必要,于是开发者需要在复用泛型类同时加上特定的泛型算法,示例代码如下:public ReverseEnumerable(IEnumerable<T> sequence)
{
sourceSequence = sequence;
// 如果序列没有实现IList<T>
// originalSequence是null,所以这是可行的
// 实现
originalSequence = sequence as IList<T>; // as的用法在第3条
}开发者既可以对泛型参数尽量少施加一些硬性的限制,又能够在其所表示的类型具备丰富的功能时提供更好的实现方式。为了达到这种效果,你需要在泛型类的复用程度与算法面对特定类型时所表现出的效率之间做出权衡。第20条:通过IComparable及IComparer定义顺序关系.NET Framework引入了两种用来定义执行排序与搜索关系的接口,即IComparable\<T>及IComparer\<T>。前者用来规定某类型的各对象之间所具备的自然顺序(natural order),后者用来表示另一种排序机制可以由需要提供排序功能的类型来实现。IComparable接口只有一个方法,就是CompareTo(),该方法遵循长久以来所形成的惯例:若本对象小于另一个受测对象,则返回小于0的值;若相等,则返回0;若大于那个对象,则返回大于0的值。在.NET环境中,比较新的API大都使用泛型版的IComparable接口,但老一些的API用的则是不带泛型的IComparable接口,因此,实现前者的时候应该同时实现后者。既然非泛型版的IComparable有这么多缺点,那为什么还要实现它呢?这有两个原因。第一个原因很简单:为了保持向后兼容(backward compatibility)。第二个原因在于,这样写,可以满足那些确实需要使用该方法的人,同时又能够把无意中的错误用法拦截下来。第21条:创建泛型类时,总是应该给实现了IDisposable的类型参数提供支持为泛型类指定约束条件会对开发者自身及该类的用户产生两方面影响。第一,会把程序在运行的时候有可能发生的错误提前暴露于编译期。第二,相当于明确告诉该类的用户在通过泛型类来创建具体的类型时所提供的类型参数必须满足一定的要求。如果你在泛型类里面根据类型参数创建了实例,那么就应该判断该实例所属的类型是否实现了IDisposable接口。如果实现了,就必须编写相关的代码,以防程序在离开泛型类之后发生资源泄漏。泛型类本身也可能需要以惰性初始化的形式根据类型参数去创建实例,并实现IDisposable接口,这需要多写一些代码,然而如果想创建出实用的泛型类,有时就必须这么做才行。第22条:考虑支持泛型协变与逆变变体(type variance)机制,尤其是协变(covariance)与逆变(contravariance)确定了某类型的值在什么样的情况下可以转换成其他类型的值。协变与逆变是指能否根据类型参数之间的兼容情况在两个泛型类之间化约。对于以T为类型参数的泛型类型C\<T>来说,如果在X可以转换为Y的前提下能够把C\<X>当成C\<Y>来用,那么该泛型对T协变。如果在Y可以转换为X的前提下能够把C\<X>当成C\<Y>来用,那么该泛型对T逆变。C#语言允许开发者在泛型接口与委托中运用in与out修饰符,以表达它们与类型参数之间的逆变及协变关系。public interface ICovariantDelegates<out T>
{
T GetAnItem();
Func<T> GetAnItemFactory();
void GiveItemLater(Action<T> whatToDo);
}
public interface IContravariantDelegates<in T>
{
void ActOnAnItem(T item);
void GetAnItemFactory(Func<T> item);
Action<T> ActOnAnItemLater();
} 第23条:用委托要求类型参数必须提供某种方法C#为开发者所提供的约束似乎比较有限,你只能要求某个泛型参数所表示的类型必须继承自某个超类、实现某个接口、必须是引用类型、必须是值类型或是必须具备无参数的构造函数。你可能会要求用户提供的类型必须支持某种运算符、必须拥有某个静态方法、必须与某种形式的委托相符或是必须能够以某种方式来构造,这些要求其实都可以用委托来表示。也就是说,你可以定义相应的委托类型,并要求用户在使用泛型类的时候必须提供这样的委托对象。现在就以Add()为例来谈谈这个问题,首先,创建IAdd接口,其次,编写代码,给类型参数施加约束,规定其必须实现该接口。public static class Example
{
public static T Add<T>(T left, T right, Func<T, T, T> AddFunc) => AddFunc(left, right);
} int a = 6;
int b = 7;
int sum = Example.Add(a, b, (x, y) => x+y);总之,如果你在设计泛型的时候需要对用户所提供的类型提出要求,但这种要求又不便以C#内置的约束条件来表达,那么就应该考虑通过其他办法(委托)来保证这一点,而不能放弃这项要求。第24条:如果有泛型方法,就不要再创建针对基类或接口的重载版本与基类版本同泛型版本之间的优先顺序相似,接口版本与泛型版本之间的优先顺序也有可能令人困惑。一般来说,在已经有了泛型版本的前提之下,即便想要给某个类及其子类提供特殊的支持,也不应该轻易去创建专门针对该类的重载版本。这条原则同样适用于接口。如果想专门针对某个接口创建与已有的泛型方法相互重载的方法,那么也必须同时为实现了该接口的所有类型都分别创建对应的方法(使得编译器能够把调用该方法的代码解析到合适的版本上面)。第25条:如果不需要把类型参数所表示的对象设为实例字段,那么应该优先考虑创建泛型方法,而不是泛型类用包含大量泛型方法的非泛型工具类实现可能会更加清晰(可读性更高)用户可能会给出很多套符合约束的泛型参数,而C#编译器则必须针对每一套泛型参数都生成一份完整的IL码,用以表示与这套参数相对应的泛型类。public static class Utils
{
public static T Max<T>(T left, T right) =>
Comparer<T>.Default.Compare(left, right) < 0 ? right : left;
public static double Max(double left, double right) =>
Math.Max(left, right);
// 省略了其他数字类型的版本
public static T Min<T>(T left, T right) =>
Comparer<T>.Default.Compare(left, right) < 0 ? left : right;
public static double Min(double left, double right) =>
Math.Min(left, right);
// 其他数字类型的版本被省略
}这样做的好处:首先,调用起来比较简单。由于编译器会自动判断出最为匹配的版本,因此无须调用方明确指定。其次,对于程序库的开发者来说,这样写可以令将来的工作更加灵活。在两种情况下,必须把类写成泛型类:第一种情况,该类需要将某个值用作其内部状态,而该值的类型必须以泛型来表达(例如集合类);第二种情况,该类需要实现泛型版的接口。除此之外的情况,都应该考虑使用包含泛型方法的非泛型类来实现。第26条:实现泛型接口的同时,还应该实现非泛型接口由于各种各样的原因,开发者还是必须考虑怎样与非泛型的内容打交道;这条建议适用于三项内容:一,要编写的类以及这些类所支持的接口;二,public属性;三,打算序列化(serialize)的那些元素。在绝大多数情况下,如果想给旧版接口提供支持,那么只需要在类里面添加签名正确的方法就可以了。在实现这些接口时,应该明确加以限定,以防用户在本来打算使用新版接口的时候无意间调用了旧版接口。第27条:只把必备的契约定义在接口中,把其他功能留给扩展方法去实现定义接口的时候,只把必备的功能列出来就行了,而其他一些功能则可以在别的类里面以扩展方法的形式去编写,那些方法能够借助原接口所定义的基本功能来完成自身的任务。这样做使得实现该接口的人只需要给少数几个方法编写代码,而客户端则既可以使用这几个基本方法,又可以使用基于这些方法所开发出来的扩展方法。有一个问题需要注意:如果已经针对某个接口定义了扩展方法,而其他一些类又想要以它们自己的方式来实现这个扩展方法,那么就有可能产生奇怪的结果。在实际的编程工作中,应该保证扩展方法的行为与类里面的同名方法相一致。也就是说,如果想在类中以更为高效的算法重新实现早前所定义的扩展方法,那么应该保证其行为与之相同。保证了这一点,就不会影响程序正常运行。第28条:考虑通过扩展方法增强已构造类型的功能编写应用程序时,可能需要使用一些采用特定类型参数构造的泛型类型,例如可能需要使用List及Dictionary<EmployeeID,Employee>等形式的集合。之所以创建这种形式的集合,是因为应用程序要向集合中放入特殊类型的元素,因而需要专门针对这样的元素给集合定义一些特殊的功能。// 下面以IEnumerable<int>为例来列举其中的几个:
public static class Enumerable
{
public static int Average(this IEnumerable<int> sequence);
public static int Max(this IEnumerable<int> sequence);
public static int Min(this IEnumerable<int> sequence);
public static int Sum(this IEnumerable<int> sequence);
// 省略其他方法
}若能将这些方法实现成针对某个泛型类型或泛型接口的扩展方法,则会令那个以特定参数而构造的泛型类型或接口具备丰富的功能。这样做还可以最大限度地将数据的存储模型(storage model)与使用方式相解耦。第四章、合理地运用LINQLINQ的一个目标是令语言中的元件能够在各种数据源上面执行相同的操作。合理地运用LINQ更加顺畅地处理各种数据源,如果有需要的话,还可以创建自己的数据提供程序(data provider)。第29条:优先考虑提供迭代器方法,而不要返回集合迭代器方法是一种采用yield return语法来编写的方法,它会等到调用方请求获取某个元素的时候再去生成序列中的这个元素。// 下面是个简单的迭代器方法,用来生成由小写英文字母所构成的序列:
public static IEnumerable<char> GenerateAlphabet()
{
var letter = 'a';
while(letter <= 'z')
{
yield return letter;
letter++;
}
}生成该元素的操作只有在调用方真正使用这个元素时才会发生。只有当调用方真正用到序列中的某个元素时程序才会通过那个对象创建该元素。这使得程序在调用生成器方法(generator method)时只需要执行少量的代码。缺点:给迭代器方法传入了错误的参数,那么这个错误要等到程序真正使用函数的返回值时才能够暴露,而无法在传入错误参数的时候就直接以异常的形式表现出来。有没有哪种场合是不适宜用迭代器方法来生成序列的?其实这些问题应该留给调用迭代器方法的人去考虑,你不用刻意去猜测别人会怎样使用你创建的这个方法,因为他们可以自己去决定如何使用该方法所返回的结果。有了这样的方法,开发者就可以自由选择是通过ToList或ToArray将整个序列都提早生成出来,还是通过你所提供的方法逐个生成并处理序列中的每个元素。第30条:优先考虑通过查询语句来编写代码,而不要使用循环语句与采用循环语句所编写的命令式结构相比,查询语句(也包括实现了查询表达式模式(query expression pattern)的查询方法)能够更为清晰地表达开发者的想法。// 循环语句
private static IEnumerable<Tuple<int, int>> ProduceIndices()
{
for (var x = 0; x < 100; x++)
for (var y = 0; y < 100; y++)
yield return Tuple.Create(x, y);
}
// 查询语句
private static IEnumerable<Tuple<int, int>> QueryIndices()
{
return from x in Enumerable.Range(0, 100)
from y in Enumerable.Range(0, 100)
select Tuple.Create(x, y);
}这两种写法看上去差不多,但是后一种写法(查询语句)在问题变得复杂之后依然能够保持简洁。命令式的写法则必须创建存储空间来保存中间结果。还有一条理由也能说明查询语句比循环结构要好,因为前者可以创建出更容易拼接的API。如果你怀疑查询式的写法在某种特定情况下运行得不够快,可以通过.AsParallel()方法来并行地执行这些查询。编写循环结构时,总是应该想想能不能改用查询语句来实现相同的功能,如果不行,那再想想能不能改用查询方法来写。第31条:把针对序列的API设计得更加易于拼接针对整个集合中的每一个元素执行操作,那么程序的效率会很低。把通用的IEnumerable\<T>或针对某种类型的IEnumerable\<T>设计成方法的输入及输出参数是一种比较少见的思路,因此,很多开发者都不会这样去做,但是这种思路确实能带来很多好处。迭代器方法会等调用方真正用到某个元素时才去执行相应的代码,与传统的命令式方法相比,这种延迟执行(deferred execution,参见第37条)机制可以降低算法所需的存储空间,并使算法的各个部分之间能够更为灵活地拼接起来(参见第40条)。// 为了演示迭代器方法的好处,笔者先举一个简单的例子,然后用迭代器方法改写。
public static void Unique(IEnumerable<int> nums)
{
var uniqueVals = new HashSet<int>();
foreach(var num in nums)
{
if(!uniqueVals.Contains(num))
{
uniqueVals.Add(num);
Console.WriteLine(num);
}
}
}
// 为此,可以考虑改用迭代器方法来实现:
public static IEnumerable<int> UniqueV2(IEnumerable<int> nums)
{
var uniqueVals = new HashSet<int>();
foreach (var num in nums)
{
if (!uniqueVals.Contains(num))
{
uniqueVals.Add(num);
yield return num;
}
}
}迭代器方法真正强大之处在于它可以把多个步骤拼接成一整套流程。如果能把复杂的算法拆解成多个步骤,并把每个步骤都表示成这种小型的迭代器方法,那么就可以将这些方法拼成一条管道,使得程序只需把源序列处理一遍即可对其中的元素执行许多种小的变换。第32条:将迭代逻辑与操作、谓词及函数解耦要想把这种算法内部的某些逻辑开放给调用方去定制,只能将这些逻辑表示成方法或函数对象,并传给表示该算法的那个外围方法。具体到C#来说,就是要把那个可供定制的内部逻辑定义成delegate。匿名的委托主要有两种习惯用法,一种是用来表示函数,另一种是用来表示操作。这样做的的好处主要在于可以把迭代序列时所用的逻辑与处理序列中的元素时所用的逻辑分开。public static IEnumerable<T> Transform<T>(IEnumerable<T> sequence, Func<T,T> method)
{
// null检查序列和方法被省略
foreach(T element in sequence)
{
yield return method(element);
}
}
// 写好这个方法之后,可以用下面这行代码对序列中的每个整数取平方,从而令这些平方值构成新的序列:
foreach (int i in Transform(myInts, value => value * value))
Console.WriteLine(i);public static IEnumerable<Tout> Transform<Tin, Tout>(IEnumerable<Tin> sequence, Func<Tin, Tout> method)
{
// null检查序列和方法被省略
foreach (Tin element in sequence)
yield return method(element);
}
foreach (string s in Transform(myInts, value => value.ToString()))
WriteLine(s); 第33条:等真正用到序列中的元素时再去生成在前面的建议有提到过,就是强调迭代器yield return的用法。static IEnumerable<int> CreateSequence(int numOfElements, int startAt, int stepBy)
{
for (int i = 0; i < numOfElements; i++)
{
yield return startAt + i * stepBy;
}
}在消费该序列的代码真正用到某个元素时再去生成此元素是一种很好的做法,因为如果整个算法只需执行一小部分即可满足消费方的要求,那么就不用再花时间去执行其余那一部分了。这样做可能只会小幅提升程序的效率,但如果创建元素所需的开销比较大,那么提升的幅度也会很大。第34条:考虑通过函数参数来放松耦合关系如果使用委托或其他一些通信机制来放松耦合关系,那么编译器可能就不会执行某些检查工作了,因此,你需要自己设法来做这些检查。设计组件时,首先还是应该考虑能否把本组件与客户代码之间的沟通方式约定成接口或者采用委托来描述本组件所要使用的方法,那么用起来会更加灵活。根据具体的情况来选择是接口还是委托。设计组件时,首先还是应该考虑能否把本组件与客户代码之间的沟通方式约定成接口。如果有一些默认的实现代码需要编写,那么可以考虑将其放入抽象基类中,使得调用方无须重新编写这些代码。如果采用委托来描述本组件所要使用的方法,那么用起来会更加灵活,但开发工具对此提供的支持也会更少,因此,你需要编写更多的代码才能确保这种灵活的设计能够正常运作。第35条:绝对不要重载扩展方法在第27与28条说过,针对接口或类型来创建扩展方法有三个好处:- 第一,能够为接口实现默认的行为;
- 第二,能够针对封闭的泛型类型实现某些逻辑;
- 第三,能够创建出易于拼接的接口方法。通过扩展方法来编写默认代码是专门针对接口而言的,如果要扩展的是类,那么还有更好的办法可供选用。滥用或误用扩展方法很容易使方法之间产生冲突,从而令代码难于维护。扩展方法并不是根据对象的运行期类型而派发的,它依据的是编译期类型,这本身就容易出错,再加上有些人又想通过切换命名空间(作者很不推荐这么做)来切换扩展方法的版本,这就更容易出问题了。如果你发现自己正在编写很多个签名相同的扩展方法,那么赶紧停下来,把方法的签名改掉,并考虑将其设计成普通的静态方法,而不要做出那种通过切换using指令来影响程序行为的设计方案,因为那样会令开发者感到困惑。第36条:理解查询表达式与方法调用之间的映射关系LINQ构建在两个概念之上:一是查询语言(query language)本身,二是该语言与查询方法之间的转换关系。在设计某个类时,你必须清楚由系统所提供的那些查询方法是否合适,自己能不能针对当前这个类实现出更好的版本。完整的查询表达式模式(query expression pattern)包含11个方法。编译器只能保证你创建的接口方法符合语法规定,但无法保证它能满足用户的要求。如果你觉得自己可以利用该类内部的某些特性编写出比默认方式更为高效的实现代码,那么就必须保证该类完全遵从查询表达式模式所做出的约定。第37条:尽量采用惰性求值的方式来查询,而不要及早求值每迭代一遍都产生一套新的结果,这叫作惰性求值(lazy evaluation),反之,如果像编写普通的代码那样直接查询某一套变量的取值并将其立刻记录下来,那么就称为及早求值(eager evaluation)。首先通过一段代码来理解惰性求值与及早求值之间的区别:private static IEnumerable<TResult>
Generate<TResult>(int number, Func<TResult> generator)
{
for(var i = 0; i < number; i++)
{
yield return generator();
}
}
private static void LazyEvaluation()
{
WriteLine($"Start time for Test One: {DateTime.Now:T}");
var sequence = Generate(10, () => DateTime.Now);
WriteLine("Waiting.... tPress Return");
ReadLine();
WriteLine("Iterating...");
foreach (var value in sequence)
WriteLine($"{value:T}");
WriteLine("Waiting.... tPress Return");
ReadLine();
WriteLine("Iterating...");
foreach (var value in sequence)
WriteLine($"value:T}");
}通过这种办法可以很有效地了解C#系统如何对LINQ查询求值。如果代码写得较为合理,那么程序只需检查序列的开头部分即可,因为它可以在找到所需的答案时停下来。编写算法的时候,如果能把那种需要处理整个序列的操作放在合适的时机去执行,那么算法可能会执行得很快,反之,则有可能耗费极长的时间。在个别情况下,你可能确实想给序列中的值做一份快照,这时可以考虑ToList()及ToArray()这两个方法,它们都能够立刻根据查询结果来生成序列,并保存到容器中。其区别在于,前者用List\<T>保存,后者用Array保存。总之,与及早求值的方式相比,惰性求值基本上都能减少程序的工作量,而且使用起来也更加灵活。在少数几种需要及早求值的场合,可以用ToList()或ToArray()来执行查询并保存结果,但除非确有必要,否则还是应该优先考虑惰性求值。第38条:考虑用lambda表达式来代替方法涉及查询表达式与lambda的地方应该用更为合理的办法去创建可供复用的代码块。var allEmployees = FindAllEmployees();
// 寻找第一批员工:
var earlyFolks = from e in allEmployees
where e.Classification == EmployeeType.Salary
where e.YearsOfService > 20
where e.MonthlySalary< 4000
select e;
// 找到最新的人:
var newest = from e in allEmployees
where e.Classification == EmployeeType.Salary
where e.YearsofService< 20
where e.MonthlySalary< 4000
select e; 你可以把查询操作分成许多个小方法来写,其中一些方法在其内部用lambda表达式处理序列,而另一些方法则可以直接以lambda表达式做参数。把这些小方法拼接起来,就可以实现整套的操作。这样写既可以同时支持IEnumerable与IQueryable,又能够令系统有机会构建出表达式树,以便高效地执行查询。第39条:不要在Func与Action中抛出异常例如下面这段代码,要给每位员工加薪5%:var allEmployees = FindAllEmployees();
allEmployees.ForEach(e => e.MonthlySalary *= 1.05M);假如这段代码在运行过程中抛出异常,那么该异常很有可能既不是在处理第一位员工之前抛出的,又不是在处理完最后一位员工之后抛出的,导致你无法掌握程序的状态,因此必须把所有数据都手工检查一遍,才能令其保持一致。要想解决此问题,你可以设法做出强异常保证,也就是在这段代码未能顺利执行完毕的情况下,确保程序状态(与执行之前相比)不会出现明显的变化。需要仔细定义相关的函数及谓词,以确保方法所订立的契约在各种情况下都能得到满足,即便是在发生错误的情况下也是如此。首先你的做法可以过滤到那些可能加薪失败的员工(比如不在数据库内),但是这并不彻底。其次,你可以先复制一份,等到副本可以全部成功加薪之后,再赋给原对象,但是,开销会变大。同时使算法的拼接性变差。与一般的写法相比,用lambda表达式来编写Action及Func会令其中的异常更加难以发觉。因此,在返回最终结果之前,必须确定这些操作都没有出现异常,然后才能用处理结果把整个源序列替换掉。第40条:掌握尽早执行与延迟执行之间的区别声明式的代码(declarative code)的重点在于把执行结果定义出来,而命令式的代码(imperative code)则重在详细描述实现该结果所需的步骤。//命令式写法
var answer = DoStuff(Method1(), Method2(), Method3());
//声明式写法
var answer = DoStuff(()=>Method1(), ()=>Method2(), ()=>Method3());总之,只有当程序确实要用到某个方法的执行结果时,才会去调用这个方法。这是声明式写法与命令式写法之间的重要区别。如果把两种写法混起来用,那么程序可能就会出现严重的问题。这两种写法之间最重要的区别在于:前者必须先把数据算好,而后者则可以等到将来再去计算。如果采用第一种写法(命令式),那么必须提前调用相关方法,以获取该方法所计算出的数据,而不像第二种写法(声明式)那样,可以按照函数式编程的风格,用包含该方法本身的lambda表达式来暂时代替这个方法,等真正要用到该方法的执行结果时再去计算。在某些情况下,把这两种求值方式混起来用的效果是最好的。也就是说,其中某些结果可以尽早计算并缓存起来,而另一些结果则等到用的时候再去计算。编写C#算法时,先要判断用数据(也就是算法的结果)当参数与用函数(也就是算法本身)当参数会不会导致程序的运行结果有所区别。在难以判断的情况下,不妨优先考虑把算法当成参数来传递,这样做可以令编写函数的人更为灵活,因为他既可以采用惰性求值的方式稍后再去调用该算法,也可以采用及早求值的方法立刻获取该算法的执行结果。第41条:不要把开销较大的资源捕获到闭包中闭包(closure)会创建出含有约束变量(bound variable)的对象,但是这些对象的生存期可能与你想的不一样,而且通常会给程序带来负面效果。如果算法使用了一些查询表达式,那么编译器在编译这个方法时,就会把同一个作用域内的所有表达式合起来纳入同一个闭包中,并创建相应的类来实现该闭包。这个类的实例会返回给方法的调用者。对于迭代器方法来说,这个实例有可能是实现了迭代逻辑的那个对象中的成员。只有当该实例的使用方全都从系统中移除之后,它才有可能得到回收,这就会产生很多问题。如果程序从方法中返回的是一个用来实现闭包的对象,那么与闭包相关的那些变量就全都会出现在该对象里面。你需要考虑程序此后是否真的需要用到这些变量。如果不需要使用其中的某些变量,那么就应该调整代码,令其在方法返回的时候能够及时得到清理,而不会随着闭包泄漏到方法之外。第42条:注意IEnumerable与IQueryable形式的数据源之间的区别IQueryable\<T>与IEnumerable\<T>这两个类型在API签名上面很像,而且前者继承自后者,因此很多情况下它们可以互换。var q = from c in dbContext.Customers
where c.City == "London"
select c;
var finalAnswer = from c in q
orderby c.Name
select c;
// 迭代被省略的最终答案序列的代码
var q = (from c in dbContext.Customers
where c.City == "London"
select c).AsEnumerable();
var finalAnswer = from c in q
orderby c.Name
select c;
//迭代最终答案的代码IQueryable\<T>内置的LINQ to SQL机制,IEnumerable\<T>是把数据库对象强制转为IEnumerable形式的序列,并把排序等工作放在本地完成。有些功能用IQueryable实现起来要比用IEnumerable快得多。用IEnumerable所写的那个版本必须在本地执行,系统要把lambda表达式编译到方法里面,并在本地计算机上面运行,这意味着无论有待处理的数据在不在本地,都必须先获取过来才行。用IQueryable实现出来的版本则会解析表达式树,在解析的时候,系统会把这棵树所表示的逻辑转换成provider能够操作的格式,并将其放在离数据最近的地方去执行如果在性能与健壮这两项因素之间更看重后者,那么可以把查询结果明确转换成IEnumerable,这样做的缺点是LINQ to SQL引擎必须把dbContext.Products中的所有内容都从数据库中获取过来。可以使用AsEnumerable()与AsQueryable()进行相互转换。IQueryable更适合远程执行(数据库)。第43条:用Single()及First()来明确地验证你对查询结果所做的假设如果你要确保查询结果里面有且仅有一个元素,那么就应该使用Single()来表达这个意思,因为这样做是很清晰的。只要查询结果中的元素数量与自己的预期不符,程序就会立刻抛出异常。var answer = (from p in somePeople where p.FirstName == "Larry" select p).Single();由这段代码的写法可以看出,开发者希望能够查到一位名叫Larry的人。var answer = (from p in somePeople where p.FirstName == "Larry" select p).SingleOrDefault(); 如果你想表达的意思是要么查不到任何元素,要么只能查到一个元素,那么可以用SingleOrDefault()来验证,然而要注意,如果查到的元素不只一个,那么该方法也会像Single()那样抛出异常。有的时候,你并不在乎查到的元素是不是有很多,而只是想取出这样的一个元素而已,这种情况下,可以考虑用First()或FirstOrDefault()方法来表达这个意思。除了这些方法之外,尽量不要用别的方法来获取查询结果中的特定元素,而是应该考虑通过更好的写法来寻找那个元素,使得其他开发者与代码维护者能够更为清晰地理解你想找的究竟是什么。第44条:不要修改绑定变量编译器创建的这个嵌套类会把lambda表达式所访问或修改的每个变量都囊括进来,而且原来访问局部变量的那些地方现在也会改为访问该嵌套类中的字段。如果在定义查询表达式的时候用到了某个局部变量,而在执行之前又修改了它的值,那么程序就有可能出现奇怪的错误,因此,捕获到闭包中的那些变量最好不要去修改。第五章、合理地运用异常程序总是会出错的,因为即便开发者做得再仔细,也还是会有预料不到的情况发生。令代码在发生异常时依然能够保持稳定是每一位C#程序员所应掌握的关键技能。本章中的各条会讲解怎样通过异常来清晰而精准地表达程序在运行中所发生的错误,而且还会告诉大家怎样管理程序的状态才能令其更容易地从错误中恢复。第45条:考虑在方法约定遭到违背时抛出异常如果方法不能够完成其所宣称的操作,那么就应该通过异常来指出这个错误, 同时用异常来表示程序在运行过程中所遇到的状况要比用错误码更好。与采用错误码相比,通过异常来报告错误是更加恰当的做法,因为这样做有很多好处。- 错误码必须由调用该方法的人来处理,而异常则可以沿着调用栈向上传播,直至到达合适的catch子句。
- 此外还有一个好处,就是异常不会轻易为人所忽视。如果没有适当的catch子句能够处理异常,那么应用程序就会(明确地)终止,而不会悄无声息地继续运行下去,以防数据受损。由于异常本身也是类,因此,你可以从其中派生自己的异常类型,从而表达出较为丰富的错误信息。由于异常并不适合当作控制程序流程的常规手段,因此,还应该同时提供另外一套方法,使得开发者可以在执行操作之前先判断该操作能否顺利执行,以便在无法顺利执行的情况下采取相应的措施,而不是等到抛出了异常之后再去处理。第46条:利用using与try/finally来清理资源如果某个类型用到了非托管型的系统资源,那么就需要通过IDisposable接口的Dispose()方法来明确地释放。拥有非托管资源的那些类型,都实现了IDisposable接口,此外还提供了finalizer(终结器/终止化器),以防用户忘记释放该资源。using语句能够确保Dispose()总是可以得到调用。如果函数里面只用到了一个IDisposable对象,那么要想确保它总是能够适当地得到清理,最简单的办法就是使用using语句,该语句会把这个对象放在try/finally结构里面去分配。凡是实现了IDisposable接口的对象都应该放在using语句中或者try块中去实现,否则就有可能泄露资源。SqlConnection myConnection = null;
// 示例:
using (myConnection = new SqlConnection(connString))
{
myConnection.Open();
}
// Try / Catch块:
try
{
myConnection = new SqlConnection(connString);
myConnection.Open();
}
finally
{
myConnection.Dispose();
}如果你不清楚某个对象是否实现了IDisposable接口,那么可以通过as子句来安全地处置它://正确的修复
//对象是否支持IDisposable
object obj = Factory.CreateResource();
using (obj as IDisposable)
Console.WriteLine(obj.ToString());Dispose()方法并不会把对象从内存中移除,它只是提供了一次机会,令其能够释放非托管型的资源。最好是把这样的对象包裹在using语句或try/finally结构里面,总之,无论采用什么样的写法,你都要保证这些资源能够正确地释放。第47条:专门针对应用程序创建异常编写应用程序(或程序库)时,必须把那些需要用不同方式来处理的情况设计成不同的异常类型。第一,并不是所有的错误都必须表示成异常。至于什么样的错误应该表示成异常,什么样的错误不必表示成异常,则没有固定的规律可循。但笔者认为:如果某种状况必须立刻得到处理或汇报,否则将长期影响应用程序,那么就应该抛出异常。第二,并不是每写一条throw语句就要新建一种异常类。应该仔细想想,能不能创建一种新的异常类,以促使调用方更为清晰地理解这个错误,从而试着把应用程序恢复到正常状态。之所以要创建不同的异常类,其原因很简单,就是为了令调用API的人能够通过不同的catch子句去捕获那些状况,从而采用不同的办法加以处理。//以Exception类为例子
//默认构造函数
public Exception();
//创建一个消息
public Exception(string);
//使用消息和内部异常创建。
public Exception(string, Exception);
//从输入流创建
protected Exception(SerializationInfo, StreamingContext);一旦决定自己来创建异常类,就必须遵循相应的原则。这些类都要能够追溯到Exception才行,如果从Exception中派生子类,那么应该创建四个构造函数,以便与上述四者相对应。异常转换(exception translation),用来将底层的异常转化成高层的异常,从而提供更贴近于当前情境的错误信息(有利于调式)。在某些情况下,确实有必要抛出异常,此时应该专门做一些处理,而不要把你在调用核心框架时由.NET Framework所产生的那个异常原封不动地抛出去。第48条:优先考虑做出强异常保证异常所做的保证分成三种,即基本保证(basic guarantee)、强保证(strong guarantee)及no-throw保证(不会抛出异常的保证)。在这三种态度中,强保证是较为折中的,它既允许程序抛出异常并从中恢复,又使得开发者能够较为简便地处理该异常。应用程序中有很多种操作都会在未能顺利执行完毕的情况下令程序陷入无效的状态。这些状况很难完全顾及,因为没有哪一套标准的流程能够自动地应对它们。为此,你可以考虑做出强异常保证来避开其中的很多问题。强异常保证这种做法规定:如果某操作抛出异常,那么应用程序的状态必须和执行该操作之前相同。也就是说,这项操作要么完全成功,要么彻底失败。除了基本保证与强保证之外,还有一种最为严格的保证,叫作no-throw保证。它指的就是字面上的意思,即保证方法肯定能够运行完毕而绝对不会从中抛出异常。对于大型的程序来说,要求其中的所有例程都达到这种地步是不太现实的,但在其中的某几个地方确实不能令方法抛出异常,比方说,finalizer(终结器/终止化器)与Dispose就是如此。你可以把那种较为复杂的方法包裹在try/catch结构里面去调用,从而将该方法所抛出的异常吞掉,以此来做出no-throw保证。还有一个地方也应该做出no-throw保证,那就是委托目标(delegate target)。笔者再说一遍:包括事件处理程序在内的各种委托目标都不应该抛出异常,如果抛出,那么触发事件的那段代码就无法做出强异常保证。finalizer、Dispose()方法、when子句及委托目标是四个特例,在这些场合,绝对不应该令任何异常脱离其范围。如果在拷贝出来的临时数据上面执行完操作之后想用它把原数据替换掉,而原来的数据又是引用类型,那么要多加小心,因为这可能引发很多微妙的bug。第49条:考虑用异常筛选器来改写先捕获异常再重新抛出的逻辑如果改用异常筛选器来捕获并处理异常,那么以后诊断起来就会容易一些,而且不会令应用程序的开销增大。异常筛选器是针对catch子句所写的表达式,它出现在catch右侧那个when关键字之后,用来限定该子句所能捕获的异常:var retryCount = 0;
var dataString = default(String);
while(dataString == null)
{
try
{
dataString = MakeWebRequest();
}
catch(TimeoutException e) when(retryCount++ < 3)
{
WriteLine("Operation timed out. Trying again");
//再次尝试前暂停。
Task.Delay(1000 * retryCount);
}
}采用异常筛选器来写,那么诊断信息里面就会带有程序的状态,从而令你能够判断出问题的根源。.NET CLR对带有when关键字的try/catch结构做了优化,使得程序在无须进入该结构时其性能尽量不受影响。如果异常筛选器无法处理某个异常,那么程序就无须展开调用栈,也不用进入catch块,这使得其性能要比先捕获再重新抛出的办法更高,总之无论如何,也不会比它差。使用了异常筛选器之后,可以调整原有的异常处理代码,把多余的判断逻辑去掉,只用catch子句来捕获你能够完全应对的那些异常。如果仅通过异常的类型不足以判断出自己到底能不能处理该异常,那么可以考虑给相关的catch子句添加筛选器,使得程序只有在筛选条件得以满足时才会进入这个catch块。第50条:合理利用异常筛选器的副作用来实现某些效果系统在寻找catch子句的过程中会执行这些筛选器,而此时,调用栈还没有真正展开(于是,不妨利用这一特性来实现某些效果)。可以把catch(Exception e)when log(e){}这样的写法随时套用到已有的代码中,因为它并不会干扰程序正常运行。如果合理地利用异常筛选器所引发的某些副作用,那么很容易就能观察到程序究竟是在什么样的状况下抛出异常的。注意本章图文内容均来源于《Effective C#:改善C#代码的50个有效方法》一书, 自己整理收集,方便学习参考,版权属于原作者。
通用缓存存储设计实践
目录介绍01.整体概述说明1.1 项目背景介绍1.2 遇到问题记录1.3 基础概念介绍1.4 设计目标1.5 产生收益分析02.市面存储方案2.1 缓存存储有哪些2.2 缓存策略有哪些2.3 常见存储方案2.4 市面存储方案说明2.5 存储方案的不足03.存储方案原理3.1 Sp存储原理分析3.2 MMKV存储原理分析3.3 LruCache考量分析3.4 DiskLru原理分析3.5 DataStore分析3.6 HashMap存储分析3.7 Sqlite存储分析3.8 使用存储的注意点3.9 各种数据存储文件04.通用缓存方案思路4.1 如何兼容不同缓存4.2 打造通用缓存Api4.3 切换不同缓存方式4.4 缓存的过期处理4.5 缓存的阀值处理4.6 缓存的线程安全性4.7 缓存数据的迁移4.8 缓存数据加密处理4.9 缓存效率的对比05.方案基础设计5.1 整体架构图5.2 UML设计图5.3 关键流程图5.4 模块间依赖关系06.其他设计说明6.1 性能设计说明6.2 稳定性设计6.3 灰度设计6.4 降级设计6.5 异常设计说明6.6 兼容性设计6.7 自测性设计07.通用Api设计7.1 如何依赖该库7.2 初始化缓存库7.3 切换各种缓存方案7.4 数据的存和取7.5 线程安全考量7.6 查看缓存文件数据7.7 如何选择合适方案08.其他说明介绍8.1 遇到的坑分析8.2 遗留的问题8.3 未来的规划8.4 参考链接记录01.整体概述说明1.1 项目背景介绍项目中很多地方使用缓存方案有的用sp,有的用mmkv,有的用lru,有的用DataStore,有的用sqlite,如何打造通用api切换操作不同存储方案?缓存方案众多,且各自使用场景有差异,如何选择合适的缓存方式?针对不同场景选择什么缓存方式,同时思考如何替换之前老的存储方案,而不用花费很大的时间成本!针对不同的业务场景,不同的缓存方案。打造一套通用的方案屏蔽各种缓存方式的差异性,暴露给外部开发者统一的API,外部开发者简化使用,提高开发效率和使用效率……1.2 遇到问题记录记录几个常见的问题问题1:各种缓存方案,分别是如何保证数据安全的,其内部使用到了哪些锁?由于引入锁,给效率上带来了什么影响?问题2:各种缓存方案,进程不安全是否会导致数据丢失,如何处理数据丢失情况?如何处理脏数据,其原理大概是什么?问题3:各种缓存方案使用场景是什么?有什么缺陷,为了解决缺陷做了些什么?比如sp存在缺陷的替代方案是DataStore,为何这样?问题4:各种缓存方案,他们的缓存效率是怎样的?如何对比?接入该库后,如何做数据迁移,如何覆盖操作?思考一个K-V框架的设计问题1-线程安全:使用K-V存储一般会在多线程环境中执行,因此框架有必要保证多线程并发安全,并且优化并发效率;问题2-内存缓存:由于磁盘 IO 操作是耗时操作,因此框架有必要在业务层和磁盘文件之间增加一层内存缓存;问题3-事务:由于磁盘 IO 操作是耗时操作,因此框架有必要将支持多次磁盘 IO 操作聚合为一次磁盘写回事务,减少访问磁盘次数;问题4-事务串行化:由于程序可能由多个线程发起写回事务,因此框架有必要保证事务之间的事务串行化,避免先执行的事务覆盖后执行的事务;问题5-异步或同步写回:由于磁盘 IO 是耗时操作,因此框架有必要支持后台线程异步写回;有时候又要求数据读写是同步的;问题6-增量更新:由于磁盘文件内容可能很大,因此修改 K-V 时有必要支持局部修改,而不是全量覆盖修改;问题7-变更回调:由于业务层可能有监听 K-V 变更的需求,因此框架有必要支持变更回调监听,并且防止出现内存泄漏;问题8-多进程:由于程序可能有多进程需求,那么框架如何保证多进程数据同步?问题9-可用性:由于程序运行中存在不可控的异常和 Crash,因此框架有必要尽可能保证系统可用性,尽量保证系统在遇到异常后的数据完整性;问题10-高效性:性能永远是要考虑的问题,解析、读取、写入和序列化的性能如何提高和权衡;问题11-安全性:如果程序需要存储敏感数据,如何保证数据完整性和保密性;问题12-数据迁移:如果项目中存在旧框架,如何将数据从旧框架迁移至新框架,并且保证可靠性;问题13-研发体验:是否模板代码冗长,是否容易出错。各种K—V框架使用体验如何?常见存储框架设计思考导图1.3 基础概念介绍最初缓存的概念提及缓存,可能很容易想到Http的缓存机制,LruCache,其实缓存最初是针对于网络而言的,也是狭义上的缓存,广义的缓存是指对数据的复用。缓存容量,就是缓存的大小每一种缓存,总会有一个最大的容量,到达这个限度以后,那么就须要进行缓存清理了框架。这个时候就需要删除一些旧的缓存并添加新的缓存。1.4 设计目标打造通用存储库:设计一个缓存通用方案,其次,它的结构需要很简单,因为很多地方需要用到,再次,它得线程安全。灵活切换不同的缓存方式,使用简单。内部开源该库:作为技术沉淀,当作专项来推动进展。高复用低耦合,便于拓展,可快速移植,解决各个项目使用内存缓存,sp,mmkv,sql,lru,DataStore的凌乱。抽象一套统一的API接口。1.5 产生收益分析统一缓存API兼容不同存储方案打造通用api,抹平了sp,mmkv,sql,lru,dataStore等各种方案的差异性。简化开发者使用,功能强大而使用简单!02.市面存储方案2.1 缓存存储有哪些比较常见的是内存缓存以及磁盘缓存。内存缓存:这里的内存主要指的存储器缓存;磁盘缓存:这里主要指的是外部存储器,手机的话指的就是存储卡。内存缓存:通过预先消耗应用的一点内存来存储数据,便可快速的为应用中的组件提供数据,是一种典型的以空间换时间的策略。磁盘缓存:读取磁盘文件要比直接从内存缓存中读取要慢一些,而且需要在一个UI主线程外的线程中进行,因为磁盘的读取速度是不能够保证的,磁盘文件缓存显然也是一种以空间换时间的策略。二级缓存:内存缓存和磁盘缓存结合。比如,LruCache将图片保存在内存,存取速度较快,退出APP后缓存会失效;而DiskLruCache将图片保存在磁盘中,下次进入应用后缓存依旧存在,它的存取速度相比LruCache会慢上一些。2.2 缓存策略有哪些缓存的核心思想主要是什么呢一般来说,缓存核心步骤主要包含缓存的添加、获取和删除这三类操作。那么为什么还要删除缓存呢?不管是内存缓存还是硬盘缓存,它们的缓存大小都是有限的。当缓存满了之后,再想其添加缓存,这个时候就需要删除一些旧的缓存并添加新的缓存。这个跟线程池满了以后的线程处理策略相似!缓存的常见的策略有那些FIFO(first in first out):先进先出策略,相似队列。LFU(less frequently used):最少使用策略,RecyclerView的缓存采用了此策略。LRU(least recently used):最近最少使用策略,Glide在进行内存缓存的时候采用了此策略。2.3 常见存储方案内存缓存:存储在内存中,如果对象销毁则内存也会跟随销毁。如果是静态对象,那么进程杀死后内存会销毁。Map,LruCache等等磁盘缓存:后台应用有可能会被杀死,那么相应的内存缓存对象也会被销毁。当你的应用重新回到前台显示时,你需要用到缓存数据时,这个时候可以用磁盘缓存。SharedPreferences,MMKV,DiskLruCache,SqlLite,DataStore,Room,Realm,GreenDao等等2.4 市面存储方案说明内存缓存Map:内存缓存,一般用HashMap存储一些数据,主要存储一些临时的对象LruCache:内存淘汰缓存,内部使用LinkedHashMap,会淘汰最长时间未使用的对象磁盘缓存SharedPreferences:轻量级磁盘存储,一般存储配置属性,线程安全。建议不要存储大数据,不支持跨进程!MMKV:腾讯开源存储库,内部采用mmap。DiskLruCache:磁盘淘汰缓存,写入数据到file文件SqlLite:移动端轻量级数据库。主要是用来对象持久化存储。DataStore:旨在替代原有的 SharedPreferences,支持SharedPreferences数据的迁移Room/Realm/GreenDao:支持大型或复杂数据集其他开源缓存库ACache:一款高效二级存储库,采用内存缓存和磁盘缓存2.5 存储方案的不足存储方案SharedPreferences的不足1.SP用内存层用HashMap保存,磁盘层则是用的XML文件保存。每次更改,都需要将整个HashMap序列化为XML格式的报文然后整个写入文件。2.SP读写文件不是类型安全的,且没有发出错误信号的机制,缺少事务性API3.commit() / apply()操作可能会造成ANR问题存储方案MMKV的不足1.没有类型信息,不支持getAll。由于没有记录类型信息,MMKV无法自动反序列化,也就无法实现getAll接口。2.需要引入so,增加包体积:引入MMKV需要增加的体积还是不少的。3.文件只增不减:MMKV的扩容策略还是比较激进的,而且扩容之后不会主动trim size。存储方案DataStore的不足1.只是提供异步API,没有提供同步API方法。在进行大量同步存储的时候,使用runBlocking同步数据可能会卡顿。2.对主线程执行同步 I/O 操作可能会导致 ANR 或界面卡顿。可以通过从 DataStore 异步预加载数据来减少这些问题。03.存储方案原理3.1 Sp存储原理分析SharedPreferences,它是一个轻量级的存储类,特别适合用于保存软件配置参数。轻量级,以键值对的方式进行存储。采用的是xml文件形式存储在本地,程序卸载后会也会一并被清除,不会残留信息。线程安全的。它有一些弊端如下所示对文件IO读取,因此在IO上的瓶颈是个大问题,因为在每次进行get和commit时都要将数据从内存写入到文件中,或从文件中读取。多线程场景下效率较低,在get操作时,会锁定SharedPreferences对象,互斥其他操作,而当put,commit时,则会锁定Editor对象,使用写入锁进行互斥,在这种情况下,效率会降低。不支持跨进程通讯,由于每次都会把整个文件加载到内存中,不建议存储大的文件内容,比如大json。有一些使用上的建议如下建议不要存储较大数据;频繁修改的数据修改后统一提交而不是修改过后马上提交;在跨进程通讯中不去使用;键值对不宜过多读写操作性能分析第一次通过Context.getSharedPreferences()进行初始化时,对xml文件进行一次读取,并将文件内所有内容(即所有的键值对)缓到内存的一个Map中,接下来所有的读操作,只需要从这个Map中取就可以3.2 MMKV存储原理分析早期微信的需求微信聊天对话内容中的特殊字符所导致的程序崩溃是一类很常见、也很需要快速解决的问题;而哪些字符会导致程序崩溃,是无法预知的。只能等用户手机上的微信崩溃之后,再利用类似时光倒流的回溯行为,看看上次软件崩溃的最后一瞬间,用户收到或者发出了什么消息,再用这些消息中的文字去尝试复现发生过的崩溃,最终试出有问题的字符,然后针对性解决。该需求对应的技术考量考量1:把聊天页面的显示文字写到手机磁盘里,才能在程序崩溃、重新启动之后,通过读取文件的方式来查看。但这种方式涉及到io流读写,且消息多会有性能问题。考量2:App程序都崩溃了,如何保证要存储的内容,都写入到磁盘中呢?考量3:保存聊天内容到磁盘的行为,这个做成同步还是异步呢?如果是异步,如何保证聊天消息的时序性?考量4:如何存储数据是同步行为,针对群里聊天这么多消息,如何才能避免卡顿呢?考量5:存储数据放到主线程中,用户在群聊天页面猛滑消息,如何爆发性集中式对磁盘写入数据?MMKV存储框架介绍MMKV 是基于 mmap 内存映射的 key-value 组件,底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强。MMKV设计的原理内存准备:通过 mmap 内存映射文件,提供一段可供随时写入的内存块,App 只管往里面写数据,由操作系统负责将内存回写到文件,不必担心 crash 导致数据丢失。数据组织:数据序列化方面我们选用 protobuf 协议,pb 在性能和空间占用上都有不错的表现。写入优化:考虑到主要使用场景是频繁地进行写入更新,需要有增量更新的能力。考虑将增量 kv 对象序列化后,append 到内存末尾。空间增长:使用 append 实现增量更新带来了一个新的问题,就是不断 append 的话,文件大小会增长得不可控。需要在性能和空间上做个折中。MMKV诞生的背景针对该业务,高频率,同步,大量数据写入磁盘的需求。不管用sp,还是store,还是disk,还是数据库,只要在主线程同步写入磁盘,会很卡。解决方案就是:使用内存映射mmap的底层方法,相当于系统为指定文件开辟专用内存空间,内存数据的改动会自动同步到文件里。用浅显的话说:MMKV就是实现用「写入内存」的方式来实现「写入磁盘」的目标。内存的速度多快呀,耗时几乎可以忽略,这样就把写磁盘造成卡顿的问题解决了。3.3 LruCache考量分析在LruCache的源码中,关于LruCache有这样的一段介绍:cache对象通过一个强引用来访问内容。每次当一个item被访问到的时候,这个item就会被移动到一个队列的队首。当一个item被添加到已经满了的队列时,这个队列的队尾的item就会被移除。LruCache核心思想LRU是近期最少使用的算法,它的核心思想是当缓存满时,会优先淘汰那些近期最少使用的缓存对象。采用LRU算法的缓存有两种:LrhCache和DiskLruCache,分别用于实现内存缓存和硬盘缓存,其核心思想都是LRU缓存算法。LruCache使用是计数or计量使用计数策略:1、Message 消息对象池:最多缓存 50 个对象;2、OkHttp 连接池:默认最多缓存 5 个空闲连接;3、数据库连接池使用计量策略:1、图片内存缓存;2、位图池内存缓存那么思考一下如何理解 计数 or 计量 ?针对计数策略使用Lru仅仅只统计缓存单元的个数,针对计量则要复杂一点。LruCache策略能否增加灵活性在缓存容量满时淘汰,除了这个策略之外,能否再增加一些辅助策略,例如在 Java 堆内存达到某个阈值后,对 LruCache 使用更加激进的清理策略。比如:Glide 除了采用 LRU 策略淘汰最早的数据外,还会根据系统的内存紧张等级 onTrimMemory(level) 及时减少甚至清空 LruCache。/**
* 这里是参考glide中的lru缓存策略,低内存的时候清除
* @param level level级别
*/
public void trimMemory(int level) {
if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
clearMemory();
} else if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN || level == android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) {
trimToSize(maxSize() / 2);
}
}关于Lru更多的原理解读,可以看:AppLruCache3.4 DiskLru原理分析DiskLruCache 用于实现存储设备缓存,即磁盘缓存,它通过将缓存对象写入文件系统从而实现缓存的效果。DiskLruCache最大的特点就是持久化存储,所有的缓存以文件的形式存在。在用户进入APP时,它根据日志文件将DiskLruCache恢复到用户上次退出时的情况,日志文件journal保存每个文件的下载、访问和移除的信息,在恢复缓存时逐行读取日志并检查文件来恢复缓存。DiskLruCache缓存基础原理流程图关于DiskLruCache更多的原理解读,可以看:AppLruDisk3.5 DataStore分析为何会有DataStoreDataStore 被创造出来的目标就是替代 Sp,而它解决的 SharedPreferences 最大的问题有两点:一是性能问题,二是回调问题。DataStore优势是异步ApiDataStore 的主要优势之一是异步API,所以本身并未提供同步API调用,但实际上可能不一定始终能将周围的代码更改为异步代码。提出一个问题和思考如果使用现有代码库采用同步磁盘 I/O,或者您的依赖项不提供异步API,那么如何将DataStore存储数据改成同步调用?使用阻塞式协程消除异步差异使用 runBlocking() 从 DataStore 同步读取数据。runBlocking()会运行一个新的协程并阻塞当前线程直到内部逻辑完成,所以尽量避免在UI线程调用。频繁使用阻塞式协程会有问题吗要注意的一点是,不用在初始读取时调用runBlocking,会阻塞当前执行的线程,因为初始读取会有较多的IO操作,耗时较长。更为推荐的做法则是先异步读取到内存后,后续有需要可直接从内存中拿,而非运行同步代码阻塞式获取。3.6 HashMap存储分析内存缓存的场景比如 SharedPreferences 存储中,就做了内存缓存的操作。3.7 Sqlite存储分析注意:缓存的数据库是存放在/data/data/databases/目录下,是占用内存空间的,如果缓存累计,容易浪费内存,需要及时清理缓存。3.8 使用缓存注意点在使用内存缓存的时候须要注意防止内存泄露,使用磁盘缓存的时候注意确保缓存的时效性针对SharedPreferences使用建议有:因为 SharedPreferences 虽然是全量更新的模式,但只要把保存的数据用合适的逻辑拆分到多个不同的文件里,全量更新并不会对性能造成太大的拖累。它设计初衷是轻量级,建议当存储文件中key-value数据超过30个,如果超过30个(这个只是一个假设),则开辟一个新的文件进行存储。建议不同业务模块的数据分文件存储……针对MMKV使用建议有:如果项目中有高频率,同步存储数据,使用MMKV更加友好。针对DataStore使用建议有:建议在初始化的时候,使用全局上下文Context给DataStore设置存储路径。针对LruCache缓存使用建议:如果你使用“计量”淘汰策略,需要重写 SystemLruCache#sizeOf() 测量缓存单元的内存占用量,否则缓存单元的大小默认视为 1,相当于 maxSize 表示的是最大缓存数量。3.9 各种数据存储文件SharedPreferences 存储文件格式如下所示<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="name">杨充</string>
<int name="age" value="28" />
<boolean name="married" value="true" />
</map>MMKV 存储文件格式如下所示MMKV的存储结构,分了两个文件,一个数据文件,一个校验文件crc结尾。大概如下所示:这种设计最直接问题就是占用空间变大了很多,举一个例子,只存储了一个字段,但是为了方便MMAP映射,磁盘直接占用了8k的存储。LruDiskCache 存储文件格式如下所示DataStore 存储文件格式如下所示04.通用缓存方案思路4.1 如何兼容不同缓存定义通用的存储接口不同的存储方案,由于api不一样,所以难以切换操作。要是想兼容不同存储方案切换,就必须自己制定一个通用缓存接口。定义接口,然后各个不同存储方案实现接口,重写抽象方法。调用的时候,获取接口对象调用api,这样就可以统一Api定义一个接口,这个接口有什么呢?主要是存和取各种基础类型数据,比如saveInt/readInt;saveString/readString等通用抽象方法4.2 打造通用缓存Api通用缓存Api设计思路:通用一套api + 不同接口实现 + 代理类 + 工厂模型定义缓存的通用API接口,这里省略部分代码interface ICacheable {
fun saveXxx(key: String, value: Int)
fun readXxx(key: String, default: Int = 0): Int
fun removeKey(key: String)
fun totalSize(): Long
fun clearData()
}基于接口而非实现编程的设计思想将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低耦合性,提高扩展性。4.3 切换不同缓存方式传入不同类型方便创建不同存储方式隐藏存储方案创建具体细节,开发者只需要关心所需产品对应的工厂,无须关心创建细节,甚至无须知道具体存储方案的类名。需要符合开闭原则那么具体该怎么实现呢?看到下面代码是不是有种很熟悉的感觉,没错,正是使用了工厂模式,灵活切换不同的缓存方式。但针对应用层调用api却感知不到影响。public static ICacheable getCacheImpl(Context context, @CacheConstants.CacheType int type) {
if (type == CacheConstants.CacheType.TYPE_DISK) {
return DiskFactory.create().createCache(context);
} else if (type == CacheConstants.CacheType.TYPE_LRU) {
return LruCacheFactory.create().createCache(context);
} else if (type == CacheConstants.CacheType.TYPE_MEMORY) {
return MemoryFactory.create().createCache(context);
} else if (type == CacheConstants.CacheType.TYPE_MMKV) {
return MmkvFactory.create().createCache(context);
} else if (type == CacheConstants.CacheType.TYPE_SP) {
return SpFactory.create().createCache(context);
} else if (type == CacheConstants.CacheType.TYPE_STORE) {
return StoreFactory.create().createCache(context);
} else {
return MmkvFactory.create().createCache(context);
}
}4.4 缓存的过期处理说一个使用场景比如你准备做WebView的资源拦截缓存,针对模版页面,为了提交加载速度。会缓存css,js,图片等资源到本地。那么如何选择存储方案,如何处理过期问题?思考一下该问题比如WebView缓存方案是数据库存储,db文件。针对缓存数据,猜想思路可能是Lru策略,或者标记时间清除过期文件。那么缓存过期处理的策略有哪些定时过期:每个设置过期时间的key都需要创建⼀个定时器,到过期时间就会立即清除。惰性过期:只有当访问⼀个 key 时,才会判断该key是否已过期,过期则清除。定期过期:每隔⼀定的时间,会扫描⼀定数量的数据库的 expires 字典中⼀定数量的key(是随机的), 并 清除其中已过期的key 。分桶策略:定期过期的优化,将过期时间点相近的 key 放在⼀起,按时间扫描分桶。4.5 缓存的阀值处理淘汰一个最早的节点就足够吗?以Lru缓存为案例做分析……标准的 LRU 策略中,每次添加数据时最多只会淘汰一个数据,但在 LRU 内存缓存中,只淘汰一个数据单元往往并不够。例如在使用 “计量” 的内存图片缓存中,在加入一个大图片后,只淘汰一个图片数据有可能依然达不到最大缓存容量限制。那么在LRUCache该如何做呢?在复用 LinkedHashMap 实现 LRU 内存缓存时,前文提到的 LinkedHashMap#removeEldestEntry() 淘汰判断接口可能就不够看了,因为它每次最多只能淘汰一个数据单元。LruCache是如何解决这个问题这个地方就需要重写LruCache中的sizeOf()方法,然后拿到key和value对象计算其内存大小。4.6 缓存的线程安全性为何要强调缓存方案线程安全性缓存虽好,用起来很快捷方便,但在使用过程中,大家一定要注意数据更新和线程安全,不要出现脏数据。针对LruCache中使用LinkedHashMap读写不安全情况保证LruCache的线程安全,在put,get等核心方法中,添加synchronized锁。这里主要是synchronized (this){ put操作 }针对DiskLruCache读写不安全的情况DiskLruCache 管理多个 Entry(key-values),因此锁粒度应该是 Entry 级别的。get 和 edit 方法都是同步方法,保证内部的 Entry Map 的安全访问,是保证线程安全的第一步。4.7 缓存数据的迁移如何将Sp数据迁移到DataStore通过属性委托的方式创建DataStore,基于已有的SharedPreferences文件进行创建DataStore。将sp文件名,以参数的形式传入preferencesDataStore,DataStore会自动将该文件中的数据进行转换。val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = "user_info",
produceMigrations = { context ->
listOf(SharedPreferencesMigration(context, "sp_file_name"))
})如何将sp数据迁移到MMKVMMKV 提供了 importFromSharedPreferences() 函数,可以比较方便地迁移数据过来。MMKV 还额外实现了一遍 SharedPreferences、SharedPreferences.Editor 这两个 interface。MMKV preferences = MMKV.mmkvWithID("myData");
// 迁移旧数据
{
SharedPreferences old_man = getSharedPreferences("myData", MODE_PRIVATE);
preferences.importFromSharedPreferences(old_man);
old_man.edit().clear().commit();
}思考一下,MMKV框架实现了sp的两个接口,即磨平了数据迁移差异性那么使用这个方式,借鉴该思路,你能否尝试用该方法,去实现LruDiskCache方案的sp数据一键迁移。4.8 缓存数据加密思考一下,如果让你去设计数据的加密,你该怎么做?具体可以参考MMKV的数据加密过程。4.9 缓存效率的对比测试数据测试写入和读取。注意分别使用不同的方式,测试存储或获取相同的数据(数据为int类型数字,还有String类型长字符串)。然后查看耗时时间的长短……比较对象SharePreferences/DataStore/MMKV/LruDisk/Room。使用华为手机测试测试数据案例1在主线程中测试数据,同步耗时时间(主线程还有其他的耗时)跟异步场景有较大差别。测试数据案例2测试1000组长字符串数据,MMKV 就不具备优势了,反而成了耗时最久的;而这时候的冠军就成了 DataStore,并且是遥遥领先。最后思考说明从最终的数据来看,这几种方案都不是很慢。虽然这半秒左右的主线程耗时看起来很可怕,但是要知道这是 1000 次连续写入的耗时。而在真正写程序的时候,怎么会一次性做 1000 次的长字符串的写入?所以真正在项目中的键值对写入的耗时,不管你选哪个方案,都会比这份测试结果的耗时少得多的,都少到了可以忽略的程度,这是关键。05.方案基础设计5.1 整体架构图统一存储方案的架构图5.2 UML设计图通用存储方案UML设计图5.3 代码说明图项目中代码相关说明图5.4 关键流程图mmap的零拷贝流程图5.5 模块间依赖关系存储库依赖的关系MMKV需要依赖一些腾讯开源库的服务;DataStore存储需要依赖datastore相关的库;LruDisk存储需要依赖disk库如果你要拓展其他的存储方案,则需要添加其依赖。需要注意,添加的库使用compileOnly。06.其他设计说明6.1 性能设计关于基础库性能如何考量具体性能可以参考测试效率的对比。6.2 稳定性设计针对多进程初始化遇到问题:对于多进程在Application的onCreate创建几次,导致缓存存储库初始化了多次。问题分析:该场景不是该库的问题,建议判断是否是主进程,如果是则进行初始化。如何解决:思路是获取当前进程名,并与主进程对比,来判断是否为主进程。具体可以参考:优雅判断是否是主进程6.3 灰度设计暂无灰度设计6.4 降级设计由于缓存方式众多,在该库中配置了降级,如何设置降级//设置是否是debug模式
CacheConfig cacheConfig = builder.monitorToggle(new IMonitorToggle() {
@Override
public boolean isOpen() {
//todo 是否降级,如果降级,则不使用该功能。留给AB测试开关
return true;
}
})
//创建
.build();
CacheInitHelper.INSTANCE.init(this,cacheConfig);降级后的逻辑处理是如果是降级逻辑,则默认使用谷歌官方存储框架SharedPreferences。默认是不会降级的!if (CacheInitHelper.INSTANCE.isToggleOpen()){
//如果是降级,则默认使用sp
return SpFactory.create().createCache();
}6.5 异常设计说明DataStore初始化遇到的坑遇到问题:不能将DataStore初始化代码写到Activity里面去,否则重复进入Activity并使用Preferences DataStore时,会尝试去创建一个同名的.preferences_pb文件。问题分析:SingleProcessDataStore#check(!activeFiles.contains(it)),该方法会检查如果判断到activeFiles里已经有该文件,直接抛异常,即不允许重复创建。如何解决:在项目中只在顶层调用一次 preferencesDataStore 方法,这样可以更轻松地将 DataStore 保留为单例。MMKV遇到的坑说明MMKV 是有数据损坏的概率的,MMKV 的 GitHub wiki 页面显示,微信的 iOS 版平均每天有 70 万次的数据校验不通过(即数据损坏)。6.6 兼容性设计MMKV数据迁移比较难MMKV都是按字节进行存储的,实际写入文件把类型擦除了,这也是MMKV不支持getAll的原因,虽然说getAll用的不多问题不大,但是MMKV因此就不具备导出和迁移的能力。比较好的方案是每次存储,多用一个字节来存储数据类型,这样占用的空间也不会大很多,但是具备了更好的可扩展性。6.7 自测性设计MMKV不太方便查看数据和解析数据官方目前支持了5个平台,Android、iOS、Win、MacOS、python,但是没有提供解析数据的工具,数据文件和crc都是字节码,除了中文能看出一些内容,直接查看还是存在大量乱码。比如线上出了问题,把用户的存储文件捞上来,还得替换到系统目录里,通过代码断点去看,这也太不方便了。Sp,FastSp,DiskCache,Store等支持查看文件解析数据傻瓜式的查看缓存文件,操作缓存文件。具体看该库:MonitorFileLib磁盘查看工具07.通用Api设计7.1 如何依赖该库依赖该库如下所示//通用缓存存储库,支持sp,fastsp,mmkv,lruCache,DiskLruCache等
implementation 'com.github.yangchong211.YCCommonLib:AppBaseStore:1.4.8'7.2 初始化缓存库通用存储库初始化CacheConfig.Builder builder = CacheConfig.Companion.newBuilder();
//设置是否是debug模式
CacheConfig cacheConfig = builder.debuggable(BuildConfig.DEBUG)
//设置外部存储根目录
.extraLogDir(null)
//设置lru缓存最大值
.maxCacheSize(100)
//内部存储根目录
.logDir(null)
//创建
.build();
CacheInitHelper.INSTANCE.init(MainApplication.getInstance(),cacheConfig);
//最简单的初始化
//CacheInitHelper.INSTANCE.init(CacheConfig.Companion.newBuilder().build());7.3 切换各种缓存方案如何调用api切换各种缓存方案//这里可以填写不同的type
val cacheImpl = CacheFactoryUtils.getCacheImpl(CacheConstants.CacheType.TYPE_SP)7.4 数据的存和取存储数据和获取数据//存储数据
dataCache.saveBoolean("cacheKey1",true);
dataCache.saveFloat("cacheKey2",2.0f);
dataCache.saveInt("cacheKey3",3);
dataCache.saveLong("cacheKey4",4);
dataCache.saveString("cacheKey5","doubi5");
dataCache.saveDouble("cacheKey6",5.20);
//获取数据
boolean data1 = dataCache.readBoolean("cacheKey1", false);
float data2 = dataCache.readFloat("cacheKey2", 0);
int data3 = dataCache.readInt("cacheKey3", 0);
long data4 = dataCache.readLong("cacheKey4", 0);
String data5 = dataCache.readString("cacheKey5", "");
double data6 = dataCache.readDouble("cacheKey5", 0.0);也可以通过注解的方式存储数据class NormalCache : DataCache() {
@BoolCache(KeyConstant.HAS_ACCEPTED_PARENT_AGREEMENT, false)
var hasAcceptParentAgree: Boolean by this
}
//如何使用
object CacheHelper {
//常规缓存数据,记录一些重要的信息,慎重清除数据
private val normal: NormalCache by lazy {
NormalCache().apply {
setCacheImpl(
DiskCache.Builder()
.setFileId("NormalCache")
.build()
)
}
}
fun normal() = normal
}
//存数据
CacheHelper.normal().hasAcceptParentAgree = true
//取数据
val hasAccept = CacheHelper.normal().hasAcceptParentAgree7.5 查看缓存文件数据android缓存路径查看方法有哪些呢?将手机打开开发者模式并连接电脑,在pc控制台输入cd /data/data/目录,使用adb主要是方便测试(删除,查看,导出都比较麻烦)。如何简单快速,傻瓜式的查看缓存文件,操作缓存文件,那么该项目小工具就非常有必要呢!采用可视化界面读取缓存数据,方便操作,直观也简单。一键接入该工具FileExplorerActivity.startActivity(this);开源项目地址:https://github.com/yangchong211/YCAndroidTool查看缓存文件数据如下所示7.6 如何选择合适方案比如常见的缓存、浏览器缓存、图片缓存、线程池缓存、或者WebView资源缓存等等那就可以选择LRU+缓存淘汰算法。它的核心思想是当缓存满时,会优先淘汰那些近期最少使用的缓存对象。比如针对高频率,同步存储,或者跨进程等存储数据的场景那就可以选择MMKV这种存储方案。它的核心思想就是高速存储数据,且不会阻塞主线程卡顿。比如针对存储表结构,或者一对多这类的数据那就可以选择DataStore,Room,GreenDao等存储库方案。比如针对存储少量用户类数据其实也可以将json转化为字符串,然后选择sp,mmkv,lruDisk等等都可以。08.其他说明介绍8.1 遇到的坑分析Sp存储数据commit() / apply()操作可能会造成ANR问题commit()是同步提交,会在UI主线程中直接执行IO操作,当写入操作耗时比较长时就会导致UI线程被阻塞,进而产生ANR;apply()虽然是异步提交,但异步写入磁盘时,如果执行了Activity / Service中的onStop()方法,那么一样会同步等待SP写入完毕,等待时间过长时也会引起ANR问题。首先分析一下SharedPreferences源码中apply方法SharedPreferencesImpl#apply(),这个方法主要是将记录的数据同步写到Map集合中,然后在开启子线程将数据写入磁盘SharedPreferencesImpl#enqueueDiskWrite(),这个会将runnable被写入了队列,然后在run方法中写数据到磁盘QueuedWork#queue(),这个将runnable添加到sWork(LinkedList链表)中,然后通过handler发送处理队列消息MSG_RUN然后再看一下ActivityThread源码中的handlePauseActivity()、handleStopActivity()方法。ActivityThread#handlePauseActivity()/handleStopActivity(),Activity在pause和stop的时候会调用该方法ActivityThread#handlePauseActivity()#QueuedWork.waitToFinish(),这个是等待QueuedWork所有任务处理完的逻辑QueuedWork#waitToFinish(),这个里面会通过handler查询MSG_RUN消息是否有,如果有则会waiting等待那么最后得出的结论是handlePauseActivity()的时候会一直等待 apply() 方法将数据保存成功,否则会一直等待,从而阻塞主线程造成 ANR。但普通存储的场景,这种可能性很小。8.2 项目开发分享通用缓存存储库开源代码https://github.com/yangchong211/YCCommonLib/tree/master/AppBaseStore
重识Flutter 用于解决复杂滑动视窗问题的Slivers - part1
前言在日常的开发工作中,仅仅使用ListView、ListView.builder等这样的滑动组件就能满足大部分的业务需求,在碰到较为复杂的滑动页面时,加上Slivers系列中的几个常用组件也简单的实现。这也就导致了一些朋友没有较为完整的去了解Slivers系列。那么在重识Flutter这个专栏中,从组件的使用到其背后的渲染原理,让我们一起探索Slivers的魅力吧!视窗是什么? Sliver是什么?相信点进这篇文章的朋友,一定会知道ListView这样的滚动组件,滚动组件会提供一个区块,用于滚动的显示内容,但内容很多时,我们只能看到可滚动内容中的部分内容。这个就是视窗(ViewPort),也就是列表的可视区域大小。例如一个ListView的显示区域高度为500像素,它列表项总高度可能远远超过500个像素,但是它的ViewPort仍为500像素。那么Sliver是什么呢?我们可以通过ListView.builder(),设置itemCount为null,构建一个无限的列表内容,只有当我们滚动时,才会动态的去创建需要呈现在视窗中的内容。这个就是Sliver,如果一个滚动组件支持Sliver模型,那么这个组件会将子组件分成很多个Sliver,只有当Sliver出现在视窗中才会构建。CustomScrollView像ListView、GridView等组件,在底层实现中都有着对应的Sliver,如SliverList、SliverGrid。Sliver版本的可滚动组件和非Sliver版本的可滚动组件最大的区别就是:Sliver版本的组件不包含滚动模型(Scrollable),组件自身不能滚动。所以,如果想要使用Sliver系列的组件,就需要给它们添加滚动模型。Flutter提供了CustomScrollView,做为Sliver系列组件运行的容器。CustomScrollView主要的作用就是提供Viewport和一个公共的Scrollable,多个Sliver组件共用CustomScrollView的Scrollable,就达到了单一滚动的场景。CustomScrollView有着许多属性,其中最常用的便是slivers,用来传入Sliver组件列表。就像这样:Scaffold( body: CustomScrollView( slivers: [ SliverList(/**/), SliverGrid(/**/), ], ), );有了CustomScrollView组件的帮助,实现复杂的滚动效果,好像不是那么困难。SliverList 如果需要在一个界面创建多个列表,刚了解Flutter的朋友可能会使用Column中包裹多个ListView去实现,就像这样:但是这样的效果肯定不符合需求,如果想要让它们一起滚动,或添加一些复杂的动画,实现像这样的效果:那么借助SliverList就能很简单的实现。SliverList是Sliver Widget的一种,作用是将Widget排列在一个List中,使用SliverList需要定义delegate。Sliver delegate是用于描述使用哪种方法对组件进行渲染,通常有两种:static和builder。在SliverList中,可以定义两种类型的delegate:SliverChildListDelegate:获取需要显示的组件列表。此列表中定义的组件将被立即呈现。不会有任何延迟加载。SliverChildBuilderDelegate:获取将延迟创建的小部件列表。这意味着随着用户滚动,剩余的组件才会开始渲染。可以简单的把ListView理解为:CustomScrollView + SliverList + SliverChildListDelegate;把ListView.Builder理解为:CustomScrollView + SliverList + SliverChildBuilderDelegate。在了解了SliverList需要定义的delegate后,那么使用它就和使用ListView一样简单:CustomScrollView( slivers: [ SliverList( delegate: SliverChildListDelegate([ Container( height: 50, color: Colors.primaries[0], ), ]), ), SliverList( delegate: SliverChildBuilderDelegate((BuildContext ctx, int index) { return Container( height: 50, color: Colors.primaries[index % Colors.primaries.length], ); }, childCount: 5), ), ], ), SliverGridSliverGrid与GridView一样,将组件以一行两个或一行多个的形式排列。它除了和SliverList一样需要定义一个正常的delegate之外,还需要传入gridDelegate,用于描述每行如何显示组件。就像这样:每行最多4个,每个组件的宽度是高度的1.5倍。SliverGrid( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 4, crossAxisSpacing: 5, mainAxisSpacing: 3, childAspectRatio: 1.5), delegate: SliverChildBuilderDelegate((BuildContext context, int index) { return Container( color: Colors.primaries[index % Colors.primaries.length], ); }, childCount: 20),)在SliverGrid中可以定义两种gridDelegate:SliverGridDelegateWithFixedCrossAxisCount:指定一行中有多少列,使用它会自动扩展到屏幕最大宽度。crossAxisCount属性是列数childAspectRatio属性是宽高比XXXSpacing属性是指每个item之间的边距SliverGridDelegateWithMaxCrossAxisExtent: 可以指定列的宽度maxCrossAxisExtent 是列中的最大宽度可以把GridView.builder理解为:CustomScrollView + SliverGrid + SliverChildBuilderDelegate + gridDelegate遇到一些简单的需求,也可以使用缩写组件:SliverGrid.count :SliverGrid + SliverChildListDelegate + SliverGridDelegateWithFixedCrossAxisCountSliverGrid.extent:SliverGrid + SliverChildListDelegate + SliverGridDelegateWithMaxCrossAxisExtentSliverGrid.count( crossAxisCount: 3, children: [ ...List.generate( 3, (index) => Container( color: Colors.primaries[index % Colors.primaries.length], ), ) ],),SliverGrid.extent( maxCrossAxisExtent: 100, children: [ ...List.generate( 9, (index) => Container( color: Colors.primaries[index % Colors.primaries.length], ), ) ],)SliverGrid与SliverList一起使用即可获得这样的效果:SliverAppBarAppBar是大部分应用程序很重要的组件,它位于App的顶部,主要控制一些可操作按钮。在Flutter中,常在Scaffold下使用AppBar组件。那么什么是SliverAppBar呢?SliverAppBar Widget是 Flutter 中用于兼容 CustomScrollView的,它与AppBar组件相同,意味着它具有AppBar的所有属性,如:title、actions、leading,但它也有一些额外的参数,如pinned, floating, snap,expandedHeight用于自定义AppBar的行为。SliverAppBar通常作为CustomScrollView slivers中的第一个组件。Scaffold( body: CustomScrollView( slivers: [ SliverAppBar( title: Text("Hello SliverAppBar & Taxze"), actions: <Widget>[ IconButton(onPressed: () => null, icon: const Icon(Icons.add)) ], ), ], ),)这就是一个很经典的Material应用程序的AppBar。SliverAppBar属性众多,与AppBar相同的属性在本文就不过多介绍,主要讲解其特有的属性。expandedHeight该属性定义了AppBar完全展开时的大小,高度会随着向下滚动而缩小。SliverAppBar( title: Text("Hello SliverAppBar & Taxze"), expandedHeight: 200, actions: <Widget>[ IconButton(onPressed: () => null, icon: const Icon(Icons.add)) ],),未设置expandedHeightexpandedHeight: 200pinned该属性用于确定当用户向下滚动时,AppBar在屏幕上是否保持可见。SliverAppBar( title: Text("Hello SliverAppBar & Taxze"), expandedHeight: 200, pinned: true, actions: <Widget>[ IconButton(onPressed: () => null, icon: const Icon(Icons.add)) ],), pinned: true pinned: falsefloating该属性如果设置为true,则AppBar将在用户向上滚动时立即可见。如果为false那么只有当滚动到顶部才能可见。SliverAppBar( title: Text("Hello SliverAppBar & Taxze"), expandedHeight: 200, floating: true, actions: <Widget>[ IconButton(onPressed: () => null, icon: const Icon(Icons.add)) ],),floating: truefloating: falsesnap该属性如果设置为true,那么用户向上滚动一点,即可见完整的AppBar。使用该属性需要将floating设置为true。SliverAppBar( title: Text("Hello SliverAppBar & Taxze"), expandedHeight: 200, floating: true, snap: true, actions: <Widget>[ IconButton(onPressed: () => null, icon: const Icon(Icons.add)) ],),snap: truefloating: true在效果图中能明显看出snap和floating的明显区别。flexibleSpace该属性用于给AppBar提供background和collapseMode,还有可以随用户滚动而改变位置的title。SliverAppBar( expandedHeight: 200, flexibleSpace: FlexibleSpaceBar( title: Text("First FlexibleSpace",style: TextStyle(color: Colors.red),), background: Image.network( "https://p3-passport.byteimg.com/img/user-avatar/af5f7ee5f0c449f25fc0b32c050bf100~180x180.awebp", fit: BoxFit.cover), ), actions: <Widget>[ IconButton(onPressed: () => null, icon: const Icon(Icons.add)) ],),当用户向上滚动时,就会得到视差效果,这是因为collapseMode,它有三种模式:parallax , pin , none。collapseMode默认为CollapseMode.parallax,如果将其设置为pin,那么你将不会得到视差效果,只有简单的淡入淡出。flexibleSpace: FlexibleSpaceBar( title: Text("First FlexibleSpace",style: TextStyle(color: Colors.black),), collapseMode: CollapseMode.pin, background: Image.network( "https://p3-passport.byteimg.com/img/user-avatar/af5f7ee5f0c449f25fc0b32c050bf100~180x180.awebp", fit: BoxFit.cover),),CollapseMode.parallaxCollapseMode.pinstretch使用该属性前,需要先设置CustomScrollView的physics,给它一个弹性效果,在滚动到内容尽头时仍然运行滚动。stretch属性设置为true时,会让 FlexibleSpaceBar 与外部组件同步滚动。SliverAppBar( expandedHeight: 200, pinned: true, stretch: true, flexibleSpace: FlexibleSpaceBar( title: Text("First FlexibleSpace",style: TextStyle(color: Colors.black),), // collapseMode: CollapseMode.pin, background: Image.network( "https://p3-passport.byteimg.com/img/user-avatar/af5f7ee5f0c449f25fc0b32c050bf100~180x180.awebp", fit: BoxFit.cover), ), actions: <Widget>[ IconButton(onPressed: () => null, icon: const Icon(Icons.add)) ],),stretch: truestretch: falsestretchModes当stretch属性设置为true时,此时会触发FlexibleSpaceBar容器放大导致的背景图片变化的一个动画效果->stretchModes。stretchModes有三种属性:zoomBackground默认效果,放大背景图片blurBackground模糊背景图片fadeTitle淡化titleflexibleSpace: FlexibleSpaceBar( title: Text("First FlexibleSpace",style: TextStyle(color: Colors.black),), // collapseMode: CollapseMode.pin, background: Image.network( "https://p3-passport.byteimg.com/img/user-avatar/af5f7ee5f0c449f25fc0b32c050bf100~180x180.awebp", fit: BoxFit.cover), stretchModes: [ // StretchMode.fadeTitle, // StretchMode.blurBackground, StretchMode.zoomBackground ],),zoomBackgroundblurBackgroundfadeTitle
Flink X Hologres 构建企业级 Streaming Warehouse
摘要:本文整理自阿里云资深技术专家,阿里云 Hologres 负责人姜伟华(果贝),在 FFA 实时湖仓专场的分享。本篇内容主要分为四个部分:实时数仓分层的技术需求阿里云一站式实时数仓 Hologres 介绍Flink x Hologres:天作之合基于 Flink Catalog 的 Streaming Warehouse 实践点击查看直播回放 & 演讲PPT一、实时数仓分层的技术需求首先,我们讲一讲分层技术。大数据现在越来越讲究实时化,在各种场景下都需要实时。因此大数据需要构建实时数仓,但如何构建实时数仓呢?离线数仓的构建,有非常标准的方法论体系,首先通过分层 ODS->DWD->DWS->ADS,然后通过定时调度去实现分层和构造。相比离线数仓,实时数仓没有明确的方法论体系。因此在实践中,有各种各样的方法,但没有一个方法是万能。最近行业内提出了 Streaming Warehouse。Streaming Warehouse 的本质是分层之间能够做到实时数据的流动,从而解决实时数仓分层的问题。下面,我们来了解下实时数仓的主流分层方案。第一个方案,数据通过 Flink 清洗后,写到 Kafka 形成 ODS 层。再从 Kafka 消费,经过加工形成 DWD 层。然后 Flink 加工成 DWS 层,最后通过加工形成 ADS 层的数据写到 KV 引擎,供应用消费。因为 Kafka 数据进行分析和探查很麻烦。所以,我们会同步一份 Kafka 数据到实时数仓,通过实时数仓进行分析和探查。这个体系的优势是层次明确,分工明确。但它的劣势是需要有大量的同步任务;数据资源消耗很大;数据有很多冗余;处理链路较复杂,需要好多组件。除此之外,在这个体系下构建的实时数仓分层,尤其是 Kafka 分层,它的复用性非常差。第二个方案,这个方法不需要分层,整体架构只需要一层。我们把数据加工清洗后,写到实时数仓里,形成 DWD 层。所有的查询都是查询 DWD 层的明细数据。所以该方法也被称之为 ELT,把明细数据写进去(EL),变换(T)在查询时进行。这个方案的好处在于,没有 ETL,只有一层;数据修订很方便。但它的弊端有两个方面:在查询性能方面,由于是明细数据查询,所以在某些场景下不能满足 QPS 或延迟的要求。因为没有分层,所以数据复用很困难,很难兼顾各方面的诉求。第三个方案,既然实时流式无法完成数据的实时数仓分层,我们可以将数据实时写入实时数仓的 DWD 层。DWS 层、ADS 层用离线的高频调度方法,实现分钟级的调度。从而借用离线数仓,进行分层构造。这个方案的好处在于,它可以复用很多离线经验,方案成本低且成熟。但这个方案存在如下缺点:延迟大。每一层的延迟都跟调度相关,随着层次越多,调度延迟越大。实时数仓也变成了准实时数仓。不能完全复用离线方案。离线调度一般是小时级或天级,我们可以使用全量计算。但在分钟级调度时,必须做增量计算,否则无法及时调度。第四个方案,Flink 实时加工后,将数据写到实时数仓形成 DWD 层。但 DWS 层或 ADS 层的构造,依赖于实时数仓的实时物化视图的能力。现在实时数仓都开始提供物化视图的能力,这种能力本质上是提供了一些简单的聚合类物化视图。如果用户的物化视图需求较简单,可以利用实时数仓里的实时物化视图能力,将 DWS 层到 ADS 层的构建自动化。从而让物化视图的查询保证较高的 QPS。这个方案最大的缺点在于,实时物化视图还不成熟,能力有限。二、阿里云一站式实时数仓 Hologres 介绍接下来,介绍一下阿里云一站式实时数仓 Hologres 产品。Hologres 是阿里云自研的一站式实时数仓,它同时包含三种能力:它支持传统的实时数仓和 OLAP。它支持 Serving(KV)场景,有非常高的 QPS 和很低的延迟。它支持数据湖和阿里云离线数仓 MaxCompute 加速的能力。首先,在实时数仓方面。大家可以把 Hologres 当做一个常见的实时数仓。它的特点在于写入侧支持百万 RPS 实时写入,写入即可查,没有延迟。我们支持高性能的实时整行更新和局部更新。其中。整行更新是把整行替换掉,局部更新可以更新一行中的局部字段,二者都是实时更新。在查询侧,一方面对于实时看板类的需求,支持高 QPS。比如几百或上千 QPS 的简单查询。另一方面,我们也支持复杂计算。近期,Hologres 拿到了 TPC-H 30TB 的性能世界第一的 TPC 官方认证成绩。其次,在数据服务方面。Hologres 不但支持百万 QPS KV 点查,而且支持阿里云达摩院的 Proxima 向量检索引擎,可以支持非常高的向量检索能力。这些能力在 Hologres 中是全 SQL 表达,对用户使用非常友好。为了同时兼顾数据服务和实时数仓的需求,我们支持行列共存的数据格式。行列共存是指,一张表的数据既有一份行存,又有一份列存,并且系统保证这两份数据是强一致的。对于 OLAP 分析,优化器会自动选择列存。对于线上服务,它会自动选择行存。因为 Hologres 同时支持 OLAP 分析和线上服务,其中线上服务要求非常高的稳定性和 SLA。所以我们要保证 OLAP 分析和线上服务时,不会发生冲突。因此,我们支持读写的分离、OLAP 分析和数据服务的强隔离。这里的强隔离不同于普通实时数仓的弱隔离。弱隔离一般通过资源组的方式来实现,Hologres 直接做了强隔离,并没有通过资源组。最后,在湖仓数据交互式分析方面。对于阿里云 MaxCompute 离线数仓里的数据,数据湖的数据都可以做秒级的交互式分析。且不需要做任何的数据搬迁。这种表我们称为“外表”。用户可以将外表和内表进行关联分析。因为 Hologres 的定位是一站式的企业级实时数仓,所以除了上述能力,我们还有很多其他能力。包括数据的治理、成本治理、数据血缘、数据脱敏、数据加密、IP 白名单、数据的备份和恢复等等。三、Flink x Hologres:天作之合Flink 和 Hologres 有非常深度的整合关系。最初 Hologres 团队和 Flink 团队是同一个团队,所以 Hologres 在设计之初,着重考虑如何支持 Flink。那么 Hologres 和 Flink 有哪些深度整合的能力?第一,Hologres 可以作为 Flink 的维表。因为 Hologres 有很强的点查能力,所以它可以作为 Flink 的维表使用。在实时计算的场景下,Flink 对维表的需求很强。我们支持百万级至千万级的 RPS 查询,且维表可实时更新。因此,很多用户会把 Hologres 用作 Flink 的实时维表(比方说实时特征存储)。第二,Hologres 可以作为 Flink 的结果表。Hologres 有非常强的实时写入和整行实时更新的能力,跟 Flink 非常匹配。因为 Flink 的输出需要强大的 Update,结果表需要很强的覆盖能力、以及实时更新能力。与此同时,Hologres 还有很强的局部更新能力。局部更新能力在很多场景下,可以替代 Flink 的多流 Join,为客户节省成本。第三,Hologres 可以作为 Flink 的源表。Hologres 提供了 Binlog,一张表的任何变化,比如 insert、update、delete 等等,都会产生 Binlog 事件。Flink 可以订阅 Binlog,进行驱动计算。由于 Flink 支持 Hologres 的整表读取,二者结合构成了 Flink 全增量一体化的读取能力。并且,我们对接 Flink CDC,它可以驱动 CDC 的计算。第四,我们在 Flink 中实现了 Hologres 的 Catalog。用户不需要在 Flink 里建 Hologres 的外表。通过 Catalog 的任何操作,都会直接实时反映到 Hologres 里。通过这种方法,Flink+Hologres 就具备了整库同步、Schema Evolution 的能力。由此可见,我们在维表、结果表、源表、Catalog 四个方面,对 Flink 和 Hologres 做了深度整合。接下来,介绍一下 Flink 和 Hologres,如何构建 Streaming Warehouse?相比常见的 Flink+Kafka 的分层方案,Flink+Hologres 可以完全将其替换。首先,将 Flink 写到 Hologres 里,形成 ODS 层。Flink 订阅 ODS 层的 Binlog 进行加工,将 Flink 从 DWD 层再次写入 Hologres 里。然后,再订阅 DWD 层的 Binlog,通过计算形成 DWS 层,将其再次写入 Hologres 里。最后,由 Hologres 对外提供应用查询。该方案相比 Kafka 有如下优点:第一,解决了传统中间层 Kafka 数据不易查、不易更新、不易修正的问题。Hologres 的每一层都可查、可更新、可修正。第二,Hologres 的每一层都可以单独对外提供服务。因为每一层的数据都是可查的,所以数据的复用会更好,真正实现数仓分层复用的目标。第三,Hologres 支持数据复用,模型统一,架构简化。目前,该方案已经有很多客户使用。通过 Flink+Hologres,实现实时数仓分层。上面讲的 Flink+Hologres 的 Streaming Warehouse 方案,其强依赖于以下三个 Hologres 核心能力:第一个能力是 Binlog。因为实时数仓一般没有 Binlog,但 Hologres 提供了 Binlog 能力,用来驱动 Flink 做实时计算,正因为有了 binlog,Hologres 才能作为流式计算的上游。第二个能力是行列共存。一张表既有行存数据,又有列存数据。这两份数据是强一致的。行列共存的特性让中间层的每张表,不但能够给 Flink 使用,而且可以给其他应用(比方说 OLAP、或者线上服务)使用。第三个能力是资源强隔离。实时数仓一般是弱隔离或软隔离,通过资源组、资源队列的方法实现资源隔离。如果 Flink 的资源消耗很大,可能影响中间层的点查性能。但在强隔离的情况下,Flink 对 Binlog 的数据拉取,不会影响线上服务。通过 Binlog、行列共存、资源强隔离的三个特点,不仅能让 Flink+Hologres 形成 Streaming Warehouse,并且能够使中间的每层数据复用,被其他应用或线上服务使用。接下来,讲一讲基于 Flink+Hologres 的多流合并。因为 Hologres 有特别强大的局部更新能力,所以我们可以简化 Flink 的多流 Join。比如在风控场景下,我们需要基于用户 ID 构建用户的多侧面画像。用户画像来自很多数据源,比如客户的浏览行为、成交行为、履约行为等等。把数据源的数据按照用户 ID,把每个用户放到一行里,形成不同的字段,形成用户的完整画像。传统的方式需要用 Flink 多流 Join 实现,Flink 把上游的多个数据源关联到一起。Join 后写到 Kafka 里,然后驱动下游的 Flink,加工这行完整的数据。多流 Join 非常耗资源。所以在 Flink+Hologres 场景下,Hologres 可以利用局部更新能力,把一张表定为定义成 Hologres 的行存表或行列共存表。此时,整个方案就简化成上游每个数据源,同步数据到 Hologres 表的若干个字段里,若干个任务同时写入这张表,然后利用 Hologres 的局部更新能力,把数据汇总在一起。如果打开这张表的 Binlog,上游任何数据源的变化都会更新这张表,使这张表的 Binlog 中生成行数据的最新状态,然后驱动下游的 Flink 继续计算,从而完美匹配常见的风控场景。这种用法下,资源消耗、运维都得到了极大的简化。四、基于 Flink Catalog 的 Streaming Warehouse 实践虽然上述方案已经非常成熟,但唯一的缺点在于,用户需要在两个系统之间切换,过程比较繁琐。为了让用户操作更简单,我们基于 Flink Catalog 提供了更加简单的使用体验。下面我们来看看怎么样基于 Flink Catalog 去构建基于 Flink+Hologres 的 Streaming Warehouse。我们会发现,有了 Flink Catalog 后,整个使用体验会很简单,并能充分发挥 Flink 和 Hologres 两个产品的强大能力。上图是一个典型的 Flink+Hologres 实时 ETL 链路;ODS 层、DWD 层、ODS 层的数据都存在 Hologres 中。链路中所有的数据加工都是通过 Flink SQL 完成。在整个 ETL 链路中,用户不需要任何 Hologres SQL,直接写 Flink SQL 即可。Flink 用户可以通过 Flink SQL 对每层中的 Hologres 数据进行数据探查(流模式和批模式都可以)。比方说,当我们发现 DWS 层的数据结果出现问题,需要查看哪层的结果有问题或逻辑有错误。此时,我们可以复用原来的 Flink SQL 来进行探查、定位或者数据重新消费。Hologres 中的每层数据都可以对外提供查询和服务(通过 Hologres SQL)。接下来,以某个电商场景为例,演示一下基于 Flink Catalog 的 Streaming Warehouse。如上图所示,有一个 MySQL 数据库作为订单库,里面有订单表 orders、订单支付表 orders_pay、以及产品品类表 product_catalog。第一步,我们通过 Flink 的实时数仓,把数据实时同步到 Hologres 里,形成 ODS 层。第二步,加工 DWD 层。将 DWD 层的数据写到 Hologres 里。在这个过程中,我们需要把订单表和订单支付表,合并成一张表,实现多路合并。与此同时,我们希望 orders 表关联商品品类表 product_catalog。第三步,驱动下游计算,构建 DWS 层。以用户维度和商店维度,收集统计数据。比如用户每天的订单金额和商店每天的订单金额,从而形成一条完整的链路。第四步,将 DWS 层的表推荐给系统使用。作为用户和商店的特征,用做推荐用途。第五步,DWD 层的表能够直接用来做实时统计分析、统计产品、实时大屏、实时报表。上图中的绿色链路,全部使用 Flink SQL 完成。橙色链路对外提供服务,由 Hologres SQL 完成。接下来,讲一讲每个步骤是如何运行的。第一步,在 Flink 实时数仓,形成 ODS 层。首先,创建一个 Hologres 的 Catalog。MySQL 中存储订单、支付以及商品信息 3 张表,通过 Flink Catalog 功能,将 MySQL 整库的数据实时同步至 Hologres,形成 ODS。相关代码如上图所示。我们可以看到,Mysql 整库同步到 Hologres,通过 Flink SQL 来表达是非常简单的。第二步,DWD 实时构建。数据实时写入 ODS 层后,Flink 读取 Hologres Binlog,并用多流合并、维表关联将订单、交易、商品 3 个表打成一个大宽表,实时写入至 Hologres 的订单汇总表中,形成 DWD 层。左图中的 SQL 是 DWD 层表的建表语句。这张目标表包含了来自 orders、orders_pay、product_catalog 的字段,关联了相关的用户信息、商户信息、订单信息、商品品类信息等等,形成了一张宽表。右图中的 SQL 是真正的计算逻辑。这里包含两个 INSERT 语句。第一个 INSERT 语句是从 orders 表实时打宽后写入。这里用到了 Hologres 的维表关联能力。实时打宽后,写入目标表的部分字段。第二个 INSERT 语句是从 orders_pay 表实时同步到同一张目标表,更新另外一些字段。这两个 INSERT 语句最大的关联在于,它们写的是同一张表,会自动利用目标表的主键 ID 进行关联。每个 INSERT 都是做了目标表的局部更新,两者的合力结果是实时更新的目标宽表。在上述过程中,它用了多种 Hologres 能力。比如驱动 Flink 做构建 DWD 层表;维表关联能力;局部更新能力等等。第三步,DWS 层的实时聚合。在 DWD 的基础上,通过 Flink 读取 Hologres DWD 的 Binlog 数据,进行实时指标聚合计算,比如按照用户维度聚合,按照商户维度聚合等,然后实时写入 Hologres,形成 DWS 层。上图中,左边是 DDL 语句,右边是聚合语句。经过简单的三步后,Flink SQL 构建了完整的 Streaming Warehouse 分层体系。第四步,构建应用。基于 DWS 层,对外提供服务。数据的分层和加工完成后,业务就可以通过 Hologres 查询数据并应用。在这个例子里,推荐系统要求非常高的点查性能,所以要求百万级的 QPS 检查能力。Hologres 的行存表或者行列共存表完全可以满足。这个方案和传统的实时数仓最大的差别是:传统的实时数仓只有最后一层的数据,可对外提供服务。而在 Hologres 里,DWD 等中间层数据也可以对外提供服务,进行实时报表统计。用户可以在中间层进行查询操作,对接各种实时应用、实时大屏。比如直接查 DWD 层的数据,典型的如根据用户 ID 返回推荐商品(KV 场景),以及实时报表查看订单量和退单量(OLAP)。第五步,问题排查:Flink 数据探查。如果某个业务指标出现异常,Flink 可以直接探查每层表的数据来快速定位。比如用 Flink 探查 Hologres DWD 层的 orders 表。Hologres 支持 Flink 的流模式和批模式对数据的探查。由于流模式是 Flink 的默认模式,因此我们不需要设置执行模式。它可以直接记录数据变化,从而非常方便的查看数据异常。流模式可以探查获取一段时间范围内的数据及其变化情况。与此同时,批模式探查是获取当前时刻的最新数据。Hologres 也支持 Flink 批模式的数据探查。批模式和流模式的区别在于,流模式关注的是变化,批模式关注的是表中的最新状态。综上所述,当 Hologres 跟 Flink 深度整合,就可以构建强大的 Streaming Warehouse。全链路都可以用 SQL 表示,并且只需要用到 Flink 和 Hologres 两个组件,操作非常方便。实时 ETL 链路、数据分层完全可以用 Flink SQL 实现。对外查询可以用强大的 Hologres 计算引擎来做,每层数据可复用、可查,方便构建实时数仓的数据分层和复用体系。这种体系有着很好的性能。Hologres 有非常强的实时写入、实时更新能力和强大的 OLAP、点查能力,Flink 有着非常强的实时加工能力。用户可以基于这个方案,利用 Hologres 强大的百万 QPS 点查能力和高性能 OLAP 能力构建各种实时应用。与此同时,我们有很多企业级能力,让大家的运维更简单,可观测性更好,安全能力更强,从而更加方便的构建企业级的 Streaming Warehouse。点击查看直播回放 & 演讲PPT更多内容活动推荐阿里云基于 Apache Flink 构建的企业级产品-实时计算Flink版现开启活动:99 元试用 实时计算Flink版(包年包月、10CU)即有机会获得 Flink 独家定制卫衣;另包 3 个月及以上还有 85 折优惠!了解活动详情:https://www.aliyun.com/product/bigdata/sc
React开发的设计模式及原则
介绍设计模式是对常见的,通用问题的可复用解决方案的归纳总结,通常被认为是解决该类问题的最佳实践,使用设计模式能帮助我们写出更容易维护,更健壮的代码。设计模式有很多,通常它们都会遵循一些共同的设计原则,接下来我们一起回顾下React社区里出现过的一些设计模式,以及它们所遵循的设计原则。一些设计原则单一职责原则(Single-responsibility responsibility) : 每个实体(class, function, module)只应该有一个职责。例如当一个组件接收了太多的props,我们应该考虑组件是不是做了太多的事情,有没有必要进行拆分。开闭原则(Open-closed principle):实体(class, function, module) 应该对扩展开放,但是对修改关闭。开闭原则意味着应该存在不直接修改的方式扩展实体的功能。依赖反转原则(Dependency inversion principle):依赖于抽象,而不是具体的实现。依赖注入是一种实现依赖反转的方式。不要自我重复 (Don't repeat yourself):重复代码会造成代码维护的困难。Composition over inheritance: 通过组合集成的两个组件是松耦合关系,通过props来约束。但是有继承关系的两个组件是强耦合关系,对父组件的修改可能会导致子组件的未预期的结果。React设计模式Container & presentational component把业务组件划分成container组件和presentational组件。 Presentational component中负责组件的ui渲染,Container component负责数据的获取和事件的响应。遵循的设计原则:单一职责原则: Presentational component负责ui,Container component负责数据和行为。Don't repeat yourself: Presentational component是纯ui组件,不包含业务逻辑,通常可以被复用。示例import React from "react";
// Presentational component
export default function ImageList({ images, onClick }) {
return images.map((img, i) => <img src={img} key={i} onClick={onClick} />);
}
// Container component
export default class ImageListContainer extends React.Component {
constructor() {
super();
this.state = {
images: []
};
}
componentDidMount() {
fetch("https://images.com")
.then(res => res.json())
.then(({ images }) => this.setState({ images }));
}
handleClick() {
// ...
}
render() {
return <ImageList images={this.state.images} onClick={handleClick} />;
}
}HOCHigher-order component 是一个以组件为参数,返回一个新组件的函数,用于复用组件的逻辑,Redux的 connect 和 Relay的createFragmentContainer都有使用HOC模式。遵循的设计原则:Don't repeat yourself:把可复用的逻辑放到HOC中,实现代码复用。Composition over inheritance: hoc中传入的组件和返回的组件是组合的关系, 也可以把多个HOC进行多次的嵌套组合。示例import React from "react";
export default function withLoader(Component, url) {
return class HOC extends React.Component {
constructor(props) {
super(props);
this.state = {
loading: true,
data: {},
};
}
componentDidMount() {
fetch(url)
.then((res) => res.json())
.then(({ data }) => this.setState({ data }))
.finally(() => this.setState({loading: false}))
}
render() {
if (this.state.loading) {
return <div>Loading...</div>;
}
return <Component {...this.props} data={this.state.data} />;
}
};
}Render propRender prop是指组件的使用者通过组件暴露的函数属性来参与定制渲染相关的逻辑。使用Render prop模式的库包括: React Router, Downshift and Formik.遵循的设计原则:Don't repeat yourself:把可复用的逻辑放到组件中,实现代码复用。依赖反转原则:通过render prop注入渲染相关的实现。开闭原则(Open-closed principle): 通过render prop暴露扩展点,而不是直接定制组件。示例import React from "react";
class Loader extends React.Component {
constructor(props) {
super(props);
this.state = {
loading: true,
data: {},
};
}
componentDidMount() {
fetch(url)
.then((res) => res.json())
.then(({ data }) => this.setState({ data }))
.finally(() => this.setState({ loading: false }));
}
render() {
if (this.state.loading) {
return <div>Loading...</div>;
}
return this.props.renderData(this.state.data);
}
}Compound componentsCompound components是指通过多个组件的组合来完成特定任务,这些组件通过共享的状态、逻辑进行关联。典型的例子是Select和Select.Option组件。使用Compound components模式的库包括:semantic ui; 遵循的设计原则:单一职责原则(Single-responsibility responsibility): 拆分成多个组件,每个组件承担自己的职责。开闭原则(Open-closed principle): 需要迭代增强功能时,可以通过创建新的子组件的方式进行扩展。示例import React from "react";
const SelectContext = React.createContext({});
export function Select({ value, onChange, children }) {
const [open, setOpen] = React.useState(false);
const [val, setValue] = React.useState(value);
return (
<div className={`select`}>
<div
className="select-value"
onClick={() => {
setOpen(true);
}}
>
{val}
</div>
<SelectContext.Provider
value={{
value: val,
setOpen,
setValue: (newValue) => {
setValue(newValue);
if (value !== newValue) {
onChange(newValue);
}
},
}}
>
{open && children}
</SelectContext.Provider>
</div>
);
}
function Option({ children, value }) {
const {
setOpen,
setValue,
value: selectedValue,
} = React.useContext(SelectContext);
return (
<div
className={`select-option ${value === selectedValue ? "selected" : ""}`}
onClick={() => {
setValue(value);
setOpen(false);
}}
>
{children}
</div>
);
}
function OptionGroup({ children, label }) {
return (
<div className="select-option-group">
<div className="select-option-group-label">{label}</div>
{children}
</div>
);
}
Select.Option = Option;
Select.OptionGroup = OptionGroup;
function Demo() {
const [city, setCity] = React.useState("北京市");
return (
<Select value={city} onChange={setCity}>
<Select.Option value="北京市">北京市</Select.Option>
<Select.OptionGroup label="河北省">
<Select.Option value="石家庄市">石家庄市</Select.Option>
<Select.Option value="保定市">保定市</Select.Option>
</Select.OptionGroup>
</Select>
);
}Custom hooks自定义hooks可以做到把与state和生命周期关联的可复用逻辑封装到独立的函数中, 上面的提及的一些模式都是基于组件的方案,自定义hooks是更细粒度的解决方案。遵循的设计原则:Don't repeat yourself:把可复用的逻辑放到自定义hooks中,实现代码复用。单一职责原则:每个自定义hooks是都是一个独立的逻辑单元。示例:import { useState, useEffect } from "react";
function useLoader(url) {
const [data, setData] = useState({});
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
fetch(url)
.then((res) => res.json())
.then(({ data }) => {
setData({ data });
})
.finally(() => setLoading(false));
}, [url]);
return { data, loading };
}结尾上面提及的曾经在社区流行的设计模式,往往遵守了一些设计原则,从而能帮助开发者写出健壮,易维护的代码。但是我们需要能根据实际的场景做出判断,是否需要引入这些模式,毕竟还有一个设计原则是YAGNI (You aren't gonna need it)。
JAVA实战:如何让单元测试覆盖率达到80%甚至以上
什么是单元测试?单元测试(unit testing)是指对软件中的最小可测试单元进行检查和验证。它是软件测试中的一种基本方法,也是软件开发过程中的一个重要步骤。单元测试的目的是在于确保软件的每个独立模块都被正确地测试,并且没有潜在的缺陷或漏洞。在单元测试中,需要对每个模块进行测试,以确保它们能够按照预期的方式工作,并且没有任何错误或漏洞。单元测试通常包括以下几个步骤:确定测试范围:在开始测试之前,需要确定测试的范围,即要测试的功能或模块。编写测试用例:根据确定的测试范围,编写测试用例,这些用例应该覆盖软件中的每个模块。执行测试用例:使用测试工具(如JUnit、TestNG、Mock等)执行测试用例,以确保每个模块都按照预期的方式工作。分析测试结果:在测试完成后,需要分析测试结果,以确定是否存在缺陷或漏洞。修复缺陷或漏洞:如果发现缺陷或漏洞,需要修复它们,以确保软件的质量。单元测试的意义提高代码质量:通过编写单元测试,可以发现代码中的错误和漏洞,从而提高代码的质量。提高开发效率:通过编写单元测试,可以快速地发现代码中的问题,从而减少测试时间,提高开发效率。降低维护成本:通过编写单元测试,可以及早地发现代码中的问题,从而减少维护成本,提高代码的可维护性。提高代码可靠性:通过编写单元测试,可以检查代码中的错误和漏洞,从而提高代码的可靠性,减少故障的发生。前言:看完上面的就知道什么时候或者为什么要编写单元测试了。其他的我们不多说了,直接进入实战操作,这次使用的是springboot+Mockito框架,在最后会指出一些小技巧和bug。实战一.Mockito的jar包导入:<dependencies>
<!-- 单元测试 -->
<dependency>
<groupId>org.jmockit</groupId>
<artifactId>jmockit</artifactId>
<version>1.38</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.powermock</groupId>
<artifactId>powermock-module-junit4</artifactId>
<version>2.0.2</version>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
</exclusion>
<exclusion>
<groupId>org.objenesis</groupId>
<artifactId>objenesis</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.powermock</groupId>
<artifactId>powermock-api-mockito2</artifactId>
<version>2.0.2</version>
<scope>test</scope>
<exclusions>
<exclusion>
<artifactId>mockito-core</artifactId>
<groupId>org.powermock</groupId>
</exclusion>
<exclusion>
<artifactId>mockito-core</artifactId>
<groupId>org.mockito</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>3.9.0</version>
<scope>test</scope>
</dependency>
</dependencies><build>
<plugins>
<!-- 单元测试 -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.7</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.12.2</version>
<configuration>
<testFailureIgnore>true</testFailureIgnore>
</configuration>
</plugin>
</plugins>
<!-- 修改对应名称 -->
<finalName>iot-open-api</finalName>
</build>没法上传pom文件二.创建单元测试类package com.shimao.iot.iotopenapi.service.impl;
import com.shimao.iot.common.bean.AttributesEntity;
import com.shimao.iot.common.bean.DeviceDataEntity;
import com.shimao.iot.common.bean.DeviceEntity;
import com.shimao.iot.common.bean.DeviceTypeEntity;
import com.shimao.iot.common.bean.device.UpdateBatchDeviceAttributeReq;
import com.shimao.iot.common.bean.member.EditShimaoFaceReq;
import com.shimao.iot.common.bean.member.ShimaoFaceReq;
import com.shimao.iot.common.elk.entity.DeviceReportEntity;
import com.shimao.iot.common.entity.ResultVO;
import com.shimao.iot.common.model.device.req.DeviceReportHeartReq;
import com.shimao.iot.common.model.device.req.DeviceReportInfoReq;
import com.shimao.iot.common.model.face.req.AlarmInfo;
import com.shimao.iot.common.model.face.req.DeviceStateReq;
import com.shimao.iot.common.model.face.req.FaceCollectInfoReq;
import com.shimao.iot.common.model.face.req.FaceCollectReq;
import com.shimao.iot.common.model.face.req.PassRecord;
import com.shimao.iot.iotopenapi.bean.dto.device.DeviceExtDataEntity;
import com.shimao.iot.iotopenapi.kafka.KafkaProducer;
import com.shimao.iot.iotopenapi.serviceFeign.DeviceFeignService;
import com.shimao.iot.iotopenapi.serviceFeign.ElkClient;
import com.shimao.iot.iotopenapi.serviceFeign.MemberClient;
import com.shimao.iot.iotopenapi.serviceFeign.OssService;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.powermock.modules.junit4.PowerMockRunner;
import org.springframework.beans.factory.annotation.Value;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
*
* @author zhangtonghao
* @create 2023-01-31 14:41
*/
@RunWith(PowerMockRunner.class)
public class DeviceReportServiceImplTest {
@Mock
private DeviceFeignService deviceFeignService;
@Mock
private OssService ossService;
@InjectMocks
com.shimao.iot.iotopenapi.service.Impl.DeviceReportServiceImpl deviceReportServiceImpl;
static {
System.setProperty("env", "baseline");
}
@Before
public void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
public void testDeviceLockState() {
// Setup
DeviceStateReq req = new DeviceStateReq();
req.setEntityCode("entityCode");
req.setGwCode("gwCode");
req.setTimestamp("timestamp");
req.setReqId("reqId");
req.setTypeCode("typeCode");
req.setOpt("opt");
req.setMsgType("msgType");
//存取code
AlarmInfo alarmInfo = new AlarmInfo();
alarmInfo.setCode("10000");
alarmInfo.setMessage("message");
alarmInfo.setPictureUrl("pictureUrl");
req.setAlarmInfo(alarmInfo);
req.setAttributesEntities(Arrays.asList(new AttributesEntity(0L, 0L, "attributeCode", "value")));
PassRecord passRecord = new PassRecord();
passRecord.setId("id");
passRecord.setRecordId("recordId");
passRecord.setName("name");
passRecord.setPassPhoto("passPhoto");
passRecord.setPassMode("passMode");
passRecord.setResultType(0);
passRecord.setPassTime("passTime");
passRecord.setCode("10000");
passRecord.setPersonType(0);
req.setPassRecords(Arrays.asList(passRecord));
// Configure DeviceFeignService.queryDeviceInfoByDeviceCode(...).
DeviceExtDataEntity deviceExtDataEntity = getDeviceExtDataEntity();
Mockito.when(deviceFeignService.queryDeviceInfoByDeviceCode(Mockito.any())).thenReturn(deviceExtDataEntity);
Mockito.when(deviceFeignService.updateAttributesById(Mockito.any())).thenReturn(ResultVO.ok(null));
Mockito.when(ossService.uploadByBase64(Mockito.any())).thenReturn(ResultVO.ok(null));
// Run the test
ResultVO result = deviceReportServiceImpl.deviceLockState(req);
// Verify the results
Assert.assertNotNull(result);
}
private DeviceExtDataEntity getDeviceExtDataEntity() {
AttributesEntity attributesEntity = new AttributesEntity();
attributesEntity.setEntityId(11L);
attributesEntity.setAttributeCode("11L");
attributesEntity.setValue("11");
List<AttributesEntity> attributes = new ArrayList<>();
attributes.add(attributesEntity);
DeviceExtDataEntity deviceExtDataEntity = new DeviceExtDataEntity();
deviceExtDataEntity.setChannel(1);
deviceExtDataEntity.setSpaceId(11L);
deviceExtDataEntity.setTypeCode("1");
deviceExtDataEntity.setComdTopic("1");
deviceExtDataEntity.setDeviceCode("11");
deviceExtDataEntity.setDeviceId(11L);
deviceExtDataEntity.setDeviceName("11L");
deviceExtDataEntity.setDiff("11");
deviceExtDataEntity.setPanType("11");
deviceExtDataEntity.setSourcePlatform("11");
deviceExtDataEntity.setSpaceId(11L);
deviceExtDataEntity.setIconUrl("11");
deviceExtDataEntity.setRootSpaceId(11L);
deviceExtDataEntity.setAttributesEntities(attributes);
deviceExtDataEntity.setStatus(1);
return deviceExtDataEntity;
}
}三.常用注解了解简洁版:@InjectMocks:通过创建一个实例,它可以调用真实代码的方法,其余用@Mock(或@Spy)注解创建的mock将被注入到用该实例中。@Mock:对函数的调用均执行mock(即虚假函数),不执行真正部分。@Spy:对函数的调用均执行真正部分。(几乎不会使用)Mockito.when( 对象.方法名() ).thenReturn( 自定义结果 ):后面自定返回结果,需要和方法返回结果类型一致,Mockito.any():用于匹配任意类型的参数详细版:@RunWith(PowerMockRunner.class)是JUnit的一个Runner,PowerMockRunner通过使用Java Instrumentation API和字节码操作库ByteBuddy,使得Java类和对象避免了Java单继承和final类限制,能够进行更灵活的mock测试。在JUnit中使用@RunWith(PowerMockRunner.class)来运行单元测试,可以使用PowerMock框架进行Mocking、Stubbing和Verification等操作,它可以完全模拟一个无法模拟的对象,如静态方法、final类、private类等。此外,PowerMockRunner还支持EasyMock和Mockito等常见的Mock技术。@Mock所谓的mock就是创建一个类的虚假的对象,在测试环境中,用来替换掉真实的对象,以达到两大目的:验证这个对象的某些方法的调用情况,调用了多少次,参数是什么等等指定这个对象的某些方法的行为,返回特定的值,或者是执行特定的动作是一个Mockito框架中的注解,它可以用于创建一个模拟对象。使用@Mock注解可以使测试代码更简洁并且便于阅读,无需手动创建模拟对象。 具体来说,@Mock注解通常用于测试类中需要测试的类所依赖的对象。当我们使用@Mock注解标注一个对象时,这个对象的行为可以被模拟,以便对测试目标类进行测试。在对模拟对象进行测试时,我们可以设定模拟对象的返回值或行为,并用这些值来测试测试目标类的行为。 需要注意的是,使用@Mock注解必须先使用Mockito.mock()初始化Mock对象。通常,我们会在测试类的setUp()方法中使用@Mock注解来初始化Mock对象,这样测试类的每个测试方法都可以使用它。同时还需要注意,@Mock注解只是用于创建一个模拟对象,在使用这个对象进行测试时,需要手动设定其返回值或行为。@InjectMocks是Mockito框架中的注解。它可以自动为测试类中声明的变量注入被mock的对象。使用@InjectMocks注解可以让测试代码更加简洁和易读,无需手动创建对象。 具体来说,@InjectMocks注解通常用于注入一个类的成员变量,这个成员变量通常是另外一个类的实例(被mock的对象)。在测试类实例化时,Mockito会自动查找这个被mock对象的实例,然后把它注入到@InjectMocks注解标识的变量中。 需要注意的是,@InjectMocks注解仅仅用于自动注入成员变量。如果需要mock类的方法,应该使用@Mock注解。同时,如果一个类里面有多个同类型的成员变量,需要手动使用@Qualifier注解来指定需要注入的对象。当然你也可以通过不同名称来区分同一类型的变量。Mockito.when()是Mockito框架中的一个方法,它可以被用于设定模拟对象的行为。该方法通常和@Mock或@Spy注解一起使用,用于模拟对象的行为并指定返回值或者其他行为。 具体来说,Mockito.when()方法接受两个参数,一个是模拟对象的方法调用,另一个是指定的行为或返回值。当模拟对象的方法被调用时,Mockito就会按照when()方法中指定的方式进行处理。例如,可以使用Mockito.when()方法来模拟一个方法的返回值.需要注意的是,Mockito.when()方法并不会真正地执行方法,而是返回了一个指定的返回值或设定的行为,用于在测试中进行验证。同样需要注意的是,如果模拟对象的方法参数不是一个基本类型或String,则需要手动匹配参数。Mockito.any()它可以用于匹配任意类型的参数。在测试代码中,当需要匹配方法的参数但不关心具体的参数值时,可以使用Mockito.any()方法来匹配参数。 具体来说,Mockito.any()方法可以用于模拟对象的方法调用或验证方法调用时的参数匹配。需要注意的是,当使用Mockito.any()方法时,需要确保模拟方法的返回值与模拟方法的参数类型兼容。常用的 Mockito 方法Mockito的使用,一般有以下几种组合:参考链接do/when:包括doThrow(…).when(…)/doReturn(…).when(…)/doAnswer(…).when(…)given/will:包括given(…).willReturn(…)/given(…).willAnswer(…)when/then: 包括when(…).thenReturn(…)/when(…).thenAnswer(…)/when(…).thenThrow(…)Mockito 的多种匹配函数,部分如下:函数名匹配类型any()所有对象类型anyInt()基本类型 int、非 null 的 Integer 类型anyChar()基本类型 char、非 null 的 Character 类型anyShort()基本类型 short、非 null 的 Short 类型anyBoolean()基本类型 boolean、非 null 的 Boolean 类型anyDouble()基本类型 double、非 null 的 Double 类型anyFloat()基本类型 float、非 null 的 Float 类型anyLong()基本类型 long、非 null 的 Long 类型anyByte()基本类型 byte、非 null 的 Byte 类型anyString()String 类型(不能是 null)anyList()List<T> 类型(不能是 null)anyMap()Map<K, V>类型(不能是 null)四:常见问题1.我自己明明已经模拟了方法,为什么还无法走通?mock中模拟Mockito.when( 对象.方法名() ).thenReturn( 自定义结果 ),方法名()中参数有的人会使用实际的参数,这样会导致模拟是无法找到正确的结果。所以我们需要使用Mockito.any()去替代,让mock自己去模拟。以及thenReturn中返回的值要符合业务逻辑才能保证业务能够走通。参考:Mockito.when(deviceFeignService.queryDeviceInfoByDeviceCode(Mockito.any())).thenReturn(deviceExtDataEntity);2.为什么有时候使用Mockito.any()模拟方法时会报错?这个是因为有时模拟时的参数类型不正确的原因,参考:Mockito 的多种匹配函数。如果还是报错,建议使用准确值,比如参数为int=1。但就会出现问题一无法返回结果。有知道的大佬可以评论。3.有时候需要启动参数或者需要连接真实配置(一般junit需要同适用)怎么办?代表启动参数或者是使用的某个配置文件,注解和代码选择其中之一。参考下图@ActiveProfiles("baseline")或者static {System.setProperty("env", "baseline");}4.有的代码中需要判断常量值才能继续往下走,如何模拟?说实话,这个问题很恶心,麻烦了很久。后来查到可以使用映射测试模拟类,参考:ReflectionTestUtils.setField()方法接受三个参数:要设置属性值的对象、属性名称和属性值。通过这个方法,我们可以方便地通过反射去设置一个对象的私有变量值,从而在测试代码中控制这个对象的行为。需要注意的是,如果想要通过ReflectionTestUtils.setField()方法修改的变量是静态的,那么第一个参数应为null,因为静态变量属于类级别的而不是实例级别的。ReflectionTestUtils.setField(deviceServiceImpl, "deviceTypeCodes", "1000");5.代码比较老旧,或者有的需要通过连接redis等组件返回结果,业务才能继续往下走?因为返回的对象无法正常new,我们可以通过Mockito.mock()方法可以创建类或接口的模拟对象。比如// redisTemplate写法ListOperations<String, String> listOperations = Mockito.mock(ListOperations.class);Mockito.when(redisTemplate.opsForList()).thenReturn(listOperations);Mockito.when(listOperations.size(Mockito.any())).thenReturn(10L);//JDBC写法你可以直接带@Before方法中去先初始化模拟@Mock
DbUtils openCustomDbUtils;
@Mock
DbUtils newCustomDbUtils;
@InjectMocks
NluDataDao test;
@Before
public void setUp() {
MockitoAnnotations.openMocks(this);
getTestByOne();
}
private void getTestByOne() {
try {
Connection conn = Mockito.mock(Connection.class);
conn.setAutoCommit(true);
PreparedStatement ps = Mockito.mock(PreparedStatement.class);
ResultSet rs = Mockito.mock(ResultSet.class);
ps.setString(1, "1");
int i = ps.executeUpdate();
PowerMockito.when(conn.prepareStatement(Mockito.any())).thenReturn(ps);
PowerMockito.when(ps.getGeneratedKeys()).thenReturn(rs);
PowerMockito.when(ps.executeUpdate()).thenReturn(1);
PowerMockito.when(openCustomDbUtils.getConn()).thenReturn(conn);
} catch (Exception e) {
}
}
@Test
public void testLoadAllAppVOs() {
// Setup
getTestByOne();
getTestByFour();
// Run the test
test.loadAllAppVOs();
}test.loadAllAppVOs()方法代码:6.有得使用了一些框架或者工具类去查询数据,比如mybatiesPlus。代码走不下去怎么办?其实这也是我为什么讨厌有的人炫技的原因之一。下列报错:解决方法:Config config = new Config();EntityHelper.initEntityNameMap(IotStrategyTriggerSensorDO.class,config);jar包选择:import tk.mybatis.mapper.entity.Config;import tk.mybatis.mapper.mapperhelper.EntityHelper;五:小技巧有的工程师写完以后想看一下自己覆盖率的多少,以idea为例有两种方法。(方法2通用)1.2.第二种相当于执行mvn test命令。有的时候测试报告和idea扫描的会有不同,需要以自己环境为准.idea插件:Squaretest,帮助自动生成单元测试类。选择第二种使用。注意:生成后的需要修改,别忘了上面碰到的问题。创作不易,感觉不错的话请给点个赞吧!我是老白,我们下期再见!
小程序的框架以及响应的数据绑定
框架小程序开发框架的目标是通过尽可能简单、高效的方式让开发者可以在微信中开发具有原生 APP 体验的服务。整个小程序框架系统分为两部分:逻辑层(App Service)和 视图层(View)。小程序提供了自己的视图层描述语言 WXML 和 WXSS,以及基于 JavaScript 的逻辑层框架,并在视图层与逻辑层间提供了数据传输和事件系统,让开发者能够专注于数据与逻辑。响应的数据绑定框架的核心是一个响应的数据绑定系统,可以让数据与视图非常简单地保持同步。当做数据修改的时候,只需要在逻辑层修改数据,视图层就会做相应的更新。通过这个简单的例子来看:在开发者工具中预览效果<!-- This is our View -->
<view> Hello {{name}}! </view>
<button bindtap="changeName"> Click me! </button>
// This is our App Service.
// This is our data.
var helloData = {
name: 'Weixin'
}
// Register a Page.
Page({
data: helloData,
changeName: function(e) {
// sent data change to view
this.setData({
name: 'MINA'
})
}
})开发者通过框架将逻辑层数据中的 name 与视图层的 name 进行了绑定,所以在页面一打开的时候会显示 Hello Weixin!;当点击按钮的时候,视图层会发送 changeName 的事件给逻辑层,逻辑层找到并执行对应的事件处理函数;回调函数触发后,逻辑层执行 setData 的操作,将 data 中的 name 从 Weixin 变为 MINA,因为该数据和视图层已经绑定了,从而视图层会自动改变为 Hello MINA!。页面管理框架 管理了整个小程序的页面路由,可以做到页面间的无缝切换,并给以页面完整的生命周期。开发者需要做的只是将页面的数据、方法、生命周期函数注册到框架中,其他的一切复杂的操作都交由框架处理。基础组件框架 提供了一套基础的组件,这些组件自带微信风格的样式以及特殊的逻辑,开发者可以通过组合基础组件,创建出强大的微信小程序 。丰富的 API框架 提供丰富的微信原生 API,可以方便的调起微信提供的能力,如获取用户信息,本地存储,支付功能等。逻辑层 App Service小程序开发框架的逻辑层使用 JavaScript 引擎为小程序提供开发者 JavaScript 代码的运行环境以及微信小程序的特有功能。逻辑层将数据进行处理后发送给视图层,同时接受视图层的事件反馈。开发者写的所有代码最终将会打包成一份 JavaScript 文件,并在小程序启动的时候运行,直到小程序销毁。这一行为类似 ServiceWorker,所以逻辑层也称之为 App Service。在 JavaScript 的基础上,我们增加了一些功能,以方便小程序的开发:增加 App 和 Page 方法,进行程序注册和页面注册。增加 getApp 和 getCurrentPages 方法,分别用来获取 App 实例和当前页面栈。提供丰富的 API,如微信用户数据,扫一扫,支付等微信特有能力。提供模块化能力,每个页面有独立的作用域。注意:小程序框架的逻辑层并非运行在浏览器中,因此 JavaScript 在 web 中一些能力都无法使用,如 window,document 等。小程序的生命周期每个小程序都需要在 app.js 中调用 App 方法注册小程序实例,绑定生命周期回调函数、错误监听和页面不存在监听函数等。详细的参数含义和使用请参考 App 参考文档 。// app.js
App({
onLaunch (options) {
// Do something initial when launch.
},
onShow (options) {
// Do something when show.
},
onHide () {
// Do something when hide.
},
onError (msg) {
console.log(msg)
},
globalData: 'I am global data'
})整个小程序只有一个 App 实例,是全部页面共享的。开发者可以通过 getApp 方法获取到全局唯一的 App 实例,获取 App 上的数据或调用开发者注册在 App 上的函数。// xxx.js
const appInstance = getApp()
console.log(appInstance.globalData) // I am global data前台、后台定义: 当用户点击左上角关闭,或者按了设备 Home 键离开微信,小程序并没有直接销毁,而是进入了后台;当再次进入微信或再次打开小程序,又会从后台进入前台。只有当小程序进入后台一定时间,或者系统资源占用过高,才会被真正的销毁。注意:1.不要在定义于 App() 内的函数中调用 getApp() ,使用 this 就可以拿到 app 实例。2.不要在 onLaunch 的时候调用 getCurrentPage(),此时 page 还没有生成。3.通过 getApp() 获取实例之后,不要私自调用生命周期函数。注册页面对于小程序中的每个页面,都需要在页面对应的 js 文件中进行注册,指定页面的初始数据、生命周期回调、事件处理函数等。使用 Page 构造器注册页面简单的页面可以使用 Page() 进行构造。代码示例://index.js
Page({
data: {
text: "This is page data."
},
onLoad: function(options) {
// 页面创建时执行
},
onShow: function() {
// 页面出现在前台时执行
},
onReady: function() {
// 页面首次渲染完毕时执行
},
onHide: function() {
// 页面从前台变为后台时执行
},
onUnload: function() {
// 页面销毁时执行
},
onPullDownRefresh: function() {
// 触发下拉刷新时执行
},
onReachBottom: function() {
// 页面触底时执行
},
onShareAppMessage: function () {
// 页面被用户分享时执行
},
onPageScroll: function() {
// 页面滚动时执行
},
onResize: function() {
// 页面尺寸变化时执行
},
onTabItemTap(item) {
// tab 点击时执行
console.log(item.index)
console.log(item.pagePath)
console.log(item.text)
},
// 事件响应函数
viewTap: function() {
this.setData({
text: 'Set some data for updating view.'
}, function() {
// this is setData callback
})
},
// 自由数据
customData: {
hi: 'MINA'
}
})详细的参数含义和使用请参考 Page 参考文档 。在页面中使用 behaviors基础库 2.9.2 开始支持,低版本需做兼容处理。页面可以引用 behaviors 。 behaviors 可以用来让多个页面有相同的数据字段和方法。// my-behavior.js
module.exports = Behavior({
data: {
sharedText: 'This is a piece of data shared between pages.'
},
methods: {
sharedMethod: function() {
this.data.sharedText === 'This is a piece of data shared between pages.'
}
}
})
// page-a.js
var myBehavior = require('./my-behavior.js')
Page({
behaviors: [myBehavior],
onLoad: function() {
this.data.sharedText === 'This is a piece of data shared between pages.'
}
})具体用法参见 behaviors 。使用 Component 构造器构造页面基础库 1.6.3 开始支持,低版本需做兼容处理。Page 构造器适用于简单的页面。但对于复杂的页面, Page 构造器可能并不好用。此时,可以使用 Component 构造器来构造页面。 Component 构造器的主要区别是:方法需要放在 methods: { } 里面。代码示例:Component({
data: {
text: "This is page data."
},
methods: {
onLoad: function(options) {
// 页面创建时执行
},
onPullDownRefresh: function() {
// 下拉刷新时执行
},
// 事件响应函数
viewTap: function() {
// ...
}
}
})这种创建方式非常类似于 自定义组件 ,可以像自定义组件一样使用 behaviors 等高级特性。具体细节请阅读 Component 构造器 章节。页面的生命周期下图说明了页面 Page 实例的生命周期。写微信小程序,他的生命周期不能不知道,不知道小程序就会出现各种bug而无法解决。小程序由两大线程组成:负责界面的线程(view thread)和服务线程(appservice thread),各司其职由互相配合页面路由在小程序中所有页面的路由全部由框架进行管理。页面栈框架以栈的形式维护了当前的所有页面。 当发生路由切换的时候,页面栈的表现如下:路由方式页面栈表现初始化新页面入栈打开新页面新页面入栈页面重定向当前页面出栈,新页面入栈页面返回页面不断出栈,直到目标返回页Tab 切换页面全部出栈,只留下新的 Tab 页面重加载页面全部出栈,只留下新的页面开发者可以使用 getCurrentPages() 函数获取当前页面栈。路由方式对于路由的触发方式以及页面生命周期函数如下:路由方式触发时机路由前页面路由后页面初始化小程序打开的第一个页面 onLoad, onShow打开新页面调用 API wx.navigateTo 使用组件 ``onHideonLoad, onShow页面重定向调用 API wx.redirectTo 使用组件 ``onUnloadonLoad, onShow页面返回调用 API wx.navigateBack 使用组件`` 用户按左上角返回按钮onUnloadonShowTab 切换调用 API wx.switchTab 使用组件 `` 用户切换 Tab 各种情况请参考下表重启动调用 API wx.reLaunch 使用组件 ``onUnloadonLoad, onShowTab 切换对应的生命周期(以 A、B 页面为 Tabbar 页面,C 是从 A 页面打开的页面,D 页面是从 C 页面打开的页面为例):当前页面路由后页面触发的生命周期(按顺序)AANothing happendABA.onHide(), B.onLoad(), B.onShow(),B.onReady()AB(再次打开)A.onHide(), B.onShow()CAC.onUnload(), A.onShow()CBC.onUnload(), B.onLoad(), B.onShow()DBD.onUnload(), C.onUnload(), B.onLoad(), B.onShow()D(从转发进入)AD.onUnload(), A.onLoad(), A.onShow()D(从转发进入)BD.onUnload(), B.onLoad(), B.onShow()注意事项navigateTo, redirectTo 只能打开非 tabBar 页面。a--navigateTo--c, c-->redirectTo-->dswitchTab 只能打开 tabBar 页面。reLaunch 可以打开任意页面。页面底部的 tabBar 由页面决定,即只要是定义为 tabBar 的页面,底部都有 tabBar。调用页面路由带的参数可以在目标页面的onLoad中获取。模块化模块化可以将一些公共的代码抽离成为一个单独的 js 文件,作为一个模块。模块只有通过 module.exports 或者 exports 才能对外暴露接口。注意:exports 是 module.exports 的一个引用,因此在模块里边随意更改 exports 的指向会造成未知的错误。所以更推荐开发者采用 module.exports 来暴露模块接口,除非你已经清晰知道这两者的关系。小程序目前不支持直接引入 node_modules , 开发者需要使用到 node_modules 时候建议拷贝出相关的代码到小程序的目录中,或者使用小程序支持的 npm 功能。// common.js
function sayHello(name) {
console.log(`Hello ${name} !`)
}
function sayGoodbye(name) {
console.log(`Goodbye ${name} !`)
}
module.exports.sayHello = sayHello
exports.sayGoodbye = sayGoodbye在需要使用这些模块的文件中,使用 require 将公共代码引入;也可以使用import导入var common = require('common.js')
Page({
helloMINA: function() {
common.sayHello('MINA')
},
goodbyeMINA: function() {
common.sayGoodbye('MINA')
}
})文件作用域在 JavaScript 文件中声明的变量和函数只在该文件中有效;不同的文件中可以声明相同名字的变量和函数,不会互相影响。通过全局函数 getApp 可以获取全局的应用实例,如果需要全局的数据可以在 App() 中设置,如:// app.js
App({
globalData: 1
})
// a.js
// The localValue can only be used in file a.js.
var localValue = 'a'
// Get the app instance.
var app = getApp()
// Get the global data and change it.
app.globalData++
// b.js
// You can redefine localValue in file b.js, without interference with the localValue in a.js.
var localValue = 'b'
// If a.js it run before b.js, now the globalData shoule be 2.
console.log(getApp().globalData)
API小程序开发框架提供丰富的微信原生 API,可以方便的调起微信提供的能力,如获取用户信息,本地存储,支付功能等。详细介绍请参考 API 文档。通常,在小程序 API 有以下几种类型:事件监听 API我们约定,以 on 开头的 API 用来监听某个事件是否触发,如:wx.onSocketOpen,wx.onCompassChange 等。这类 API 接受一个回调函数作为参数,当事件触发时会调用这个回调函数,并将相关数据以参数形式传入。代码示例wx.onCompassChange(function (res) {
console.log(res.direction)
})
同步 API我们约定,以 Sync 结尾的 API 都是同步 API, 如 wx.setStorageSync,wx.getSystemInfoSync 等。此外,也有一些其他的同步 API,如 wx.createWorker,wx.getBackgroundAudioManager 等,详情参见 API 文档中的说明。同步 API 的执行结果可以通过函数返回值直接获取,如果执行出错会抛出异常。代码示例try {
wx.setStorageSync('key', 'value')
} catch (e) {
console.error(e)
}
异步 API大多数 API 都是异步 API,如 wx.request,wx.login 等。这类 API 接口通常都接受一个 Object 类型的参数,这个参数都支持按需指定以下字段来接收接口调用结果:Object 参数说明参数名类型必填说明successfunction否接口调用成功的回调函数failfunction否接口调用失败的回调函数completefunction否接口调用结束的回调函数(调用成功、失败都会执行)其他Any-接口定义的其他参数回调函数的参数success,fail,complete 函数调用时会传入一个 Object 类型参数,包含以下字段:属性类型说明errMsgstring错误信息,如果调用成功返回 ${apiName}:okerrCodenumber错误码,仅部分 API 支持,具体含义请参考对应 API 文档,成功时为 0。其他Any接口返回的其他数据异步 API 的执行结果需要通过 Object 类型的参数中传入的对应回调函数获取。部分异步 API 也会有返回值,可以用来实现更丰富的功能,如 wx.request,wx.connectSocket 等。代码示例wx.login({
success(res) {
console.log(res.code)
}
})
异步 API 返回 Promise基础库 2.10.2 版本起,异步 API 支持 callback & promise 两种调用方式。当接口参数 Object 对象中不包含 success/fail/complete 时将默认返回 promise,否则仍按回调方式执行,无返回值。注意事项部分接口如 downloadFile, request, uploadFile, connectSocket, createCamera(小游戏)本身就有返回值, 它们的 promisify 需要开发者自行封装。当没有回调参数时,异步接口返回 promise。此时若函数调用失败进入 fail 逻辑, 会报错提示 Uncaught (in promise),开发者可通过 catch 来进行捕获。wx.onUnhandledRejection 可以监听未处理的 Promise 拒绝事件。代码示例// callback 形式调用
wx.chooseImage({
success(res) {
console.log('res:', res)
}
})
// promise 形式调用
wx.chooseImage().then(res => console.log('res: ', res))
视图层 View框架的视图层由 WXML 与 WXSS 编写,由组件来进行展示。将逻辑层的数据反映成视图,同时将视图层的事件发送给逻辑层。WXML(WeiXin Markup language) 用于描述页面的结构。WXS(WeiXin Script) 是小程序的一套脚本语言,结合 WXML,可以构建出页面的结构。WXSS(WeiXin Style Sheet) 用于描述页面的样式。组件(Component)是视图的基本组成单元。WXMLWXML(WeiXin Markup Language)是框架设计的一套标签语言,结合基础组件、事件系统,可以构建出页面的结构。要完整了解 WXML 语法,请参考WXML 语法参考。用以下一些简单的例子来看看 WXML 具有什么能力:数据绑定<!--wxml-->
<view> {{message}} </view>
// page.js
Page({
data: {
message: 'Hello MINA!'
}
})
列表渲染<!--wxml-->
<view wx:for="{{array}}"> {{item}} </view>
// page.js
Page({
data: {
array: [1, 2, 3, 4, 5]
}
})
条件渲染<!--wxml-->
<view wx:if="{{view == 'WEBVIEW'}}"> WEBVIEW </view>
<view wx:elif="{{view == 'APP'}}"> APP </view>
<view wx:else="{{view == 'MINA'}}"> MINA </view>
// page.js
Page({
data: {
view: 'MINA'
}
})
模板<!--wxml-->
<template name="staffName">
<view>
FirstName: {{firstName}}, LastName: {{lastName}}
</view>
</template>
<template is="staffName" data="{{...staffA}}"></template>
<template is="staffName" data="{{...staffB}}"></template>
<template is="staffName" data="{{...staffC}}"></template>
// page.js
Page({
data: {
staffA: {firstName: 'Hulk', lastName: 'Hu'},
staffB: {firstName: 'Shang', lastName: 'You'},
staffC: {firstName: 'Gideon', lastName: 'Lin'}
}
})
具体的能力以及使用方式在以下章节查看:数据绑定、列表渲染、条件渲染、模板、引用WXSSWXSS (WeiXin Style Sheets)是一套样式语言,用于描述 WXML 的组件样式。WXSS 用来决定 WXML 的组件应该怎么显示。为了适应广大的前端开发者,WXSS 具有 CSS 大部分特性。同时为了更适合开发微信小程序,WXSS 对 CSS 进行了扩充以及修改。与 CSS 相比,WXSS 扩展的特性有:尺寸单位样式导入尺寸单位rpx(responsive pixel): 可以根据屏幕宽度进行自适应。规定屏幕宽为750rpx。如在 iPhone6 上,屏幕宽度为375px,共有750个物理像素,则750rpx = 375px = 750物理像素,1rpx = 0.5px = 1物理像素。设备rpx换算px (屏幕宽度/750)px换算rpx (750/屏幕宽度)iPhone51rpx = 0.42px1px = 2.34rpxiPhone61rpx = 0.5px1px = 2rpxiPhone6 Plus1rpx = 0.552px1px = 1.81rpx建议: 开发微信小程序时设计师可以用 iPhone6 作为视觉稿的标准。注意: 在较小的屏幕上不可避免的会有一些毛刺,请在开发时尽量避免这种情况。样式导入使用@import语句可以导入外联样式表,@import后跟需要导入的外联样式表的相对路径,用;表示语句结束。示例代码:/** common.wxss **/
.small-p {
padding:5px;
}
/** app.wxss **/
@import "common.wxss";
.middle-p {
padding:15px;
}
内联样式框架组件上支持使用 style、class 属性来控制组件的样式。style:静态的样式统一写到 class 中。style 接收动态的样式,在运行时会进行解析,请尽量避免将静态的样式写进 style 中,以免影响渲染速度。<view style="color:{{color}};" />
class:用于指定样式规则,其属性值是样式规则中类选择器名(样式类名)的集合,样式类名不需要带上.,样式类名之间用空格分隔。<view class="normal_view" />
选择器目前支持的选择器有:选择器样例样例描述.class.intro选择所有拥有 class="intro" 的组件#id#firstname选择拥有 id="firstname" 的组件elementview选择所有 view 组件element, elementview, checkbox选择所有文档的 view 组件和所有的 checkbox 组件::afterview::after在 view 组件后边插入内容::beforeview::before在 view 组件前边插入内容全局样式与局部样式定义在 app.wxss 中的样式为全局样式,作用于每一个页面。在 page 的 wxss 文件中定义的样式为局部样式,只作用在对应的页面,并会覆盖 app.wxss 中相同的选择器。WXSWXS(WeiXin Script)是小程序的一套脚本语言,结合 WXML,可以构建出页面的结构。示例新建一个wxs文件var toDecimal2 = function (x) {
var f = parseFloat(x);
if (isNaN(f)) {
return '0.00'
}
var f = Math.round(x * 100) / 100;
var s = f.toString();
var rs = s.indexOf('.');
if (rs < 0) {
rs = s.length;
s += '.';
}
while (s.length <= rs + 2) {
s += '0';
}
return s;
}
//module.exports = toDecimal2
module.exports = {
toDecimal2:toDecimal2
}
在wxml中使用<!--pages/c/c.wxml-->
<wxs src="../../wxs/PageUtils.wxs" module="PageUtils"></wxs>
<wxs module="m1">
var msg = "hello world";
module.exports.message = msg;
</wxs>
<view>
<text>pages/c/c.wxml,</text>
<text>{{m1.message}}</text>
<view>
<text>{{PageUtils.toDecimal2(123.453)}}</text>
</view>
<view>
<button type="primary" bindtap="jump">跳转到D页面</button>
</view>
</view>
注意事项WXS 不依赖于运行时的基础库版本,可以在所有版本的小程序中运行。WXS 与 JavaScript 是不同的语言,有自己的语法,并不和 JavaScript 一致。WXS 的运行环境和其他 JavaScript 代码是隔离的,WXS 中不能调用其他 JavaScript 文件中定义的函数,也不能调用小程序提供的API。WXS 函数不能作为组件的事件回调。由于运行环境的差异,在 iOS 设备上小程序内的 WXS 会比 JavaScript 代码快 2 ~ 20 倍。在 android 设备上二者运行效率无差异。以下是一些使用 WXS 的简单示例,要完整了解 WXS 语法,请参考WXS 语法参考。页面渲染<!--wxml-->
<wxs module="m1">
var msg = "hello world";
module.exports.message = msg;
</wxs>
<view> {{m1.message}} </view>
页面输出:hello world
数据处理// page.js
Page({
data: {
array: [1, 2, 3, 4, 5, 1, 2, 3, 4]
}
})
<!--wxml-->
<!-- 下面的 getMax 函数,接受一个数组,且返回数组中最大的元素的值 -->
<wxs module="m1">
var getMax = function(array) {
var max = undefined;
for (var i = 0; i < array.length; ++i) {
max = max === undefined ?
array[i] :
(max >= array[i] ? max : array[i]);
}
return max;
}
module.exports.getMax = getMax;
</wxs>
<!-- 调用 wxs 里面的 getMax 函数,参数为 page.js 里面的 array -->
<view> {{m1.getMax(array)}} </view>
页面输出:5
事件什么是事件事件是视图层到逻辑层的通讯方式。事件可以将用户的行为反馈到逻辑层进行处理。事件可以绑定在组件上,当达到触发事件,就会执行逻辑层中对应的事件处理函数。事件对象可以携带额外信息,如 id, dataset, touches。事件的使用方式在组件中绑定一个事件处理函数。如bindtap,当用户点击该组件的时候会在该页面对应的 Page 中找到相应的事件处理函数。<view id="tapTest" data-hi="Weixin" bindtap="tapName"> Click me! </view>
在相应的 Page 定义中写上相应的事件处理函数,参数是event。Page({
tapName: function(event) {
console.log(event)
}
})
可以看到 log 出来的信息大致如下:{
"type":"tap",
"timeStamp":895,
"target": {
"id": "tapTest",
"dataset": {
"hi":"Weixin"
}
},
"currentTarget": {
"id": "tapTest",
"dataset": {
"hi":"Weixin"
}
},
"detail": {
"x":53,
"y":14
},
"touches":[{
"identifier":0,
"pageX":53,
"pageY":14,
"clientX":53,
"clientY":14
}],
"changedTouches":[{
"identifier":0,
"pageX":53,
"pageY":14,
"clientX":53,
"clientY":14
}]
}
使用 WXS 函数响应事件基础库 2.4.4 开始支持,低版本需做兼容处理。从基础库版本2.4.4开始,支持使用 WXS 函数绑定事件,WXS函数接受2个参数,第一个是event,在原有的 event 的基础上加了event.instance对象,第二个参数是ownerInstance,和event.instance一样是一个ComponentDescriptor对象。具体使用如下:在组件中绑定和注册事件处理的 WXS 函数。<wxs module="wxs" src="./test.wxs"></wxs>
<view id="tapTest" data-hi="Weixin" bindtap="{{wxs.tapName}}"> Click me! </view>
**注:绑定的 WXS 函数必须用{{}}括起来**
test.wxs文件实现 tapName 函数function tapName(event, ownerInstance) {
console.log('tap Weixin', JSON.stringify(event))
}
module.exports = {
tapName: tapName
}
ownerInstance包含了一些方法,可以设置组件的样式和class,具体包含的方法以及为什么要用 WXS 函数响应事件,请点击查看详情。事件详解事件分类事件分为冒泡事件和非冒泡事件:冒泡事件:当一个组件上的事件被触发后,该事件会向父节点传递。非冒泡事件:当一个组件上的事件被触发后,该事件不会向父节点传递。WXML的冒泡事件列表:类型触发条件最低版本touchstart手指触摸动作开始 touchmove手指触摸后移动 touchcancel手指触摸动作被打断,如来电提醒,弹窗 touchend手指触摸动作结束 tap手指触摸后马上离开 longpress手指触摸后,超过350ms再离开,如果指定了事件回调函数并触发了这个事件,tap事件将不被触发1.5.0longtap手指触摸后,超过350ms再离开(推荐使用 longpress 事件代替) transitionend会在 WXSS transition 或 wx.createAnimation 动画结束后触发 animationstart会在一个 WXSS animation 动画开始时触发 animationiteration会在一个 WXSS animation 一次迭代结束时触发 animationend会在一个 WXSS animation 动画完成时触发 touchforcechange在支持 3D Touch 的 iPhone 设备,重按时会触发1.9.90注:除上表之外的其他组件自定义事件如无特殊声明都是非冒泡事件,如 form 的submit事件,input 的input事件,scroll-view 的scroll事件,(详见各个组件)普通事件绑定事件绑定的写法类似于组件的属性,如:<view bindtap="handleTap">
Click here!
</view>
如果用户点击这个 view ,则页面的 handleTap 会被调用。事件绑定函数可以是一个数据绑定,如:<view bindtap="{{ handlerName }}">
Click here!
</view>
此时,页面的 this.data.handlerName 必须是一个字符串,指定事件处理函数名;如果它是个空字符串,则这个绑定会失效(可以利用这个特性来暂时禁用一些事件)。自基础库版本 1.5.0 起,在大多数组件和自定义组件中, bind 后可以紧跟一个冒号,其含义不变,如 bind:tap 。基础库版本 2.8.1 起,在所有组件中开始提供这个支持。绑定并阻止事件冒泡除 bind 外,也可以用 catch 来绑定事件。与 bind 不同, catch 会阻止事件向上冒泡。例如在下边这个例子中,点击 inner view 会先后调用handleTap3和handleTap2(因为 tap 事件会冒泡到 middle view,而 middle view 阻止了 tap 事件冒泡,不再向父节点传递),点击 middle view 会触发handleTap2,点击 outer view 会触发handleTap1。<view id="outer" bindtap="handleTap1">
outer view
<view id="middle" catchtap="handleTap2">
middle view
<view id="inner" bindtap="handleTap3">
inner view
</view>
</view>
</view>
互斥事件绑定自基础库版本 2.8.2 起,除 bind 和 catch 外,还可以使用 mut-bind 来绑定事件。一个 mut-bind 触发后,如果事件冒泡到其他节点上,其他节点上的 mut-bind 绑定函数不会被触发,但 bind 绑定函数和 catch 绑定函数依旧会被触发。换而言之,所有 mut-bind 是“互斥”的,只会有其中一个绑定函数被触发。同时,它完全不影响 bind 和 catch 的绑定效果。例如在下边这个例子中,点击 inner view 会先后调用 handleTap3 和 handleTap2 ,点击 middle view 会调用 handleTap2 和 handleTap1 。<view id="outer" mut-bind:tap="handleTap1">
outer view
<view id="middle" bindtap="handleTap2">
middle view
<view id="inner" mut-bind:tap="handleTap3">
inner view
</view>
</view>
</view>
事件的捕获阶段自基础库版本 1.5.0 起,触摸类事件支持捕获阶段。捕获阶段位于冒泡阶段之前,且在捕获阶段中,事件到达节点的顺序与冒泡阶段恰好相反。需要在捕获阶段监听事件时,可以采用capture-bind、capture-catch关键字,后者将中断捕获阶段和取消冒泡阶段。在下面的代码中,点击 inner view 会先后调用handleTap2、handleTap4、handleTap3、handleTap1。<view id="outer" bind:touchstart="handleTap1" capture-bind:touchstart="handleTap2">
outer view
<view id="inner" bind:touchstart="handleTap3" capture-bind:touchstart="handleTap4">
inner view
</view>
</view>
如果将上面代码中的第一个capture-bind改为capture-catch,将只触发handleTap2。<view id="outer" bind:touchstart="handleTap1" capture-catch:touchstart="handleTap2">
outer view
<view id="inner" bind:touchstart="handleTap3" capture-bind:touchstart="handleTap4">
inner view
</view>
</view>
事件对象如无特殊说明,当组件触发事件时,逻辑层绑定该事件的处理函数会收到一个事件对象。BaseEvent 基础事件对象属性列表:属性类型说明基础库版本typeString事件类型 timeStampInteger事件生成时的时间戳 targetObject触发事件的组件的一些属性值集合 currentTargetObject当前组件的一些属性值集合 markObject事件标记数据2.7.1CustomEvent 自定义事件对象属性列表(继承 BaseEvent):属性类型说明detailObject额外的信息TouchEvent 触摸事件对象属性列表(继承 BaseEvent):属性类型说明touchesArray触摸事件,当前停留在屏幕中的触摸点信息的数组changedTouchesArray触摸事件,当前变化的触摸点信息的数组特殊事件: canvas 中的触摸事件不可冒泡,所以没有 currentTarget。type代表事件的类型。timeStamp页面打开到触发事件所经过的毫秒数。target触发事件的源组件。属性类型说明idString事件源组件的iddatasetObject事件源组件上由data-开头的自定义属性组成的集合currentTarget事件绑定的当前组件。属性类型说明idString当前组件的iddatasetObject当前组件上由data-开头的自定义属性组成的集合说明: target 和 currentTarget 可以参考上例中,点击 inner view 时,handleTap3 收到的事件对象 target 和 currentTarget 都是 inner,而 handleTap2 收到的事件对象 target 就是 inner,currentTarget 就是 middle。dataset在组件节点中可以附加一些自定义数据。这样,在事件中可以获取这些自定义的节点数据,用于事件的逻辑处理。在 WXML 中,这些自定义数据以 data- 开头,多个单词由连字符 - 连接。这种写法中,连字符写法会转换成驼峰写法,而大写字符会自动转成小写字符。如:data-element-type ,最终会呈现为 event.currentTarget.dataset.elementType ;data-elementType ,最终会呈现为 event.currentTarget.dataset.elementtype 。示例:<view data-alpha-beta="1" data-alphaBeta="2" bindtap="bindViewTap"> DataSet Test </view>
Page({
bindViewTap:function(event){
event.currentTarget.dataset.alphaBeta === 1 // - 会转为驼峰写法
event.currentTarget.dataset.alphabeta === 2 // 大写会转为小写
}
})
mark在基础库版本 2.7.1 以上,可以使用 mark 来识别具体触发事件的 target 节点。此外, mark 还可以用于承载一些自定义数据(类似于 dataset )。当事件触发时,事件冒泡路径上所有的 mark 会被合并,并返回给事件回调函数。(即使事件不是冒泡事件,也会 mark 。)代码示例:在开发者工具中预览效果<view mark:myMark="last" bindtap="bindViewTap">
<button mark:anotherMark="leaf" bindtap="bindButtonTap">按钮</button>
</view>
在上述 WXML 中,如果按钮被点击,将触发 bindViewTap 和 bindButtonTap 两个事件,事件携带的 event.mark 将包含 myMark 和 anotherMark 两项。Page({
bindViewTap: function(e) {
e.mark.myMark === "last" // true
e.mark.anotherMark === "leaf" // true
}
})
mark 和 dataset 很相似,主要区别在于: mark 会包含从触发事件的节点到根节点上所有的 mark: 属性值;而 dataset 仅包含一个节点的 data- 属性值。细节注意事项:如果存在同名的 mark ,父节点的 mark 会被子节点覆盖。在自定义组件中接收事件时, mark 不包含自定义组件外的节点的 mark 。不同于 dataset ,节点的 mark 不会做连字符和大小写转换。touchestouches 是一个数组,每个元素为一个 Touch 对象(canvas 触摸事件中携带的 touches 是 CanvasTouch 数组)。 表示当前停留在屏幕上的触摸点。Touch 对象属性类型说明identifierNumber触摸点的标识符pageX, pageYNumber距离文档左上角的距离,文档的左上角为原点 ,横向为 X 轴,纵向为 Y 轴clientX, clientYNumber距离页面可显示区域(屏幕除去导航条)左上角距离,横向为 X 轴,纵向为 Y 轴CanvasTouch 对象属性类型说明特殊说明identifierNumber触摸点的标识符 x, yNumber距离 Canvas 左上角的距离,Canvas 的左上角为原点 ,横向为 X 轴,纵向为 Y 轴 changedToucheschangedTouches 数据格式同 touches。 表示有变化的触摸点,如从无变有(touchstart),位置变化(touchmove),从有变无(touchend、touchcancel)。detail自定义事件所携带的数据,如表单组件的提交事件会携带用户的输入,媒体的错误事件会携带错误信息,详见组件定义中各个事件的定义。点击事件的detail 带有的 x, y 同 pageX, pageY 代表距离文档左上角的距离。
K8S学习-当我们部署应用的时候都发生了什么?(第一篇)
前言最近在学习K8S,基础的知识几乎全部来自于《Kubernetes in Action》,所以文章中大部分的图也都是书中所提供的。看完整本书基本上用了2~3个月的时间,进度比较慢;主要都是每天早晨到公司后,在正常工作时间之前的1个小时里完成的。由于时间拉的很长,各章的知识在我脑袋里是散状的,所以在我整理这篇笔记的时候,就想通过一个主题,把相关的内容串起来。第一篇笔记定的主题是“调度-当我们部署应用的时候都发生了什么?”,先从大的框架上记录一下K8S的架构与原理;对于卷、网络、configmap等组件会放在第二篇。初学者级别的学习笔记,有问题的地方大佬们及时勘误。我在这里先给大家简单描述一下,当我们在aone里手动点击了升级之后,k8s里都发生了什么?当我们在aone里手动点击了重新部署,aone会通过k8s的api接口通知master节点创建一个Deployment,Deployment会按照配置里的声明要求创建一个新的RepliactionSet,RepliactionSet会按照配置的创建一个或者若干个Pod,pod会调度到相应的工作节点上,通过docker拉取镜像,启动应用。如果你能完全看懂我的描述,并且知道这些英文的工作原理,那你可以关掉网页了;如果你看完这段描述并不知道我在说什么,并且还想了解”当我们部署应用的时候都发生了什么?“的话,那请继续往下看。经常混在一起出现的名词:Docker&虚拟机&容器我在看之前经常会把一些名词”搞混“,至少是没太明确的了解其概念和关系-Docker&虚拟机&容器。Docker&虚拟机应用容器引擎:让开发者可以打包他们的应用以及依赖包到一个可移植的镜像中,然后发布到任何流行的Linux或Windows操作系统的机器上,也可以实现虚拟化;如Docker、RKT等。虚拟机(Virtual Machine):指通过软件模拟的具有完整硬件系统功能的、运行在一个完全隔离环境中的完整计算机系统;如vmware、virtualBox。其实严格意义上来讲,Docker和虚拟机不是同一个层级的定义。应用容器引擎和虚拟机是俩种虚拟技术方案,而Docker只是应用容器引擎中最出名的,RKT是后起之秀。左图为Docker容器,右图为虚拟机系统,我们从下往上对比大概有如下四个区别 Docker共享宿主操作系统的内核,虚拟机独立操作系统内核Docker不同容器之间资源隔离,虚拟机在同一个VM上的app共享资源Docker镜像分层,相同的层可以共享,减少网络传输压力;虚拟机镜像不能共享Docker可移植性没有虚拟机好,需要考虑内核版本等;虚拟机完全独立可以移植其实这跟我们很多中台系统建设的俩个极端是很像的,灵活性越高,通用性就越差;通用性好那就难免要做一些定制化的需求。容器容器技术:同一台机器上运行多个服务,提供不同的环境给服务,服务之间相互隔离。容器是通过linux内核上提供的cgroups和 Namespace来实现隔离的。cgroups将进程分组管理的内核功能通过cgroups可以隔离进程,同时还可以控制进程的资源占用(CPU、内存等等)情况在操作系统底层限制物理资源;Namespace用来隔离IP地址、用户空间、进程ID。值得提前一提的是容器的文件系统隔离,App的写入完全隔离,如果修改底层文件,则复制一份到容器所挂载的文件系统内,不直接修改底层文件。K8S简介-What Why HowWhat&Why从上一节的介绍应该可以看出来,一个应用的部署使用Docker已经完全可以运行起来,那K8S是什么,在整个应用声明周期中的作用是什么?先看一下用户在使用docker部署应用的过程。Docker部署的时候,需要开发者来做运维操作的有俩步,一是用Docker构建和推送镜像到镜像库,二是在生产机器上拉取镜像并运行。这俩步操作看起来没什么工作量,毕竟Docker在里面做了大量的工作;但如果一个App需要部署多台服务,并且还要做升级更新以及后续的运维操作;如果一个人要负责多个系统成百上千个服务的部署运维,那工作量对他来说就是个灾难。所以我们需要一个软件系统,帮我们更简单的部署和管理应用,这就是K8S存在的价值。开发者需要做的是:1、将镜像构建并推送到镜像库(在我们常使用的Devops平台,比如Aone,甚至已经将打包镜像通过平台实现),2、定义好每个应用需要数据的节点数量,其他的运维工作都由K8S来代理;这里还是需要运维同学的,但由于K8S出色的运维能力支持,使运维工作也很轻松,物理资源利用率会更高。How先简单抛一下K8S的架构简图,可以先有个大概的了解。架构可以分为俩部分,控制面板(也就是master节点)用于控制和管理整个集群;工作节点是运行容器化应用的机器,通过一些组件来完成运行、监控、管理。工作节点容器:docker、rtk等kubelet:与API服务器通信,管理所在节点容器kube-proxy:组件之间的负载均衡控制面板(master)KubernetesAPI:与其他组件通信Scheculer:调度应用ControllerManager:执行集群级别的功能,复制组件、跟踪工作节点、处理节点失败etcd:持久化存储集群配置看到这些概念肯定会更头疼,后面的笔记会尽量用平时我们工作接触到的东西来帮大家逐步理解。Aone 、EAS、ASI、K8S、Docker的关系?一张图简单表示一下,我们工作中经常出现的一些平台的关系k8s和docker的关系我们已然了解;ASI是阿里云基于K8S之上封装的一个容器管理平台,支持几乎所有阿里上云的中间件,集成了阿里的安全组件,使集团内部使用更方便和安全;Aone和EAS都是依赖ASI+各种Devops功能,以此应对各自垂直场景上的运维易用性需求。应用都跑在哪里?Pod在K8S架构简图里有这样一个细节,有一些应用是独立容器部署的,有一些则是多个容器组合放在一起部署,这是符合实际需求的。比如俩个应用需要通过ipc(进程之间通信)、或者需要共享本地文件系统,但在linux容器之间是相互隔离的。所以在K8S中,并不是以容器为最基础的部署单位来运行的,而是抽象出一个概念叫Pod。概念:Pod是一个并置容器,作为K8S最基础的构建模块。一个pod中可以有多个容器,但一个容器是不会跨工作节点的。我们平时部署的应用就是在pod中运行的,一个容器中原则上只有一个应用进程(除非有守护进程),这样做的好处也是为了可以减少应用之间的依赖方便扩缩容等运维动作。实现原理K8S会将同一个pod中的容器,放在同一个linux的namespace下。对于容器和pod之间如何合理的组合,个人理解一个最基础的判断方法,就是这俩个容器是否是同生命周期的;其实我们遇到的大多数的情况都是一个pod中只有一个容器。标签(label)标签是一种简单却功能强大的Kubernetes特性,不仅可以组织pod,也可以组织所有其他的Kubernetes资源。之所以在这里提出来标签的概念,是因为后续介绍的K8S的运行机制里,大量的使用标签筛选器(Selector),是个非常重要的属性。后面聊到的各种资源里圈定控制的范围就是通过标签选择器来搞定的。如下图所示,通过标签的筛选分类,可以区分出各种环境,以此来做灵活的批量运维管理。如何保证启动的数量和版本?ReplicationController因为这篇笔记主要想记录K8S中部署应用时是如何调度的,所以我们先略去用Pod内部涉及到的一些其他组件,比如volume、网络、configMap等。我们直接进入到K8S的副本机制,也就是K8S是通过什么机制来保证Pod符合配置要求(镜像版本,部署个数等)。ReplicationController是一种kubernetes资源,确保运行的pod符合配置要求(声明式),简称RC。之所以把”声明式“标亮,是因为这跟K8S实现机理有关,所有的资源定义的API都是声明式api,这个在后续会详细聊。用途1、pod或者节点发生故障时,可以自动恢复;2、实现水平伸缩组成RC的声明主要分为三个部分,pod selector、replicas、pod template。通过这些我们就可以告诉K8S一个应用在部署在容器中时,用哪个版本的镜像,运行多少个。RC也会通过pod selector进行管理和运维动作。Pod selector:用于确认作用域内有哪些podReplicas:指定应运行的副本数量Podtemplate:用于创建新的pod副本模板How it works场景1:当一个Pod出现故障,我们将其手动触发删除时;RC会拉取template中配置版本的镜像,创建一个相同标签的Pod。在实际工作中,如果遇到某一个节点无法work,我们在aone里操作销毁的时候,最底层的调度就是这样的。场景2:当一个标签为kubia的pod,被重新打上新标签的时候,RC会发现数量不符合要求,拉取template中配置版本的镜像,创建标签为kubia的Pod。在实际工作中,当我们在重新部署的时候,第一个要重新部署的pod可能会被打上”待销毁“的标签,等新的pod启动之后,才正式的销毁。场景3:当RC感知到 Replicas声明减少。实际工作中,在缩容的时候,K8S会按照一定的优先级规则,对已有容器进行销毁。优先级规则的底层逻辑总结起来就是:状态越稳定,销毁优先级越低。优先级如下:1 如果pod没分配到节点,先被删除 。2 如果pod的状态是 Pending>PodUnknown>PodRunning,则Pending优先被删除,PodUnknown次之,PodRunning最后被删除。3 不是Ready状态的Pod先被删除。4 如果Pod都是Ready状态,则最后一个变成Ready状态的Pod先被删除(Ready时间最短的)。5 重启次数大的Pod先被删除。6 创建时间最新的Pod先被删除。场景4:当RC感知到Replicas声明增加。实际工作中的扩容场景,过程与场景1类似,不赘述。场景5:当镜像版本升级的时候。注意,修改模板的动作,并不会触发容器的重新部署,但当场景1、2出现的时候,RC会控制用新的镜像版本创建Pod。ReplicationSet和StatefulSetReplicationSet与ReplicationController类似,但拥有较RC更强大的标签筛选器,是RC的上位替代品,简称RS。StatefulSet:从rc和rs的副本机制可以看出来,当新创建一个pod的时候,和原有pod是完全没有关系的,也就是说他们是用来管理无状态Pod的。而StatefulSet是用于管理有状态Pod的,应对新的pod需要与原pod具有相同的网络标识,可以访问同一份持久化数据等需求。由于涉及到的其他资源很多,我会放到第二篇笔记中来记录,顺便引出volume和网络等资源。如何触发应用升级?副本机制和其他控制器-手动升级上一节我们了解了K8S内部是如何用rc、rs等资源来保证pod的是符合配置要求的。也提到了镜像版本升级是不会触发更新的,所以在实际运维的时候,用户不可能手动的来触发。我们先来看下手动操作带来的问题:手动升级的弊端手动操作通常有俩种模式。第一种是recreate:升级完rc之后把所有的pod全部删除等rc自动创建;或者创建俩个rc,等新rc下的pod全部可用,再把服务重定向到新的pod上。这样会导致服务的短暂(也不一定短暂)不可用,或者造成资源的浪费。第二种是rolling-update,也是创建俩个rc,销毁一个旧的,然后创建一个新的,直到全部切换。这样的问题是手工操作很复杂,通过kubctl客户端访问api的方式进行,很有可能中间中断鲁棒性很低。而且很大一个问题是由于是先销毁,所以无法回滚。手工操作太麻烦了怎么办?DeploymentDeployment声明式升级应用-代理了手工操作为了解决上述手动操作的弊端,K8S使用了计算机接经典解决方案-套一层,一层解决不了就俩层。K8S提供了一个更高阶的资源Deployment,用于部署应用并以声明的方式升级应用,代理了上一节提到的手工操作。Deployment的优点在于,只需要做声明式的升级,仅改动一个字段,k8s就会接管后续所有的升级动作,稳定可靠。并且Deployment会默认保存俩版replicationSet(只保留就的rs,不保留旧pod),所以可以做到回滚。控制滚动升级速率k8s发布的时候,可能经常遇到发布“太快不稳定”或“太慢体验差”的情况。Deployment是通过maxSurge 和 maxUnava工lable俩个参数来控制滚动升级的速率:maxUnavailable:和期望ready的副本数比,不可用副本数最大比例(或最大值),这个值越小,越能保证服务稳定,更新越平滑;maxSurge:和期望ready的副本数比,超过期望副本数最大比例(或最大值),这个值调的越大,副本更新速度越快。机理:声明式+监听到这里其实我们已经算是了解了手动触发升级的时候,k8s里会涉及到的大部分的资源组件,以及他们各自的工作原理,接下来我们把他们整个流程串起来,还原一下现场。简单回顾一下k8s的架构简图,架构分为控制平面(master)与工作节点。API服务器用于所有内部外部的组件通信;etcd是个分布式持久化引擎,用于存储pod、rs等等的配置;控制器执行集群级别的功能,复制组件、跟踪工作节点、处理节点失败;调度器负责判断pod应该调度到哪个工作节点上;kubelet用于工作节点与api服务器通信和控制本工作节点上几乎所有事情。为了保证高可用状态,集群里有多个主节点,默认配置下会有三个主节点,其他俩个的控制器和调度器就是非活跃状态,但是API服务器是都会接受通信请求,etcd之间也会同步配置信息。现场还原当主节点通过API服务器收到部署的命令时1、API服务器会通过接口传过来的声明配置,创建Deployment资源。2、Deployment控制器会监听到Deployment资源创建的消息,调用API服务器根据声明来创建ReplicasSet资源。3、ReplicaSet控制器会监听到ReplicaSet资源创建的消息,调用API服务器根据声明来创建Pod资源。4、调度器会监听到Pod资源创建的消息,然后根据规则给Pod分配一个工作节点,调用API服务器写入到Pod的配置中(如何决定给pod分配哪个工作节点,后面会说)。5、工作节点的Kubelet会监听到Pod分配工作节点的消息,根据Pod的声明来来通知Docker拉取镜像,运行容器。在整个实现流程中,我highlight了俩个词,一个是”监听“、一个是”声明“,这是K8S架构实现中最重要的俩个机理。首先“监听”,大家应该都理解,通过相关组件之间的消息监听,事件链的方式完成整个部署工作,可以解耦各个资源之间的依赖。另一个是“声明“,主节点中提供的所有API都是声明式的,只定义期望的状态,系统来负责向指定的状态来进行工作;而命令式api需要调用者直接下达执行命令,并监控状态,再进行下一步命令的下达。声明式 vs. 命令式1、可以有效的避免单点故障,系统会想统一的状态工作,不会发生某个命令没有收到而带来状态的不统一。2、Master节点更简单,简单在大多数情况下就意味着稳定和效率。3、K8s的组件可以更具备组合性和扩展性,可以升级到达某个状态的资源和组件,不用考虑历史命令式API的兼容问题。声明式API也分为俩种,eedge triggering(边缘触发) 和 level triggering(水平触发)水平触发(level-triggered,也被称为条件触发)LT: 只要满足条件,就触发一个事件(只要有数据没有被获取,就不断通知你)边缘触发(edge-triggered)ET: 每当状态变化时,触发一个事件这俩种方式各有各的优势与应用场景,K8S使用的水平触发(LT)方式,因为虽然在正常情况下ET比LT更节省资源,但如果在状态变化的时候,使用ET的方式并且正好丢掉了消息(在实际复杂网络环境下很有可能发生),就会使状态与实际预想的不一致。而K8S架构中最重要的就是状态的声明,如果消息丢失,则会引发各种不受控的问题出现。做了一次分享,会觉得这部分比较难理解,举个例子:团队有100个bug,Leader在群里发消息:100个bug明天下班之前改完(谁改Leader不管),这就是声明式。100个bug,A处理60个,B处理40个;第二天Leader看了下aone,还有超过50个没改,@C一起来处理,这就是命令式。声明式的好处是:1、Leader只要结果不关注细节,可以做其他事情。2、如果A居家隔离,没有收到消息,为了达到目标,团队里会有人自动补位一起改bug。不管哪种方式的消息,如果Leader一直在确认是否所有人都收到,不是就不断的发,这是水平触发;如果Leader发进群里就不再管了,这是边缘触发。如何自动扩缩容?HPA&VPA我们可以通过调高 ReplicationController、 ReplicaSet、 Deployment等可伸缩资源的 replicas 字段,来手动实现 pod中应用的横向扩容 。我们也可以通过增加pod容器的资源请求和限制来纵向扩容pod (尽管目前该操作只能在pod创建时,而非运行时进行)。虽然如果你能预先知道负载何时会飙升,或者如果负载的变化是较长时间内逐渐发生的,手动扩容也是可以接受的,但指望靠 人工干预来处理突发而不可预测的流量增长,仍然不够理想 。K8S提供了HorizontalpodAutoscaler(HPA),就是英文字面意思。横向伸缩的意思是,通过增加或者减少同样配置的pod的数量,来动态满足业务需求量。HPA是用来声明横向自动扩缩容的高级资源,它需要监控到相关pod的资源使用情况。K8S在每个工作节点的kubelet中都有一个cAdvisor,用于收集本工作节点的资源使用情况,然后在集群中会启动一个Heapster来从所有节点采集度量数据。这样HPA就可以通过Heapster拿到它想要的数据。Heapster经常与influxdb、grafana一起使用,被称为监控三件套,从图中可以看出Heapster也是以pod的方式运行在某个工作节点上。How it works?1、首先HPA会从Heapster中获取到所管理的pod的资源度量。2、计算当前状态下,所需要的pod数量。如下图,假设Hpa中设定的cpu使用率为50%,hpa会将各个pod的cpu使用率加和,再除以50%,向上取整。3、如果发现所需pod数与现有副本数有差别,则会更新deployment中的replicas字段,就会触发自动的扩缩容。当然扩缩容太过于频繁会导致服务的不稳定,所以hpa中支持最快伸缩的间隔,默认扩容最快3分钟,缩容最快5分钟。 上面介绍的是个简单的场景,用户只通过cpu使用率来控制伸缩,如果用户希望通过多硬件指标监控的话, K8S会取多个度量的最大值来进行扩容。HPA也同时支持多种类型的度量方式,Resource度量就是上面提到的,根据硬件指标来做度量;Pod度量类型可以用来监控QPS或者消息队列中的数量等;Object度量类型可以用来监控外部资源,比如依赖外部服务的个数来动态调整pod的个数。VPA 全称 Vertical Pod Autoscaler,即垂直 Pod 自动扩缩容,它根据容器资源使用率自动设置 CPU 和 内存 的requests,从而允许在节点上进行适当的调度,以便为每个 Pod 提供适当的资源。垂直自动扩容是在k8s1.9版本之后才提供的。VPA vs. HPA优点:可以节省资源,比如一个这批pod的内容使用量特别高,但cpu使用效率很低,HPA会直接扩N个相同配置的pod,来满足内存使用需求;VPA会启动一个内存量更大,cpu量更小的pod来运行应用。HPA会明显浪费更多的资源。缺点:VPA的扩容需要重启pod,这样可能会造成服务的不稳定。虽然在社区中已经有一些优化方案(https://github.com/kubernetes/enhancements/issues/1287),但重建Pod环境无论怎样都会有一些风险。所以HPA和VPA各有各的使用场景,我们团队是做toB的业务,我大概看了一下,基本上都是VPA的伸缩方式。但如果是2C的应用,对稳定性的要求非常高,建议使用HPA,在应用发布之前应该做好每个pod的各项资源的最佳配比。上面一直在聊的是Pod的伸缩,但如果集群中工作节点的资源不能满足需求了怎么办?K8S提供了Cluster Autoscaler(CA),它的伸缩方式与HPA大体一致,如果有感兴趣的可以自行了解一下细节。结尾本篇文章最后,把前言中的”当我们在aone里手动点击了升级之后,k8s里都发生了什么?“再贴在这里看一下↓↓↓当我们在aone里手动点击了重新部署,aone会通过k8s的api接口通知master节点创建一个Deployment,Deployment会按照配置里的声明要求创建一个新的RepliactionSet,RepliactionSet会按照配置的创建一个或者若干个Pod,pod会调度到相应的工作节点上,通过docker拉取镜像,启动应用。如果大家看完文章能看懂这段文字,说明已经了解了整个过程,要是还能想起来一些运行机理或者其中资源的实现原理就更好了。