Thursday, December 30, 2021

SegmentFault 最新的文章

SegmentFault 最新的文章


从“智能湖仓”架构的技术演进,看现代化数据平台的发展方向

Posted: 30 Dec 2021 01:56 AM PST

在 2021 年初全年技术趋势展望中,数据湖与数据仓库的融合,成为大数据领域的趋势重点。直至年末,关于二者的讨论依然热烈,行业内的主要分歧点在于数据湖、数据仓库对存储系统访问、权限管理等方面的把控;行业内的主要共识点则是二者结合必能降低大数据分析的成本,提高易用性。

而此类争论,又反映了行业在大数据处理领域的核心诉求:如何通过数据湖、数据仓库的设计,有效满足现代化应用的数据架构要求。亚马逊云科技作为行业头部云厂商,也推出了与数据湖、数据仓库融合相关的"智能湖仓"。为什么"智能湖仓"可以更智能地集成数据湖、数据仓库和其他数据处理服务?"智能湖仓"架构备受关注意味着什么?在技术行业风向标的 2021 亚马逊云科技 re:Invent 大会上,我们看到了"智能湖仓"架构的现在和未来构想。

被广泛关注的"智能湖仓"架构

理解"智能湖仓"架构的现在和未来,需要先了解它的过去。早在 2017 年,"智能湖仓"架构就已初具雏形。当时,亚马逊云科技发布了 Amazon Redshift Spectrum,让 Amazon Redshift 具备了打通数据仓库和数据湖的能力,实现了跨数据湖、数据仓库的数据查询。

这件事情启发了"智能湖仓"架构的形成。在 2020 年的亚马逊云科技 re:Invent 大会上,亚马逊云科技正式发布"智能湖仓"。如果从早期的技术探索开始算起,在 2021 亚马逊云科技 re:Invent 大会上发布的 Serverless 能力,代表了"智能湖仓"架构的第 8 轮技术演进。如今,"智能湖仓"基于 Amazon S3 构建数据湖,绕湖集成数据仓库、大数据处理、日志分析、机器学习数据服务,利用 Amazon Lake Formation、Amazon Glue 等工具可以实现数据的自由流动与统一治理。

图片

具体而言,"智能湖仓"架构下,首先需要打破数据孤岛形成一个数据湖;其次,需要围绕着数据湖,在不同应用场景为用户提供相应的分析工具;另外,需要确保数据在湖、仓以及专门的服务之间能够自由移动;此外,需要确保用统一的方式去管理湖里面数据的安全性、访问控制和审计;最终,需要能够采用低成本的方法将湖、仓各自的优势有效利用起来,并利用人工智能等创新手段进行创新。

就像 Amazon Redshift 在 2012 年发布时,引导了云原生数仓的发展方向一样,"智能湖仓"架构一经发布就引发业内广泛关注,一方面是因为亚马逊云科技作为头部云厂商的行业地位,另一方面是因为此架构在技术上的创新思路能够为行业带来一些新的思考。

"智能湖仓"更强调"架构"而非"产品",更强调数据的自由流动与统一治理,以及基于湖仓的"智能创新"。如今,"智能湖仓"架构不是简单地将湖与仓打通,而是将湖、仓与专门构建的数据服务连接成为一个整体,让数据在其间无缝移动。面对向 TB 级、PB 级,甚至 EB 级增长的数据,"如何存"和"如何用"不再是相对孤立的话题。"智能湖仓"向行业传递了一个信号:企业需要统一数据分析工具,实现数据在整个数据平台的自由流转。

不管是企业数据管理理念的视角,还是在技术视角下,"智能湖仓"架构被广泛关注也意味着,随着数据湖和数据仓库的边界在逐渐淡化,基于两者的大数据处理体系的架构正在被重构。

"智能湖仓"架构下,重构中的大数据基础设施

这种重构大概可以分为几个维度来理解,其中最重要的是更强的数据安全、治理和数据共享能力,更敏捷的构建方式,更智能的创新手段。

数据安全、治理和共享,重点聚焦跨湖、跨仓库甚至跨企业的数据流通和治理,致力于实现真正意义上的数据跨域互通;更敏捷的构建方式则要将企业的敏态追求提升到极致,Serverless 能力的应用是其关键;更智能的创新手段则把 AI/ML 能力和大数据治理并入统一范畴,避免走入"为了大数据而大数据"的误区。

在 2022 年,当我们再次谈起数据湖和数据仓库的融合问题时,包含以上关键点的"智能湖仓"架构,很可能成为被业内重点参考的构建思路之一。

更强的数据安全、治理和数据共享能力

数据的安全、治理和共享,原是大数据的本职任务,但当数据达到 PB 乃至 EB 级,需要跨多个区域、组织、账户进行数据共享或数据交互时,企业有些时候并非不想细颗粒度管理数据,而是无法管理。这种颗粒度的权限控制往往比单机系统设计或者单一的分布式系统要复杂得多。所以,数据治理成为了"智能湖仓"重要的发力点。

在 2021 亚马逊云科技 re:Invent 大会上,支撑数据统一治理和自由流动能力的"智能湖仓"组件 Amazon Lake Formation 发布了多项新功能。除了之前早已支持的表和列级安全,Amazon Lake Formation 现在支持行和单元级权限,通过只限制用户对部分数据的访问权限,让限制访问敏感信息变得更加简单。

此外,Data mesh 的概念在 2021 亚马逊云科技 re:Invent 大会上也被提及。Data mesh 概念也是 Gartner 提出的十大数据技术趋势之一。在 Data mesh 模式下,"智能湖仓"能够实现领域数据成为产品、轻松启用细粒度授权、数据更容易被使用、数据调用跨企业可见和联邦的数据管控与合规。这意味着,"智能湖仓"架构下,Data mesh 可以实现跨数据湖的数据共享和计算。亚马逊云科技借助自身数据湖安全、tag 级别的访问控制和共享能力,为 Data mesh 提供了实现方式与手段,让 Data mesh 概念走向落地。

更敏捷的构建方式

除了更强的数据安全、治理和数据共享能力,更敏捷的构建方式也是绝大多数企业当下主要关注的技术创新之一。敏捷在企业间的认可度和应用程度越来越高,而"智能湖仓"原本就是敏捷的架构。在"智能湖仓"架构中,Amazon Lake Formation 能够将建立数据湖的时间从数月缩短到数天。用户可以使用像 Amazon Glue 这样的 Serverless 数据集成工具快速实现数据入湖;使用 Amazon Athena 这样的 Serverless 查询引擎直接实现基于 SQL 语言的湖上数据查询分析。无论是超大型公司还是工作室,都可以从这种敏捷的构建方式中快速获益,提取数据的价值。

为了让构建方式更敏捷,在 2021 亚马逊云科技 re:Invent 大会上,亚马逊云科技宣布推出更多数据分析服务的无服务器版,借助无服务器的能力,让用户可以更敏捷地构建自己的数据存储、分析、智能应用解决方案。

  • Amazon Redshift Serverless ,让数据仓库更敏捷,支持在几秒钟内自动设置和扩展资源,用户无需管理数据仓库集群,实现 PB 级数据规模运行高性能分析工作负载;
  • Amazon Managed Streaming for Apache Kafka (Amazon MSK) Serverless ,让流式数据接入与处理,支持快速扩展资源,简化实时数据摄取和流式传输,实现全面监控、移动甚至跨集群加载分区,自动调配和扩展计算和存储资源,让用户可以按需使用 Kafka;
  • Amazon EMR Serverless 让大数据处理更敏捷,用户无需部署、管理和扩展底层基础设施,使用开源大数据框架(如 Apache Spark、Hive 和 Presto)运行分析型应用程序;
  • Amazon Kinesis Data Streams on Demand 让流式数据分析与实时数据场景搭建更敏捷。每分钟可以处理数 GB 的写入和读取吞吐量,而不必预置与管理服务器、存储,在成本和性能之间取得平衡且变得更加简单。

图片

来自亚马逊云科技的数据显示,现在每天有数以万计的用户每天在使用 Amazon Redshift 处理超过 2EB 的数据。全球最大的制药公司之一罗氏制药(Roche)首席云平台和机器学习工程师 Yannick Misteli 博士表示:"Amazon Redshift Serverless 可减轻运营负担,降低成本,并帮助罗氏制药规模化实践 Go-to-Market 策略。这种极简的方式改变了游戏规则,帮助我们快速上手并支持各种繁重的分析场景。"

更智能的创新手段

正如 Yannick Misteli 提到的一样,近些年来,底层的技术创新推动业务层的改变,而业务层的诉求也倒逼底层技术的进步。游戏规则正在技术升级中改变。如今,"智能"是绝大多数技术的演进目标。在亚马逊云科技的"智能湖仓"架构中,也将"智能"提到了一个相当重要的位置。

"智能湖仓"架构下,数据库服务与人工智能和机器学习深度集成。在具体的产品上,亚马逊云科技提供了 Amazon Aurora ML、Amazon Neptune ML、Amazon Redshift ML 等诸多数据库原生的机器学习服务。

同时,在"智能湖仓"架构中,还有云原生人工智能平台 Amazon SageMaker ,它提供了多类机器学习库和开发工具包,帮助用户快速构建人工智能应用。当用户需要面对大量数据处理场景时,可以使用 Amazon SageMaker 内置的工具轻松快速连接到 Amazon EMR 集群进行大数据处理。而 Amazon EMR Serverless,也帮助人工智能相关的数据处理与分析变得足够敏捷。

在 Gartner 2021 年发布的报告《Magic Quadrant for Cloud Database Management Systems》中,亚马逊云科技连续 7 年被评为"领导者",这项报告面向的主要是对各大厂商提供的云数据库、云数据分析工具进行全景评估,并给出最终位置的"测评报告",含金量可见一斑。亚马逊云科技参与评测的产品均为"智能湖仓"架构中的代表产品,这个"领导者地位"背后代表的技术成熟度不言自明。

我们可以看到,"智能湖仓"提供的每一款服务工具的迭代,都在向更敏捷、更安全、更智能的数据架构目标迈进。数据架构作为企业数字化转型的最底层,也是应用现代化的底层动力。"智能湖仓"带来的数据管理方式的变革,也承载着亚马逊云科技对应用现代化的构想。

写在最后

回到文章开篇提到的问题,目前行业内已经形成了数据湖和数据仓库的融合必将降低大数据分析成本的共识,主要分歧点在于数据湖、数据仓库对存储系统访问、权限管理等方面的把控。在这些方面,亚马逊云科技的"智能湖仓"架构围绕这些问题都提供了相关的工具或服务。

无论是在数据基础架构、统一分析还是业务创新上,从连接数据湖和数据仓库到跨数据库、跨域共享,"智能湖仓"在实际的业务场景中并非孤立存在,而是与应用程序紧密相连。

底层数据架构的现代化演进,也将为企业乃至全行业带来更大的价值。数据,作为与土地、劳动力、资本、技术并列的"第五大生产要素",重要性不言而喻。如今,亚马逊云科技"智能湖仓"架构在企业中的实践,已经为企业构建现代化数据平台提供了一条可供遵循的路径。

活动推荐

对于技术圈而言,这一年中,各种技术与领域的发展,既站高峰,也历跌宕。在 2021 年最后时刻,我们也想聆听来自云计算领域开发者的声音,为此,云计算开发者有奖调研正式开启,诚邀各位伙伴参与,多重好礼等你来领!

图片

图片

彻底搞懂 Kubernetes 中的 Events

Posted: 28 Dec 2021 08:58 PM PST

大家好,我是张晋涛。

之前我写了一篇《更优雅的 Kubernetes 集群事件度量方案》,利用 Jaeger 利用 tracing 的方式来采集 Kubernetes 集群中的 events 并进行展示。最终效果如下:

写那篇文章的时候,立了个 flag 要详细介绍下其中的原理,鸽了很久,现在年底了,也该发出来了。

Eents 概览

我们先来做个简单的示例,来看看 Kubernetes 集群中的 events 是什么。

创建一个新的名叫 moelove 的 namespace ,然后在其中创建一个叫做 redis 的 deployment。接下来查看这个 namespace 中的所有 events。

(MoeLove) ➜ kubectl create ns moelove namespace/moelove created (MoeLove) ➜ kubectl -n moelove create deployment redis --image=ghcr.io/moelove/redis:alpine  deployment.apps/redis created (MoeLove) ➜ kubectl -n moelove get deploy NAME    READY   UP-TO-DATE   AVAILABLE   AGE redis   1/1     1            1           11s (MoeLove) ➜ kubectl -n moelove get events LAST SEEN   TYPE     REASON              OBJECT                        MESSAGE 21s         Normal   Scheduled           pod/redis-687967dbc5-27vmr    Successfully assigned moelove/redis-687967dbc5-27vmr to kind-worker3 21s         Normal   Pulling             pod/redis-687967dbc5-27vmr    Pulling image "ghcr.io/moelove/redis:alpine" 15s         Normal   Pulled              pod/redis-687967dbc5-27vmr    Successfully pulled image "ghcr.io/moelove/redis:alpine" in 6.814310968s 14s         Normal   Created             pod/redis-687967dbc5-27vmr    Created container redis 14s         Normal   Started             pod/redis-687967dbc5-27vmr    Started container redis 22s         Normal   SuccessfulCreate    replicaset/redis-687967dbc5   Created pod: redis-687967dbc5-27vmr 22s         Normal   ScalingReplicaSet   deployment/redis              Scaled up replica set redis-687967dbc5 to 1

但是我们会发现默认情况下 kubectl get events 并没有按照 events 发生的顺序进行排列,所以我们往往需要为其增加 --sort-by='{.metadata.creationTimestamp}' 参数来让其输出可以按时间进行排列。

这也是为何 Kubernetes v1.23 版本中会新增 kubectl alpha events 命令的原因。我在之前的文章《K8S 生态周报| Kubernetes v1.23.0 正式发布,新特性一览》中已进行了详细的介绍,这里就不展开了。

按时间排序后可以看到如下结果:

(MoeLove) ➜ kubectl -n moelove get events --sort-by='{.metadata.creationTimestamp}' LAST SEEN   TYPE     REASON              OBJECT                        MESSAGE 2m12s       Normal   Scheduled           pod/redis-687967dbc5-27vmr    Successfully assigned moelove/redis-687967dbc5-27vmr to kind-worker3 2m13s       Normal   SuccessfulCreate    replicaset/redis-687967dbc5   Created pod: redis-687967dbc5-27vmr 2m13s       Normal   ScalingReplicaSet   deployment/redis              Scaled up replica set redis-687967dbc5 to 1 2m12s       Normal   Pulling             pod/redis-687967dbc5-27vmr    Pulling image "ghcr.io/moelove/redis:alpine" 2m6s        Normal   Pulled              pod/redis-687967dbc5-27vmr    Successfully pulled image "ghcr.io/moelove/redis:alpine" in 6.814310968s 2m5s        Normal   Created             pod/redis-687967dbc5-27vmr    Created container redis 2m5s        Normal   Started             pod/redis-687967dbc5-27vmr    Started container redis

通过以上的操作,我们可以发现 events 实际上是 Kubernetes 集群中的一种资源。当 Kubernetes 集群中资源状态发生变化时,可以产生新的 events

深入 Events

单个 Event 对象

既然 events 是 Kubernetes 集群中的一种资源,正常情况下它的 metadata.name 中应该包含其名称,用于进行单独操作。所以我们可以使用如下命令输出其 name :

(MoeLove) ➜ kubectl -n moelove get events --sort-by='{.metadata.creationTimestamp}' -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' redis-687967dbc5-27vmr.16c4fb7bde8c69d2 redis-687967dbc5.16c4fb7bde6b54c4 redis.16c4fb7bde1bf769 redis-687967dbc5-27vmr.16c4fb7bf8a0ab35 redis-687967dbc5-27vmr.16c4fb7d8ecaeff8 redis-687967dbc5-27vmr.16c4fb7d99709da9 redis-687967dbc5-27vmr.16c4fb7d9be30c06

选择其中的任意一条 event 记录,将其输出为 YAML 格式进行查看:

(MoeLove) ➜ kubectl -n moelove get events redis-687967dbc5-27vmr.16c4fb7bde8c69d2 -o yaml action: Binding apiVersion: v1 eventTime: "2021-12-28T19:31:13.702987Z" firstTimestamp: null involvedObject:   apiVersion: v1   kind: Pod   name: redis-687967dbc5-27vmr   namespace: moelove   resourceVersion: "330230"   uid: 71b97182-5593-47b2-88cc-b3f59618c7aa kind: Event lastTimestamp: null message: Successfully assigned moelove/redis-687967dbc5-27vmr to kind-worker3 metadata:   creationTimestamp: "2021-12-28T19:31:13Z"   name: redis-687967dbc5-27vmr.16c4fb7bde8c69d2   namespace: moelove   resourceVersion: "330235"   uid: e5c03126-33b9-4559-9585-5e82adcd96b0 reason: Scheduled reportingComponent: default-scheduler reportingInstance: default-scheduler-kind-control-plane source: {} type: Normal

可以看到其中包含了很多信息, 这里我们先不展开。我们看另一个例子。

kubectl describe 中的 Events

我们可以分别对 Deployment 对象和 Pod 对象执行 describe 的操作,可以得到如下结果(省略掉了中间输出):

  • 对 Deployment 操作
(MoeLove) ➜ kubectl -n moelove describe deploy/redis                 Name:                   redis Namespace:              moelove ... Events:   Type    Reason             Age   From                   Message   ----    ------             ----  ----                   -------   Normal  ScalingReplicaSet  15m   deployment-controller  Scaled up replica set redis-687967dbc5 to 1
  • 对 Pod 操作
(MoeLove) ➜ kubectl -n moelove describe pods redis-687967dbc5-27vmr Name:         redis-687967dbc5-27vmr                                                                  Namespace:    moelove Priority:     0 Events:   Type    Reason     Age   From               Message   ----    ------     ----  ----               -------   Normal  Scheduled  18m   default-scheduler  Successfully assigned moelove/redis-687967dbc5-27vmr to kind-worker3   Normal  Pulling    18m   kubelet            Pulling image "ghcr.io/moelove/redis:alpine"   Normal  Pulled     17m   kubelet            Successfully pulled image "ghcr.io/moelove/redis:alpine" in 6.814310968s   Normal  Created    17m   kubelet            Created container redis   Normal  Started    17m   kubelet            Started container redis

我们可以发现 对不同的资源对象进行 describe 的时候,能看到的 events 内容都是与自己有直接关联的。在 describe Deployment 的时候,看不到 Pod 相关的 Events 。

这说明, Event 对象中是包含它所描述的资源对象的信息的,它们是有直接联系的。

结合前面我们看到的单个 Event 对象,我们发现 involvedObject 字段中内容就是与该 Event 相关联的资源对象的信息

更进一步了解 Events

我们来看看如下的示例,创建一个 Deployment ,但是使用一个不存在的镜像:

(MoeLove) ➜ kubectl -n moelove create deployment non-exist --image=ghcr.io/moelove/non-exist deployment.apps/non-exist created (MoeLove) ➜ kubectl -n moelove get pods NAME                        READY   STATUS         RESTARTS   AGE non-exist-d9ddbdd84-tnrhd   0/1     ErrImagePull   0          11s redis-687967dbc5-27vmr      1/1     Running        0          26m

我们可以看到当前的 Pod 处于一个 ErrImagePull 的状态。查看当前 namespace 中的 events (我省略掉了之前 deploy/redis 的记录)

(MoeLove) ➜ kubectl -n moelove get events --sort-by='{.metadata.creationTimestamp}'                                                            LAST SEEN   TYPE      REASON              OBJECT                           MESSAGE 35s         Normal    SuccessfulCreate    replicaset/non-exist-d9ddbdd84   Created pod: non-exist-d9ddbdd84-tnrhd 35s         Normal    ScalingReplicaSet   deployment/non-exist             Scaled up replica set non-exist-d9ddbdd84 to 1 35s         Normal    Scheduled           pod/non-exist-d9ddbdd84-tnrhd    Successfully assigned moelove/non-exist-d9ddbdd84-tnrhd to kind-worker3 17s         Warning   Failed              pod/non-exist-d9ddbdd84-tnrhd    Error: ErrImagePull 17s         Warning   Failed              pod/non-exist-d9ddbdd84-tnrhd    Failed to pull image "ghcr.io/moelove/non-exist": rpc error: code = Unknown desc = failed to pull and unpack image "ghcr.io/moelove/non-exist:latest": failed to resolve reference "ghcr.io/moelove/non-exist:latest": failed to authorize: failed to fetch anonymous token: unexpected status: 403 Forbidden 18s         Normal    Pulling             pod/non-exist-d9ddbdd84-tnrhd    Pulling image "ghcr.io/moelove/non-exist" 4s          Warning   Failed              pod/non-exist-d9ddbdd84-tnrhd    Error: ImagePullBackOff 4s          Normal    BackOff             pod/non-exist-d9ddbdd84-tnrhd    Back-off pulling image "ghcr.io/moelove/non-exist"

对这个 Pod 执行 describe 操作:

(MoeLove) ➜ kubectl -n moelove describe pods non-exist-d9ddbdd84-tnrhd ... Events:   Type     Reason     Age                    From               Message   ----     ------     ----                   ----               -------   Normal   Scheduled  4m                     default-scheduler  Successfully assigned moelove/non-exist-d9ddbdd84-tnrhd to kind-worker3   Normal   Pulling    2m22s (x4 over 3m59s)  kubelet            Pulling image "ghcr.io/moelove/non-exist"   Warning  Failed     2m21s (x4 over 3m59s)  kubelet            Failed to pull image "ghcr.io/moelove/non-exist": rpc error: code = Unknown desc = failed to pull and unpack image "ghcr.io/moelove/non-exist:latest": failed to resolve reference "ghcr.io/moelove/non-exist:latest": failed to authorize: failed to fetch anonymous token: unexpected status: 403 Forbidden   Warning  Failed     2m21s (x4 over 3m59s)  kubelet            Error: ErrImagePull   Warning  Failed     2m9s (x6 over 3m58s)   kubelet            Error: ImagePullBackOff   Normal   BackOff    115s (x7 over 3m58s)   kubelet            Back-off pulling image "ghcr.io/moelove/non-exist"

我们可以发现,这里的输出和之前正确运行 Pod 的不一样。最主要的区别在与 Age 列。这里我们看到了类似 115s (x7 over 3m58s) 这样的输出。

它的含义表示: 该类型的 event 在 3m58s 中已经发生了 7 次,最近的一次发生在 115s 之前

但是当我们去直接 kubectl get events 的时候,我们并没有看到有 7 次重复的 event 。这说明 Kubernetes 会自动将重复的 events 进行合并

选择最后一条 Events (方法前面内容已经讲了) 并将其内容使用 YAML 格式进行输出:

(MoeLove) ➜ kubectl -n moelove get events non-exist-d9ddbdd84-tnrhd.16c4fce570cfba46 -o yaml apiVersion: v1 count: 43 eventTime: null firstTimestamp: "2021-12-28T19:57:06Z" involvedObject:   apiVersion: v1   fieldPath: spec.containers{non-exist}   kind: Pod   name: non-exist-d9ddbdd84-tnrhd   namespace: moelove   resourceVersion: "333366"   uid: 33045163-146e-4282-b559-fec19a189a10 kind: Event lastTimestamp: "2021-12-28T18:07:14Z" message: Back-off pulling image "ghcr.io/moelove/non-exist" metadata:   creationTimestamp: "2021-12-28T19:57:06Z"   name: non-exist-d9ddbdd84-tnrhd.16c4fce570cfba46   namespace: moelove   resourceVersion: "334638"   uid: 60708be0-23b9-481b-a290-dd208fed6d47 reason: BackOff reportingComponent: "" reportingInstance: "" source:   component: kubelet   host: kind-worker3 type: Normal

这里我们可以看到其字段中包括一个 count 字段,表示同类 event 发生了多少次。以及 firstTimestamplastTimestamp 分别表示了这个 event 首次出现了最近一次出现的时间。这样也就解释了前面的输出中 events 持续的周期了。

彻底搞懂 Events

以下内容是从 Events 中随便选择的一条,我们可以看到它包含的一些字段信息:

apiVersion: v1 count: 1 eventTime: null firstTimestamp: "2021-12-28T19:31:13Z" involvedObject:   apiVersion: apps/v1   kind: ReplicaSet   name: redis-687967dbc5   namespace: moelove   resourceVersion: "330227"   uid: 11e98a9d-9062-4ccb-92cb-f51cc74d4c1d kind: Event lastTimestamp: "2021-12-28T19:31:13Z" message: 'Created pod: redis-687967dbc5-27vmr' metadata:   creationTimestamp: "2021-12-28T19:31:13Z"   name: redis-687967dbc5.16c4fb7bde6b54c4   namespace: moelove   resourceVersion: "330231"   uid: 8e37ec1e-b3a1-420c-96d4-3b3b2995c300 reason: SuccessfulCreate reportingComponent: "" reportingInstance: "" source:   component: replicaset-controller type: Normal

其中主要字段的含义如下:

  • count: 表示当前同类的事件发生了多少次 (前面已经介绍)
  • involvedObject: 与此 event 有直接关联的资源对象 (前面已经介绍) , 结构如下:
type ObjectReference struct {     Kind string     Namespace string     Name string     UID types.UID     APIVersion string     ResourceVersion string     FieldPath string }
  • source: 直接关联的组件, 结构如下:
type EventSource struct {     Component string     Host string }
  • reason: 简单的总结(或者一个固定的代码),比较适合用于做筛选条件,主要是为了让机器可读,当前有超过 50 种这样的代码;
  • message: 给一个更易让人读懂的详细说明
  • type: 当前只有 NormalWarning 两种类型, 源码中也分别写了其含义:
// staging/src/k8s.io/api/core/v1/types.go const (     // Information only and will not cause any problems     EventTypeNormal string = "Normal"     // These events are to warn that something might go wrong     EventTypeWarning string = "Warning" )

所以,当我们将这些 Events 都作为 tracing 的 span 采集回来后,就可以按照其 source 进行分类,按 involvedObject 进行关联,按时间进行排序了。

总结

在这篇文章中,我主要通过两个示例,一个正确部署的 Deploy,以及一个使用不存在镜像部署的 Deploy,深入的介绍了 Events 对象的实际的作用及其各个字段的含义。

对于 Kubernetes 而言,Events 中包含了很多有用的信息,但是这些信息却并不会对 Kubernetes 造成什么影响,它们也并不是实际的 Kubernetes 的日志。默认情况下 Kubernetes 中的日志在 1 小时后就会被清理掉,以便释放对 etcd 的资源占用。

所以为了能更好的让集群管理员知道发生了什么,在生产环境中,我们通常会把 Kubernetes 集群的 events 也给采集回来。我个人比较推荐的工具是: https://github.com/opsgenie/k...

当然你也可以按照我之前的文章 《更优雅的 Kubernetes 集群事件度量方案》,利用 Jaeger 利用 tracing 的方式来采集 Kubernetes 集群中的 events 并进行展示。


欢迎订阅我的文章公众号【MoeLove】

电子表格实战锦囊:巧用稀疏数组是关键!

Posted: 28 Dec 2021 07:18 PM PST

前文中我们详细介绍过稀疏数组的那些事儿,以及在实际项目中,稀疏数组如何在前端电子表格中发挥出它最大的效果。而这次,我们将从实战应用出发,为大家介绍稀疏数组在前端中的具体应用。

我们都知道在Javascript中是通过Array()构造函数构件稀疏矩阵,或者通过数组,设定数组的索引长度大于当前数组长度的方式来创建稀疏矩阵。

var arr = new Array(100)   //arr没有元素,但arr.length是100 var a = [];  //创建一个空数组,length为0 a[50] = 50;  //赋值添加一个元素,length为 51  

稀疏数组中,没有元素的结点为empty,获取这些结点将返回结果undefined。通过使用index in array可以判断一个结点是否有元素。例如下面代码中,a[0]和a[1]的返回都为undefined,但是a[1]其实为空。

JS中已经支持稀疏数组的存储,但在实际情况中,我们保存稀疏数组的保存并不是直接进行,而是会根据实际情况构建其他存储方式保存稀疏数组。想了解为什么要多此一举,这里就需要大家了解一个概念——数据持久化。

我们在前端进行许多操作时,会产生许多数据,例如在前端表格进行多人填报、协同的时候,会出现很多需要长期保存的数据,有些数据还要转移到其它位置中便于人们存储、管理、操作等。而实现这一目标的关键点就是数据的持久化,我们需要将内存中数据序列化为json等存储格式保存到数据库并还能反序列化到内存。在之前的文章详解电子表格中的json数据:序列化与反序列化已经具体介绍了,大家有兴趣可以查看。

看到这里,你以为问题彻底解决了吗,图样图森破。

为了解决数据持久化,我们使用了JSON,但这时新的问题也随之出现,JSON存储中没有undefined。我们对数组进行操作的时候,数组中empty字段都会序列化为null,如下图所示。

JSON.stringify(a) '[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,50]' 

再次parse后,数组便不再是稀疏数组了。

JSON.parse(JSON.stringify(a)) (51) [null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, 50] 

这种情况下,为了解决JSON数据在转化过程中上述出现的情况,我们就需要构建一些其他存储方式,来更好的地解决这个问题~而这些存储方式又有哪些特点,让我们一起看看。

1、对象存储

在前端利用JS的语言特点,我们可以通过Object可以轻松实现Sparse Array。例如在Spread JS中,对象属性名称对应所在单元格的行列,value属性保存单元格的值,同样可以拓展出formula和style等属性保存单元格公式和样式。使用Sparse Array不用初始化大小也不用关心数据的扩容,需要做行列操作时也只需要改变行列属性的引用即可。

上图中的数据存储结果如下

{     "0": {         "0": {             "value": 0         }     },     "2": {         "1": {             "value": 2         },         "3": {             "value": "S"         }     },     "4": {         "3": {             "value": 3         }     } }  

需要存取数据时候直接通过对象属性访问。下面是JS Sparse Array的一个简单对象实现。

 function SparseArray(){     this._array = {} } SparseArray.prototype.setValue = function(row, col, data){     if(!this._array[row]){         this._array[row] = {}     }     this._array[row][col] = data } SparseArray.prototype.getValue = function(row, col){     if(this._array[row]){         return this._array[row][col]     }     return undefined; } let arr = new SparseArray(); arr.setValue(3, 3, 5); console.log(arr.getValue(3, 3))    // 5

2、三元组

在矩阵中每一个元素有行标,列标,元素值三个信息,将元素按需放入数组中便是三元组存储。存储结构可以是一个包含元素信息对象,也可以直接简化为一个长度为3的数组。三元组的存储方式可以方便记录类似下图的轨迹信息或者自由曲线信息,通过对数组进行push和pop,可以方便进行回退和前进。

上图中的轨迹信息,以数组三元组存储后如下,元素value代表当前已元素数量,也可以使用对象记录时间等更多信息。

[     [1,1,1],     [5,8,2],     [4,3,3],      [1,5,4] ]

下面,我们就用这种方式建立一个undoStack记录回退。

function TSMatrix(){ this._array = []; this.undoStack = [] }    TSMatrix.prototype.addNode = function(row, col, value){ this._array.push([row, col, value]) } TSMatrix.prototype.canUndo = function(){ return this._array.length > 0; } TSMatrix.prototype.undo = function(){ if(this._array.length > 0){ this.undoStack.push(this._array.pop()) } } TSMatrix.prototype.canRedo = function(){ return this.undoStack.length > 0; } TSMatrix.prototype.redo = function(){ if(this._array.length > 0){ this._array.push(this.undoStack.pop()) } } TSMatrix.prototype.print = function(){ console.log(JSON.stringify(this._array)) }  let mat = new TSMatrix(); mat.addNode(1, 1, 1) mat.addNode(5, 8, 2) mat.addNode(4, 3, 3) mat.addNode(1, 5, 4) mat.print() //[[1,1,1],[5,8,2],[4,3,3],[1,5,4]] mat.undo() mat.print()  //[[1,1,1],[5,8,2],[4,3,3]] mat.redo() mat.print()  //[[1,1,1],[5,8,2],[4,3,3],[1,5,4]] 

除了以上两种方式,还可以将上述方式结合,建立十字链表以应对更复杂的场景。大家如果感兴趣点个赞我们下次继续说。

在后续的内容中,我们还会继续为大家带来其他前端电子表格技术中的深度解密,走过路过不要错过。

难搞的偏向锁终于被 Java 移除了

Posted: 28 Dec 2021 04:58 PM PST

背景

在 JDK1.5 之前,面对 Java 并发问题, synchronized 是一招鲜的解决方案:

  1. 普通同步方法,锁上当前实例对象
  2. 静态同步方法,锁上当前类 Class 对象
  3. 同步块,锁上括号里面配置的对象

拿同步块来举例:

public void test(){   synchronized (object) {     i++;   } }

经过 javap -v 编译后的指令如下:

monitorenter 指令是在编译后插入到同步代码块的开始位置;monitorexit是插入到方法结束和异常的位置(实际隐藏了try-finally),每个对象都有一个 monitor 与之关联,当一个线程执行到 monitorenter 指令时,就会获得对象所对应的 monitor 的所有权,也就获得到了对象的锁

当另外一个线程执行到同步块的时候,由于它没有对应 monitor 的所有权,就会被阻塞,此时控制权只能交给操作系统,也就会从 user mode 切换到 kernel mode, 由操作系统来负责线程间的调度和线程的状态变更, 需要频繁的在这两个模式下切换(上下文转换)。这种有点竞争就找内核的行为很不好,会引起很大的开销,所以大家都叫它重量级锁,自然效率也很低,这也就给很多童鞋留下了一个根深蒂固的印象 —— synchronized关键字相比于其他同步机制性能不好

免费的 Java 并发编程小册在此

锁的演变

来到 JDK1.6,要怎样优化才能让锁变的轻量级一些? 答案就是:

轻量级锁:CPU CAS

如果 CPU 通过简单的 CAS 能处理加锁/释放锁,这样就不会有上下文的切换,较重量级锁而言自然就轻了很多。但是当竞争很激烈,CAS 尝试再多也是浪费 CPU,权衡一下,不如升级成重量级锁,阻塞线程排队竞争,也就有了轻量级锁升级成重量级锁的过程

程序员在追求极致的道路上是永无止境的,HotSpot 的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,同一个线程反复获取锁,如果还按照轻量级锁的方式获取锁(CAS),也是有一定代价的,如何让这个代价更小一些呢?

偏向锁

偏向锁实际就是锁对象潜意识「偏心」同一个线程来访问,让锁对象记住线程 ID,当线程再次获取锁时,亮出身份,如果同一个 ID 直接就获取锁就好了,是一种 load-and-test 的过程,相较 CAS 自然又轻量级了一些

可是多线程环境,也不可能只是同一个线程一直获取这个锁,其他线程也是要干活的,如果出现多个线程竞争的情况,也就有了偏向锁升级的过程

这里可以先思考一下:偏向锁可以绕过轻量级锁,直接升级到重量级锁吗?

都是同一个锁对象,却有多种锁状态,其目的显而易见:

占用的资源越少,程序执行的速度越快

偏向锁,轻量锁,它俩都不会调用系统互斥量(Mutex Lock),只是为了提升性能,多出的两种锁的状态,这样可以在不同场景下采取最合适的策略,所以可以总结性的说:

  • 偏向锁:无竞争的情况下,只有一个线程进入临界区,采用偏向锁
  • 轻量级锁:多个线程可以交替进入临界区,采用轻量级锁
  • 重量级锁:多线程同时进入临界区,交给操作系统互斥量来处理

到这里,大家应该理解了全局大框,但仍然会有很多疑问:

  1. 锁对象是在哪存储线程 ID 才可以识别同一个线程的?
  2. 整个升级过程是如何过渡的?

想理解这些问题,需要先知道 Java 对象头的结构

认识 Java 对象头

按照常规理解,识别线程 ID 需要一组 mapping 映射关系来搞定,如果单独维护这个 mapping 关系又要考虑线程安全的问题。奥卡姆剃刀原理,Java 万物皆是对象,对象皆可用作锁,与其单独维护一个 mapping 关系,不如中心化将锁的信息维护在 Java 对象本身上

Java 对象头最多由三部分构成:

  1. MarkWord
  2. ClassMetadata Address
  3. Array Length (如果对象是数组才会有这部分

其中 Markword 是保存锁状态的关键,对象锁状态可以从偏向锁升级到轻量级锁,再升级到重量级锁,加上初始的无锁状态,可以理解为有 4 种状态。想在一个对象中表示这么多信息自然就要用存储,在 64 位操作系统中,是这样存储的(注意颜色标记),想看具体注释的可以看 hotspot(1.8) 源码文件 path/hotspot/src/share/vm/oops/markOop.hpp 第 30 行

有了这些基本信息,接下来我们就只需要弄清楚,MarkWord 中的锁信息是怎么变化的

认识偏向锁

单纯的看上图,还是显得十分抽象,作为程序员的我们最喜欢用代码说话,贴心的 openjdk 官网提供了可以查看对象内存布局的工具 JOL (java object layout)

Maven Package

<dependency>   <groupId>org.openjdk.jol</groupId>   <artifactId>jol-core</artifactId>   <version>0.14</version> </dependency>

Gradle Package

implementation 'org.openjdk.jol:jol-core:0.14'

接下来我们就通过代码来深入了解一下偏向锁吧

注意:

上图(从左到右) 代表 高位 -> 低位

JOL 输出结果(从左到右)代表 低位 -> 高位

来看测试代码

场景1

    public static void main(String[] args) {         Object o = new Object();         log.info("未进入同步块,MarkWord 为:");         log.info(ClassLayout.parseInstance(o).toPrintable());         synchronized (o){             log.info(("进入同步块,MarkWord 为:"));             log.info(ClassLayout.parseInstance(o).toPrintable());         }     }

来看输出结果:

上面我们用到的 JOL 版本为 0.14, 带领大家快速了解一下位具体值,接下来我们就要用 0.16 版本查看输出结果,因为这个版本给了我们更友好的说明,同样的代码,来看输出结果:

看到这个结果,你应该是有疑问的,JDK 1.6 之后默认是开启偏向锁的,为什么初始化的代码是无锁状态,进入同步块产生竞争就绕过偏向锁直接变成轻量级锁了呢?

虽然默认开启了偏向锁,但是开启有延迟,大概 4s。原因是 JVM 内部的代码有很多地方用到了synchronized,如果直接开启偏向,产生竞争就要有锁升级,会带来额外的性能损耗,所以就有了延迟策略

我们可以通过参数 -XX:BiasedLockingStartupDelay=0 将延迟改为0,但是不建议这么做。我们可以通过一张图来理解一下目前的情况:

场景2

那我们就代码延迟 5 秒来创建对象,来看看偏向是否生效

    public static void main(String[] args) throws InterruptedException {         // 睡眠 5s         Thread.sleep(5000);         Object o = new Object();         log.info("未进入同步块,MarkWord 为:");         log.info(ClassLayout.parseInstance(o).toPrintable());         synchronized (o){             log.info(("进入同步块,MarkWord 为:"));             log.info(ClassLayout.parseInstance(o).toPrintable());         }     }

重新查看运行结果:

这样的结果是符合我们预期的,但是结果中的 biasable 状态,在 MarkWord 表格中并不存在,其实这是一种匿名偏向状态,是对象初始化中,JVM 帮我们做的

这样当有线程进入同步块:

  1. 可偏向状态:直接就 CAS 替换 ThreadID,如果成功,就可以获取偏向锁了
  2. 不可偏向状态:就会变成轻量级锁

那问题又来了,现在锁对象有具体偏向的线程,如果新的线程过来执行同步块会偏向新的线程吗?

场景3

    public static void main(String[] args) throws InterruptedException {         // 睡眠 5s         Thread.sleep(5000);         Object o = new Object();         log.info("未进入同步块,MarkWord 为:");         log.info(ClassLayout.parseInstance(o).toPrintable());         synchronized (o){             log.info(("进入同步块,MarkWord 为:"));             log.info(ClassLayout.parseInstance(o).toPrintable());         }          Thread t2 = new Thread(() -> {             synchronized (o) {                 log.info("新线程获取锁,MarkWord为:");                 log.info(ClassLayout.parseInstance(o).toPrintable());             }         });          t2.start();         t2.join();         log.info("主线程再次查看锁对象,MarkWord为:");         log.info(ClassLayout.parseInstance(o).toPrintable());          synchronized (o){             log.info(("主线程再次进入同步块,MarkWord 为:"));             log.info(ClassLayout.parseInstance(o).toPrintable());         }     }

来看运行结果,奇怪的事情发生了:

  • 标记1: 初始可偏向状态
  • 标记2:偏向主线程后,主线程退出同步代码块
  • 标记3: 新线程进入同步代码块,升级成了轻量级锁
  • 标记4: 新线程轻量级锁退出同步代码块,主线程查看,变为不可偏向状态
  • 标记5: 由于对象不可偏向,同场景1主线程再次进入同步块,自然就会用轻量级锁

至此,场景一二三可以总结为一张图:

从这样的运行结果上来看,偏向锁像是"一锤子买卖",只要偏向了某个线程,后续其他线程尝试获取锁,都会变为轻量级锁,这样的偏向非常有局限性。事实上并不是这样,如果你仔细看标记2(已偏向状态),还有个 epoch 我们没有提及,这个值就是打破这种局限性的关键,在了解 epoch 之前,我们还要了解一个概念——偏向撤销

免费的 Java 并发编程小册在此

偏向撤销

在真正讲解偏向撤销之前,需要和大家明确一个概念——偏向锁撤销和偏向锁释放是两码事

  1. 撤销:笼统的说就是多个线程竞争导致不能再使用偏向模式的时候,主要是告知这个锁对象不能再用偏向模式
  2. 释放:和你的常规理解一样,对应的就是 synchronized 方法的退出或 synchronized 块的结束

何为偏向撤销?

从偏向状态撤回原有的状态,也就是将 MarkWord 的第 3 位(是否偏向撤销)的值,从 1 变回 0

如果只是一个线程获取锁,再加上「偏心」的机制,是没有理由撤销偏向的,所以偏向的撤销只能发生在有竞争的情况下

想要撤销偏向锁,还不能对持有偏向锁的线程有影响,所以就要等待持有偏向锁的线程到达一个 safepoint 安全点 (这里的安全点是 JVM 为了保证在垃圾回收的过程中引用关系不会发生变化设置的一种安全状态,在这个状态上会暂停所有线程工作), 在这个安全点会挂起获得偏向锁的线程

在这个安全点,线程可能还是处在不同状态的,先说结论(因为源码就是这么写的,可能有疑惑的地方会在后面解释)

  1. 线程不存活或者活着的线程但退出了同步块,很简单,直接撤销偏向就好了
  2. 活着的线程但仍在同步块之内,那就要升级成轻量级锁

这个和 epoch 貌似还是没啥关系,因为这还不是全部场景。偏向锁是特定场景下提升程序效率的方案,可并不代表程序员写的程序都满足这些特定场景,比如这些场景(在开启偏向锁的前提下):

  1. 一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种case下,会导致大量的偏向锁撤销操作
  2. 明知有多线程竞争(生产者/消费者队列),还要使用偏向锁,也会导致各种撤销

很显然,这两种场景肯定会导致偏向撤销的,一个偏向撤销的成本无所谓,大量偏向撤销的成本是不能忽视的。那怎么办?既不想禁用偏向锁,还不想忍受大量撤销偏向增加的成本,这种方案就是设计一个有阶梯的底线

批量重偏向(bulk rebias)

这是第一种场景的快速解决方案,以 class 为单位,为每个 class 维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器 +1,当这个值达到重偏向阈值(默认20)时:

BiasedLockingBulkRebiasThreshold = 20

JVM 就认为该class的偏向锁有问题,因此会进行批量重偏向, 它的实现方式就用到了我们上面说的 epoch

Epoch,如其含义「纪元」一样,就是一个时间戳。每个 class 对象会有一个对应的epoch字段,每个处于偏向锁状态对象mark word 中也有该字段,其初始值为创建该对象时 class 中的epoch的值(此时二者是相等的)。每次发生批量重偏向时,就将该值加1,同时遍历JVM中所有线程的栈

  1. 找到该 class 所有正处于加锁状态的偏向锁对象,将其epoch字段改为新值
  2. class 中不处于加锁状态的偏向锁对象(没被任何线程持有,但之前是被线程持有过的,这种锁对象的 markword 肯定也是有偏向的),保持 epoch 字段值不变

这样下次获得锁时,发现当前对象的epoch值和class的epoch,本着今朝不问前朝事 的原则(上一个纪元),那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过 CAS 操作将其mark word的线程 ID 改成当前线程 ID,这也算是一定程度的优化,毕竟没升级锁;

如果 epoch 都一样,说明没有发生过批量重偏向, 如果 markword 有线程ID,还有其他锁来竞争,那锁自然是要升级的(如同前面举的例子 epoch=0)

批量重偏向是第一阶梯底线,还有第二阶梯底线

批量撤销(bulk revoke)

当达到重偏向阈值后,假设该 class 计数器继续增长,当其达到批量撤销的阈值后(默认40)时,

BiasedLockingBulkRevokeThreshold = 40

JVM就认为该 class 的使用场景存在多线程竞争,会标记该 class 为不可偏向。之后对于该 class 的锁,直接走轻量级锁的逻辑

这就是第二阶梯底线,但是在第一阶梯到第二阶梯的过渡过程中,也就是在彻底禁用偏向锁之前,还给一次改过自新的机会,那就是另外一个计时器:

BiasedLockingDecayTime = 25000
  1. 如果在距离上次批量重偏向发生的 25 秒之内,并且累计撤销计数达到40,就会发生批量撤销(偏向锁彻底 game over)
  2. 如果在距离上次批量重偏向发生超过 25 秒之外,那么就会重置在 [20, 40) 内的计数, 再给次机会

大家有兴趣可以写代码测试一下临界点,观察锁对象 markword 的变化

至此,整个偏向锁的工作流程可以用一张图表示:

到此,你应该对偏向锁有个基本的认识了,但是我心中的好多疑问还没有解除,咱们继续看:

HashCode 哪去了

上面场景一,无锁状态,对象头中没有 hashcode;偏向锁状态,对象头还是没有 hashcode,那我们的 hashcode 哪去了?

首先要知道,hashcode 不是创建对象就帮我们写到对象头中的,而是要经过第一次调用 Object::hashCode() 或者System::identityHashCode(Object) 才会存储在对象头中的。第一次生成的 hashcode后,该值应该是一直保持不变的,但偏向锁又是来回更改锁对象的 markword,必定会对 hashcode 的生成有影响,那怎么办呢?,我们来用代码验证:

场景一

    public static void main(String[] args) throws InterruptedException {         // 睡眠 5s         Thread.sleep(5000);          Object o = new Object();         log.info("未生成 hashcode,MarkWord 为:");         log.info(ClassLayout.parseInstance(o).toPrintable());          o.hashCode();         log.info("已生成 hashcode,MarkWord 为:");         log.info(ClassLayout.parseInstance(o).toPrintable());          synchronized (o){             log.info(("进入同步块,MarkWord 为:"));             log.info(ClassLayout.parseInstance(o).toPrintable());         }     }

来看运行结果

结论就是:即便初始化为可偏向状态的对象,一旦调用 Object::hashCode() 或者System::identityHashCode(Object) ,进入同步块就会直接使用轻量级锁

场景二

假如已偏向某一个线程,然后生成 hashcode,然后同一个线程又进入同步块,会发生什么呢?来看代码:

    public static void main(String[] args) throws InterruptedException {         // 睡眠 5s         Thread.sleep(5000);          Object o = new Object();         log.info("未生成 hashcode,MarkWord 为:");         log.info(ClassLayout.parseInstance(o).toPrintable());          synchronized (o){             log.info(("进入同步块,MarkWord 为:"));             log.info(ClassLayout.parseInstance(o).toPrintable());         }          o.hashCode();         log.info("生成 hashcode");         synchronized (o){             log.info(("同一线程再次进入同步块,MarkWord 为:"));             log.info(ClassLayout.parseInstance(o).toPrintable());         }     }

查看运行结果:

结论就是:同场景一,会直接使用轻量级锁

场景三

那假如对象处于已偏向状态,在同步块中调用了那两个方法会发生什么呢?继续代码验证:

    public static void main(String[] args) throws InterruptedException {         // 睡眠 5s         Thread.sleep(5000);          Object o = new Object();         log.info("未生成 hashcode,MarkWord 为:");         log.info(ClassLayout.parseInstance(o).toPrintable());          synchronized (o){             log.info(("进入同步块,MarkWord 为:"));             log.info(ClassLayout.parseInstance(o).toPrintable());             o.hashCode();             log.info("已偏向状态下,生成 hashcode,MarkWord 为:");             log.info(ClassLayout.parseInstance(o).toPrintable());         }     }

来看运行结果:

结论就是:如果对象处在已偏向状态,生成 hashcode 后,就会直接升级成重量级锁

最后用书中的一段话来描述 锁和hashcode 之前的关系

调用 Object.wait() 方法会发生什么?

Object 除了提供了上述 hashcode 方法,还有 wait() 方法,这也是我们在同步块中常用的,那这会对锁产生哪些影响呢?来看代码:

    public static void main(String[] args) throws InterruptedException {         // 睡眠 5s         Thread.sleep(5000);          Object o = new Object();         log.info("未生成 hashcode,MarkWord 为:");         log.info(ClassLayout.parseInstance(o).toPrintable());          synchronized (o) {             log.info(("进入同步块,MarkWord 为:"));             log.info(ClassLayout.parseInstance(o).toPrintable());              log.info("wait 2s");             o.wait(2000);              log.info(("调用 wait 后,MarkWord 为:"));             log.info(ClassLayout.parseInstance(o).toPrintable());         }     }

查看运行结果:

结论就是,wait 方法是互斥量(重量级锁)独有的,一旦调用该方法,就会升级成重量级锁(这个是面试可以说出的亮点内容哦)

最后再继续丰富一下锁对象变化图:


免费的 Java 并发编程小册在此

告别偏向锁

看到这个标题你应该是有些慌,为啥要告别偏向锁,因为维护成本有些高了,来看 Open JDK 官方声明,JEP 374: Deprecate and Disable Biased Locking,相信你看上面的文字说明也深有体会

这个说明的更新时间距离现在很近,在 JDK15 版本就已经开始了

一句话解释就是维护成本太高

最终就是,JDK 15 之前,偏向锁默认是 enabled,从 15 开始,默认就是 disabled,除非显示的通过 UseBiasedLocking 开启

其中在 quarkus 上的一篇文章说明的更加直接

偏向锁给 JVM 增加了巨大的复杂性,只有少数非常有经验的程序员才能理解整个过程,维护成本很高,大大阻碍了开发新特性的进程(换个角度理解,你掌握了,是不是就是那少数有经验的程序员了呢?哈哈)

总结

偏向锁可能就这样的走完了它的一生,有些同学可能直接发问,都被 deprecated 了,JDK都 17 了,还讲这么多干什么?

  1. java 任它发,我用 Java8,这是很多主流的状态,至少你用的版本没有被 deprecated
  2. 面试还是会被经常问到
  3. 万一哪天有更好的设计方案,"偏向锁"又以新的形式回来了呢,了解变化才能更好理解背后设计
  4. 奥卡姆剃刀原理,我们现实中的优化也一样,如果没有必要不要增加实体,如果增加的内容带来很大的成本,不如大胆的废除掉,接受一点落差

之前对于偏向锁我也只是单纯的理论认知,但是为了写这篇文章,我翻阅了很多资料,包括也重新查看 Hotspot 源码,说的这些内容也并不能完全说明偏向锁的整个流程细节,还需要大家具体实践追踪查看,这里给出源码的几个关键入口,方便大家追踪:

  1. 偏向锁入口: http://hg.openjdk.java.net/jd...
  2. 偏向撤销入口:http://hg.openjdk.java.net/jd...
  3. 偏向锁释放入口:http://hg.openjdk.java.net/jd...

文中有疑问的地方欢迎留言讨论,有错误的地方还请大家帮忙指正

灵魂追问

  1. 轻量级和重量级锁,hashcode 存在了什么位置?

参考资料

感谢各路前辈的精华总结,可以让我参考理解:

  1. https://www.oracle.com/techne...
  2. https://www.oracle.com/techne...
  3. https://wiki.openjdk.java.net...
  4. https://github.com/farmerjohn...
  5. https://zhuanlan.zhihu.com/p/...
  6. https://mp.weixin.qq.com/s/G4...
  7. https://www.jianshu.com/p/884...

日拱一兵 | 原创

平平淡淡又一年 | 边城在思否的 2021 年总结

Posted: 28 Dec 2021 04:00 PM PST

又到了年末写总结的时候。我不断地提醒自己过去这一年是 2021,不是 2022 —— 是的,作为一个使用了 VS2022 好几个月的人来说,感觉已经在 2022 好久了。然而, 2022 只是即将来临。回往昔,我都干了啥?

说起来,只记得最近为了迎接元宇宙,我去复习了《Matrix I、II、III》 —— 好吧,说实话,是为了《Matrix IV》。

不过为了做这个 2021 的思否社区总结,我是真的下了功夫 —— 去扒了下 SF 的个人数据:

------------------------- Article Info -------------------------   count:          20   votes:          216   comments:       39   winner:         [34] 安全地在前后端之间传输数据 - 「1」技术预研 --------------------- Top 5 Vote Articles  ---------------------  1. [34] 安全地在前后端之间传输数据 - 「1」技术预研  2. [28] 安全地在前后端之间传输数据 - 「3」真的安全吗?  3. [22] 我与思否,七年之痒 | 思否 9 周年  4. [19] 处理可能超时的异步操作  5. [16] 尝试对 jsjiami 加密结果手工解密 ------------------------- Replies Info -------------------------   total:          338   accepted:       113 (33.43%)   votes:          307 ---------------------- Notifications Info ----------------------   total:          1901   invited:        210 (11.05%)   askMe:          3   askFee:         148.8   voteReply:      241   voteArticle:    227 --------------------- Top 10 Vote Articles ---------------------  1. [----] (115) 理解 JavaScript 的 async/await  2. [2021] (8) 安全地在前后端之间传输数据 - 「3」真的安全吗?  3. [----] (8) 在微信小程序中使用 async/await  4. [----] (8) Token 认证的来龙去脉  5. [2021] (7) 安全地在前后端之间传输数据 - 「2」注册和登录示例  6. [----] (7) 还搞不懂闭包算我输(JS 示例)  7. [2021] (6) 安全地在前后端之间传输数据 - 「1」技术预研  8. [2021] (6) 尝试对 jsjiami 加密结果手工解密  9. [2021] (6) 我与思否,七年之痒 | 思否 9 周年 10. [2021] (6) 把程序做成系统服务 11. [----] (5) JavaScript 的 this 指向问题深度解析

要说怎么扒的,当然是使用传说中的"爬虫技术"…… 的入门技术:找到 API,找到 Token,写段代码迭代拉取数据,直到把今年的拉完。这不是重点,重点是 ——

2021 年间我写了 20 篇博文,收获 216 个赞。其中获赞最多的是《安全地在前后端之间传输数据 - 「1」技术预研》,有 34 个。回答问题 338 个,其中 113 个答案被接受,占比 33.43%;回答获赞 307 个,比博文多,但平均下来看还是博文更值钱。

除了统计 2021 年的文章和问题之外,我也想知道往年的文章在今年获赞的情况。想了一下,可能这个信息要从提醒消息里去拉。结果拉出来 1900 多条提醒。其中邀请回答 210 条,占 2021 年所有回答的 60%。确实,回想起来,整个 2021 年间,有超过半年的时间没有什么创作动力,也不怎么想回答问题,基本上是有邀请才答,没邀请拉倒。

不过爬出来的数据有效性不好说。你看,从提醒数据中提取出来文章共获得 227 个赞,除去前面统计的 2021 年文章的 216 个,只有 16 个赞在以前的文章上。然而,仅《理解 JavaScript 的 async/await》(发于 2016 年)一篇就占了 115 个赞,这不合理。然后我想,是不是"收藏"的也算了赞,但是没有单独出现"赞"的提醒呢,所以重新统计了一下:

---------------------- Notifications Info ----------------------   total:          1901   invited:        210 (11.05%)   askMe:          3   askFee:         148.8   voteReply:      241   voteArticle:    475 --------------------- Top 10 Vote Articles ---------------------  1. [----] (241) 理解 JavaScript 的 async/await  2. [----] (17) Token 认证的来龙去脉  3. [----] (17) 在微信小程序中使用 async/await  4. [2021] (16) 安全地在前后端之间传输数据 - 「1」技术预研  5. [2021] (15) 安全地在前后端之间传输数据 - 「3」真的安全吗?  6. [----] (15) 还搞不懂闭包算我输(JS 示例)  7. [----] (13) JavaScript 的 this 指向问题深度解析  8. [2021] (11) 安全地在前后端之间传输数据 - 「2」注册和登录示例  9. [2021] (10) 尝试对 jsjiami 加密结果手工解密 10. [2021] (10) 把程序做成系统服务 11. [2021] (9) 过滤/筛选树节点

结果看起来更接近一些,但是仍然存在差异。可能"有人收藏了你的文章"这里提到的"有人"有时候不止一个吧 —— 也只能这么解释了!但不管怎么说,这个统计数据已经很说明问题了,2021 一年的博文,比不过曾经一篇的余热 😰!

差点忘了,2021 年还有一点现实的收入,3 个付费问题,共计收入 ¥148.8 😁。

除了统计结果,我还想聊点细节。

总的来说,2021 年回答的问题都不是很难,但从我的感觉来看,同学们在数据处理上的问题比较多。怎么说呢 —— 还是得加强数据结构和算法和深入学习和理解,以及多运用,多实践,多积累经验。大概总结了一下,我回答过的问题有这么一些分类:

还有一些其他的分类,比如框架类(Vue 等)的,工作类的,正则表达式类的 …… 其中最让我感到高兴的是有不少代码优化类的问题,这充分说明了越来越多的人开始关注代码的可读性和可维护性,而不再只是简单地追求"能跑"了 —— 别想多了,代码真的是给人读的!要不然人类终将会输给机器。

我的 2021 过得很平淡,平淡得不知不觉就过完了。本以为也没什么好写的,没想到还是写了一大篇。

牛年,我就像老水牛一般低调;虎年,我是不是该站出来咆哮!—— 也许是吧,但那不是我的性格。2022 年又会怎么过呢?

本文参与了 SegmentFault 思否征文「2021 总结」,欢迎正在阅读的你也加入。

一看就懂,一写就懵?搞懂回溯算法,一口气刷了20多道题

Posted: 26 Dec 2021 12:36 AM PST

一、回溯算法

1.1什么是回溯?

回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就"回溯"返回,尝试别的路径。——摘自《百度百科》

1.1 一般步骤:

  1. 针对所给问题,定义问题的解空间,它至少包含问题的一个(最优)解。
  2. 确定易于搜索的解空间结构,使得能用回溯法方便地搜索整个解空间 。
  3. 以深度优先的方式搜索解空间,并且在搜索过程中用剪枝函数避免无效搜索。

1.2 如何理解回溯算法?

  1. 为问题建立解空间结构
  2. 在解空间结构上进行DFS搜索
  3. 设立回溯出口和剪枝点,减少无效搜索,出口处保存有效解.

1.3 解决那些问题?

  1. 组合问题:N个数⾥⾯按⼀定规则找出k个数的集合
  2. 切割问题:⼀个字符串按⼀定规则有⼏种切割⽅式
  3. ⼦集问题:⼀个N个数的集合⾥有多少符合条件的⼦集
  4. 排列问题:N个数按⼀定规则全排列,有⼏种排列⽅式
  5. 棋盘问题:N皇后,解数独等等。

1.4递归与回溯

首先先说明一下对递归 (Recursive)与回溯 (Backtrack)的理解。

1.4.1 递归 (Recursive)

程序调用自身的编程技巧称为递归。
递归做为一种算法在程序设计语言中广泛应用。 一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解,递归策略只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。 ——摘自《百度百科》

通常来说,为了描述问题的某一状态,必须用到该状态的上一个状态;而如果要描述上一个状态,又必须用到上一个状态的上一个状态…… 这样用自己来定义自己的方法就是递归。

1.4.2. 回溯 (Backtrack)

回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就"回溯"返回,尝试别的路径。 ——摘自《百度百科》

在这种思想下,我们需要清晰的找出三个要素:选择 (Options),限制 (Restraints),结束条件 (Termination)。

1.5.递归与回溯的区别

递归是一种算法结构。递归会出现在子程序中,形式上表现为直接或间接的自己调用自己。典型的例子是阶乘,计算规律为:n!=n×(n−1)!n!=n \times (n-1)!,基本如下所示:

let fac = (n)=> {     if(n == 1){        return n;     }else{       return (n*fac(n - 1));      }     }

回溯是一种算法思想,它是用递归实现的。回溯的过程类似于穷举法,但回溯有"剪枝"功能,即自我判断过程。

二、Leetcode回溯题目

2.1- 22. 括号生成

数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。

示例 1:

输入:n = 3 输出:["((()))","(()())","(())()","()(())","()()()"]

示例 2:

输入:n = 1 输出:["()"]

提示:
1 <= n <= 8

思路分析

  1. 判断左右括号所剩的数量,最初始都是n;当左括号(()有剩余,继续做选择;
  2. 判断当右括号比左括号剩的多,才能选右括号;继续递归做选择
  3. 出口:构建的字符串是 2n的时候,此时已经该分支已经构建完成,加入选项;

简答绘制图形

解题代码

var generateParenthesis = function (n) {     const res = [];     const backTracing = (lRemain, rRemain, str) => { // 左右括号所剩的数量,str是当前构建的字符串         if (str.length == 2 * n) { // 字符串构建完成             res.push(str);           // 加入解集             return;                  // 结束当前递归分支         }         if (lRemain > 0) {         // 只要左括号有剩,就可以选它,然后继续做选择(递归)             backTracing(lRemain - 1, rRemain, str + "(");         }         if (lRemain < rRemain) {   // 右括号比左括号剩的多,才能选右括号             backTracing(lRemain, rRemain - 1, str + ")"); // 然后继续做选择(递归)         }     };     backTracing(n, n, ""); // 递归的入口,剩余数量都是n,初始字符串是空串     return res; };

2.2 - 46. 全排列

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

示例 1:

输入:nums = [1,2,3] 输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

示例 2:

输入:nums = [0,1] 输出:[[0,1],[1,0]]

示例 3:

输入:nums = [1] 输出:[[1]]

提示:
1 <= nums.length <= 6
-10 <= nums[i] <= 10
nums 中的所有整数 互不相同

解题思路

  1. 回溯终止条件:该条路径长度与达到nums长度;
  2. 加入当前值到路径,如果结果里面已经包含这个路径,则不加入结果里面,否则继续选择这个选项;

解题代码

/**  * @param {number[]} nums  * @return {number[][]}  */ var permute = function (nums) {     if (!nums.length) return     let res = []     let backTrack = path => {         //长度满足条件,加入结果         if (path.length === nums.length) {             res.push(path)             return         }         nums.forEach(item => {             if (path.includes(item)) return //不包含重复的数字             backTrack([...path, item]) //加入路径,继续递归选择         });     }     backTrack([])     return res }; 

[图片上传失败...(image-40cdd5-1639281547994)]

2.3 - n 皇后问题

研究的是如何将 n 个皇后放置在 n × n 的棋盘上,并且使皇后彼此之间不能相互攻击。

给你一个整数 n ,返回 n 皇后问题 不同的解决方案的数量。*

image

皇后走法规则

皇后的走法是:可以横直斜走,格数不限。因此要求皇后彼此之间不能相互攻击,等价于要求任何两个皇后都不能在同一行、同一列以及同一条斜线上。

示例

示例 1:
image

输入:n = 4 输出:2

解释:如上图所示,4 皇后问题存在两个不同的解法。

示例 2:

输入:n = 1 输出:1

提示:
1 <= n <= 9

解题思路

  1. 定义判断当前位置的检验函数,约束条件包含 ,不能同行,不能同列,不能同对角线(45度和135度)
  2. 定义棋盘;标准回溯处理;

使用回溯的具体做法是:依次在每一行放置一个皇后,每次新放置的皇后都不能和已经放置的皇后之间有攻击,即新放置的皇后不能和任何一个已经放置的皇后在同一列以及同一条斜线上。当 NNN 个皇后都放置完毕,则找到一个可能的解,将可能的解的数量加 111。

图片来源

解题代码

var totalNQueens = function (n) {     let count = 0; //皇后可放置的总数     let isValid = (row, col, board, n) => {         //所在行不用判断,每次都会下移一行         //判断同一列的数据是否包含         for (let i = 0; i < row; i++) {             if (board[i][col] === 'Q') {                 return false             }         }         //判断45度对角线是否包含         for (let i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) {             if (board[i][j] === 'Q') {                 return false             }         }         //判断135度对角线是否包含         for (let i = row - 1, j = col - 1; i >= 0 && j >= 0; j--, i--) {             if (board[i][j] === 'Q') {                 return false             }         }         return true     }      let backTracing = (row, board) => {         //走到最后一行,统计次数         if (row === n) {             count++;             return         }          for (let x = 0; x < n; x++) {             //判断该位置是否可以放置 皇后             if (isValid(row, x, board, n)) {                 board[row][x] = 'Q'; //放置皇后                 backTracing(row + 1, board); //递归                 board[row][x] = '.'; //回溯,撤销处理结果             }         }     }     backTracing(0, board)     let board = [...Array(n)].map(v => v = ([...Array(n)]).fill('.')) //棋盘     return count };

2.4 - 78. 子集

给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。

解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。

示例 1:

输入:nums = [1,2,3] 输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

示例 2:

输入:nums = [0] 输出:[[],[0]]

提示:
1 <= nums.length <= 10
-10 <= nums[i] <= 10
nums 中的所有元素 互不相同

解题思路

  1. 枚举出所有可选的数;加入选项;
  2. 撤销加入的选项,将选项加入结果

解题代码

const subsets = (nums) => {     const res = [];     const backTracing = (index, list) => {         res.push(list.slice());     // 调用子递归前,加入解集         for (let i = index; i < nums.length; i++) { // 枚举出所有可选的数             list.push(nums[i]);       // 选这个数             backTracing(i + 1, list);         // 基于选这个数,继续递归             list.pop();               // 撤销选这个数         }     };     backTracing(0, []);     return res; };

2.5 - 77. 组合

给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。

你可以按 任何顺序 返回答案。

示例 1:

输入:n = 4, k = 2 输出: [   [2,4],   [3,4],   [2,3],   [1,2],   [1,3],   [1,4], ]

示例 2:

输入:n = 1, k = 1 输出:[[1]]

提示:

1 <= n <= 20 1 <= k <= n 

解题思路

  1. 枚举出所有可选的数;加入选项;
  2. 撤销加入的选项,将选项加入结果
  3. 剪枝条件:选项的长度满足条件;

解题代码

/**  * @param {number} n  * @param {number} k  * @return {number[][]}  */ var combine = function (n, k) {     let result = [];     let backTracing = (start, path) => {         // 如果已经选满了的话,加入结果集中         if (path.length == k) {             result.push(path.slice());             return;         }         // 从开始的数字到末尾的数字         for (let i = start; i <= n; i++) {             // 加入选择列表中             path.push(i);             // 继续深度搜索             backTracing(i + 1, path);             // 撤销选择             path.pop(i);         }     };     backTracing(1, []);     return result; };

2.6 - 081. 允许重复选择元素的组合

给定一个无重复元素的正整数数组 candidates 和一个正整数 target ,找出 candidates 中所有可以使数字和为目标数 target 的唯一组合。

candidates 中的数字可以无限制重复被选取。如果至少一个所选数字数量不同,则两种组合是唯一的。
对于给定的输入,保证和为 target 的唯一组合数少于 150 个。

示例 1:

输入: candidates = [2,3,6,7], target = 7 输出: [[7],[2,2,3]]

示例 2:

输入: candidates = [2,3,5], target = 8 输出: [[2,2,2,2],[2,3,3],[3,5]]

示例 3:

输入: candidates = [2], target = 1 输出: []

示例 4:

输入: candidates = [1], target = 1 输出: [[1]]

示例 5:

输入: candidates = [1], target = 2 输出: [[1,1]]

提示:
1 <= candidates.length <= 30
1 <= candidates[i] <= 200
candidate 中的每个元素都是独一无二的。
1 <= target <= 500

解题思路

  1. 将当前元素加入到选项里面,并将计算结果,传到选项,继续递归;
  2. 当选项和大于目标值时,结束这个选项,直到找到符合的选项,并将选项加入结果;

解题代码

var combinationSum = function (candidates, target) {     const result = [], visited = [];     backTracing(0, 0);     return result;      function backTracing(sum, cur) {         if (target === sum) result.push(visited.slice());         if (target <= sum) return;         for (let i = cur; i < candidates.length; i++) {             visited.push(candidates[i]); //加入到选项里面             backTracing(sum + candidates[i], i);//选择这个选项,继续递归             visited.pop(); //插销这个选择         }     } };

2.7 - 216. 组合总和 III

找出所有相加之和为 n 的 k 个数的组合。组合中只允许含有 1 - 9 的正整数,并且每种组合中不存在重复的数字。

说明:
所有数字都是正整数。
解集不能包含重复的组合。

示例 1:

输入: k = 3, n = 7 输出: [[1,2,4]]

示例 2:

输入: k = 3, n = 9 输出: [[1,2,6], [1,3,5], [2,3,4]]

解题思路

同组合1

解题代码

var combinationSum3 = function (k, n) {     let ans = [];     let backTracing = (start, path) => {         if (path.length === k && path.reduce((acc, prev) => acc += prev) === n) {             ans.push(path.slice())             return         }         for (let i = start; i <= 9; i++) {             path.push(i)             backTracing(i + 1, path)             path.pop(i)         }     }     backTracing(1, [])     return ans };

2.8 - 17. 电话号码的字母组合

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。

给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

示例 1:

输入:digits = "23" 输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]

示例 2:

输入:digits = "" 输出:[]

示例 3:

输入:digits = "2" 输出:["a","b","c"]

提示:
0 <= digits.length <= 4
digits[i] 是范围 ['2', '9'] 的一个数字。

解题思路

  1. 找到当前按钮对应的字母字符串
  2. 拼接按钮对应的字符串组合
  3. 选项满足长度,加入结果

解题代码

var letterCombinations = function (digits) {     if(!digits.length) return []     const dic = {         2: 'abc',         3: 'def',         4: 'ghi',         5: 'jkl',         6: 'mno',         7: 'pqrs',         8: 'tuv',         9: 'wxyz',     }, ans = [];      let backTracing = (cur, index) => {         if (index > digits.length - 1) { //选项满足长度,加入结果             ans.push(cur)             return         }         let curDic = dic[digits[index]] //找到当前按钮对应的字母字符串         for (let prop of curDic) {             backTracing(cur + prop,index+1) //拼接按钮对应的字符串组合         }     }     backTracing('', 0)     return ans };

2.9 - 08.01. 三步问题

三步问题。有个小孩正在上楼梯,楼梯有n阶台阶,小孩一次可以上1阶、2阶或3阶。实现一种方法,计算小孩有多少种上楼梯的方式。结果可能很大,你需要对结果模1000000007。

示例1:

 输入:n = 3   输出:4  说明: 有四种走法

示例2:

 输入:n = 5  输出:13

提示:
n范围在[1, 1000000]之间

解题代码(会超时)

 var waysToStep = function (n) {     let ans = [], map = [1, 2, 3]     let backTracing = (path, sum) => {         if (sum >= n) {             if (sum == n) {                 ans++;             }             return         }         for (let i = 0; i < 3; i++) {             path.push(map[i]);             backTracing(path, sum + map[i])             path.pop(i)         }     }     backTracing([], 0)     return ans };

动态规划解法

/**  * @param {number} n  * @return {number}  */ var waysToStep = function (n) {     let dp =[],mod = 1000000007;     dp[0]=0,dp[1]=1,dp[2]=2,dp[3]=4;     for (let i = 4; i <= n; i++) {         dp[i] = (dp[i - 1] + dp[i - 2] + dp[i - 3]) % mod     }     return dp[n] };

2-10 - 40. 组合总和 II

给定一个数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。

candidates 中的每个数字在每个组合中只能使用一次。
注意:解集不能包含重复的组合。

示例 1:

输入: candidates = [10,1,2,7,6,1,5], target = 8, 输出: [ [1,1,6], [1,2,5], [1,7], [2,6] ]

示例 2:

输入: candidates = [2,5,2,1,2], target = 5, 输出: [ [1,2,2], [5] ]

提示:
1 <= candidates.length <= 100
1 <= candidates[i] <= 50
1 <= target <= 30

解题思路

思路同组合1

解题代码

 /**  * @param {number[]} candidates  * @param {number} target  * @return {number[][]}  */  var combinationSum2 = function (candidates, target) {     candidates.sort((a,b)=>a-b)     let ans = [];     let backTracing = (start, path, sum) => {         if (sum >= target) {             if (sum === target) {                 ans.push(path.slice())             }             return         }         for (let i = start; i < candidates.length; i++) {             if (i - 1 >= start && candidates[i - 1] == candidates[i]) {                 continue;             }             path.push(candidates[i])             backTracing(i + 1, path, sum + candidates[i])             path.pop()         }     }     backTracing(0, [], 0)     return ans };

2-11 - 47. 全排列 II

给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。

示例 1:

输入:nums = [1,1,2] 输出: [[1,1,2],  [1,2,1],  [2,1,1]]

示例 2:

输入:nums = [1,2,3] 输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

提示:
1 <= nums.length <= 8
-10 <= nums[i] <= 10

## 解题思路
同上全排列

## 解题代码

var permuteUnique = function (nums) {    let ans = [];    let used = Array(nums.length).fill(false)    let backTracing = (start, path) => {        if (start === nums.length) {            ans.push(path.slice())            return        }        for (let i = 0; i < nums.length; ++i) {            if (used[i] || (i > 0 && nums[i] === nums[i - 1] && !used[i - 1])) {                continue;            }            path.push(nums[i])            used[i] = true            backTracing(start + 1, path)            used[i] = false            path.pop()        }    }    nums.sort((a, b) => a - b)    backTracing(0, [])    return ans  };

三、总结

主要运用了回溯算法;而解决一个回溯问题,实际上就是一个决策树的遍历过程。

3.1 模板

let backtracking=(路径,选择列表) =>{     if (满足结束条件)) {         存放路径;         return;     }     for (选择:路径,选择列表) {         做出选择;         backtracking(路径,选择列表); // 递归         回溯,撤销处理结果     } }

即:

  1. 1.路径:也就是已经做出的选择。
  2. 2.选择列表:也就是你当前可以做的选择。
  3. 3.结束条件:也就是到达决策树底层,无法再做选择的条件。

3.2 剪枝函数

  1. 1.用约束条件剪除得不到的可行解的子树
  2. 2.用目标函数剪取得不到的最优解的子树

3.3 回溯法的一般步骤:

  1. 1.设置初始化的方案(给变量赋初始值,读入已知数据等)
  2. 2.变换方式去试探,若全部试完侧转(7)
  3. 3.判断此法是否成功(通过约束函数),不成功则转(2)
  4. 4.试探成功则前进一步再试探
  5. 5.正确方案还是未找到则转(2)
  6. 6.以找到一种方案则记录并打印
  7. 7.退回一步(回溯),若未退到头则转(2)
  8. 8.已退到头则结束或打印无解

继续加油!!!

# 四、参考文献

  1. LeetCode 刷题笔记——递归与回溯的理解LeetCode 刷题笔记——递归与回溯的理解
  1. 图解回溯算法图解回溯算法
  2. 回溯算法总结回溯算法总结

使用 Nginx 构建前端日志统计服务

Posted: 26 Dec 2021 07:15 PM PST

背景

之前的几篇文章都是关于之前提到的低代码平台的。

这个大的项目以 low code 为核心,囊括了编辑器前端、编辑器后端、C 端 H5、组件库、组件平台、后台管理系统前端、后台管理系统后台、统计服务、自研 CLI 九大系统。

今天就来说一下其中的统计服务:目的主要是为了实现 H5 页面的分渠道统计(其实不仅仅是分渠道统计,核心是想做一个自定义事件统计服务,只是目前有分渠道统计的需求),查看每个渠道具体的 PV 情况。(具体会在 url 上面体现,会带上页面名称、id、渠道类型等)

先放一下整体流程图吧:

日志收集

常见的日志收集方式有手动埋点和自动埋点,这里我们不关注于如何收集日志,而是如何将收集的日志的发送到服务器。

在常见的埋点方案中,通过图片来发送埋点请求是一种经常被采纳的,它有很多优势:

  • 没有跨域
  • 体积小
  • 能够完成整个 HTTP 请求+响应(尽管不需要响应内容)
  • 执行过程无阻塞

这里的方案就是在 nginx 上放一张 1px * 1px 的静态图片,然后通过访问该图片(http://xxxx.png?env=xx&event=xxx),并将埋点数据放在query参数上,以此将埋点数据落到nginx日志中。

iOS 上会限制 get 请求的 url 长度,但我们这里真实场景发送的数据不会太多,所以目前暂时采用这种方案

这里简单阐述一下为什么图片地址的query key 要这么设计,如果单纯是为了统计渠道和作品,很有可能会把key设计为channelworkId这种,但上面也说到了,我们是想做一个自定义事件统计服务,那么就要考虑字段的可扩展性,字段应更有通用语义。所以参考了很多统计服务的设计,这里采用的字段为:

  • env
  • event
  • key
  • value

之后每次访问页面,nginx就会自动记录日志到access_log中。

有了日志,下面我们来看下如何来对其进行拆分。

日志拆分

为何要拆分日志

access.log日志默认不会拆分,会越积累越多,系统磁盘的空间会被消耗得越来越多,将来可能面临着日志写入失败、服务异常的问题。

日志文件内容过多,对于后续的问题排查和分析也会变得很困难。

所以日志的拆分是有必要也是必须的。

如何拆分日志

我们这里拆分日志的核心思路是:将当前的access.log复制一份重命名为新的日志文件,之后清空老的日志文件。

视流量情况(流量越大日志文件积累的越快),按天、小时、分钟来拆分。可以把access.log按天拆分到某个文件夹中。

log_by_day/2021-12-19.log log_by_day/2021-12-20.log log_by_day/2021-12-21.log

但上面的复制 -> 清空操作肯定是要自动处理的,这里就需要启动定时任务,在每天固定的时间(我这里是在每天凌晨 00:00)来处理。

定时任务

其实定时任务不仅在日志拆分的时候会用到,在后面的日志分析和日志清除都会用到,这里先简单介绍一下,最终会整合拆分、分析和清除。

linux中内置的cron进程就是来处理定时任务的。在node中我们一般会用node-schedulecron来处理定时任务。

这里使用的是cron

/**     cron 定时规则 https://www.npmjs.com/package/cron     *    *    *    *    *    *     ┬    ┬    ┬    ┬    ┬    ┬     │    │    │    │    │    │     │    │    │    │    │    └ day of week (0 - 6) (Sun-Sat)     │    │    │    │    └───── month (1 - 12)     │    │    │    └────────── day of month (1 - 31)     │    │    └─────────────── hour (0 - 23)     │    └──────────────────── minute (0 - 59)     └───────────────────────── second (0 - 59)  */

具体使用方式就不展开说明了。

编码

有了上面这些储备,下面我就来写一下这块代码,首先梳理下逻辑:

1️⃣ 读取源文件 access.log

2️⃣ 创建拆分后的文件夹(不存在时需自动创建)

3️⃣ 创建日志文件(天维度,不存在时需自动创建)

4️⃣ 拷贝源日志至新文件

5️⃣ 清空 access.log

/**  * 拆分日志文件  *  * @param {*} accessLogPath  */ function splitLogFile(accessLogPath) {   const accessLogFile = path.join(accessLogPath, "access.log");    const distFolder = path.join(accessLogPath, DIST_FOLDER_NAME);   fse.ensureDirSync(distFolder);    const distFile = path.join(distFolder, genYesterdayLogFileName());   fse.ensureFileSync(distFile);   fse.outputFileSync(distFile, ""); // 防止重复,先清空    fse.copySync(accessLogFile, distFile);    fse.outputFileSync(accessLogFile, ""); }

日志分析

日志分析就是读取上一步拆分好的文件,然后按照一定规则去处理、落库。这里有一个很重要的点要提一下:node在处理大文件或者未知内存文件大小的时候千万不要使用readFile,会突破 V8 内存限制。正是考虑到这种情况,所以这里读取日志文件的方式应该是:createReadStream创建一个可读流交给 readline 逐行读取处理

readline

readline 模块提供了用于从可读流每次一行地读取数据的接口。 可以使用以下方式访问它:

const readline = require("readline");

readline 的使用也非常简单:创建一个接口实例,传入对应的参数:

const readStream = fs.createReadStream(logFile); const rl = readline.createInterface({   input: readStream, });

然后监听对应事件即可:

rl.on("line", (line) => {   if (!line) return;    // 获取 url query   const query = getQueryFromLogLine(line);   if (_.isEmpty(query)) return;    // 累加逻辑   // ... }); rl.on("close", () => {   // 逐行读取结束,存入数据库   const result = eventData.getResult();   resolve(result); });

这里用到了lineclose事件:

  • line事件:每当 input 流接收到行尾输入(\n、\r 或 \r\n)时,则会触发 'line' 事件
  • close事件:一般在传输结束时会触发该事件

逐行分析日志结果

了解了readline 的使用,下面让我们来逐行对日志结果进行分析吧。

首先来看下access.log中日志的格式:

我们取其中一行来分析:

127.0.0.1 - - [19/Feb/2021:15:22:06 +0800] "GET /event.png?env=h5&event=pv&key=24&value=2 HTTP/1.1" 200 5233 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_0_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36" "-"

我们要拿到的就是urlquery部分,也就是我们在h5中自定义的数据。

通过正则匹配即可:

const reg = /GET\s\/event.png\?(.+?)\s/; const matchResult = line.match(reg); console.log("matchResult", matchResult);  const queryStr = matchResult[1]; console.log("queryStr", queryStr);

打印结果为:

queryStr可通过node中的querystring.parse()来处理:

const query = querystring.parse(queryStr);  console.log('query', query) {   env: 'h5',   event: 'pv',   key: '24',   value: '2' }

剩下的就是对数据做累加处理了。

但如何去做累加,我们要想一下,最开始也说了是要去做分渠道统计,那么最终的结果应该可以清晰的看到两个数据:

  • 所有渠道的数据
  • 每个渠道单独的数据

只有这样的数据对于运营才是有价值的,数据的好坏也直接决定了后面在每个渠道投放的力度。

这里我参考了 Google Analytics中的多渠道漏斗的概念,由上到下分维度记录每个维度的数据,这样就可以清晰的知道每个渠道的情况了。

具体实现也不麻烦,我们先来看下刚刚从一条链接中得到的有用数据:

{   env: 'h5',   event: 'pv',   key: '24',   value: '2' }

这里的env代表环境,这里统计的都是来源于h5页面,所以envh5,但是为了扩展,所以设置了这个字段。

event表示事件名称,这里主要是统计访问量,所以为pv

key是作品 id。

value是渠道 code,目前主要有:1-微信、2-小红书、3-抖音。

再来看下最终统计得到的结果吧:

{   date: '2021-12-21',   key: 'h5',   value: { num: 1276} } {   date: '2021-12-21',   key: 'h5.pv',   value: { num: 1000} } {   date: '2021-12-21',   key: 'h5.pv.12',   value: { num: 200} } {   date: '2021-12-21',   key: 'h5.pv.12.1',   value: { num: 56} } {   date: '2021-12-21',   key: 'h5.pv.12.2',   value: { num: 84} } {   date: '2021-12-21',   key: 'h5.pv.12.3',   value: { num: 60} }

这是截取了2021-12-21当天的数据,我给大家分析一波:

1️⃣ h5:当天 h5 页面的自定义事件上报总数为 1276

2️⃣ h5.pv:其中 所有 pv(也就是 h5.pv)为 1000

3️⃣ h5.pv.12:作品 id 为 12 的 pv 一共有 200

4️⃣ h5.pv.12.1:作品 id 为 12 的在微信渠道的 pv 为 56

5️⃣ h5.pv.12.2:作品 id 为 12 的在小红书渠道的 pv 为 84

6️⃣ h5.pv.12.2:作品 id 为 12 的在抖音渠道的 pv 为 60

这样就能清楚的得到某一天某个作品在某条渠道的访问情况了,后续再以这些数据为支撑做成可视化报表,效果就一目了然了。

统计结果入库

目前这部分数据是放在了mongoDB中,关于node中使用mongoDB就不展开说了,不熟悉的可以参考我另外一篇文章Koa2+MongoDB+JWT 实战--Restful API 最佳实践

这里贴下model吧:

/**  * @description event 数据模型  */ const mongoose = require("../db/mongoose");  const schema = mongoose.Schema(   {     date: Date,     key: String,     value: {       num: Number,     },   },   {     timestamps: true,   } );  const EventModel = mongoose.model("event_analytics_data", schema);  module.exports = EventModel;

日志删除

随着页面的持续访问,日志文件会快速增加,超过一定时间的日志文件存在的价值也不是很大,所以我们要定期清除日志文件。

这个其实比较简单,遍历文件,因为文件名都是以日期命名的(格式:2021-12-14.log),所以只要判断时间间隔大于 90 天就删除日志文件。

贴一下核心实现:

// 读取日志文件 const fileNames = fse.readdirSync(distFolder); fileNames.forEach((fileName) => {   try {     // fileName 格式 '2021-09-14.log'     const dateStr = fileName.split(".")[0];     const d = new Date(dateStr);     const t = Date.now() - d.getTime();     if (t / 1000 / 60 / 60 / 24 > 90) {       // 时间间隔,大于 90 天,则删除日志文件       const filePath = path.join(distFolder, fileName);       fse.removeSync(filePath);     }   } catch (error) {     console.error(`日志文件格式错误 ${fileName}`, error);   } });

定时任务整合

到这里,日志的拆分、分析和清除都说完了,现在要用cron来对他们做整合了。

首先来创建定时任务:

function schedule(cronTime, onTick) {   if (!cronTime) return;   if (typeof onTick !== "function") return;    // 创建定时任务   const c = new CronJob(     cronTime,     onTick,     null, // onComplete 何时停止任务     true, // 初始化之后立刻执行     "Asia/Shanghai" // 时区   );    // 进程结束时,停止定时任务   process.on("exit", () => c.stop()); }

然后每一阶段都在不同的时间阶段去处理(定时拆分 -> 定时分析 -> 定时删除)

定时拆分

function splitLogFileTiming() {   const cronTime = "0 0 0 * * *"; // 每天的 00:00:00   schedule(cronTime, () => splitLogFile(accessLogPath));   console.log("定时拆分日志文件", cronTime); }

定时分析并入库

function analysisLogsTiming() {   const cronTime = "0 0 3 * * *"; // 每天的 3:00:00 ,此时凌晨,访问量较少,服务器资源处于闲置状态   schedule(cronTime, () => analysisLogsAndWriteDB(accessLogPath));   console.log("定时分析日志并入库", cronTime); }

定时删除

function rmLogsTiming() {   const cronTime = "0 0 4 * * *"; // 每天的 4:00:00 ,此时凌晨,访问量较少,服务器资源处于闲置状态   schedule(cronTime, () => rmLogs(accessLogPath));   console.log("定时删除过期日志文件", cronTime); }

然后在应用入口按序调用即可:

// 定时拆分日志文件 splitLogFileTiming(); // 定时分析日志并入库 analysisLogsTiming(); // 定时删除过期日志文件 rmLogsTiming();

总结

ok,到这里,一个简易的统计服务就完成了。

React Native新架构剖析

Posted: 26 Dec 2021 06:18 AM PST

目前 React Native 新架构所依赖的 React 18 已经发了 beta 版,React Native 新架构面向生态库和核心开发者的文档也正式发布,React Native 团队成员 Kevin Gozali 也在最近一次访谈中谈到新架构离正式发版还差最后一步延迟初始化,而最后一步工作大约会在 2022 年上半年完成。种种迹象表明,React Native 新架构真的要来了。

前面,RN官方宣布:Hermes将成为React Native默认的JS引擎。在文章中,我们简单的介绍了即将发布的新渲染器 Fabric,那么我们重点来认识下这个新的渲染器 Fabric 。

一、Fabric

1.1 基本概念

Fabric 是 React Native 新架构的渲染系统,是从老架构的渲染系统演变而来的。核心原理是在 C++ 层统一更多的渲染逻辑,提升与宿主平台(host platforms)互操作性,即能够在 UI 线程上同步调用JavaScript代码,渲染效率得到明显的提高。Fabric研发始于 2018 年,Facebook 内部的很多React Native 应用使用的就是新的渲染器Fabric。

在简介新渲染器(new renderer)之前,我们先介绍几个单词和概念:

  • 宿主平台(Host platform):React Native 嵌入的平台,比如 Android、iOS、Windows、macOS。
  • Fabric 渲染器(Fabric Renderer):React Native 执行的 React 框架代码,和 React 在 Web 中执行代码是同一份。但是,React Native 渲染的是通用平台视图(宿主视图)而不是 DOM 节点(可以认为 DOM 是 Web 的宿主视图)。

在更换了底层的渲染流程之后,Fabric 渲染器使得渲染宿主视图变得可行。Fabric 让 React 与各个平台直接通信并管理其宿主视图实例。Fabric 渲染器存在于 JavaScript 中,并且它调用的是由 C++ 代码暴露的接口。

1.2 新渲染器的初衷

开发新的渲染架构的初衷是为了更好的提升用户体验,而这种新体验是在老架构上是不可能实现的。主要体现为:

  • 提升宿主视图(host views)和 React 视图(React views)的互操作性,渲染器必须有能力同步地测量和渲染 React 界面。在老架构中,React Native 布局是异步的,这导致在宿主视图中渲染嵌套的 React Native 视图,会有布局"抖动"的问题。
  • 借助多优先级和同步事件的能力,渲染器可以提高用户交互的优先级,来确保他们的操作得到及时的处理。
  • React Suspense 的集成,允许开发者在 React 中更合理的组织请求数据代码。
  • 允许开发者在 React Native 使用 React Concurrent 中断渲染功能。
  • 更容易的实现 React Native 的服务端渲染。

除此之外,新的Fabric渲染器在代码质量、性能、可扩展性方面也是有了质的飞升。

  • 类型安全:代码生成工具(code generation)确保了 JS 和宿主平台两方面的类型安全。代码生成工具使用 JavaScript 组件声明作为唯一事实源,生成 C++ 结构体来持有 props 属性。不会因为 JavaScript 和宿主组件 props 属性不匹配而出现构建错误。
  • 共享 C++ core:渲染器是用 C++ 实现的,其核心 core 在平台之间是共享的。这增加了一致性并且使得新的平台能够更容易采用 React Native。(译注:例如 VR 新平台)
  • 更好的宿主平台互操作性:当宿主组件集成到 React Native 时,同步和线程安全的布局计算提升了用户体验(译注:没有异步的【抖动】)。
  • 性能提升:新的渲染系统的实现是跨平台的,每个平台都从那些原本只在某个特定平台的实现的性能优化中得到了更好的用户体验。比如拍平视图层级,原本只是 Android 上的性能优化方案,现在 Android 和 iOS 都直接有了。
  • 一致性:新的渲染系统的实现是跨平台的,不同平台之间更容易保持一致。
  • 更快的启动速度:默认情况下,宿主组件的初始化是懒执行的。
  • JS 和宿主平台之间的数据序列化更少:React 使用序列化 JSON 在 JavaScript 和宿主平台之间传递数据。新的渲染器用 JSI(JavaScript Interface)直接获取 JavaScript 数据。

二、渲染流程

2.1 渲染流程

React Native 渲染器通过一系列加工处理,将 React 代码渲染到宿主平台。这一系列加工处理就是渲染流水线(pipeline),它的作用是初始化渲染和 UI 状态更新。接下来,我们重点介绍一下React Native 渲染流水线,及其在各种场景中的不同之处。

渲染流水线可大致分为三个阶段:

  • 渲染:在 JavaScript 中,React 执行那些产品逻辑代码创建 React 元素树(React Element Trees)。然后在 C++ 中,用 React 元素树创建 React 影子树(React Shadow Tree)。
  • 提交:在 React 影子树完全创建后,渲染器会触发一次提交。这会将 React 元素树和新创建的 React 影子树的提升为"下一棵要挂载的树"。这个过程中也包括了布局信息计算。
  • 挂载:React 影子树有了布局计算结果后,它会被转化为一个宿主视图树(Host View Tree)。

这里有几个名词需要解释下:

React元素树

React 元素树是通过 JavaScript 中的 React 创建的,该树由一系类 React 元素组成。一个 React 元素就是一个普通的 JavaScript 对象,它描述了需要在屏幕中展示的内容。一个元素包括属性 props、样式 styles、子元素 children。React 元素分为两类:React 复合组件实例(React Composite Components)和 React 宿主组件(React Host Components)实例,并且它只存在于 JavaScript 中。

React 影子树

React 影子树是通过 Fabric 渲染器创建的,树由一系列 React 影子节点组成。一个 React 影子节点是一个对象,代表一个已经挂载的 React 宿主组件,其包含的属性 props 来自 JavaScript。它也包括布局信息,比如坐标系 x、y,宽高 width、height。在新渲染器 Fabric 中,React 影子节点对象只存在于 C++ 中。而在老架构中,它存在于手机运行时的堆栈中,比如 Android 的 JVM。

宿主视图树

宿主视图树就是一系列的宿主视图,宿主平台有 Android 平台、iOS 平台等等。在 Android 上,宿主视图就是 android.view.ViewGroup实例、 android.widget.TextView实例等等。宿主视图就像积木一样地构成了宿主视图树。每个宿主视图的大小和坐标位置基于的是 LayoutMetrics,而 LayoutMetrics是通过React Native得布局引擎 Yoga 计算出来的。宿主视图的样式和内容信息,是从 React 影子树中得到的。

React Native渲染流水线的各个阶段可能发生在不同的线程中,参考线程模型部分。
在这里插入图片描述
在React Native中,涉及渲染的操作通常有三种:

  • 初始化渲染
  • React 状态更新
  • React Native 渲染器的状态更新

2.2 初始化渲染

2.2.1 渲染阶段

假如,有下面一个组件需要执行渲染:

function MyComponent() {   return (     <View>       <Text>Hello, World</Text>     </View>   ); }

在上面的例子中,<MyComponent />最终会被React 简化为最基础的React 宿主元素。每一次递归地调用函数组件 MyComponet ,或类组件的 render 方法,直至所有的组件都被调用过。最终,得到一棵 React 宿主组件的 React 元素树。
在这里插入图片描述
在这里,有几个重要的名词需要解释下"

  • React 组件:React 组件就是 JavaScript 函数或者类,描述如何创建 React 元素。
  • React 复合组件:React 组件的 render 方法中,包括其他 React 复合组件和 React 宿主组件。(译注:复合组件就是开发者声明的组件)
  • React 宿主组件:React 组件的视图是通过宿主视图,比如 <View><Text>实现的。在 Web 中,ReactDOM 的宿主组件就是 <p>标签、<div>标签代表的组件。

在元素简化的过程中,每调用一个 React 元素,渲染器同时会同步地创建 React 影子节点。这个过程只发生在 React 宿主组件上,不会发生在 React 复合组件上。比如,一个 <View>会创建一个 ViewShadowNode 对象,一个<Text>会创建一个TextShadowNode对象。而我们开发的组件,由于不是基础组件,因此没有直接的React 影子节点与之对应,所以<MyComponent>并没有直接对应的 React 影子节点。

在 React 为两个 React 元素节点创建一对父子关系的同时,渲染器也会为对应的 React 影子节点创建一样的父子关系。上面代码,各个渲染阶段的产物如下图所示。

在这里插入图片描述

2.2.2 提交阶段

在 React 影子树创建完成后,渲染器触发了一次 React 元素树的提交。
在这里插入图片描述
提交阶段(Commit Phase)由两个操作组成:布局计算和树提升。

布局计算

这一步会计算每个 React 影子节点的位置和大小。在 React Native 中,每一个 React 影子节点的布局都是通过 Yoga 布局引擎来计算的。实际的计算需要考虑每一个 React 影子节点的样式,该样式来自于 JavaScript 中的 React 元素。计算还需要考虑 React 影子树的根节点的布局约束,这决定了最终节点能够拥有多少可用空间。
在这里插入图片描述

树提升

从新树到下一棵树(Tree Promotion,New Tree → Next Tree),这一步会将新的 React 影子树提升为要挂载的下一棵树。这次提升代表着新树拥有了所有要挂载的信息,并且能够代表 React 元素树的最新状态,下一棵树会在 UI 线程下一个"tick"进行挂载(译注:tick 是 CUP 的最小时间单元)。

并且,绝大多数布局计算都是 C++ 中执行,只有某些组件,比如 Text、TextInput 组件等的布局计算是在宿主平台执行的。文字的大小和位置在每个宿主平台都是特别的,需要在宿主平台层进行计算。为此,Yoga 布局引擎调用了宿主平台的函数来计算这些组件的布局。

2.2.3 挂载阶段

在这里插入图片描述
挂载阶段(Mount Phase)会将已经包含布局计算数据的 React 影子树,转换为以像素形式渲染在屏幕中的宿主视图树。

站在更高的抽象层次上,React Native 渲染器为每个 React 影子节点创建了对应的宿主视图,并且将它们挂载在屏幕上。在上面的例子中,渲染器为<View> 创建了android.view.ViewGroup 实例,为 <Text> 创建了文字内容为"Hello World"的 android.widget.TextView实例 。iOS 也是类似的,创建了一个 UIView 并调用 NSLayoutManager 创建文本。然后会为宿主视图配置来自 React 影子节点上的属性,这些宿主视图的大小位置都是通过计算好的布局信息配置的。

在这里插入图片描述
挂载阶段又细分为三个步骤:

  • 树对比: 这个步骤完全用的是 C++ 计算的,会对比"已经渲染的树"和"下一棵树"之间的元素差异。计算的结果是一系列宿主平台上的原子变更操作,比如 createView, updateView, removeView, deleteView 等等。在这个步骤中,还会将 React 影子树重构,来避免不必要的宿主视图创建。
  • 树提升,从下一棵树到已渲染树: 在这个步骤中,会自动将"下一棵树"提升为"先前渲染的树",因此在下一个挂载阶段,树的对比计算用的是正确的树。
  • 视图挂载: 这个步骤会在对应的原生视图上执行原子变更操作,该步骤是发生在原生平台的 UI 线程的。

同时,挂载阶段的所有操作都是在 UI 线程同步执行的。如果提交阶段是在后台线程执行,那么在挂载阶段会在 UI 线程的下一个"tick"执行。另外,如果提交阶段是在 UI 线程执行的,那么挂载阶段也是在 UI 线程执行。挂载阶段的调度和执行很大程度取决于宿主平台。例如,当前 Android 和 iOS 挂载层的渲染架构是不一样的。

2.3 React 状态更新

接下来,我们继续看 React 状态更新时,渲染流水线的各个阶段的情况。假设,在初始化渲染时渲染的是如下组件。

function MyComponent() {   return (     <View>       <View         style={{ backgroundColor: 'red', height: 20, width: 20 }}       />       <View         style={{ backgroundColor: 'blue', height: 20, width: 20 }}       />     </View>   ); }

通过初始化渲染部分学的知识,我们可以得到如下的三棵树:
在这里插入图片描述
可以看到,节点 3 对应的宿主视图背景是 红的,而 节点 4 对应的宿主视图背景是 蓝的。假设 JavaScript 的产品逻辑是,将第一个内嵌的<View>的背景颜色由红色改为黄色。新的 React 元素树看起来大概是这样的。

<View>   <View     style={{ backgroundColor: 'yellow', height: 20, width: 20 }}   />   <View     style={{ backgroundColor: 'blue', height: 20, width: 20 }}   /> </View>

此时,我们或许会有一个疑问:React Native 是如何处理这个更新的呢?

从概念上讲,当发生状态更新时,为了更新已经挂载的宿主视图,渲染器需要直接更新 React 元素树。但是为了线程的安全,React 元素树和 React 影子树都必须是不可变的(immutable)。这意味着 React 并不能直接改变当前的 React 元素树和 React 影子树,而是必须为每棵树创建一个包含新属性、新样式和新子节点的新副本。

2.3.1 渲染阶段

在这里插入图片描述
React 要创建了一个包含新状态的新的 React 元素树,它就要复制所有变更的 React 元素和 React 影子节点。复制后,再提交新的 React 元素树。

React Native 渲染器利用结构共享的方式,将不可变特性的开销变得最小。为了更新 React 元素的新状态,从该元素到根元素路径上的所有元素都需要复制。但 React 只会复制有新属性、新样式或新子元素的 React 元素,任何没有因状态更新发生变动的 React 元素都不会复制,而是由新树和旧树共享。

在上面的例子中,React 创建新树使用了下面这些操作:

  1. CloneNode(Node 3, {backgroundColor: 'yellow'}) → Node 3'
  2. CloneNode(Node 2) → Node 2'
  3. AppendChild(Node 2', Node 3')
  4. AppendChild(Node 2', Node 4)
  5. CloneNode(Node 1) → Node 1'
  6. AppendChild(Node 1', Node 2')

操作完成后,节点 1'(Node 1') 就是新的 React 元素树的根节点,我们用 T 代表"先前渲染的树",用 T' 代表"新树"。
在这里插入图片描述
注意,节点 4 在 T and T' 之间是共享的。结构共享提升了性能并减少了内存的使用。

2.3.2 提交阶段

在这里插入图片描述
在 React 创建完新的 React 元素树和 React 影子树后,需要提交它们,也会涉及以下几个步骤:

  • 布局计算: 状态更新时的布局计算,和初始化渲染的布局计算类似。一个重要的不同之处是布局计算可能会导致共享的 React 影子节点被复制。这是因为,如果共享的 React 影子节点的父节点引起了布局改变,共享的 React 影子节点的布局也可能发生改变。
  • 树提升: 和初始化渲染的树提升类似。
  • 树对比: 这个步骤会计算"先前渲染的树"(T)和"下一棵树"(T')的区别。计算的结果是原生视图的变更操作。

在上面的例子中,这些操作包括:UpdateView('Node 3', {backgroundColor: 'yellow'})

2.3.3 挂载阶段

在这里插入图片描述

  • 树提升:在这个步骤中,会自动将"下一棵树"提升为"先前渲染的树",因此在下一个挂载阶段,树的对比计算用的是正确的树。
  • 视图挂载:这个步骤会在对应的原生视图上执行原子变更操作。在上面的例子中,只有 视图 3(View 3) 的背景颜色会更新,变为黄色。

在这里插入图片描述

2.4 渲染器状态更新

对于影子树中的大多数信息而言,React 是唯一所有方也是唯一事实源。并且所有来源于 React 的数据都是单向流动的。

但有一个例外。这个例外是一种非常重要的机制:C++ 组件可以拥有状态,且该状态可以不直接暴露给 JavaScript,这时候 JavaScript (或 React)就不是唯一事实源了。通常,只有复杂的宿主组件才会用到 C++ 状态,绝大多数宿主组件都不需要此功能。

例如,ScrollView 使用这种机制让渲染器知道当前的偏移量是多少。偏移量的更新是宿主平台的触发,具体地说是 ScrollView 组件。这些偏移量信息在 React Native 的 measure 等 API 中有用到。因为偏移量数据是由 C++ 状态持有的,所以源于宿主平台更新,不影响 React 元素树。

从概念上讲,C++ 状态更新类似于我们前面提到的 React 状态更新,但有两点不同:

  • 因为不涉及 React,所以跳过了"渲染阶段"(Render phase)。
  • 更新可以源自和发生在任何线程,包括主线程。

在这里插入图片描述
提交阶段(Commit Phase):在执行 C++ 状态更新时,会有一段代码把影子节点 (N) 的 C++ 状态设置为值 S。React Native 渲染器会反复尝试获取 N 的最新提交版本,并使用新状态 S 复制它 ,并将新的影子节点 N' 提交给影子树。如果 React 在此期间执行了另一次提交,或者其他 C++ 状态有了更新,本次 C++ 状态提交失败。这时渲染器将多次重试 C++ 状态更新,直到提交成功,这可以防止真实源的冲突和竞争。

在这里插入图片描述
挂载阶段(Mount Phase)实际上与 React 状态更新的挂载阶段相同。渲染器仍然需要重新计算布局、执行树对比等操作。

三、跨平台实现

在上一代 React Native 渲染器中,React 影子树、布局逻辑、视图拍平算法是在各个平台单独实现的。当前的渲染器的设计上采用的是跨平台的解决方案,共享了核心的 C++ 实现。而Fabric渲染器直接使用 C++ core 渲染实现了跨平台共享。

使用 C++ 作为核心渲染系统有以下几个优点。

  • 单一实现降低了开发和维护成本。
  • 提升了创建 React 影子树的性能,同时在 Android 上,也因为不再使用 JNI for Yoga,降低了 Yoga 渲染引擎的开销,布局计算的性能也有所提升。
  • 每个 React 影子节点在 C++ 中占用的内存,比在 Kotlin 或 Swift 中占用的要小。

同时,React Native 团队还使用了强制不可变的 C++ 特性,来确保并发访问时共享资源即便不加锁保护,也不会有问题。但在 Android 端还有两种例外,渲染器依然会有 JNI 的开销:

  • 复杂视图,比如 Text、TextInput 等,依然会使用 JNI 来传输属性 props。
  • 在挂载阶段依然会使用 JNI 来发送变更操作。

React Native 团队在探索使用 ByteBuffer 序列化数据这种新的机制,来替换 ReadableMap,减少 JNI 的开销,目标是将 JNI 的开销减少 35~50%。

渲染器提供了 C++ 与两边通信的 API:

  • 与 React 通信
  • 与宿主平台通信

关于 React 与渲染器的通信,包括 渲染(render) React 树和监听 事件(event),比如 onLayout、onKeyPress、touch 等。而React Native 渲染器与宿主平台的通信,包括在屏幕上 挂载(mount) 宿主视图,包括 create、insert、update、delete 宿主视图,和监听用户在宿主平台产生的 事件(event)。

在这里插入图片描述

四、视图拍平

视图拍平(View Flattening)是 React Native 渲染器避免布局嵌套太深的优化手段。React API 在设计上希望通过组合的方式,实现组件声明和重用,这为更简单的开发提供了一个很好的模型。但是在实现中,API 的这些特性会导致一些 React 元素会嵌套地很深,而其中大部分 React 元素节点只会影响视图布局,并不会在屏幕中渲染任何内容。这就是所谓的 "只参与布局" 类型节点。

从概念上讲,React 元素树的节点数量和屏幕上的视图数量应该是 1:1 的关系。但是,渲染一个很深的"只参与布局"的 React 元素会导致性能变慢。假如,有一个应用,应用中拥有外边距 ContainerComponent的容器组件,容器组件的子组件是 TitleComponent 标题组件,标题组件包括一个图片和一行文字。React 代码示例如下:

function MyComponent() {   return (     <View>                          // ReactAppComponent       <View style={{margin: 10}} /> // ContainerComponent         <View style={{margin: 10}}> // TitleComponent           <Image {...} />           <Text {...}>This is a title</Text>         </View>       </View>     </View>   ); }

React Native 在渲染时,会生成以下三棵树:
在这里插入图片描述
在视图 2 和视图 3 是"只参与布局"的视图,因为它们在屏幕上渲染只是为了提供 10 像素的外边距。

为了提升 React 元素树中"只参与布局"类型的性能,渲染器实现了一种视图拍平的机制来合并或拍平这类节点,减少屏幕中宿主视图的层级深度。该算法考虑到了如下属性,比如 margin、padding、backgroundColor和opacity等等。

视图拍平算法是渲染器的对比(diffing)阶段的一部分,这样设计的好处是我们不需要额外的 CUP 耗时,来拍平 React 元素树中"只参与布局"的视图。此外,作为 C++ 核心的一部分,视图拍平算法默认是全平台共用的。

在前面的例子中,视图 2 和视图 3 会作为"对比算法"(diffing algorithm)的一部分被拍平,而它们的样式结果会被合并到视图 1 中。

在这里插入图片描述
不过,虽然这种优化让渲染器少创建和渲染两个宿主视图,但从用户的角度看屏幕内容没有任何区别。

五、线程模型

React Native 渲染器是线程安全的。从更高的视角看,在框架内部线程安全是通过不可变的数据结果保障的,其使用的是 C++ 的 const correctness 特性。这意味着,在渲染器中 React 的每次更新都会重新创建或复制新对象,而不是更新原有的数据结构。这是框架把线程安全和同步 API 暴露给 React 的前提。

在React Native中,渲染器使用三个不同的线程:

  • UI 线程:唯一可以操作宿主视图的线程。
  • JavaScript 线程:这是执行 React 渲染阶段的地方。
  • 后台线程:专门用于布局的线程。

下图描述了React Native渲染的完整流程:
在这里插入图片描述

5.1 渲染场景

在后台线程中渲染

这是最常见的场景,大多数的渲染流水线发生在 JavaScript 线程和后台线程。
在这里插入图片描述

在主线程中渲染

当 UI 线程上有高优先级事件时,渲染器能够在 UI 线程上同步执行所有渲染流水线。

在这里插入图片描述

默认或连续事件中断

在这个场景中,UI 线程的低优先级事件中断了渲染步骤。React 和 React Native 渲染器能够中断渲染步骤,并把它的状态和一个在 UI 线程执行的低优先级事件合并。在这个例子中渲染过程会继续在后台线程中执行。
在这里插入图片描述

不相干的事件中断

渲染步骤是可中断的。在这个场景中, UI 线程的高优先级事件中断了渲染步骤。React 和渲染器是能够打断渲染步骤的,并把它的状态和 UI 线程执行的高优先级事件合并。在 UI 线程渲染步骤是同步执行的。
在这里插入图片描述

来自 JavaScript 线程的后台线程批量更新

在后台线程将更新分派给 UI 线程之前,它会检查是否有新的更新来自 JavaScript。这样,当渲染器知道新的状态要到来时,它就不会直接渲染旧的状态。
在这里插入图片描述

C++ 状态更新

更新来自 UI 线程,并会跳过渲染步骤。
在这里插入图片描述

从微服务到云原生

Posted: 24 Dec 2021 08:16 PM PST

从微服务到云原生---入门

[TOC]

微服务

微服务架构(Microservice Architecture)是一种架构概念,旨在通过将功能分解到各个离散的服务中以实现对解决方案的解耦。

微服务是一种架构风格,一个大型复杂软件应用由一个或多个微服务组成。系统中的各个微服务可被独立部署,各个微服务之间是松耦合的。每个微服务仅关注于完成一件任务并很好地完成该任务。在所有情况下,每个任务代表着一个小的业务能力。

架构演进

微服务架构区别于传统的单体软件架构,是一种为了适应当前互联网后台服务的「三高需求:高并发、高性能、高可用」而产生的的软件架构。

单体架构

单体

单体应用程序的优点

  • 开发简单,功能都在单个程序内部,便于软件设计和开发规划。
  • 容易部署,程序单一不存在分布式集群的复杂部署环境,降低了部署难度。
  • 容易测试,没有各种复杂的服务调用关系,都是内部调用方便测试。
  • 高效通信,程序间可直接调用,通信成本低。

单体应用程序的缺点

  • 开发效率低:所有的开发在一个项目改代码,递交代码相互等待,代码冲突不断。
  • 代码维护难:代码功能耦合在一起,依赖程度过高,新人不知道何从下手,牵一发而动全身。
  • 部署不灵活:构建时间长,任何小修改必须重新构建整个项目,这个过程往往很长。
  • 稳定性不高:一个微不足道的小问题,可以导致整个应用挂掉。
  • 扩展性不够:无法满足高并发情况下的业务需求。

微服务架构

2014年,Martin FowlerJames Lewis 共同提出了微服务的概念,定义了微服务是由以单一应用程序构成的小服务,自己拥有自己的行程与轻量化处理,服务依业务功能设计,以全自动的方式部署,与其他服务使用 API 通信。同时服务会使用最小的规模的集中管理能力,服务可以用不同的编程语言与数据库等组件实现 。「维基百科」

鉴于「单体应用程序」有上述的缺点,单个应用程序被划分成各种小的、互相连接的微服务,一个微服务完成一个比较单一的功能,相互之间保持独立和解耦合,这就是微服务架构。

微服务

微服务架构的优点

  • 代码解耦易维护:每个服务独立开发,只专注做一件事,逻辑清晰,新人容易上手。独立应用更易优化,
  • 多团队协作开发:每个服务都能够由专注于该服务的团队独立开发。开发人员可以自由选择任何有用的技术,只要该服务符合API要求。
  • 独立按需扩展:每个服务都可以独立部署,独立调整资源。可以仅部署满足其容量和可用性限制的服务的实例数。此外,可以使用最符合服务资源要求的硬件。
  • 独立运行稳定:每个服务独立运行,发生故障影响面可控,不会导致整个系统down掉。

微服务架构的缺点

  • 应用间通过网络调用,通信成本高,通信效率降低。网络间调用容易出现问题,失败机率增大。应用规模变大后,服务治理难度增大。
  • 系统整体架构及调用关系复杂,开发人员对整个系统了解会有局限。
  • 应用间调用会产生分布式事务,业务实现难度更大,对开发人员要求更高。

微服务现状

​ 为了解决上面微服务架构缺点「服务治理」就出现了。出现了众多的组件,如:服务注册、配置中心、服务监控、服务容错(降级、熔断、限流、超时、重试)等。幸好,有巨人的肩膀可以借给我们站上去,通过引入「微服务框架」来帮助我们完成服务治理。

SpringCloud

Dubbo

阿里巴巴公司开源的一个Java高性能优秀的服务框架,使得应用可通过高性能的 RPC 实现服务的输出和输入功能,可以和 Spring框架无缝集成。 Apache Dubbo |ˈdʌbəʊ| 是一款高性能、轻量级的开源Java RPC框架,它提供了三大核心能力:面向接口的远程方法调用,智能容错和负载均衡,以及服务自动注册和发现 。2011 年末对外开源,仅支持 Java 语言。

下一代微服务

​ 在微服务架构下,基础架构团队一般会为应用提供一个封装了各种服务治理能力的 SDK,这种做法虽然保障了应用的正常运行,但缺点也非常明显,每次基础架构团队迭代一个新功能都需要业务方参与升级才能使用,尤其是 bugfix 版本,往往需要强推业务方升级,这里面的痛苦程度每一个基础架构团队成员都深有体会。

​ 随之而来的就是应用使用的 SDK 版本差别非常大,生产环境同时跑着各种版本的 SDK,这种现象又会让新功能的迭代必须考虑各种兼容,就好像带着枷锁前进一般,这样随着不断迭代,会让代码维护非常困难,有些祖传逻辑更是一不小心就会掉坑里。

​ 同时这种 "重" SDK 的开发模式,导致异构语言的治理能力非常薄弱,如果想为各种编程语言都提供一个功能完整且能持续迭代的 SDK 其中的成本可想而知。

​ 2018 年的时候,Service Mesh 在国内持续火爆,这种架构理念旨在把服务治理能力跟业务解耦,让两者通过进程级别的通信方式进行交互。在这种架构模式下,服务治理能力从应用中剥离,运行在独立的进程中,迭代升级跟业务进程无关,这就可以让各种服务治理能力快速迭代,并且由于升级成本低,因此每个版本都可以全部升级,解决了历史包袱问题,同时 SDK 变 "轻" 直接降低了异构语言的治理门槛,再也不用为需要给各个语言开发相同服务治理能力的 SDK 头疼了。

Service Mesh(服务网格)被认为是下一代微服务架构,Service Mesh并没有给我们带来新的功能,它是用于解决其他工具已经解决过的服务网络调用、限流、熔断和监控等问题,只不过这次是在Cloud Native 的 kubernetes 环境下的实现。

Service Mesh 有如下几个特点:

  • 应用程序间通讯的中间层
  • 轻量级网络代理
  • 应用程序无感知
  • 解耦应用程序的重试/超时、监控、追踪和服务发现

目前几款流行的 Service Mesh 开源软件 Istio) 、 Linkerd)和MOSN都可以直接在kubernetes 中集成,其中Linkerd已经成为云原生计算基金会 CNCF (Cloud Native Computing Foundation) 成员。

Service Mesh之于微服务,就像TCP/IP之于互联网,TCP/IP为网络通信提供了面向连接的、可靠的、基于字节流的基础通信功能,你不再需要关心底层的重传、校验、流量控制、拥塞控制。

云原生

云原生是一种构建和运行应用程序的方法,是一套技术体系和方法论。云原生(CloudNative)是一个组合词,Cloud+Native。Cloud表示应用程序位于云中,而不是传统的数据中心;

​ Native表示应用程序从设计之初即考虑到云的环境,原生为云而设计,在云上以最佳姿势运行,充分利用和发挥云平台的弹性+分布式优势。Pivotal公司的Matt Stine于2013年首次提出云原生(CloudNative)的概念;

​ 云原生架构总结为:微服务+容器化+DevOps+持续交付。CNCF,全称为Cloud Native Computing Foundation,中文译为"云原生计算基金会"。

容器技术(Docker)

简介

2010年一位年轻小伙子在美国旧金山成立了一家名叫【dotCloud】的公司, 开发了 Docker的核心技术,从此开启了容器技术的时代。

Docker是一个基于LXC的高级容器引擎。简单地说,docker是一个轻量级的虚拟化解决方案,或者说它是一个超轻量级的虚拟机(容器)。

Docker是一个开源的引擎,可以轻松的为任何应用创建一个轻量级的、可移植的、自给自足的容器。当前应用的运行时也从物理机时代、虚拟机时代向容器化时代演进。

Docker入门请读:https://segmentfault.com/a/1190000016749553

理念

Docker 的理念为"Build, Ship and Run Any App, Anywhere"

架构

容器化解决了软件开发过程中一个令人非常头疼的问题,用一段对话描述:

测试人员:你这个功能有问题。

开发人员:我本地是好的啊。

开发人员编写代码,在自己本地环境测试完成后,将代码部署到测试或生产环境中,经常会遇到各种各样的问题。明明本地完美运行的代码为什么部署后出现很多 bug,原因有很多:不同的操作系统、不同的依赖库等,总结一句话就是因为本地环境和远程环境不一致。

容器化技术正好解决了这一关键问题,它将软件程序和运行的基础环境分开。开发人员编码完成后将程序打包到一个容器镜像中,镜像中详细列出了所依赖的环境,在不同的容器中运行标准化的镜像,从根本上解决了环境不一致的问题。

问题与挑战

在业务发展初期只有几个微服务,这时用 Docker 就足够了,但随着业务规模逐渐扩大,容器越来越多,运维人员的工作越来越复杂,这个时候就需要编排系统解救运维同学。尽管Docker为容器化的应用程序提供了开放标准,但随着容器越来越多出现了一系列新问题:

  • 如何管理、协调和调度这些容器?
  • 如何在升级应用程序时不会中断服务?
  • 如何监视应用程序的运行状况?
  • 如何批量重新启动容器里的程序?

为解决这些问题出现了很很多容器编排技术,现在业界比较流行的有:K8S、Mesos、Docker Swarm。

一个成熟的容器编排系统需要具备以下能力:

  • 处理大量的容器
  • 服务注册发现、负载均衡
  • 鉴权和安全性
  • 管理服务通信
  • 多平台部署

容器编排(K8S)

简介

Kubernetes 是用于自动部署,扩展和管理容器化应用程序的开源系统。

​ K8S(Kubernetes)是Google研发的容器协调器,已捐赠给CNCF,现已开源。脱胎于Google内部久负盛名的大规模集群管理系统Borg,是Google在容器化基础设施领域十余年实践经验的沉淀和升华。

​ 采用了非常优雅的软件工程设计和开源开放的态度,使得用户可以根据自己的使用场景、通过灵活插拔的方式,采用自定义的网络、存储、调度、监控、日志等模块。

​ Kubernetes在容器编排领域已经成为无可争辩的事实标准。

理念

自动化的容器部署、扩展和管理

架构

在K8S中,由Master控制节点和Worker节点共同构成一个集群,如下图所示:

Master

用于管理、监控K8S集群的节点,包含如下组件

  • etcd:分布式KV数据库,使用Raft协议,用于保存集群中的相关数据,项目地址:https://github.com/etcd-io/etcd
  • API Server:集群统一入口,以restful风格进行操作,同时交给etcd存储(是唯一能访问etcd的组件);提供认证、授权、访问控制、API注册和发现等机制,可以通过kubectl命令行工具,dashboard可视化面板,或者sdk等访问
  • Scheduler:节点的调度,选择node节点应用部署
  • Controller Manager:处理集群中常规后台任务,一个资源对应一个控制器,同时监控集群的状态,确保实际状态和最终状态一致

Worker(Node)

用于运行应用的节点,包含组件如下:

  • kubelet:相当于Master派到node节点代表,管理本机容器,上报数据给API Server
  • Container Runtime:容器运行时,K8S支持多个容器运行环境:Docker、Containerd、CRI-O、Rktlet以及任何实现Kubernetes CRI (容器运行环境接口) 的软件
  • kube-proxy:实现服务(Service)抽象组件,屏蔽PodIP的变化和负载均衡

核心功能

K8S 提供功能如下:

  • 服务发现和负载均衡

    Kubernetes 可以使用 DNS 名称或自己的 IP 地址公开容器,如果进入容器的流量很大, Kubernetes 可以负载均衡并分配网络流量,从而使部署稳定。

  • 存储编排

    Kubernetes 允许你自动挂载你选择的存储系统,例如本地存储、公共云提供商等。

  • 自动部署和回滚

    你可以使用 Kubernetes 描述已部署容器的所需状态,它可以以受控的速率将实际状态 更改为期望状态。例如,你可以自动化 Kubernetes 来为你的部署创建新容器, 删除现有容器并将它们的所有资源用于新容器。

  • 自动完成装箱计算

    Kubernetes 允许你指定每个容器所需 CPU 和内存(RAM)。 当容器指定了资源请求时,Kubernetes 可以做出更好的决策来管理容器的资源。

  • 自我修复

    Kubernetes 重新启动失败的容器、替换容器、杀死不响应用户定义的 运行状况检查的容器,并且在准备好服务之前不将其通告给客户端。

  • 密钥与配置管理

    Kubernetes 允许你存储和管理敏感信息,例如密码、OAuth 令牌和 ssh 密钥。 你可以在不重建容器镜像的情况下部署和更新密钥和应用程序配置,也无需在堆栈配置中暴露密钥。

核心概念

Pod

Pod本意是豌豆荚的意思,此处指的是K8S中资源调度的最小单位,豌豆荚里面的小豆子就像是Container,豌豆荚本身就像是一个Pod

  • Pod是最小调度单元
  • Pod里面会包含一个或多个容器(Container)
  • Pod内的容器共享存储及网络,可通过localhost通信

Deployment

Deployment 是在 Pod 这个抽象上更为上层的一个抽象,它可以定义一组 Pod 的副本数目、以及这个 Pod 的版本。一般大家用 Deployment 这个抽象来做应用的真正的管理,而 Pod 是组成 Deployment 最小的单元。

  • 定义一组Pod的副本数量,版本等
  • 通过控制器维护Pod的数目
  • 自动恢复失败的Pod
  • 通过控制器以指定的策略控制版本

Service

Pod是不稳定的,IP是会变化的,所以需要一层抽象来屏蔽这种变化,这层抽象叫做Service

  • 提供访问一个或者多个Pod实例稳定的访问地址
  • 支持多种访问方式ClusterIP(对集群内部访问)NodePort(对集群外部访问)LoadBalancer(集群外部负载均衡)

Service的工作方式:

​ 不论哪种,kube-proxy都通过watch的方式监控着kube-APIServer写入etcd中关于Pod的最新状态信息,它一旦检查到一个Pod资源被删除了 或 新建,它将立即将这些变化,反应再iptables 或 ipvs规则中,以便iptables和ipvs在调度Clinet Pod请求到Server Pod时,不会出现Server Pod不存在的情况。

userspace

​ Client Pod要访问Server Pod时,它先将请求发给本机内核空间中的service规则,由它再将请求,转给监听在指定套接字上的kube-proxy,kube-proxy处理完请求,并分发请求到指定Server Pod后,再将请求递交给内核空间中的service,由service将请求转给指定的Server Pod。由于其需要来回在用户空间和内核空间交互通信,因此效率很差。

iptables

  此工作方式是直接由内核中的iptables规则,接受Client Pod的请求,并处理完成后,直接转发给指定ServerPod。

ipvs

  它是直接有内核中的ipvs规则来接受Client Pod请求,并处理该请求,再有内核封包后,直接发给指定的Server Pod。

Volume

Volume就是存储卷,在Pod中可以声明卷来问访问文件系统,同时Volume也是一个抽象层,其具体的后端存储可以是本地存储、NFS网络存储、云存储(阿里云盘、AWS云盘、Google云盘等)、分布式存储(比如说像 ceph、GlusterFS )

  • 声明在Pod中容器可以访问的文件系统。
  • 可以被挂载在Pod中一个或多个容器的指定路径下。
  • 支持多种后端储存,如NAS等。

Ingress

Ingress 公开了从集群外部到集群内服务的 HTTP 和 HTTPS 路由。对集群中服务的外部访问进行管理的 API 对象,典型的访问方式是 HTTP。

Ingress 可以提供负载均衡、SSL 终结和基于名称的虚拟托管。

下面是一个将所有流量都发送到同一 Service 的简单 Ingress 示例:

Namespace

Namespace(命令空间)是用来做资源的逻辑隔离的,比如上面的Pod、Deployment、Service都属于资源,不同Namespace下资源可以重名。同一Namespace下资源名需唯一

  • 一个集群内部的逻辑隔离机制(鉴权、资源等)
  • 每个资源都属于一个Namespace
  • 同一个Namespace中资源命名唯一
  • 不同Namespace中资源可重名

问题与挑战

  • 随着业务的不断发展,微服务的数量越来越多,微服务间的通信网络也变得十分复杂,微服务间的通信拓扑构成了一个巨大复杂的网络,治理难度加大。
  • 对于流量管控功能较弱,服务间访问的管理如服务的熔断、限流、动态路由、调用链追踪等都不在K8S的范围。
  • 使用门槛高,由于框架组件多,对于业务开发人员,就需要掌握较多非业务的知识,增加了业务开发人员的挑战。
  • 跨语言是微服务的优势之一,它可以让不同语言编写的服务通过暴露接口服务调用的方式开放能力。而使用类库的微服务,将被类库支持的语言限制。

服务网格(Istio)

简介

​ 现代应用程序通常被设计成微服务的分布式集合,每个服务执行一些离散的业务功能。服务网格是专门的基础设施层,包含了组成这类体系结构的微服务网络。 服务网格不仅描述了这个网络,而且还描述了分布式应用程序组件之间的交互。所有在服务之间传递的数据都由服务网格控制和路由。

​ 随着分布式服务的部署——比如基于 Kubernetes 的系统——规模和复杂性的增长,它可能会变得更加难以理解和管理。需求可以包括发现、负载平衡、故障恢复、度量和监视。微服务体系结构通常还有更复杂的操作需求,比如 A/B 测试、canary 部署、速率限制、访问控制、加密和端到端身份验证。

服务到服务的通信使分布式应用成为可能。在应用程序集群内部和跨应用程序集群路由这种通信变得越来越复杂。 Istio 有助于减少这种复杂性,同时减轻开发团队的压力。

Istio简介

Istio 是一个开源服务网格,它透明地分层到现有的分布式应用程序上。 Istio 强大的特性提供了一种统一和更有效的方式来保护、连接和监视服务。 Istio 是实现负载平衡、服务到服务身份验证和监视的路径——只需要很少或不需要更改服务代码。它强大的控制平面带来了重要的特点,包括:

  • 使用 TLS 加密、强身份认证和授权的集群内服务到服务的安全通信
  • 自动负载均衡的 HTTP, gRPC, WebSocket,和 TCP 流量
  • 通过丰富的路由规则、重试、故障转移和故障注入对流量行为进行细粒度控制
  • 一个可插入的策略层和配置 API,支持访问控制、速率限制和配额
  • 对集群内的所有流量(包括集群入口和出口)进行自动度量、日志和跟踪

​ Istio为微服务应用提供了一个完整的解决方案,可以以统一的方式去检测和管理微服务。同时,它还提供了管理流量、实施访问策略、收集数据等功能,而所有这些功能都对业务代码透明,即不需要修改业务代码就能实现。

​ 有了Istio,就几乎可以不需要其他的微服务框架,也不需要自己去实现服务治理等功能,只要把网络层委托给Istio,它就能帮助完成这一系列的功能。简单来说,Istio就是一个提供了服务治理能力的服务网格,是Kubernetes的好帮手。

​ Istio的多样化特性可以让你高效地运行分布式微服务架构,并提供一种统一的方式来保护、连接和监控微服务。

理念

连接、安全、控制和可观测服务

Service Mesh 是微服务时代的 TCP/IP 协议。

架构

Istio的架构从逻辑上分成数据平面(Data Plane)和控制平面(Control Plane),Kubernetes的架构也具有相似的结构,分为控制节点和计算节点。毫无疑问,这样的设计可以很好地解耦各个功能组件。

  • 数据平面:由一组和业务服务成对出现的Sidecar代理(Envoy)构成,它的主要功能是接管服务的进出流量,传递并控制服务和Mixer组件的所有网络通信(Mixer是一个策略和遥测数据的收集器,稍后会介绍)。
  • 控制平面:主要包括了Pilot、Mixer、Citadel和Galley共4个组件,主要功能是通过配置和管理Sidecar代理来进行流量控制,并配置Mixer去执行策略和收集遥测数据(Telemetry)。

Istio的网络

核心功能

类别功能说明
流量管理请求路由A/B测试、金丝雀发布等,包括对集群出入口、及集群内部的流量的控制。比如某应用新版本发布,可以配置为5%的流量流向新版本,95%的给旧版本
流量转移与上一条请求路由类似,可以平滑的将流量从旧版本转移到新版本上
负载均衡目前支持3种方式,轮询、随机和带权重的最少请求
服务发现带心跳的健康检查,失败率超标的Pod移出负载均衡池
故障处理超时、重发、熔断、上游并发请求或下游连接数限制等
微调支持用特殊的请求头参数,覆盖默认的超时、重发值
故障注入由Enovy在正常的集群中人为注入故障,比如TCP包损坏或延迟、HTTP错误码等,支持按百分比注入,比如给10%的流向服务A的请求包增加5秒延迟
多重匹配上述规则的配置,支持按多种条件匹配,且支持and或or的方式匹配多条规则
Gateway接管集群入口的流量,替代了Ingress,从而对入口流量执行其他规则
Service Entry接管集群内部访问外部服务的流量,从而对出口流量执行一些规则
镜像支持将特定的流量镜像到服务路径之外,而不影响主服务路径的正常执行
安全命名空间访问控制支持配置某命名空间的所有或个别服务可以被其他命名空间访问
服务级别访问控制允许或禁止访问某个服务
双向TLSHTTPS加密传输
其他安全策略
策略速率限制比如限制每秒的请求次数
黑白名单支持基于IP或属性的黑名单、白名单
遥测日志收集支持将Prometheus、Jaeger等系统插入Mixer,从而完成数据的采集
指标采集
分布式追踪

流量治理

​ Istio的流量管理是通过Pilot和Envoy这两个组件实现的,将流量和基础设施进行了解耦。Pilot负责配置规则,并把规则分发到Envoy代理去实施;而Envoy按照规则执行各种流量管理的功能,比如动态请求路由,超时、重试和熔断,还可以通过故障注入来测试服务之间的容错能力。下面对这些具体的功能进行逐一介绍。

路由

​ 不同需求开发在测试、发布阶段往往需要将特殊 pod 与基准服务 pod 进行联合调试,通过 virtual rule 和 destination rule 的配置规则,可以精确的将特性流量转到不同的微服务的指定特性 pod 上。

通过 istio 可以快速构建下图所示的基准环境、分支特性环境的通信过程,适应业务的需要。将请求动态路由到微服务的多个版本。

超时

可使用 Istio 在 Envoy 中设置请求超时时间,方便的控制应用间调用的超时时间。

重试

可设置请求重试次数与重试间隔时间,方便恢复服务。

熔断

熔断,是创建弹性微服务应用程序的重要模式。熔断能够使您的应用程序具备应对来自故障、潜在峰值和其他未知网络因素影响的能力。

流量镜像

​ 流量镜像,也称为影子流量,镜像会将实时流量的副本发送到镜像服务。镜像流量发生在主服务的关键请求路径之外。

网关

​ 服务间通信是通过Envoy代理进行的。同样,我们也可以在整个系统的入口和出口处部署代理,使得所有流入和流出的流量都由代理进行转发,而这两个负责入口和出口的代理就叫作入口网关和出口网关。它们相当于整个微服务应用的边界代理,把守着进入和流出服务网格的流量。图2-6展示了Ingress和Egress在请求流中的位置,通过设置Envoy代理,出入服务网格的流量也得到了控制。

入口网关(Ingress)

​ 除了支持 Kubernetes Ingress,Istio还提供了另一种配置模式,Istio Gateway。与 Ingress 相比,Gateway 提供了更简单方便的自定义性和灵活性,并允许将 Istio 功能(例如监控和路由规则)应用于进入集群的流量。

出口网关(Egress)

​ 由于默认情况下,来自Pod 的所有出站流量都会重定向到其 Sidecar 代理,集群外部 URL 的可访问性取决于代理的配置。默认情况下,Istio 将 Envoy 代理配置为允许访问未知服务的请求。尽管这为入门 Istio 带来了方便,但是,通常情况下,应该配置更严格的控制策略来管理和管制对外访问。

故障处理

​ Istio的故障处理都由Envoy代理完成。Envoy提供了一整套现成的故障处理机制,比如超时、重试、限流和熔断等。这些功能都能够以规则的形式进行动态配置,并且执行运行时修改。这使得服务具有更好的容错能力和弹性,并保证服务的稳定性。

故障注入

​ 简单来说,故障注入就是在系统中人为地设置一些故障,来测试系统的稳定性和系统恢复的能力。比如为某服务设置一个延迟,使其长时间无响应,然后检测调用方是否能处理这种超时问题而自身不受影响(如及时终止对故障发生方的调用,避免自己受到影响且使故障扩展)。

Isito支持注入两种类型的故障:延迟和中断。延迟是模拟网络延迟或服务过载的情况;中断是模拟上游服务崩溃的情况,表现为HTTP的错误码和TCP连接失败。

服务管理

服务发现与负载均衡

服务发现的前提条件是具有服务注册的能力。目前Kubernetes这类容器编排平台也提供了服务注册的能力。Istio基于平台实现服务发现和负载均衡时,需要通过Pilot和Envoy协作完成,如图2-7所示。Pilot组件会从平台获取服务的注册信息,并提供服务发现的接口,Envoy获得这些信息并更新到自己的负载均衡池。Envoy会定期地对池中的实例进行健康检查,剔除离线的实例,保证服务信息的实时性。

可观测性

1.策略

在微服务应用中,除了流量管理以外,常常还需要进行一些额外的控制,比如限流(对调用频率、速率进行限制)、设置白名单和黑名单等。

Istio中的策略控制是依靠Mixer完成的。Envoy代理在每次网络请求时,都会调用Mixer进行预先检查,确定是否满足对应的策略。同时,Mixer又可以根据这些来自流量的数据,进行指标数据的采集和汇总,这就是遥测功能。

2.遥测(Telemetry)

遥测是工业上常用的一种技术,它是指从远程设备中收集数据,并传输到接收设备进行监测。在软件开发中,遥测的含义引申为对各种指标(metric)数据进行收集,并监控、分析这些指标,比如我们经常听到的BI数据分析。

Mixer的一大主要功能就是遥测。前面已经说过,Envoy代理会发送数据给Mixer,这就使得Mixer具有了数据收集的能力。在本章2.3节对Mixer的介绍中读者已经了解到Mixer的插件模型,也就是适配器。Mixer可以接入不同的后端设施作为适配器,来处理收集到的指标数据,比如日志分析系统、监控系统等。

核心组件

Envoy

Istio的核心原理,是网络代理,拦截下所有想拦截的TCP流量。通过对拦截下来的流量的解析和修改。

Envoy本质上是一个为面向服务的架构而设计的7层代理和通信总线。Envoy基于C++11开发而成,性能出色。Envoy包括但不限于以下功能:

• 动态服务发现
• 负载均衡
• TLS证书卸载
• HTTP/2 & gRPC 代理
• 熔断器
• 健康检查、基于百分比流量拆分的灰度发布
• 故障注入
• 丰富的度量指标

Pilot

​ Pilot是为我们提供Istio管理配置台,如智能路由(如A/B测试、金丝雀发布等)、弹性(超时、重发、熔断等)等功能的管理系统,它提供了一系列rules api,允许运维人员指定一系列高级的流量管理规则。Pilot负责将我们的配置转换并写入到每个sidecar(Enovy)。

简单来说,Pilot的主要任务有两个。

  • 从平台(如Kubernetes)获取服务信息,完成服务发现。
  • 获取Istio的各项配置,转换成Envoy代理可读的格式并分发。

Mixer

​ Mixer混合了各种策略以及后端数据采集或遥测系统的适配器,从而实现了前端Proxy与后端系统的隔离与汇合。Mixer是一个灵活的插件模型(无论是k8s还是Istio,实现上都很青睐于插件模型,这是一个很灵活的实现方式),它一端连着Envoy,同时我们可以将日志、监控、遥测等各种系统"插入"到Mixer的另一端中,从而得到我们想要的数据或结果。

Citadel

​ 它管理着集群的密钥和证书,是集群的安全部门。典型的如果我们的服务是跨网络通讯(Istio允许我们建立一个安全的集群的集群网络),开发人员想省事懒得对通讯数据进行加解密和身份认证,这事就可以交给Citadel来处理了。更详细的说,Istio各个模块在安全性上分别扮演以下角色:
• Citadel,用于密钥和证书管理
• Sidecar和周边代理,实现客户端和服务器之间的安全通信
• Pilot,将授权策略和安全命名信息分发给代理
• Mixer,管理授权和审计

Galley

​ 目前这个组件的作用是验证用户编写的Istio api配置。从官网的说法来看,后面这个组件会逐步接管获取配置、处理和分配的工作,比如从k8s的数据中心(etcd)获取集群信息的活,理论上应该交给Galley。Galley的定位类似于k8s的api server组件,提供集群内统一的配置信息接口,从而将用户配置的细节隔离开来。

其它组件

Kiali : 专用于 istio 系统的可视化的APM软件。

Jaeger: 是一个用于分布式链路追踪的开源软件,提供原生 OpenTracing 支持。

性能评估

官方给出了对最新版本V1.1.4的性能测试结果。在由1000个服务和2000个sidecar组成,每秒产生70000个网格范围内的请求的网格中,得到以下结果:
• Envoy在每秒处理 1000 请求的情况下,使用 0.6 个 vCPU 以及 50 MB 的内存。
• istio-telemetry在每秒1000个网格范围内的请求的情况下,消耗了0.6个vCPU。
• Pilot使用了 1 个 vCPU 以及 1.5 GB 的内存。
• Envoy在第 90 个百分位上增加了 8 毫秒的延迟。

问题与挑战

  • 底层技术门槛大大增加,需要有更专业的基础平台的开发和运维来维护该复杂的平台,对人员知识量及专业度要求极高。
  • 平台层若出现问题,调查难度较大,一般业务开发对网络、存储、安全等方面知识薄弱,好在平台一般都会提供各种工具协助。
  • 业务开发人员也需要了解容器化或服务网格相关原理,对人员技能要求较高。

下一代云原生?

WASM

WebAssembly是一种新的编码方式,可以在现代的网络浏览器中运行 ,是一个可移植、体积小、加载快并且兼容 Web 的全新格式程序规范。WebAssembly 是由主流浏览器厂商组成的 W3C 社区团体 制定的一个新的规范。

高效:WebAssembly 有一套完整的语义,实际上 wasm 是体积小且加载快的二进制格式, 其目标就是充分发挥硬件能力以达到原生执行效率

安全:WebAssembly 运行在一个沙箱化的执行环境中,甚至可以在现有的 JavaScript 虚拟机中实现。在web环境中,WebAssembly将会严格遵守同源策略以及浏览器安全策略。

开放:WebAssembly 设计了一个非常规整的文本格式用来、调试、测试、实验、优化、学习、教学或者编写程序。可以以这种文本格式在web页面上查看wasm模块的源码

标准:WebAssembly 在 web 中被设计成无版本、特性可测试、向后兼容的。WebAssembly 可以被 JavaScript 调用,进入 JavaScript 上下文,也可以像 Web API 一样调用浏览器的功能。当然,WebAssembly 不仅可以运行在浏览器上,也可以运行在非web环境下。

Serverless

2019 年,Serverless 被 Gartner 称为最有潜力的云计算技术发展方向,并被赋予是必然性的发展趋势。从行业趋势看,Serverless 是云计算必经的一场革命。

Serverless ,被称为无服务器,可以解读为一种软件系统架构方法,通常称为 Serverless 架构。代表的是无需理解、管理服务器,按需使用,按使用付费的产品。Serverless 产品中,其中可以包含存储、计算等多种类型的产品,而典型的计算产品,就是云函数这种形态。

  • 免运维:不需要管理服务器主机或者服务器进程。
  • 弹性伸缩:根据负载进行自动规模伸缩与自动配置。伸缩范围零到无穷大。
  • 按需付费:根据使用情况决定实际成本。
  • 高可用:具备隐含的高可用性。

分布式应用运行时(Dapr)

Dapr 是 Distributed Application Runtime (分布式应用运行时)的缩写。是一种可移植的,事件驱动的运行时,用于构建跨云和边缘的分布式应用。号称:"Any language, any framework, anywhere"

1.2w字 | 从 0 到 1 上手 Web Components 业务组件库开发

Posted: 23 Dec 2021 06:31 AM PST

组件化是前端发展的一个重要方向,它一方面提高开发效率,另一方面降低维护成本。主流的 Vue.js、React 及其延伸的 Ant Design、uniapp、Taro 等都是组件框架。

Web Components 是一组 Web 原生 API 的总称,允许我们创建可重用的自定义组件,并在我们 Web 应用中像使用原生 HTML 标签一样使用。目前已经很多前端框架/库支持 Web Components

本文将带大家回顾 Web Components 核心 API,并从 0 到 1 实现一个基于 Web Components API 开发的业务组件库。

最终效果:https://blog.pingan8787.com/exe-components/demo.html
仓库地址:https://github.com/pingan8787/Learn-Web-Components

一、回顾 Web Components

在前端发展历史中,从刚开始重复业务到处复制相同代码,到 Web Components 的出现,我们使用原生 HTML 标签的自定义组件,复用组件代码,提高开发效率。通过 Web Components 创建的组件,几乎可以使用在任何前端框架中。

1. 核心 API 回顾

Web Components 由 3 个核心 API 组成:

  • Custom elements(自定义元素):用来让我们定义自定义元素及其行为,对外提供组件的标签;
  • Shadow DOM(影子 DOM):用来封装组件内部的结构,避免与外部冲突;
  • HTML templates(HTML 模版):包括 <template><slot> 元素,让我们可以定义各种组件的 HTML 模版,然后被复用到其他地方,使用过 Vue/React 等框架的同学应该会很熟悉。
另外,还有 HTML imports,但目前已废弃,所以不具体介绍,其作用是用来控制组件的依赖加载。

2. 入门示例

接下来通过下面简单示例快速了解一下如何创建一个简单 Web Components 组件

  • 使用组件
<!DOCTYPE html> <html lang="en"> <head>     <script src="./index.js" defer></script> </head> <body>     <h1>custom-element-start</h1>     <custom-element-start></custom-element-start> </body> </html>
  • 定义组件
/**  * 使用 CustomElementRegistry.define() 方法用来注册一个 custom element  * 参数如下:  * - 元素名称,符合 DOMString 规范,名称不能是单个单词,且必须用短横线隔开  * - 元素行为,必须是一个类  * - 继承元素,可选配置,一个包含 extends 属性的配置对象,指定创建的元素继承自哪个内置元素,可以继承任何内置元素。  */  class CustomElementStart extends HTMLElement {     constructor(){         super();         this.render();     }     render(){         const shadow = this.attachShadow({mode: 'open'});         const text = document.createElement("span");         text.textContent = 'Hi Custom Element!';         text.style = 'color: red';         shadow.append(text);     } }  customElements.define('custom-element-start', CustomElementStart)

上面代码主要做 3 件事:

  1. 实现组件类

通过实现 CustomElementStart 类来定义组件。

  1. 定义组件

将组件的标签和组件类作为参数,通过 customElements.define 方法定义组件。

  1. 使用组件

导入组件后,跟使用普通 HTML 标签一样直接使用自定义组件 <custom-element-start></custom-element-start>

随后浏览器访问 index.html 可以看到下面内容:

3. 兼容性介绍

MDN | Web Components 章节中介绍了其兼容性情况:

  • Firefox(版本63)、Chrome和Opera都默认支持Web组件。
  • Safari支持许多web组件特性,但比上述浏览器少。
  • Edge正在开发一个实现。

关于兼容性,可以看下图:

图片来源:https://www.webcomponents.org/

这个网站里面,有很多关于 Web Components 的优秀项目可以学习。

4. 小结

这节主要通过一个简单示例,简单回顾基础知识,详细可以阅读文档:

二、EXE-Components 组件库分析设计

1. 背景介绍

假设我们需要实现一个 EXE-Components 组件库,该组件库的组件分 2 大类:

  1. components 类型

通用简单组件为主,如exe-avatar头像组件、 exe-button按钮组件等;

  1. modules 类型

复杂、组合组件为主,如exe-user-avatar用户头像组件(含用户信息)、exe-attachement-list附件列表组件等等。

详细可以看下图:

接下来我们会基于上图进行 EXE-Components 组件库设计和开发。

2. 组件库设计

在设计组件库的时候,主要需要考虑以下几点:

  1. 组件命名、参数命名等规范,方便组件后续维护;
  2. 组件参数定义;
  3. 组件样式隔离;

当然,这几个是最基础需要考虑的点,随着实际业务的复杂,还需要考虑更多,比如:工程化相关、组件解耦、组件主题等等。

针对前面提到这 3 点,这边约定几个命名规范:

  1. 组件名称以 exe-功能名称 进行命名,如 exe-avatar表示头像组件;
  2. 属性参数名称以 e-参数名称 进行命名,如 e-src 表示 src 地址属性;
  3. 事件参数名称以 on-事件类型 进行命名,如 on-click表示点击事件;

3. 组件库组件设计

这边我们主要设计 exe-avatarexe-buttonexe-user-avatar三个组件,前两个为简单组件,后一个为复杂组件,其内部使用了前两个组件进行组合。这边先定义这三个组件支持的属性:

这边属性命名看着会比较复杂,大家可以按照自己和团队的习惯进行命名。

这样我们思路就清晰很多,实现对应组件即可。

三、EXE-Components 组件库准备工作

本文示例最终将对实现的组件进行组合使用,实现下面「用户列表」效果:

体验地址:https://blog.pingan8787.com/exe-components/demo.html

1. 统一开发规范

首先我们先统一开发规范,包括:

  1. 目录规范

  1. 定义组件规范

  1. 组件开发模版

组件开发模版分 index.js组件入口文件template.js 组件 HTML 模版文件

// index.js 模版 const defaultConfig = {     // 组件默认配置 }  const Selector = "exe-avatar"; // 组件标签名  export default class EXEAvatar extends HTMLElement {     shadowRoot = null;     config = defaultConfig;      constructor(){         super();         this.render(); // 统一处理组件初始化逻辑     }      render() {         this.shadowRoot = this.attachShadow({mode: 'closed'});         this.shadowRoot.innerHTML = renderTemplate(this.config);     } }  // 定义组件 if (!customElements.get(Selector)) {     customElements.define(Selector, EXEAvatar) }
// template.js 模版  export default config => {     // 统一读取配置     const { avatarWidth, avatarRadius, avatarSrc } = config;     return `         <style>             /* CSS 内容 */         </style>         <div class="exe-avatar">             /* HTML 内容 */         </div>     ` }

2. 开发环境搭建和工程化处理

为了方便使用 EXE-Components 组件库,更接近实际组件库的使用,我们需要将组件库打包成一个 UMD 类型的 js 文件。这边我们使用 rollup 进行构建,最终打包成 exe-components.js 的文件,使用方式如下:

<script src="./exe-components.js"></script>

接下来通过 npm init -y生成 package.json文件,然后全局安装 rollup 和 http-server(用来启动本地服务器,方便调试):

npm init -y npm install --global rollup http-server

然后在 package.jsonscript 下添加 "dev""build"脚本:

{     // ...   "scripts": {     "dev": "http-server -c-1 -p 1400",     "build": "rollup index.js --file exe-components.js --format iife"   }, }

其中:

  • "dev" 命令:通过 http-server 启动静态服务器,作为开发环境使用。添加 -c-1 参数用来禁用缓存,避免刷新页面还会有缓存,详细可以看 http-server 文档
  • "build"命令:将 index.js 作为 rollup 打包的入口文件,输出 exe-components.js 文件,并且是 iife 类型的文件。

这样就完成简单的本地开发和组件库构建的工程化配置,接下来就可以进行开发了。

四、EXE-Components 组件库开发

1. 组件库入口文件配置

前面 package.json 文件中配置的 "build" 命令,会使用根目录下 index.js 作为入口文件,并且为了方便 components 通用基础组件和 modules 通用复杂组件的引入,我们创建 3 个 index.js,创建后目录结构如下:

三个入口文件内容分别如下:

// EXE-Components/index.js import './components/index.js'; import './modules/index.js';  // EXE-Components/components/index.js import './exe-avatar/index.js'; import './exe-button/index.js';  // EXE-Components/modules/index.js import './exe-attachment-list/index.js.js'; import './exe-comment-footer/index.js.js'; import './exe-post-list/index.js.js'; import './exe-user-avatar/index.js';

2. 开发 exe-avatar 组件 index.js 文件

通过前面的分析,我们可以知道 exe-avatar组件需要支持参数:

  • e-avatar-src:头像图片地址,例如:./testAssets/images/avatar-1.png
  • e-avatar-width:头像宽度,默认和高度一致,例如:52px
  • e-button-radius:头像圆角,例如:22px,默认:50%
  • on-avatar-click:头像点击事件,默认无

接着按照之前的模版,开发入口文件 index.js

// EXE-Components/components/exe-avatar/index.js import renderTemplate from './template.js'; import { Shared, Utils } from '../../utils/index.js';  const { getAttributes } = Shared; const { isStr, runFun } = Utils;  const defaultConfig = {     avatarWidth: "40px",     avatarRadius: "50%",     avatarSrc: "./assets/images/default_avatar.png",     onAvatarClick: null, }  const Selector = "exe-avatar";  export default class EXEAvatar extends HTMLElement {     shadowRoot = null;     config = defaultConfig;      constructor(){         super();         this.render();     }      render() {         this.shadowRoot = this.attachShadow({mode: 'closed'});         this.shadowRoot.innerHTML = renderTemplate(this.config);// 生成 HTML 模版内容     }          // 生命周期:当 custom element首次被插入文档DOM时,被调用。     connectedCallback() {         this.updateStyle();         this.initEventListen();     }      updateStyle() {         this.config = {...defaultConfig, ...getAttributes(this)};         this.shadowRoot.innerHTML = renderTemplate(this.config); // 生成 HTML 模版内容     }      initEventListen() {         const { onAvatarClick } = this.config;         if(isStr(onAvatarClick)){ // 判断是否为字符串             this.addEventListener('click', e => runFun(e, onAvatarClick));         }     } }  if (!customElements.get(Selector)) {     customElements.define(Selector, EXEAvatar) } 

其中有几个方法是抽取出来的公用方法,大概介绍下其作用,具体可以看源码:

  • renderTemplate 方法

来自 template.js 暴露的方法,传入配置 config,来生成 HTML 模版。

  • getAttributes 方法

传入一个 HTMLElement 元素,返回该元素上所有属性键值对,其中会对 e-on- 开头的属性,分别处理成普通属性和事件属性,示例如下:

// input <exe-avatar     e-avatar-src="./testAssets/images/avatar-1.png"     e-avatar-width="52px"     e-avatar-radius="22px"     on-avatar-click="avatarClick()" ></exe-avatar>    // output {   avatarSrc: "./testAssets/images/avatar-1.png",   avatarWidth: "52px",   avatarRadius: "22px",   avatarClick: "avatarClick()" }
  • runFun方法

由于通过属性传递进来的方法,是个字符串,所以进行封装,传入 event 和事件名称作为参数,调用该方法,示例和上一步一样,会执行 avatarClick() 方法。

另外,Web Components 生命周期可以详细看文档:使用生命周期回调函数

3. 开发 exe-avatar 组件 template.js 文件

该文件暴露一个方法,返回组件 HTML 模版:

// EXE-Components/components/exe-avatar/template.js export default config => {   const { avatarWidth, avatarRadius, avatarSrc } = config;   return `     <style>       .exe-avatar {         width: ${avatarWidth};         height: ${avatarWidth};         display: inline-block;         cursor: pointer;       }       .exe-avatar .img {         width: 100%;         height: 100%;         border-radius: ${avatarRadius};         border: 1px solid #efe7e7;       }     </style>     <div class="exe-avatar">       <img class="img" src="${avatarSrc}" />     </div>   ` }

最终实现效果如下:

开发完第一个组件,我们可以简单总结一下创建和使用组件的步骤:

4. 开发 exe-button 组件

按照前面 exe-avatar组件开发思路,可以很快实现 exe-button 组件。
需要支持下面参数:

  • e-button-radius:按钮圆角,例如:8px
  • e-button-type:按钮类型,例如:default, primary, text, dashed
  • e-button-text:按钮文本,默认:打开
  • on-button-click:按钮点击事件,默认无
// EXE-Components/components/exe-button/index.js import renderTemplate from './template.js'; import { Shared, Utils } from '../../utils/index.js';  const { getAttributes } = Shared; const { isStr, runFun } = Utils; const defaultConfig = {     buttonRadius: "6px",     buttonPrimary: "default",     buttonText: "打开",     disableButton: false,     onButtonClick: null, }  const Selector = "exe-button";  export default class EXEButton extends HTMLElement {     // 指定观察到的属性变化,attributeChangedCallback 会起作用     static get observedAttributes() {          return ['e-button-type','e-button-text', 'buttonType', 'buttonText']     }      shadowRoot = null;     config = defaultConfig;      constructor(){         super();         this.render();     }      render() {         this.shadowRoot = this.attachShadow({mode: 'closed'});     }      connectedCallback() {         this.updateStyle();         this.initEventListen();     }      attributeChangedCallback (name, oldValue, newValue) {         // console.log('属性变化', name)     }      updateStyle() {         this.config = {...defaultConfig, ...getAttributes(this)};         this.shadowRoot.innerHTML = renderTemplate(this.config);     }      initEventListen() {         const { onButtonClick } = this.config;         if(isStr(onButtonClick)){             const canClick = !this.disabled && !this.loading             this.addEventListener('click', e => canClick && runFun(e, onButtonClick));         }     }      get disabled () {         return this.getAttribute('disabled') !== null;     }      get type () {         return this.getAttribute('type') !== null;     }      get loading () {         return this.getAttribute('loading') !== null;     } }  if (!customElements.get(Selector)) {     customElements.define(Selector, EXEButton) } 

模版定义如下:

// EXE-Components/components/exe-button/tempalte.js // 按钮边框类型 const borderStyle = { solid: 'solid', dashed: 'dashed' };  // 按钮类型 const buttonTypeMap = {     default: { textColor: '#222', bgColor: '#FFF', borderColor: '#222'},     primary: { textColor: '#FFF', bgColor: '#5FCE79', borderColor: '#5FCE79'},     text: { textColor: '#222', bgColor: '#FFF', borderColor: '#FFF'}, }  export default config => {     const { buttonRadius, buttonText, buttonType } = config;      const borderStyleCSS = buttonType          && borderStyle[buttonType]          ? borderStyle[buttonType]          : borderStyle['solid'];      const backgroundCSS = buttonType          && buttonTypeMap[buttonType]          ? buttonTypeMap[buttonType]          : buttonTypeMap['default'];      return `         <style>             .exe-button {                 border: 1px ${borderStyleCSS} ${backgroundCSS.borderColor};                 color: ${backgroundCSS.textColor};                 background-color: ${backgroundCSS.bgColor};                 font-size: 12px;                 text-align: center;                 padding: 4px 10px;                 border-radius: ${buttonRadius};                 cursor: pointer;                 display: inline-block;                 height: 28px;             }             :host([disabled]) .exe-button{                  cursor: not-allowed;                  pointer-events: all;                  border: 1px solid #D6D6D6;                 color: #ABABAB;                 background-color: #EEE;             }             :host([loading]) .exe-button{                  cursor: not-allowed;                  pointer-events: all;                  border: 1px solid #D6D6D6;                 color: #ABABAB;                 background-color: #F9F9F9;             }         </style>         <button class="exe-button">${buttonText}</button>     ` }

最终效果如下:

5. 开发 exe-user-avatar 组件

该组件是将前面 exe-avatar 组件和 exe-button 组件进行组合,不仅需要支持点击事件,还需要支持插槽 slot 功能

由于是做组合,所以开发起来比较简单~先看看入口文件:

// EXE-Components/modules/exe-user-avatar/index.js  import renderTemplate from './template.js'; import { Shared, Utils } from '../../utils/index.js';  const { getAttributes } = Shared; const { isStr, runFun } = Utils;  const defaultConfig = {     userName: "",     subName: "",     disableButton: false,     onAvatarClick: null,     onButtonClick: null, }  export default class EXEUserAvatar extends HTMLElement {     shadowRoot = null;     config = defaultConfig;      constructor() {         super();         this.render();     }      render() {         this.shadowRoot = this.attachShadow({mode: 'open'});     }      connectedCallback() {         this.updateStyle();         this.initEventListen();     }      initEventListen() {         const { onAvatarClick } = this.config;         if(isStr(onAvatarClick)){             this.addEventListener('click', e => runFun(e, onAvatarClick));         }     }      updateStyle() {         this.config = {...defaultConfig, ...getAttributes(this)};         this.shadowRoot.innerHTML = renderTemplate(this.config);     } }  if (!customElements.get('exe-user-avatar')) {     customElements.define('exe-user-avatar', EXEUserAvatar) }

主要内容在 template.js 中:

// EXE-Components/modules/exe-user-avatar/template.js  import { Shared } from '../../utils/index.js';  const { renderAttrStr } = Shared;  export default config => {     const {          userName, avatarWidth, avatarRadius, buttonRadius,          avatarSrc, buttonType = 'primary', subName, buttonText, disableButton,         onAvatarClick, onButtonClick     } = config;     return `         <style>             :host{                 color: "green";                 font-size: "30px";             }             .exe-user-avatar {                 display: flex;                 margin: 4px 0;             }             .exe-user-avatar-text {                 font-size: 14px;                 flex: 1;             }             .exe-user-avatar-text .text {                 color: #666;             }             .exe-user-avatar-text .text span {                 display: -webkit-box;                 -webkit-box-orient: vertical;                 -webkit-line-clamp: 1;                 overflow: hidden;             }             exe-avatar {                 margin-right: 12px;                 width: ${avatarWidth};             }             exe-button {                 width: 60px;                 display: flex;                 justify-content: end;             }         </style>         <div class="exe-user-avatar">             <exe-avatar                 ${renderAttrStr({                     'e-avatar-width': avatarWidth,                     'e-avatar-radius': avatarRadius,                     'e-avatar-src': avatarSrc,                 })}             ></exe-avatar>             <div class="exe-user-avatar-text">                 <div class="name">                     <span class="name-text">${userName}</span>                     <span class="user-attach">                         <slot name="name-slot"></slot>                     </span>                 </div>                 <div class="text">                     <span class="name">${subName}<slot name="sub-name-slot"></slot></span>                 </div>             </div>             ${                 !disableButton &&                  `<exe-button                     ${renderAttrStr({                         'e-button-radius' : buttonRadius,                         'e-button-type' : buttonType,                         'e-button-text' : buttonText,                         'on-avatar-click' : onAvatarClick,                         'on-button-click' : onButtonClick,                     })}                 ></exe-button>`             }          </div>     ` }

其中 renderAttrStr 方法接收一个属性对象,返回其键值对字符串:

// input {   'e-avatar-width': 100,   'e-avatar-radius': 50,   'e-avatar-src': './testAssets/images/avatar-1.png', }    // output "e-avatar-width='100' e-avatar-radius='50' e-avatar-src='./testAssets/images/avatar-1.png' "

最终效果如下:

6. 实现一个用户列表业务

接下来我们通过一个实际业务,来看看我们组件的效果:


其实实现也很简单,根据给定数据,然后循环使用组件即可,假设有以下用户数据:

const users = [   {"name":"前端早早聊","desc":"帮 5000 个前端先跑 @ 前端早早聊","level":6,"avatar":"qdzzl.jpg","home":"https://juejin.cn/user/712139234347565"}   {"name":"来自拉夫德鲁的码农","desc":"谁都不救我,谁都救不了我,就像我救不了任何人一样","level":2,"avatar":"lzlfdldmn.jpg","home":"https://juejin.cn/user/994371074524862"}   {"name":"黑色的枫","desc":"永远怀着一颗学徒的心。。。","level":3,"avatar":"hsdf.jpg","home":"https://juejin.cn/user/2365804756348103"}   {"name":"captain_p","desc":"目的地很美好,路上的风景也很好。今天增长见识了吗","level":2,"avatar":"cap.jpg","home":"https://juejin.cn/user/2532902235026439"}   {"name":"CUGGZ","desc":"文章联系微信授权转载。微信:CUG-GZ,添加好友一起学习~","level":5,"avatar":"cuggz.jpg","home":"https://juejin.cn/user/3544481220801815"}   {"name":"政采云前端团队","desc":"政采云前端 ZooTeam 团队,不掺水的原创。 团队站点:https://zoo.team","level":6,"avatar":"zcy.jpg","home":"https://juejin.cn/user/3456520257288974"} ]

我们就可以通过简单 for 循环拼接 HTML 片段,然后添加到页面某个元素中:

// 测试生成用户列表模版 const usersTemp = () => {     let temp = '', code = '';     users.forEach(item => {         const {name, desc, level, avatar, home} = item;         temp +=  ` <exe-user-avatar      e-user-name="${name}"     e-sub-name="${desc}"     e-avatar-src="./testAssets/images/users/${avatar}"     e-avatar-width="36px"     e-button-type="primary"     e-button-text="关注"     on-avatar-click="toUserHome('${home}')"     on-button-click="toUserFollow('${name}')" > ${     level >= 0 && `<span slot="name-slot">         <span class="medal-item">(Lv${level})</span>     </span>`} </exe-user-avatar> ` })     return temp; }  document.querySelector('#app').innerHTML = usersTemp;

到这边我们就实现了一个用户列表的业务,当然实际业务可能会更加复杂,需要再优化。

五、总结

本文首先简单回顾 Web Components 核心 API,然后对组件库需求进行分析设计,再进行环境搭建和开发,内容比较多,可能没有每一点都讲到,还请大家看看我仓库的源码,有什么问题欢迎和我讨论。

写本文的几个核心目的:

  1. 当我们接到一个新任务的时候,需要从分析设计开始,再到开发,而不是盲目一上来就开始开发;
  2. 带大家一起看看如何用 Web Components 开发简单的业务组件库;
  3. 体验一下 Web Components 开发组件库有什么缺点(就是要写的东西太多了)。

最后看完本文,大家是否觉得用 Web Components 开发组件库,实在有点复杂?要写的太多了。
没关系,下一篇我将带大家一起使用 Stencil 框架开发 Web Components 标准的组件库,毕竟整个 ionic 已经是使用 Stencil 重构,Web Components 大势所趋~!

拓展阅读

备战2022春招,这十道题必会!

Posted: 23 Dec 2021 11:03 PM PST

大家好,我是bigsai。

最近不少小伙伴跟我交流刷题肿么刷,我给的建议就是先剑指offer和力扣hot100,在这些题中还有些重要程度和出现频率是非常非常高的,今天给大家分享当今出现频率最高的10道算法题,学到就是赚到。

image-20211223144534646

0X01翻转链表

力扣206和剑指offer24原题,题意为:

给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。

img

分析:

翻转链表,本意是不创建新的链表节点然后在原链表上实现翻转,但是这个图有点会误导人的思维,其实更好的理解你可以看下面这幅图:

image-20211220235625297

具体实现上两个思路,非递归和递归的实现方式,非递归的实现方式比较简单,利用一个pre节点记录前驱节点,向下枚举的时候改变指针指向就可以,实现代码为:

class Solution {     public ListNode reverseList(ListNode head) {        if(head==null||head.next==null)//如果节点为NULL或者单个节点直接返回             return head;         ListNode pre=head;//前驱节点         ListNode cur=head.next;//当前节点用来枚举         while (cur!=null)         {             ListNode next=cur.next;             //改变指向             cur.next=pre;             pre=cur;             cur=next;         }         head.next=null;//将原先的head节点next置null防止最后成环         return pre;     } }

而递归的方式比较巧妙,借助递归归来的过程巧妙改变指针指向和返回值传递,代码虽然精简但是理解起来有一定难度的,这里用一张图帮助大家理解:

image-20211221111146785

具体代码为:

class Solution {     public ListNode reverseList(ListNode head) {         if(head==null||head.next==null)//如果最后一个节点不操作             return  head;         ListNode node =reverseList(head.next);//先递归 到最底层 然后返回         head.next.next=head;//后面一个节点指向自己         head.next=null;//自己本来指向的next置为null         return node;//返回最后一个节点(一直被递归传递)     } }

0X02设计LRU

对应力扣146LRU缓存机制,题目要求为:

运用你所掌握的数据结构,设计和实现一个 LRU 缓存机制 。实现 LRUCache 类:

LRUCache(int capacity) 以正整数作为容量 capacity 初始化 LRU 缓存
int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。
void put(int key, int value) 如果关键字已经存在,则变更其数据值;如果关键字不存在,则插入该组「关键字-值」。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。

进阶:在 O(1) 时间复杂度内完成这两种操作

详细分析一次倒在LRU上的经历

LRU的核心就是借助哈希+双链表,哈希用于查询,双链表实现删除只知道当前节点也能O(1)的复杂度删除,不过双链表需要考虑的头尾指针特殊情况。

image-20211206174203634

具体实现的代码为:

class LRUCache {     class Node {         int key;         int value;         Node pre;         Node next;         public Node() {         }         public Node( int key,int value) {             this.key = key;             this.value=value;         }     }     class DoubleList{         private Node head;// 头节点         private Node tail;// 尾节点         private int length;         public DoubleList() {             head = new Node(-1,-1);             tail = head;             length = 0;         }         void add(Node teamNode)// 默认尾节点插入         {             tail.next = teamNode;             teamNode.pre=tail;             tail = teamNode;             length++;         }         void deleteFirst(){             if(head.next==null)                 return;             if(head.next==tail)//如果删除的那个刚好是tail  注意啦 tail指针前面移动                 tail=head;             head.next=head.next.next;              if(head.next!=null)                 head.next.pre=head;             length--;         }         void deleteNode(Node team){              team.pre.next=team.next;             if(team.next!=null)                 team.next.pre=team.pre;             if(team==tail)                 tail=tail.pre;            team.pre=null;            team.next=null;             length--;         }     }     Map<Integer,Node> map=new HashMap<>();     DoubleList doubleList;//存储顺序     int maxSize;     LinkedList<Integer>list2=new LinkedList<>();      public   LRUCache(int capacity) {         doubleList=new DoubleList();         maxSize=capacity;     }     public int get(int key) {         int val;         if(!map.containsKey(key))             return  -1;         val=map.get(key).value;         Node team=map.get(key);         doubleList.deleteNode(team);         doubleList.add(team);         return  val;     }      public void put(int key, int value) {         if(map.containsKey(key)){// 已经有这个key 不考虑长短直接删除然后更新            Node deleteNode=map.get(key);             doubleList.deleteNode(deleteNode);         }         else if(doubleList.length==maxSize){//不包含并且长度小于             Node first=doubleList.head.next;             map.remove(first.key);             doubleList.deleteFirst();         }        Node node=new Node(key,value);         doubleList.add(node);         map.put(key,node);      } }

0X03环形链表

对应力扣141和力扣142,力扣141环形链表要求为:

给定一个链表,判断链表中是否有环,用O(1)内存解决。

详细分析环形链表找入口,真的太妙了

这个问题利用快慢双指针比较高效,快指针fast每次走2步,slow每次走1步,慢指针走n步到尾时候快指针走了2n步,而环的大小一定小于等于n所以一定会相遇,如果相遇那么说明有环,如果不相遇fast先为null说明无环。

具体代码为:

public class Solution {     public boolean hasCycle(ListNode head) {         ListNode fast=head;         ListNode slow=fast;         while (fast!=null&&fast.next!=null) {             slow=slow.next;             fast=fast.next.next;             if(fast==slow)                 return true;         }         return false;         } }

力扣142是在力扣141拓展,如有有环,返回入环的那个节点,就想下图环形链表返回节点2。

img

这个问题是需要数学转换的,具体的分析可以看上面的详细分析,这里面提一下大题的步骤。

如果找到第一个交汇点,其中一个停止,另一个继续走,下一次交汇时候刚好走一圈,可以算出循环部分长度为y

所以我们知道的东西有:交汇时候fast走2x步,slow走x步,环长为y。并且快指针和慢指针交汇时候,多走的步数刚好是换长y的整数倍(它两此刻在同一个位置,快指针刚好多绕整数倍圈数才能在同一个位置相聚),可以得到2x=x+ny(x=ny)。其中所以说慢指针走的x和快指针多走的x是圈长y的整数倍。

image-20211222103731221

也就是说,从开头走到这个点共计x步,从这个点走x步也就是绕了几圈也回到这个点。如果说slow从起点出发,fast从这个点出发(每次走一步,相当于之前两步抵消slow走的路程),那么走x步还会到达这个点,但是这两个指针这次都是每次走一步,所以一旦slow到达循环圈内,两个指针就开始汇合了。

image-20211222104535857

实现代码为:

public class Solution {     public ListNode detectCycle(ListNode head) {         boolean isloop=false;         ListNode fast=new ListNode(0);//头指针         ListNode slow=fast;         fast.next=head;         if(fast.next==null||fast.next.next==null)             return null;         while (fast!=null&&fast.next!=null) {             fast=fast.next.next;             slow=slow.next;             if(fast==slow)             {                 isloop=true;                 break;             }         }         if(!isloop)//如果没有环返回             return null;         ListNode team=new ListNode(-1);//头指针 下一个才是head         team.next=head;         while (team!=fast) {//slow 和fast 分别从起点和当前点出发             team=team.next;             fast=fast.next;         }         return team;     } }

0X04两个栈实现队列

对应剑指offer09,题意为:

用两个栈实现一个队列。队列的声明如下,请实现它的两个函数 appendTail 和 deleteHead ,分别完成在队列尾部插入整数和在队列头部删除整数的功能。(若队列中没有元素,deleteHead 操作返回 -1 )

分析

解决这个问题,要知道栈是什么,队列是什么,两种常见数据结构格式很简单,栈的特点就是:后进先出,队列的特点就是:先进先出,栈可以想象成一堆书本,越在上面的取的越早,上面来上面出(比喻一下);队列就是想象成排队买东西,只能后面进前面出,所以两者数据结构还是有区别的,虽然都是单个入口进出,但是栈进出口相同,而队列不同。

上面描述的是一个普通栈和队列的数据结构,这里面让我们用两个栈实现一个队列的操作,这里比较容易想的方案就是其中一个栈stack1用作数据存储,插入尾时候直接插入stack1,而删除头的时候将数据先加入到另一个栈stack2中,返回并删除栈顶元素,将stack2顺序加入stack1中实现一个复原,但是这样操作插入时间复杂度为O(1),删除时间复杂度为O(n)比较高。

实现方式也给大家看下:

class CQueue {      Stack<Integer>stack1=new Stack<>();     Stack<Integer>stack2=new Stack<>();     public CQueue() {     }     public void appendTail(int value) {        stack1.push(value);     }     public int deleteHead() {         if(stack1.isEmpty())             return -1;                 while (!stack1.isEmpty())         {             stack2.push(stack1.pop());         }        int value= stack2.pop();         while (!stack2.isEmpty())         {             stack1.push(stack2.pop());         }         return  value;     } }

这样的时间复杂度是不被喜欢的,因为删除太鸡儿耗时了,每次都要折腾一番,有没有什么好的方法能够让删除也方便一点呢?

有啊,stack1可以顺序保证顺序插入,stack1数据放到stack2中可以保证顺序删除,所以用stack1作插入,stack2作删除,因为题目也没要求数据必须放到一个容器中,所以就这样组合使用,完美perfect!

image-20211222134837048

具体实现的时候,插入直接插入到stack1中,如果需要删除从stack2中栈顶删除,如果stack2栈为空那么将stack1中数据全部添加进来(这样又能保证stack2中所有数据是可以顺序删除的了),下面列举几个删除的例子

image-20211222135936237

其实就是将数据分成两个部分,一部分用来插入,一部分用来删除,删除的那个栈stack2空了添加所有stack1中的数据继续操作。这个操作插入删除的时间复杂度是O(1),具体实现的代码为:

class CQueue {     Deque<Integer> stack1;     Deque<Integer> stack2;          public CQueue() {         stack1 = new LinkedList<Integer>();         stack2 = new LinkedList<Integer>();     }          public void appendTail(int value) {         stack1.push(value);     }          public int deleteHead() {         // 如果第二个栈为空 将stack1数据加入stack2         if (stack2.isEmpty()) {             while (!stack1.isEmpty()) {                 stack2.push(stack1.pop());             }         } //如果stack2依然为空 说明没有数据         if (stack2.isEmpty()) {             return -1;         } else {//否则删除             int deleteItem = stack2.pop();             return deleteItem;         }     } }

0X05二叉树层序(锯齿)遍历

二叉树的遍历,对应力扣102,107,103.

详细分析一次面试,被二叉树层序遍历打爆了

如果普通二叉树层序遍历,也不是什么困难的问题,但是它会有个分层返回结果的操作,就需要你详细考虑了。

很多人会用两个容器(队列)进行分层的操作,这里其实可以直接使用一个队列,我们首先记录枚举前队列大小len,然后根据这个大小len去枚举遍历就可以得到完整的该层数据了。

还有一个难点就是二叉树的锯齿层序(也叫之字形打印),第一趟是从左往右,第二趟是从右往左,只需要记录一个奇偶层数进行对应的操作就可以了。

image-20210913161034771

这里就拿力扣103二叉树的锯齿形层序遍历作为题板给大家分享一下代码:

public List<List<Integer>> levelOrder(TreeNode root) {   List<List<Integer>> value=new ArrayList<>();//存储到的最终结果   if(root==null)     return value;   int index=0;//判断   Queue<TreeNode>queue=new ArrayDeque<>();   queue.add(root);   while (!queue.isEmpty()){     List<Integer>va=new ArrayList<>();//临时 用于存储到value中     int len=queue.size();//当前层节点的数量     for(int i=0;i<len;i++){       TreeNode node=queue.poll();       if(index%2==0)//根据奇偶 选择添加策略         va.add(node.val);       else         va.add(0,node.val);       if(node.left!=null)         queue.add(node.left);       if(node.right!=null)         queue.add(node.right);     }     value.add(va);     index++;   }   return value; }

0X06 二叉树中后序遍历(非递归)

二叉树的非递归遍历也是考察的重点,对于中序后序遍历递归实现很简单,非递归实现起来还是要点技巧的哦。

详细分析:二叉树的各种遍历(递归、非递归)

对于二叉树的中序遍历,其实就是正常情况第二次访问该节点的时候才抛出输出(第一次数前序),这样我们枚举每个节点第一次不能删除,需要先将它存到栈中,当左子节点处理完成的时候在抛出访问该节点。

image-20210916163707512

核心也就两步,叶子节点左右都为null,也可满足下列条件:

  1. 枚举当前节点(不存储输出)并用栈存储,节点指向左节点,直到左孩子为null。
  2. 抛出栈顶访问。如果有右节点,访问其右节点重复步骤1,如有没右节点,继续重复步骤2抛出。

实现代码为:

class Solution {    public List<Integer> inorderTraversal(TreeNode root) {     List<Integer>value=new ArrayList<Integer>();     Stack<TreeNode> q1 = new Stack();         while(!q1.isEmpty()||root!=null)     {         while (root!=null) {             q1.push(root);                             root=root.left;         }         root=q1.pop();//抛出         value.add(root.val);         root=root.right;//准备访问其右节点              }     return value;   } }

而后序遍历按照递归的思路其实一般是第三次访问该节点是从右子节点回来才抛出输出,这个实现起来确实有难度。但是具体的实现,我们使用一个pre节点记录上一次被抛出访问的点,如果当前被抛出的右孩子是pre或者当前节点右为null,那么就将这个点抛出,否则说明它的右侧还未被访问需要将它"回炉重造",后面再用!如果不理解可以看前面的详细介绍。

具体实现的代码为:

class Solution {     public List<Integer> postorderTraversal(TreeNode root) {         TreeNode temp=root;//枚举的临时节点         List<Integer>value=new ArrayList<>();         TreeNode pre=null;//前置节点         Stack<TreeNode>stack=new Stack<>();          while (!stack.isEmpty()||temp!=null){                      while(temp!=null){                 stack.push(temp);                 temp=temp.left;             }             temp=stack.pop();             if(temp.right==pre||temp.right==null)//需要弹出             {                 value.add(temp.val);                 pre=temp;                 temp=null;//需要重新从栈中抛出             }else{                 stack.push(temp);                 temp=temp.right;             }                      }         return value;     } }

当然,后序遍历也有用前序(根右左)的前序遍历结果最后翻转一下的,但面试官更想考察的还是上面提到的方法。

0X07 跳台阶(斐波那契、爬楼梯)

爬楼梯、跳台阶是一个经典问题,对应剑指offer10和力扣70题,题目的要求为:

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?注意:给定 n 是一个正整数。

分析:

这个问题入门级别dp,分析当前第k阶的结果,每个人可以爬1个或者2个台阶,那么说明它可能是由k-1或者k-2来的,所以就是两个子情况的叠加(需要特殊考虑一下初始情况),这个思路有人会想到递归,没错用递归确实可以解决但是用递归效率较低(因为这个是个发散的递归一个拆成两个),使用记忆化搜索会稍微好一些。

但是dp是比较好的方法,核心状态转移方程为:dp[i]=dp[i-1]+dp[i-2],有些空间优化的那就更好了,因为只用到前两个值,所以完全可以用三个值重复使用节省空间。

class Solution {     public int climbStairs(int n) {         if(n<3)return n;          int dp[]=new int[n+1];          dp[1]=1;          dp[2]=2;          for(int i=3;i<n+1;i++)          {              dp[i]=dp[i-1]+dp[i-2];          }          return dp[n];     }      public int climbStairs(int n) {         int a = 0, b = 0, c = 1;         for (int i = 1; i <= n; i++) {             a = b;              b = c;              c = a + b;         }         return c;     } }

当然,有的数据很大求余的跳台阶,可以用矩阵快速幂解决,但是这里就不介绍啦,有兴趣可以详细看看。

0X08 TOPK问题

TOPK问题真的非常经典,通常问的有最小的K个数,寻找第K大都是TOPK这种问题,这里就用力扣215寻找数组第K大元素作为板子。

详细分析:一文拿捏TOPK

TOPK的问题解决思路有很多,如果优化的冒泡或者简单选择排序,时间复杂度为O(nk),使用优化的堆排序为O(n+klogn),不过掌握快排的变形就可以应付大体上的所有问题了(面试官要是让你手写堆排序那真是有点难为你了)。

image-20211223132113634

快排每次确定一个数pivot位置,将数分成两部分:左面的都比这个数pivot小,右面的都比这个数pivot大,这样就可以根据这个k去判断刚好在pivot位置,还是左侧还是右侧?可以压缩空间迭代去调用递归最终求出结果。

很多人为了更快过测试样例将这个pivot不选第一个随机选择(为了和刁钻的测试样例作斗争),不过这里我就选第一个作为pivot了,代码可以参考:

class Solution {     public int findKthLargest(int[] nums, int k) {         quickSort(nums,0,nums.length-1,k);         return nums[nums.length-k];     }     private void quickSort(int[] nums,int start,int end,int k) {         if(start>end)             return;         int left=start;         int right=end;         int number=nums[start];         while (left<right){             while (number<=nums[right]&&left<right){                 right--;             }             nums[left]=nums[right];             while (number>=nums[left]&&left<right){                 left++;             }             nums[right]=nums[left];         }         nums[left]=number;         int num=end-left+1;         if(num==k)//找到k就终止             return;         if(num>k){             quickSort(nums,left+1,end,k);         }else {             quickSort(nums,start,left-1,k-num);         }     } }

0X09 无重复的最长子串(数组)

这个问题可能是个字符串也可能是数组,但是道理一致,无重复字符的最长子串最长无重复子数组本质一致。

题目要求为:给定一个字符串,请你找出其中不含有重复字符的 最长子串 的长度。

分析

此题就是给一个字符串让你找出最长没有重复的一个子串。 要搞清子串和子序列的区别:

子串:是连续的,可以看成原串的一部分截取。
子序列:不一定是连续的,但是要保证各个元素之间相对位置不变。

那么我们如何处理呢?

暴力查找,暴力查找当然是可以的,但是复杂度过高这里就不进行讲解了。这里选择的思路是滑动窗口,滑动窗口,就是用一个区间从左往右,右侧先进行试探,找到区间无重复最大值,当有重复时左侧再往右侧移动一直到没重复,然后重复进行到最后。在整个过程中找到最大子串即可。

image-20211223141804714

具体实现时候可以用数组替代哈希表会快很多:

class Solution {     public int lengthOfLongestSubstring(String s) {          int a[]=new int[128];          int max=0;//记录最大          int l=0;//left 用i 当成right,当有重复左就往右          for(int i=0;i<s.length();i++)          {              a[s.charAt(i)]++;              while (a[s.charAt(i)]>1) {                 a[s.charAt(l++)]--;             }              if(i-l+1>max)                  max=i-l+1;          }          return max;     } }

0X10 排序

不会真的有人以为用个Arrays.sort()就完事了吧,手写排序还是很高频的,像冒泡、插入这些简单的大家相比都会,像堆排序、希尔、基数排序等考察也不多,比较高频的就是快排了,这里额外奖励一个也很高频的归并排序,两个都是典型分治算法,也可以将快排和前面的TOPK问题比较一番。

排序详细的十大排序都有详细讲过,大家可以自行参考:程序员必知必会十大排序

快排:

image-20211223135418901

具体实现:

public void quicksort(int [] a,int left,int right) {   int low=left;   int high=right;   //下面两句的顺序一定不能混,否则会产生数组越界!!!very important!!!   if(low>high)//作为判断是否截止条件     return;   int k=a[low];//额外空间k,取最左侧的一个作为衡量,最后要求左侧都比它小,右侧都比它大。   while(low<high)//这一轮要求把左侧小于a[low],右侧大于a[low]。   {     while(low<high&&a[high]>=k)//右侧找到第一个小于k的停止     {       high--;     }     //这样就找到第一个比它小的了     a[low]=a[high];//放到low位置     while(low<high&&a[low]<=k)//在low往右找到第一个大于k的,放到右侧a[high]位置     {       low++;     }     a[high]=a[low];               }   a[low]=k;//赋值然后左右递归分治求之   quicksort(a, left, low-1);   quicksort(a, low+1, right);         }

归并排序:

image-20211223135219423

实现代码为:

private static void mergesort(int[] array, int left, int right) {   int mid=(left+right)/2;   if(left<right)   {     mergesort(array, left, mid);     mergesort(array, mid+1, right);     merge(array, left,mid, right);   } }  private static void merge(int[] array, int l, int mid, int r) {   int lindex=l;int rindex=mid+1;   int team[]=new int[r-l+1];   int teamindex=0;   while (lindex<=mid&&rindex<=r) {//先左右比较合并     if(array[lindex]<=array[rindex])     {       team[teamindex++]=array[lindex++];     }     else {                       team[teamindex++]=array[rindex++];     }   }   while(lindex<=mid)//当一个越界后剩余按序列添加即可   {     team[teamindex++]=array[lindex++];    }   while(rindex<=r)   {     team[teamindex++]=array[rindex++];   }       for(int i=0;i<teamindex;i++)   {     array[l+i]=team[i];   } }

结语

好了,今天给大家分享的10个问题,是真的在面试中非常非常高频,我敢说平均每两次面试就得遇到这里面的其中一个题(毫不夸张)!

虽说题海很深学不完,但是学过缓存的都知道要把热点数据放缓存,考过试的都知道要把必考点掌握……这十个问题已经送到嘴边。

当然,这只是非常非常高频的问题,要想拿捏笔试,肯定还要不断积累、刷题,也欢迎各位加入我的力扣打卡群坚持刷题

原创不易,求个三连!

本文首发个人技术公众号「bigsai」,转载请附上作者和本文链接。

一次说清,为什么在Antd Modal中调resetFields调了个寂寞

Posted: 23 Dec 2021 06:21 PM PST

背景

在干了大半年增删查改后(node,mysql,serverless),业务端人手短缺,老板开恩让我支持其他团队写几个页面。

久了不摸手生,除了react依稀记得,antd基本只能看着官方demo一行一行写,感觉一天能写完的,结果两天了还没联调完。中间还遇到一些似曾相识的问题,可惜以前的经验已经不管用了。

demo地址: https://codesandbox.io/s/antd...

这些问题在antd的仓库issue都反复被提及,看了下文,包括但不限于以下问题都将得到答案:
20211221224910

概括一下:

  • Form表单,React hooks 组件,initialValues初始化数据时候,第二次、第三次……传递新值,表单没有更新,永远显示第一次数据?
  • 弹出层新建表单重新设置值不起作用?

    • Modal 用了destroyOnClose,里面有 Form,并使用 form.resetFields,为什么会失效?
    • Modal中initialValues更新了,使用了form.resetFields,要连续打开两次才生效?

有事说事

语言描述显得太苍白,所以直接看动图吧:
reset-small

这是一个简单的增删查改页面,新增和编辑共享了同一个组件,期望在打开弹窗编辑表单关闭后,重新打开时,能根据initialValues重新渲染表单, 但得到的结果是,第二次打开,编辑框没有刷新.

实现的伪代码大致是这样:

import React, { useEffect } from "react"; import { Modal, Form, Input, Button, Checkbox } from "antd";  export function EditModal(props) {   const { visible, onOk, onCancel, content = {} } = props;   const [form] = Form.useForm();   const isEdit = !!content.sort;   const handlSubmit = (close) => {     // 一些提交逻辑   };   useEffect(() => {     // setTimeout(() => {     form.resetFields();     // });   }, [content]);    return (     <Modal       title={`${isEdit ? "编辑" : "新建"}备注`}       visible={visible}       destroyOnClose       onOk={onOk}       onCancel={onCancel}     >       <Form         name="basic"         labelCol={{ span: 7 }}         wrapperCol={{ span: 14 }}         form={form}         initialValues={content}         autoComplete="off"       >         {...一些表单}       </Form>     </Modal>   ); } 

相信出现问题的盆友们,大多都是和我一样,如上面这样的代码这样实现。

具体问题,具体分析

先给结论,之所以会出现上面的那些问题,主要是三个问题导致:

  • react hooks使用姿势不正确,antd4 form引入了hooks, 和antd3使用有所区别;
  • 对form表单initialValues的认识不清;
  • Modal子元素的渲染是异步的,destroyOnClose 错误使用;

initialValues初始化数据时候,第二次、第三次……传递新值,表单没有更新?

因为initialValues只在表单首次初始化时有效,只要表单没有卸载并重新挂载,改变initialValues都不会刷新表单的值,form最初的设计就如此;以下是initialValues初始化存到store的完整实现:

  this.setInitialValues = function (initialValues, init) {     _this.initialValues = initialValues || {};     if (init) {       // setValues 作用类似于Object.assign();       _this.store = setValues({}, initialValues, _this.store);     }   };

this.store 是存放在form实例中的,只要实例不销毁,store的值就不会变化。

destroyOnClose,弹出层新建表单重新设置值不起作用?

首先这里有个概念,initialValues 在Form表单实例挂载时,这个值是被存在了用hooks生成的form实例中。

所以当我们使用了destroyOnClose,虽然销毁了Modal 以及Modal框中的Form,但这个form实例仍然存在,这个hook实例是挂载在EditModal元素上的,并没有被一起销毁,所以当弹窗再次打开,Form表单又会根据这个form的store再次渲染(原因见上)。

Modal 用了destroyOnClose,里面有 Form,并使用 form.resetFields,为什么会失效?

当我们意识到form实例没有被销毁,可能保存了上一个表单编辑状态时,我们会想到使用useEffect钩子,去观察初始值,采用form.resetFields去重置实例,但最后发现这并没有起作用(我也踩到了这个坑上)。

当我去掉destroyOnClose,我发现生效了,后面我去看了一下form.resetFields的实现源码:

this.resetFields = function (nameList) {   var prevStore = _this.store;    if (!nameList) {     // console.log(JSON.stringify(prevStore), JSON.stringify(_this.initialValues));     _this.store = setValues({}, _this.initialValues);     _this.resetWithFieldInitialValue();     _this.notifyObservers(prevStore, null, {       type: 'reset'     });     return;   } }

这个实现和initialValues 一样简单明了,所以问题不在resetFields。问题是出在Modal身上,简单来讲Moda的创建有一个异步过程,所以子组件的渲染并不是同步的。正常的组件渲染是下面这样的:

20211223222510

只需和我上面一样,在resetFields加一句console, 就会发现_this.initialValues是上一次的初始值,而不是新传入的(因为Form元素还未挂载),所以这里resetFields调了个寂寞。

还有一种简单的方法证明Modal组件的子组件挂载是异步的,就是如下面这样去玩:

useEffect(() => {   setTimeout(() => {     form.resetFields();   }); }, [content]);

这个实现,你会发现resetFields居然生效了,因为一个宏任务后,Form元素已经挂载上。

所以这里告诉我们,要尽量少用destroyOnClose,因为Modal的渲染是耗时的且费力的。

Modal使用了form.resetFields初始化,要连续打开两次才生效?

相信经过上面的一系列解释,你的心中已经有了答案;destroyOnClose 确实不适合在Modal中写表单时用。

所以,Modal中重置Form initalValues的正确姿势了吗?

慎点

吃一堑,长一智

这一次经历后,我记住了:

  • destroyOnClose要慎用,因为Modal的渲染是昂贵的;
  • hooks 是个好东西,但你得用对;
  • antd是个好东西,前提是你会用;
  • 我还是太菜了;

欢迎关注我的前端公众号:前端黑洞

Redis 分布式锁的正确实现原理演化历程与 Redission 实战总结

Posted: 23 Dec 2021 06:59 PM PST

Redis 分布式锁使用 SET 指令就可以实现了么?在分布式领域 CAP 理论一直存在。

分布式锁的门道可没那么简单,我们在网上看到的分布式锁方案可能是有问题的。

「码哥」一步步带你深入分布式锁是如何一步步完善,在高并发生产环境中如何正确使用分布式锁。

在进入正文之前,我们先带着问题去思考:

  • 什么时候需要分布式锁?
  • 加、解锁的代码位置有讲究么?
  • 如何避免出现锁再也无法删除?「」
  • 超时时间设置多少合适呢?
  • 如何避免锁被其他线程释放
  • 如何实现重入锁?
  • 主从架构会带来什么安全问题?
  • 什么是 Redlock
  • Redisson 分布式锁最佳实战
  • 看门狗实现原理
  • ……

什么时候用分布式锁?

码哥,说个通俗的例子讲解下什么时候需要分布式锁呢?

诊所只有一个医生,很多患者前来就诊。

医生在同一时刻只能给一个患者提供就诊服务。如果不是这样的话,就会出现医生在就诊肾亏的「肖菜鸡」准备开药时候患者切换成了脚臭的「谢霸哥」,这时候药就被谢霸哥取走了。

治肾亏的药被有脚臭的拿去了。

当并发去读写一个【共享资源】的时候,我们为了保证数据的正确,需要控制同一时刻只有一个线程访问。

分布式锁就是用来控制同一时刻,只有一个 JVM 进程中的一个线程可以访问被保护的资源。

分布式锁入门

65 哥:分布式锁应该满足哪些特性?
  1. 互斥:在任何给定时刻,只有一个客户端可以持有锁;
  2. 无死锁:任何时刻都有可能获得锁,即使获取锁的客户端崩溃;
  3. 容错:只要大多数 Redis的节点都已经启动,客户端就可以获取和释放锁。
码哥,我可以使用 SETNX key value 命令是实现「互斥」特性。

这个命令来自于SET if Not eXists的缩写,意思是:如果 key 不存在,则设置 value 给这个key,否则啥都不做。Redis 官方地址说的:

命令的返回值:

  • 1:设置成功;
  • 0:key 没有设置成功。

如下场景:

敲代码一天累了,想去放松按摩下肩颈。

168 号技师最抢手,大家喜欢点,所以并发量大,需要分布式锁控制。

同一时刻只允许一个「客户」预约 168 技师。

肖菜鸡申请 168 技师成功:

> SETNX lock:168 1 (integer) 1 # 获取 168 技师成功

谢霸哥后面到,申请失败:

> SETNX lock 2 (integer) 0 # 客户谢霸哥 2 获取失败

此刻,申请成功的客户就可以享受 168 技师的肩颈放松服务「共享资源」。

享受结束后,要及时释放锁,给后来者享受 168 技师的服务机会。

肖菜鸡,码哥考考你如何释放锁呢?

很简单,使用 DEL 删除这个 key 就行。

> DEL lock:168 (integer) 1
码哥,你见过「龙」么?我见过,因为我被一条龙服务过。

肖菜鸡,事情可没这么简单。

这个方案存在一个存在造成锁无法释放的问题,造成该问题的场景如下:

  1. 客户端所在节点崩溃,无法正确释放锁;
  2. 业务逻辑异常,无法执行 DEL指令。

这样,这个锁就会一直占用,锁在我手里,我挂了,这样其他客户端再也拿不到这个锁了。

超时设置

码哥,我可以在获取锁成功的时候设置一个「超时时间」

比如设定按摩服务一次 60 分钟,那么在给这个 key 加锁的时候设置 60 分钟过期即可:

> SETNX lock:168 1  // 获取锁 (integer) 1 > EXPIRE lock:168 60  // 60s 自动删除 (integer) 1

这样,到点后锁自动释放,其他客户就可以继续享受 168 技师按摩服务了。

谁要这么写,就糟透了。

「加锁」、「设置超时」是两个命令,他们不是原子操作。

如果出现只执行了第一条,第二条没机会执行就会出现「超时时间」设置失败,依然出现锁无法释放。

码哥,那咋办,我想被一条龙服务,要解决这个问题

Redis 2.6.X 之后,官方拓展了 SET 命令的参数,满足了当 key 不存在则设置 value,同时设置超时时间的语义,并且满足原子性。

SET resource_name random_value NX PX 30000
  • NX:表示只有 resource_name 不存在的时候才能 SET 成功,从而保证只有一个客户端可以获得锁;
  • PX 30000:表示这个锁有一个 30 秒自动过期时间。

这样写还不够,我们还要防止不能释放不是自己加的锁。我们可以在 value 上做文章。

继续往下看……

释放了不是自己加的锁

这样我能稳妥的享受一条龙服务了么?

No,还有一种场景会导致释放别人的锁

  1. 客户 1 获取锁成功并设置设置 30 秒超时;
  2. 客户 1 因为一些原因导致执行很慢(网络问题、发生 FullGC……),过了 30 秒依然没执行完,但是锁过期「自动释放了」;
  3. 客户 2 申请加锁成功;
  4. 客户 1 执行完成,执行 DEL 释放锁指令,这个时候就把客户 2 的锁给释放了。

有个关键问题需要解决:自己的锁只能自己来释放。

我要如何删除是自己加的锁呢?

在执行 DEL 指令的时候,我们要想办法检查下这个锁是不是自己加的锁再执行删除指令。

解铃还须系铃人

码哥,我在加锁的时候设置一个「唯一标识」作为 value 代表加锁的客户端。SET resource_name random_value NX PX 30000

在释放锁的时候,客户端将自己的「唯一标识」与锁上的「标识」比较是否相等,匹配上则删除,否则没有权利释放锁。

伪代码如下:

// 比对 value 与 唯一标识 if (redis.get("lock:168").equals(random_value)){    redis.del("lock:168"); //比对成功则删除  }
有没有想过,这是 GET + DEL 指令组合而成的,这里又会涉及到原子性问题。

我们可以通过 Lua 脚本来实现,这样判断和删除的过程就是原子操作了。

// 获取锁的 value 与 ARGV[1] 是否匹配,匹配则执行 del if redis.call("get",KEYS[1]) == ARGV[1] then     return redis.call("del",KEYS[1]) else     return 0 end

这样通过唯一值设置成 value 标识加锁的客户端很重要,仅使用 DEL 是不安全的,因为一个客户端可能会删除另一个客户端的锁。

使用上面的脚本,每个锁都用一个随机字符串"签名",只有当删除锁的客户端的"签名"与锁的 value 匹配的时候,才会删除它。

官方文档也是这么说的:https://redis.io/topics/distlock

这个方案已经相对完美,我们用的最多的可能就是这个方案了。

正确设置锁超时

锁的超时时间怎么计算合适呢?

这个时间不能瞎写,一般要根据在测试环境多次测试,然后压测多轮之后,比如计算出平均执行时间 200 ms。

那么锁的超时时间就放大为平均执行时间的 3~5 倍。

为啥要放放大呢?

因为如果锁的操作逻辑中有网络 IO操作、JVM FullGC 等,线上的网络不会总一帆风顺,我们要给网络抖动留有缓冲时间。

那我设置更大一点,比如设置 1 小时不是更安全?

不要钻牛角,多大算大?

设置时间过长,一旦发生宕机重启,就意味着 1 小时内,分布式锁的服务全部节点不可用。

你要让运维手动删除这个锁么?

只要运维真的不会打你。

有没有完美的方案呢?不管时间怎么设置都不大合适。

我们可以让获得锁的线程开启一个守护线程,用来给快要过期的锁「续航」。

加锁的时候设置一个过期时间,同时客户端开启一个「守护线程」,定时去检测这个锁的失效时间。

如果快要过期,但是业务逻辑还没执行完成,自动对这个锁进行续期,重新设置过期时间。

这个道理行得通,可我写不出。

别慌,已经有一个库把这些工作都封装好了他叫 Redisson

在使用分布式锁时,它就采用了「自动续期」的方案来避免锁过期,这个守护线程我们一般也把它叫做「看门狗」线程。

一路优化下来,方案似乎比较「严谨」了,抽象出对应的模型如下。
  1. 通过 SET lock_resource_name random_value NX PX expire_time,同时启动守护线程为快要过期但还没执行完的客户端的锁续命;
  2. 客户端执行业务逻辑操作共享资源;
  3. 通过 Lua 脚本释放锁,先 get 判断锁是否是自己加的,再执行 DEL

这个方案实际上已经比较完美,能写到这一步已经打败 90% 的程序猿了。

但是对于追求极致的程序员来说还远远不够:

  1. 可重入锁如何实现?
  2. 主从架构崩溃恢复导致锁丢失如何解决?
  3. 客户端加锁的位置有门道么?

加解锁代码位置有讲究

根据前面的分析,我们已经有了一个「相对严谨」的分布式锁了。

于是「谢霸哥」就写了如下代码将分布式锁运用到项目中,以下是伪代码逻辑:

public void doSomething() {   redisLock.lock(); // 上锁     try {         // 处理业务         .....         redisLock.unlock(); // 释放锁     } catch (Exception e) {         e.printStackTrace();     } }
有没有想过:一旦执行业务逻辑过程中抛出异常,程序就无法执行释放锁的流程。

所以释放锁的代码一定要放在 finally{} 块中。

加锁的位置也有问题,放在 try 外面的话,如果执行 redisLock.lock() 加锁异常,但是实际指令已经发送到服务端并执行,只是客户端读取响应超时,就会导致没有机会执行解锁的代码。

所以 redisLock.lock() 应该写在 try 代码块,这样保证一定会执行解锁逻辑。

综上所述,正确代码位置如下 :

public void doSomething() {     try {         // 上锁         redisLock.lock();         // 处理业务         ...     } catch (Exception e) {         e.printStackTrace();     } finally {       // 释放锁       redisLock.unlock();      } }

实现可重入锁

65 哥:可重入锁要如何实现呢?

当一个线程执行一段代码成功获取锁之后,继续执行时,又遇到加锁的代码,可重入性就就保证线程能继续执行,而不可重入就是需要等待锁释放之后,再次获取锁成功,才能继续往下执行。

用一段代码解释可重入:

public synchronized void a() {     b(); } public synchronized void b() {     // pass }

假设 X 线程在 a 方法获取锁之后,继续执行 b 方法,如果此时不可重入,线程就必须等待锁释放,再次争抢锁。

锁明明是被 X 线程拥有,却还需要等待自己释放锁,然后再去抢锁,这看起来就很奇怪,我释放我自己~

Redis Hash 可重入锁

Redisson 类库就是通过 Redis Hash 来实现可重入锁

当线程拥有锁之后,往后再遇到加锁方法,直接将加锁次数加 1,然后再执行方法逻辑。

退出加锁方法之后,加锁次数再减 1,当加锁次数为 0 时,锁才被真正的释放。

可以看到可重入锁最大特性就是计数,计算加锁的次数。

所以当可重入锁需要在分布式环境实现时,我们也就需要统计加锁次数。

加锁逻辑

我们可以使用 Redis hash 结构实现,key 表示被锁的共享资源, hash 结构的 fieldKey 的 value 则保存加锁的次数。

通过 Lua 脚本实现原子性,假设 KEYS1 = 「lock」, ARGV「1000,uuid」:

---- 1 代表 true ---- 0 代表 false if (redis.call('exists', KEYS[1]) == 0) then     redis.call('hincrby', KEYS[1], ARGV[2], 1);     redis.call('pexpire', KEYS[1], ARGV[1]);     return 1; end ; if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then     redis.call('hincrby', KEYS[1], ARGV[2], 1);     redis.call('pexpire', KEYS[1], ARGV[1]);     return 1; end ; return 0;

加锁代码首先使用 Redis exists 命令判断当前 lock 这个锁是否存在。

如果锁不存在的话,直接使用 hincrby创建一个键为 lock hash 表,并且为 Hash 表中键为 uuid 初始化为 0,然后再次加 1,最后再设置过期时间。

如果当前锁存在,则使用 hexists判断当前 lock 对应的 hash 表中是否存在 uuid 这个键,如果存在,再次使用 hincrby 加 1,最后再次设置过期时间。

最后如果上述两个逻辑都不符合,直接返回。

解锁逻辑

-- 判断 hash set 可重入 key 的值是否等于 0 -- 如果为 0 代表 该可重入 key 不存在 if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then     return nil; end ; -- 计算当前可重入次数 local counter = redis.call('hincrby', KEYS[1], ARGV[2], -1); -- 小于等于 0 代表可以解锁 if (counter > 0) then     return 0; else     redis.call('del', KEYS[1]);     return 1; end ; return nil;

首先使用 hexists 判断 Redis Hash 表是否存给定的域。

如果 lock 对应 Hash 表不存在,或者 Hash 表不存在 uuid 这个 key,直接返回 nil

若存在的情况下,代表当前锁被其持有,首先使用 hincrby使可重入次数减 1 ,然后判断计算之后可重入次数,若小于等于 0,则使用 del 删除这把锁。

解锁代码执行方式与加锁类似,只不过解锁的执行结果返回类型使用 Long。这里之所以没有跟加锁一样使用 Boolean ,这是因为解锁 lua 脚本中,三个返回值含义如下:

  • 1 代表解锁成功,锁被释放
  • 0 代表可重入次数被减 1
  • null 代表其他线程尝试解锁,解锁失败.

主从架构带来的问题

码哥,到这里分布式锁「很完美了」吧,没想到分布式锁这么多门道。

路还很远,之前分析的场景都是,锁在「单个」Redis 实例中可能产生的问题,并没有涉及到 Redis 主从模式导致的问题。

我们通常使用「Cluster 集群」或者「哨兵集群」的模式部署保证高可用。

这两个模式都是基于「主从架构数据同步复制」实现的数据同步,而 Redis 的主从复制默认是异步的。

以下内容来自于官方文档 https://redis.io/topics/distlock

我们试想下如下场景会发生什么问题:

  1. 客户端 A 在 master 节点获取锁成功。
  2. 还没有把获取锁的信息同步到 slave 的时候,master 宕机。
  3. slave 被选举为新 master,这时候没有客户端 A 获取锁的数据。
  4. 客户端 B 就能成功的获得客户端 A 持有的锁,违背了分布式锁定义的互斥。

虽然这个概率极低,但是我们必须得承认这个风险的存在。

Redis 的作者提出了一种解决方案,叫 Redlock(红锁)

Redis 的作者为了统一分布式锁的标准,搞了一个 Redlock,算是 Redis 官方对于实现分布式锁的指导规范,https://redis.io/topics/distlock,但是这个 Redlock 也被国外的一些分布式专家给喷了。

因为它也不完美,有"漏洞"。

什么是 Redlock

红锁是不是这个?

泡面吃多了你,Redlock 红锁是为了解决主从架构中当出现主从切换导致多个客户端持有同一个锁而提出的一种算法。

大家可以看官方文档(https://redis.io/topics/distlock),以下来自官方文档的翻译。

想用使用 Redlock,官方建议在不同机器上部署 5 个 Redis 主节点,节点都是完全独立,也不使用主从复制,使用多个节点是为容错。

一个客户端要获取锁有 5 个步骤

  1. 客户端获取当前时间 T1(毫秒级别);
  2. 使用相同的 key value 顺序尝试从 N Redis 实例上获取锁。

    • 每个请求都设置一个超时时间(毫秒级别),该超时时间要远小于锁的有效时间,这样便于快速尝试与下一个实例发送请求。
    • 比如锁的自动释放时间 10s,则请求的超时时间可以设置 5~50 毫秒内,这样可以防止客户端长时间阻塞。
  3. 客户端获取当前时间 T2 并减去步骤 1 的 T1 来计算出获取锁所用的时间(T3 = T2 -T1)。当且仅当客户端在大多数实例(N/2 + 1)获取成功,且获取锁所用的总时间 T3 小于锁的有效时间,才认为加锁成功,否则加锁失败。
  4. 如果第 3 步加锁成功,则执行业务逻辑操作共享资源,key 的真正有效时间等于有效时间减去获取锁所使用的时间(步骤 3 计算的结果)。
  5. 如果因为某些原因,获取锁失败(没有在至少 N/2+1 个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的 Redis 实例上进行解锁(即便某些 Redis 实例根本就没有加锁成功)。

另外部署实例的数量要求是奇数,为了能很好的满足过半原则,如果是 6 台则需要 4 台获取锁成功才能认为成功,所以奇数更合理

事情可没这么简单,Redis 作者把这个方案提出后,受到了业界著名的分布式系统专家的质疑

两人好比神仙打架,两人一来一回论据充足的对一个问题提出很多论断……

Redlock 是与非

Martin Kleppmann 认为锁定的目的是为了保护对共享资源的读写,而分布式锁应该「高效」和「正确」。

  • 高效性:分布式锁应该要满足高效的性能,Redlock 算法向 5 个节点执行获取锁的逻辑性能不高,成本增加,复杂度也高;
  • 正确性:分布式锁应该防止并发进程在同一时刻只能有一个线程能对共享数据读写。

出于这两点,我们没必要承担 Redlock 的成本和复杂,运行 5 个 Redis 实例并判断加锁是否满足大多数才算成功。

主从架构崩溃恢复极小可能发生,这没什么大不了的。使用单机版就够了,Redlock 太重了,没必要。

Martin 认为 Redlock 根本达不到安全性的要求,也依旧存在锁失效的问题!

Martin 的结论

  1. Redlock 不伦不类:对于偏好效率来讲,Redlock 比较重,没必要这么做,而对于偏好正确性来说,Redlock 是不够安全的。
  2. 时钟假设不合理:该算法对系统时钟做出了危险的假设(假设多个节点机器时钟都是一致的),如果不满足这些假设,锁就会失效。
  3. 无法保证正确性:Redlock 不能提供类似 fencing token 的方案,所以解决不了正确性的问题。为了正确性,请使用有「共识系统」的软件,例如 Zookeeper

Redis 作者 Antirez 的反驳

Redis 作者的反驳文章中,有 3 个重点:

  • 时钟问题:Redlock 并不需要完全一致的时钟,只需要大体一致就可以了,允许有「误差」,只要误差不要超过锁的租期即可,这种对于时钟的精度要求并不是很高,而且这也符合现实环境。
  • 网络延迟、进程暂停问题:

    • 客户端在拿到锁之前,无论经历什么耗时长问题,Redlock 都能够在第 3 步检测出来
    • 客户端在拿到锁之后,发生 NPC,那 Redlock、Zookeeper 都无能为力
  • 质疑 fencing token 机制。

关于 Redlock 的争论我们下期再见,现在进入 Redisson 实现分布式锁实战部分。

Redisson 分布式锁

基于 SpringBoot starter 方式,添加 starter。

<dependency>   <groupId>org.redisson</groupId>   <artifactId>redisson-spring-boot-starter</artifactId>   <version>3.16.4</version> </dependency> 

不过这里需要注意 springboot 与 redisson 的版本,因为官方推荐 redisson版本与 springboot 版本配合使用。

将 Redisson 与 Spring Boot 库集成,还取决于 Spring Data Redis 模块。

「码哥」使用 SpringBoot 2.5.x 版本, 所以需要添加 redisson-spring-data-25。

<dependency>   <groupId>org.redisson</groupId>   <!-- for Spring Data Redis v.2.5.x -->   <artifactId>redisson-spring-data-25</artifactId>   <version>3.16.4</version> </dependency>

添加配置文件

spring:   redis:     database:      host:     port:     password:     ssl:      timeout:     # 根据实际情况配置 cluster 或者哨兵     cluster:       nodes:     sentinel:       master:       nodes:

就这样在 Spring 容器中我们拥有以下几个 Bean可以使用:

  • RedissonClient
  • RedissonRxClient
  • RedissonReactiveClient
  • RedisTemplate
  • ReactiveRedisTemplate

基于Redis的Redisson分布式可重入锁RLock Java对象实现了java.util.concurrent.locks.Lock接口。

失败无限重试

RLock lock = redisson.getLock("码哥字节"); try {    // 1.最常用的第一种写法   lock.lock();      // 执行业务逻辑   .....    } finally {   lock.unlock(); } 

拿锁失败时会不停的重试,具有Watch Dog 自动延期机制,默认续30s 每隔30/3=10 秒续到30s。

失败超时重试,自动续命

// 尝试拿锁10s后停止重试,获取失败返回false,具有Watch Dog 自动延期机制, 默认续30s boolean flag = lock.tryLock(10, TimeUnit.SECONDS); 

超时自动释放锁

// 没有Watch Dog ,10s后自动释放,不需要调用 unlock 释放锁。 lock.lock(10, TimeUnit.SECONDS);

超时重试,自动解锁

// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁,没有 Watch dog boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS); if (res) {    try {      ...    } finally {        lock.unlock();    } }

Watch Dog 自动延时

如果获取分布式锁的节点宕机,且这个锁还出于锁定状态,就会出现死锁。

为了避免这个情况,我们都会给锁设置一个超时自动释放时间。

然而,还是会存在一个问题。

假设线程获取锁成功,并设置了 30 s 超时,但是在 30s 内任务还没执行完,锁超时释放了,就会导致其他线程获取不该获取的锁。

所以,Redisson 提供了 watch dog 自动延时机制,提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。

也就是说,如果一个拿到锁的线程一直没有完成逻辑,那么看门狗会帮助线程不断的延长锁超时时间,锁不会因为超时而被释放。

默认情况下,看门狗的续期时间是30s,也可以通过修改Config.lockWatchdogTimeout来另行指定。

另外Redisson 还提供了可以指定leaseTime参数的加锁方法来指定加锁的时间。

超过这个时间后锁便自动解开了,不会延长锁的有效期。

原理如下图:

有两个点需要注意:

  • watchDog 只有在未显示指定加锁超时时间(leaseTime)时才会生效。
  • lockWatchdogTimeout 设定的时间不要太小 ,比如设置的是 100毫秒,由于网络直接导致加锁完后,watchdog去延期时,这个key在redis中已经被删除了。

源码导读

在调用lock方法时,会最终调用到tryAcquireAsync。调用链为:lock()->tryAcquire->tryAcquireAsync`,详细解释如下:

private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {         RFuture<Long> ttlRemainingFuture;         //如果指定了加锁时间,会直接去加锁         if (leaseTime != -1) {             ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);         } else {             //没有指定加锁时间 会先进行加锁,并且默认时间就是 LockWatchdogTimeout的时间             //这个是异步操作 返回RFuture 类似netty中的future             ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,                     TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);         }          //这里也是类似netty Future 的addListener,在future内容执行完成后执行         ttlRemainingFuture.onComplete((ttlRemaining, e) -> {             if (e != null) {                 return;             }              // lock acquired             if (ttlRemaining == null) {                 // leaseTime不为-1时,不会自动延期                 if (leaseTime != -1) {                     internalLockLeaseTime = unit.toMillis(leaseTime);                 } else {                     //这里是定时执行 当前锁自动延期的动作,leaseTime为-1时,才会自动延期                     scheduleExpirationRenewal(threadId);                 }             }         });         return ttlRemainingFuture;     }

scheduleExpirationRenewal 中会调用renewExpiration启用了一个timeout定时,去执行延期动作。

private void renewExpiration() {         ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());         if (ee == null) {             return;         }          Timeout task = commandExecutor.getConnectionManager()           .newTimeout(new TimerTask() {             @Override             public void run(Timeout timeout) throws Exception {                 // 省略部分代码                 ....                  RFuture<Boolean> future = renewExpirationAsync(threadId);                 future.onComplete((res, e) -> {                     ....                      if (res) {                         //如果 没有报错,就再次定时延期                         // reschedule itself                         renewExpiration();                     } else {                         cancelExpirationRenewal(null);                     }                 });             }             // 这里我们可以看到定时任务 是 lockWatchdogTimeout 的1/3时间去执行 renewExpirationAsync         }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);          ee.setTimeout(task);     }

scheduleExpirationRenewal 会调用到 renewExpirationAsync,执行下面这段 lua脚本。

他主要判断就是 这个锁是否在redis中存在,如果存在就进行 pexpire 延期。

protected RFuture<Boolean> renewExpirationAsync(long threadId) {         return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,                 "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +                         "redis.call('pexpire', KEYS[1], ARGV[1]); " +                         "return 1; " +                         "end; " +                         "return 0;",                 Collections.singletonList(getRawName()),                 internalLockLeaseTime, getLockName(threadId));     } 
  • watch dog 在当前节点还存活且任务未完成则每 10 s 给锁续期 30s。
  • 程序释放锁操作时因为异常没有被执行,那么锁无法被释放,所以释放锁操作一定要放到 finally {} 中;
  • 要使 watchLog机制生效 ,lock时 不要设置 过期时间。
  • watchlog的延时时间 可以由 lockWatchdogTimeout指定默认延时时间,但是不要设置太小。
  • watchdog 会每 lockWatchdogTimeout/3时间,去延时。
  • 通过 lua 脚本实现延迟。

总结

完工,我建议你合上屏幕,自己在脑子里重新过一遍,每一步都在做什么,为什么要做,解决什么问题。

我们一起从头到尾梳理了一遍 Redis分布式锁中的各种门道,其实很多点是不管用什么做分布式锁都会存在的问题,重要的是思考的过程。

对于系统的设计,每个人的出发点都不一样,没有完美的架构,没有普适的架构,但是在完美和普适能平衡的很好的架构,就是好的架构。

No comments:

Post a Comment