微服务实战 Microservices-in-Action

摘录自微服务实战


微服务实战
摩根·布鲁斯 保罗·A.佩雷拉
131个笔记

◆ 译者序

通过将现有的单体应用或者业务领域进行拆分,分而治之来降低系统的复杂性和维护难度,这是一种很普遍的理念。

微服务架构不仅涉及架构设计和开发阶段,还包含了测试、部署以及运维阶段,是一个完整的生命周期。

以往的许多图书要么过于理论化,要么过于偏向技术应用层面的实践,要么仅侧重于某一个微服务的某一个阶段。

◆ 第1章 微服务的设计与运行

微服务为我们提供了一种更好地持续交付业务影响的方式。相较于单体应用,使用微服务构建出来的应用是由一系列松耦合的、自治的服务组成的

想要通过微服务来交付价值,团队就不能只关注开发这一步,还需要在部署、监控和诊断这些运维领域具备专业能力

◆ 1.1 什么是微服务应用

微服务应用是一系列自治服务的集合,每个服务只负责完成一块功能,这些服务共同合作来就可以完成某些更加复杂的操作

将那些因相同原因而修改的内容聚合到一起,将那些因不同原因而修改的内容进行拆分

每个微服务都拥有自己的数据存储,如果有的话。这能够降低服务之间的耦合度,因为其他服务只能通过这个服务提供的接口来访问它们自己所不拥有的数据。

微服务应用具备一些很有意思的技术特性:按照单一功能来开发服务可以让架构师能够在规模和职责上很自然地划定界限;自治性使得开发者可以独立地对这些服务进行开发、部署和扩容

自治性也是一种团队文化。将各个服务的责任和所有权委派给有责任交付商业价值的团队

微服务与生俱来地具备故障隔离的机制:如果开发者独立地部署这些微服务,那么当应用或者基础设施出现故障后,故障将只会影响到整个系统的一部分功能。同样,部署的功能粒度越小,开发者越能更平缓地对系统进行变更

开发者需要使用自动化来保证部署和系统运维过程中的正确性

有一些公司同时采用了多种不同的技术,这使得微服务成为很自然的选择

让开发者在解决业务问题的时候有更大的技术选择范围

软件开发的目标是持续地缩短交付周期来产生积极的商业价值。

在没有严格规范的团队中,软件模块和上下文边界含混不清,导致组件之间产生意料之外的紧耦合

微服务通过三种方式来降低冲突和风险:在开发阶段将依赖进行隔离和最小化;开发者可以对单个的内聚组件进行思考,而非整个系统;能够持续交付轻量的、独立的变更。

通过部署更小的功能元素,开发者可以降低每次独立部署的潜在风险,更好地隔离对线上系统的改动。

◆ 1.2 微服务的挑战

在将功能和数据所有权分发到多个自治的服务上的同时,开发者也将整个应用的稳定性和安全操作的责任分配到了这些服务上

正确识别服务间的边界和契约是很困难的,而且一旦确定,是很难对它们进行改动的

微服务是分布式系统,所以需要对状态、一致性和网络可靠性这些内容做出不同的假设

任何可能出现故障的东西最终肯定会出现故障

◆ 1.3 微服务开发生命周期

微服务开发周期的三大迭代阶段:设计、部署和监控

虽然在开始的时候,微服务方案的开发速度会慢一些,但是能够降低未来开发的冲突和风险。同样,随着工具和框架越来越成熟,微服务最佳实践不再那么令人生畏,会变得越来越容易应用

开发者应该将部署步骤标准化以简化系统操作,并在这些服务上保持一致。为了做到这一点,开发者需要做到两点:其一,将微服务部署的人为操作标准化;其二,实现持续交付的流水线

容器将应用的打包过程、运行接口进行了标准化,并且为操作环境和代码提供了不可变(immutability)的特性。这使得它们成了在更高层次进行组合的强有力的构件。通过使用容器,开发者可以定义任何服务的完整执行环境并将它们相互隔离

为了能够持续地交付软件,开发者需要关注以下两个目标。(1)制订一组软件必须通过的验证条件。在部署流程的每个环节,开发者都应该能够证明代码的正确性。(2)代码从提交状态发布到生产环境上的流水线实现自动化。

在生产环境中,开发者需要了解系统的运行情况。它的重要性有两点:其一,开发者想要主动发现系统中的薄弱环节并进行重构;其二,开发者需要了解系统的运行方式。

开发者需要在设计和实现这些服务时提高“透明性”的优先级。收集日志和一些数据指标,并将它们统一起来用于分析和告警。这样开发者在监控和分析系统的行为时,就可以诉诸于所构建的这个唯一的可信来源

◆ 2.3 开发新功能

在划定微服务范围时,开发者需要确保拆分系统所增加的复杂度不会超过所带来的益处

服务协作可以是点到点方式的,也可以是事件驱动方式的。点到点的通信通常是同步的,而事件驱动的通信通常是异步的。许多微服务应用起初使用的都是同步通信方式

◆ 2.5 将功能发布到生产环境中

将开发过程标准化。开发者应该评审代码的改动、编写对应的测试代码以及维护源代码的版本控制。但愿没有人对此要求表示意外或感到奇怪

将部署过程标准化和自动化。开发者应该彻底地验证所要提交到生产环境的代码变更,且要保证部署过程不需要工程师的介入,也就是说,要做成部署流水线。

为微服务应用开发完整的监控系统是一项很复杂的任务。开发者所采用的监控深入度会随着服务的数量和复杂度的提升而演化

◆ 2.6 大规模微服务开发

虽然微服务使得不同的服务可以选择不同的语言和框架,但是我们也很容易明白,不选择一套合理的标准和限制,系统会杂乱和脆弱得难以想象。

即便是最简单的日志消息格式,一致性和标准化也能够让在不同服务之间进行问题诊断和请求跟踪更加容易

◆ 2.8 小结

API网关是一种常见的模式,它将微服务架构的复杂性进行了封装和抽象,所以前端或者外部消费者不需要考虑这部分复杂度

如果开发者可以可靠地部署和监控某个服务,就可以对这个服务更有信心。

◆ 3.1 整体架构

架构师的职责是确保应用的技术基础能够支持快节奏的开发以及频繁的变化

◆ 3.2 微服务平台

微服务并不是独立存在的。微服务需要由如下基础设施提供支持。(1)服务运行的部署目标,包括基础设施的基本元件,如负载均衡器和虚拟机。(2)日志聚合和监控聚合用于观测服务运行情况。(3)一致且可重复的部署流水线,用于测试和发布新服务或者新版本。(4)支持安全运行,如网络控制、涉密信息管理和应用加固。(5)通信通道和服务发现方案,用于支持服务间交互。

◆ 3.5 服务边界

3种相关但又不同的应用边界模式:API网关、服务于前端的后端以及消费者驱动的网关

API网关在底层的服务后端之上为客户端提供了一个统一的入口点。它会代理发给下层服务的请求并对它们的返回结果进行一定的转换。API网关也可以处理一些客户端关注的其他横向问题,比如认证和请求签名。

服务于前端的后端(BFF)模式是API网关模式的一种变形。尽管API网关模式很简洁,但是它也存在一些缺点。如果API网关为多个客户端应用充当组合点的角色,它承担的职责就会越来越多。

◆ 3.7 小结

在微服务应用中,同步通信通常是第一选择,并且它非常适合命令型的交互,但是也存在缺点——会增加耦合性和不稳定性。

异步通信更加灵活,能够适应快速的系统演化,付出的代价是复杂度增加。

◆ 4.2 按业务能力划分

业务能力是指组织为了创造价值和实现业务目标所做的事情。被划为业务能力一类的微服务直接反映的是业务目标

一个领域的任何解决方案都是由若干个限界上下文组成的。每个上下文内的各个模型是高度内聚的,并且对现实世界抱有相同的认知。每个上下文之间都有明确且牢固的边界。

◆ 5.1 分布式应用的事务一致性

。数据所有权的去中心化能有助于确保服务的独立性和松耦合,但是这也使得我们不得不在系统层面上提供一套机制来维护整体数据的一致性。

在微服务应用中,可用性是处理给定的操作所涉及的所有微服务的可用性的乘积。

将重要的编配职责交给事务管理器同样违背了微服务的核心原则:服务自治。

分布式事务是通过给处于事务中的资源添加锁来确保隔离性的。这使得分布式事务不适合于那些耗时较长的操作,因为这会增加竞争和死锁的风险

◆ 5.2 基于事件的通信

事件使得开发者可以用一种乐观的方式来实现高可用。比如,即便fee服务出现故障,order服务仍旧能够创建订单。当fee服务恢复后,它可以继续处理积压的事件。我们可以将这个方法扩展到回滚的场景中:如果由于金额不足导致fee服务收费失败,fee服务可以发送一个ChargeFailed事件,然后其他服务就可以消费该事件来取消下单操作

◆ 5.3 Saga

在设计微服务时,我们需要把补偿考虑在内,以确保整个应用有足够的恢复能力。

这种编排式的交互很有用,因为参与交互的各个服务之间不需要明确知道对方的存在,这也就确保了它们之间是松耦合的。相应地,这也提高了每个服务的自治性。可惜的是,这个方案并非完美无缺。

一般来说,当选择异步的通信方式时,开发者必须在监控和跟踪技术方面上投入较多资源来确保能够跟踪系统的执行流程。

◆ 5.4 分布式世界中的查询操作

数据所有权的去中心化同样使得获取数据变得越来越困难,因为我们不再可以通过数据库层面的join关联这样的方式来聚合相关的数据。在应用的UI层展示来自于不同服务的数据通常是必要的功能。

我们可以通过引入批量查询接口来改进查询性能,如代码清单5.1所示。不同于获取所有客户信息,我们可以只获取第一页数据;不同于一个个地获取每个客户的订单,我们可以使用一个ID列表来获取所有数据。

CAP理论告诉我们,我们不可能两全其美:我们需要在可用性(成功返回一个结果,但不保证数据是最新的)和一致性(返回当前最新的状态,或者出错)两者之间进行选择。

如果在开发系统时,将可用性放在第一位,那么开发者在面对问题时就需要避免那种本能的、面向一致性的解决方案。即便某些系统看起来应该将一致性作为第一优先级,但是为了最大限度地提高使用的成功率,也往往会优先选择可用性作为折中的办法。

◆ 5.6 小结

在跨服务的交互中实现ACID特性是很困难的,微服务需要采用不同的方式来实现一致性。

基于事件的架构可以解除各个独立组件之间的耦合,并为微服务应用的业务逻辑和查询的可扩展性打下基础。

◆ 第6章 设计高可靠服务

彻底消除微服务应用中的故障是不可能的——与之对应的投入将是一个无底洞!反之,我们的重点应该放在设计出能够容忍依赖项出现故障的微服务,让这些微服务能够优雅地从故障中恢复正常或者能够减轻这些故障对其功能的影响。

◆ 6.1 可靠性定义

高可用通常用“9”来表示。两个9表示99%,5个9表示99.999%。生产环境上的关键服务中,如果可靠性低于这个值,是非常不正常的。

尽可能地提升服务的可用性或者隔离不可靠部分的影响是至关重要的,这样才能保证应用整体的可用性

◆ 6.2 哪些会出错

极有可能的是,任何可能发生的灾难未来终会发生

需要重点注意的是,硬件冗余会增加额外的维护成本。这些冗余方案有的过于复杂而难以设计和执行,还有的方案则纯粹是金钱的浪费

◆ 6.3 设计可靠的通信方案

如果故障是孤立和暂时性的,那么重试是一个很合理的选择。当异常行为发生时,重试操作一方面能够减轻对终端用户的影响,另一方面还能减少运维工作人员的介入

但是如果故障是持续性的——比如,如果market-data服务的承载力下降了,后续的请求会使这一问题进一步恶化并导致系统稳定性下降

坏的情况是,最初的故障不断被放大,market-data服务活活被重试方案压死了。

永远要限制重试的总次数;使用带抖动的指数退避策略来均匀地分配重试请求和避免进一步加剧负载;仔细考虑哪些错误情况应该触发重试,以及哪些重试不大可能成功、哪些重试永远不会成功。

如果某个服务的依赖项出现了故障,我们可以考虑4种后备方案:优雅降级、缓存、功能冗余和桩数据。

当开发者不需要立刻得到响应,也不需要响应始终保持一致时,我们就可以使用异步通信技术来降低直接的服务调用的数量,相应地,这也能够提高系统整体的可用性

◆ 6.4 最大限度地提高服务可靠性

我们可以基于两个标准来对健康检查分类:存活性(liveness)和就绪性(readiness)。存活性检查通常只是简单地检查应用是否启动起来和是否正常运行。

就绪性检查体现的是服务是否准备好处理通信数据,因为服务存活着并不意味着请求就会成功。一个服务还会有许多依赖——数据库、第三方服务、配置数据、缓存等——所以开发者可以使用就绪性检查来判断这些组件是否能够正确处理请求

某些情况下,服务的功能会被降级和出现时延增大或者错误率增高的问题,而健康检查并不能反映出这种状态。在这种情况下,如果负载均衡器能够感知实例的时延、性能、负载情况,然后根据这些信息把请求路由给性能更高的或者负载更低的实例,那么将有很大裨益。

一个合适的解决方案是明确地限制一个时间窗口内对协作服务的请求频率或者总有效请求量

在开发一个服务时,读者应该:对服务流量的现状和预期增长情况建模,以确保对服务的可能使用情况做到心里有数;估算数据请求流量所需要的服务容量;针对估算的容量通过压力测试来验证所部署服务的实际容量;根据业务和服务指标来视情况重新估算容量。

单个服务层面,开发者应该将每个服务的压力测试实现自动化并成为交付流水线的一部分

混沌测试会倒逼着微服务应用在生产环境中出现故障。通过引入不稳定性因素以及故障,混沌测试可以精确模拟真实系统的故障,同时也让工程团队得到训练,使得他们能够处理这些故障。

◆ 6.5 默认安全

一种常见的确保服务间正确通信的方式是强制使用特定的类库:一套实现了断路器、重试和后备方案等常见的交互模式的类库

服务网格中,服务是通过一个服务网格应用来相互通信的,这个应用通常是作为一个单独的进程,它是和服务本身部署在同一台主机上的。开发者可以配置一个代理来管理相应的流量——对请求进行重试、管理超时或者在不同服务间进行负载均衡。从调用方的角度,这个网格并不存在——它和往常一样向另一个服务发送HTTP或者RPC调用。

对于那些用多种不同技术开发的应用而言,这能够减少服务之间的通信防御性工作,提高通信安全性。否则,想要在不同的语言上都实现一致的通信方式会需要大量的投入,

◆ 6.6 小结

错误量达到一定阈值时,服务之间的断路器会通过快速失败来避免连锁故障

服务可以使用限流策略来保护其免于受到突发的超过服务容量承载能力的负载请求高峰的影响

◆ 7.1 微服务底座

为了简化团队创建服务的工作,花些时间来为组织内构建和维护服务所使用的每种语言提供一套基本框架和经过审查的工具是很值得的。

(1)从一开始就支持在容器调度器中部署服务(CI/CD)。
(2)支持日志聚合。
(3)收集度量指标数据。
(4)具备同步和异步通信机制。
(5)错误报告。

◆ 7.2 微服务底座的目的

微服务底座的目的是简化服务的创建过程,同时确保开发者拥有一套所有服务都要遵循的标准,而不管哪个团队负责相应的服务

底座可以通过不断地包含进不同团队的新发现、新成果来持续演进,这样开发者就可以始终和组织内部的实践和经验保持同步更新,以应对不同的使用场景。

◆ 7.3 设计服务底座

服务的运行和维护与编写代码同等重要,而且大部分情况下,服务运行的时间要远远大于开发所需要的时间。

◆ 8.1 部署的重要性

(1)面对大量的发布和组件变更时应保持稳定性;
(2)避免会导致组件在构建阶段或者发布阶段产生依赖关系的紧耦合;
(3)服务API发布不兼容的变更可能会对客户端产生非常大的负面影响;
(4)服务下线。

◆ 8.2 微服务生产环境

微服务架构的流行、DevOps实践(如基础设施即代码,infrastructure as code)的广泛采用以及使用云服务提供商来运行应用的情况日益增多,这三者同时存在并非巧合。这些实践能够让服务快速迭代和部署,从而使微服务架构成为一种可扩展和可行的方案。

◆ 8.4 构建服务工件

理想的微服务部署工件允许开发者打包特定版本的已编译代码,指定任何二进制依赖项,并为启动和停止该服务提供标准的操作抽象概念。这应该是与环境无关的:开发者应该能够在本地、测试和生产中运行相同的工件。通过将不同语言在运行时的差异抽取出来,开发者可以降低认知负担,并为管理这些服务提供通用的抽象概念。

不同于分发整个机器,像Docker或者rkt这样的容器化工具提供了一种更加轻量级的封装应用及其依赖的方式。

◆ 第9章 基于容器和调度器的部署

对于微服务部署和运行而言,容器是一种非常优雅的抽象,它提供了跨语言的统一打包方案、应用级隔离以及快速的启动时间。

容器并不是使用微服务的必要条件。开发者可以使用许多方法来部署服务,比如使用我们在第8章中提到的单虚拟机单服务模型。但是借助调度程序,容器提供了一种特别优雅和灵活的方法来满足我们的两个部署目标:速度和自动化。

◆ 9.1 服务容器化

如果我们想要交付服务,需要先搞清楚如何把服务封装到容器里

使用公开的Docker镜像会潜在地增加安全风险。许多镜像,特别是那些非官方维护的镜像,并不会定期更新或者修复漏洞,这会增加系统的风险。

如果只在开发者的机器上使用该镜像,那么它没有多大用处。在部署此镜像时,开发者应该从Docker注册中心拉取该镜像。

◆ 9.2 集群部署

直接创建或者销毁pod都是不正常的;相反,pod是由控制器管理的。

为了引用每个协作方服务的相关端点而将端口号或IP地址硬编码到每个服务中,是非常愚蠢的行为,任何服务都不应该与另一个内部网络位置紧密耦合在一起。相反,开发者需要通过某种已知名称的方式来访问合作者。

Kubernetes集成了本地DNS服务来实现这一点,它在Kubernetes主节点上作为一个pod运行。当创建新的服务时,DNS服务会以{my-svc}.{my-namespace}. svg .cluster.local格式为该服务分配一个名称

◆ 第10章 构建微服务交付流水线

部署流水线是构建持续交付的核心基石。将其想象成工厂的生产线:一条传送带把软件从提交的代码制作成可部署的工件再到运行中的软件,并在每个阶段持续评估产出物的质量。这样做可以提高部署频率,减小部署规模,并且不会出现将爆炸性的大量改动部署到生产环境中的情况。

◆ 10.1 让部署变得平淡

发布少量的变更并系统地验证它们的质量,能够让团队在快速发布功能的过程中信心更强。版本的规模越小,相应的风险也就越低。持续交付的方法使团队能够快速独立地交付服务。

持续交付并不完全等同于持续部署。对于后者,只有每次验证后的变更才会被自动部署到生产环境。而对于前者,读者可以将每次变更都部署到生产环境,但是否部署取决于工程师团队和业务需求。

◆ 10.2 使用Jenkins构建流水线

除了没有真实流量,预发布环境的基础设施配置应该和生产环境中的基本相同。它不需要以和生产环境相同的规模来运行。

尽管预发布环境很重要,但是在微服务应用中,预发布环境是很难管理的

(1)流水线应该等待批准后才可以继续将服务部署到生产环境。
(2)在审批通过后,首先发布一个金丝雀实例。这有助于验证新版本在面对真正的生产环境请求时是否能保持稳定。
(3)如果金丝雀实例运行正常,那么流水线可以继续将剩下的实例部署到生产环境。
(4)如果金丝雀实例的运行未达预期,则回滚该金丝雀实例。

◆ 11.1 稳固的监控技术栈

监控能够让开发者了解系统是否在正常运行,而可观测性能够让开发者知道系统没有正常运行的原因。

套监控系统技术栈的各个组成部分:度量指标、日志和链路追踪。

开发者需要能够收集许多来源的数据,以进行系统监控和问题诊断或者在问题发生之前阻止其发生。

开发者应该关注四大黄金标志:时延、错误量、通信量和饱和度。

展现这些度量指标时,开发者应该聚焦于最重要的部分,如响应时间、错误量和通信量。

◆ 12.4 服务间的跟踪交互

日志并不是唯一可以使用的工具,还可以采用另一种不依赖日志数据的方法来完成此任务。

◆ 第13章 微服务团队建设

软件是由人来开发的,构建出优秀的软件不仅需要实现方案的决策,还需要有效的沟通、协调和协作。

微服务架构对于完成任务非常有用。一方面,它使读者可以借助微服务快速构建新的服务和能力,并且与现有的功能保持相互独立;另一方面,它又增加了日常工作(比如运维、安全和值班待命支持)的范围和复杂度。微服务可以显著地改变一个组织的技术战略,需要工程师具有强烈的主人翁精神和责任感。建设这种团队文化,同时尽可能减少摩擦和加快速度,这对于成功实现微服务是至关重要的。

◆ 13.1 建设高效团队

对那些成功搭建了微服务应用的组织来说,想要分清楚原因和结果是很困难的。究竟是合理的组织结构和团队表现决定了细粒度的服务开发方式,还是细粒度服务的构建经验决定了组织结构和团队表现呢?

在许多企业中,转向微服务——或者任何真正的大规模架构调整——都是极具挑战性和破坏性的。没有人能够独自取得成功:需要寻找资助、建立信任,并准备好为自己的方案进行大量的辩论

软件如何运行,如何在现实世界中观察它的行为,这些都应该反馈给软件以进行改进

如果不负责运维工作的话,这些信息常常会丢失。这一原则也是DevOps运动的核心

◆ 13.2 团队模型

跨职能分组的团队由具有不同专长和角色的人员组成,旨在实现特定的业务目标

跨职能分组的团队可以更紧密地与团队活动的最终目标保持一致。团队的多技能特点有利于加强所有权

在某种意义上,开发者可以把这些服务看作大公司内部的小型初创企业。其中每一个服务都需要强烈关注它们的客户是谁,不管它们是外部的还是内部的。

(1)观察团队规模。如果团队人数接近或超过9个人,那么这个团队很可能承担了太多的工作,或开始遇到沟通耗时的问题。

(2)考虑一致性。团队所执行的活动之间是否内聚和有紧密的联系?如果没有,那么团队中不同的工作小组之间可能存在着自然分裂。

当刚开始使用微服务时,构建应用的团队通常也负责平台的构建任务

随着时间的推移,这个平台需要满足多个团队的需求,在此阶段,读者可能会开始建立一个平台团队

工程团队要对自己所负责服务产生的告警保持随时待命;平台和基础设施团队要对底层的基础设施或者公享服务(比如部署)保持随时待命;两个团队之间存在的升级途径可以支持对告警进一步调查。

尽管自治团队提高了开发速度,但是它们也存在一些缺点:不同的团队可能会用不同的方式多次解决同一个问题;团队成员与其他团队中的同事之间的互动将会减少;团队成员可能会在不考虑全局背景或更广泛的组织需求的情况下做出局部决策。

如果可以更快地交付功能,那么一定程度的重复是可以接受的。

◆ 13.3 微服务团队的实践建议

微服务应用的变动规模可能是巨大的。要跟上所有变化可能是很难的!期望所有工程师都能够深入了解所有服务及其交互方式是不合理的,特别是这些服务的拓扑结构可能在没有任何警告的情况下发生了变化

◆ 13.5 小结

应用架构和团队结构有着共生的关系。可以使用后者来改变前者。

effective go 简读

摘录自《高效的 Go 编程 Effective Go 2020》| Go 技术论坛 , 由 chrome插件简悦 · 收集合辑整理


引言

Go 是一门全新的语言。尽管它从既有的语言中借鉴了许多理念,但其与众不同的特性, 使得使用 Go 编程在本质上就不同于其它语言。

换句话说,要想将 Go 程序写得好,就必须理解其特性和风格。了解命名、格式化、 程序结构等既定规则


格式化

格式化问题总是充满了争议,但却始终没有形成统一的定论。

若所有人都遵循相同的编码风格,在这类问题上浪费的时间将会更少。 问题就在于如何实现这种设想,而无需冗长的语言风格规范

在 Go 中我们另辟蹊径,让机器来处理大部分的格式化问题。gofmt 程序(也可用 go fmt,它以包为处理对象而非源文件)将 Go 程序按照标准风格缩进、 对齐,保留注释并在需要时重新格式化。

我们使用制表符(tab)缩进,gofmt 默认也使用它。在你认为确实有必要时再使用空格。

Go 对行的长度没有限制,别担心打孔纸不够长。如果一行实在太长,也可进行折行并插入适当的 tab 缩进。

比起 C 和 Java,Go 所需的括号更少:控制结构(ifforswitch)在语法上并不需要圆括号。


代码注释

Go 语言支持 C 风格的块注释 /* */ 和 C++ 风格的行注释 //。 行注释更为常用,而块注释则主要用作包的注释,当然也可在禁用一大段代码时使用。

注释无需进行额外的格式化,如用星号来突出等。生成的输出甚至可能无法以等宽字体显示, 因此不要依赖于空格对齐,godoc 会像 gofmt 那样处理好这一切。 注释是不会被解析的纯文本

fmt 包的注释就用了这种不错的效果

go doc fmt

godoc 既是一个程序,又是一个 Web 服务器,它对 Go 的源码进行处理,并提取包中的文档内容。 出现在顶级声明之前,且与该声明之间没有空行的注释,将与该声明一起被提取出来,作为该条目的说明文档。

参考 ftm.print包

文档注释最好是完整的句子,这样它才能适应各种自动化的展示。 第一句应当以被声明的东西开头,并且是单句的摘要。

godoc -http=:6060

1
2
3
// Compile 用于解析正则表达式并返回,如果成
// 功,则 Regexp 对象就可用于匹配所针对的文本。
func Compile(str string) (*Regexp, error) {

命名规则

正如命名在其它语言中的地位,它在 Go 中同样重要

当一个包被导入后,包名就会成了内容的访问器。在以下代码

之后,被导入的包就能通过 bytes.Buffer 来引用了

按照惯例, 包应当以小写的单个单词来命名,且不应使用下划线或驼峰记法

Go 并不对获取器(getter)和设置器(setter)提供自动支持。 你应当自己提供获取器和设置器,通常很值得这样做,但若要将 Get 放到获取器的名字中,既不符合习惯,也没有必要。若你有个名为 owner (小写,未导出)的字段,其获取器应当名为 Owner(大写,可导出)而非 GetOwner。大写字母即为可导出的这种规定为区分方法和字段提供了便利。 若要提供设置器方法,SetOwner 是个不错的选择。两个命名看起来都很合理:

接口命名#

按照约定,只包含一个方法的接口应当以该方法的名称加上 - er 后缀来命名,如 ReaderWriterFormatterCloseNotifier 等。

驼峰 命名#

最后,Go 中的约定是使用 MixedCapsmixedCaps 而不是下划线来编写多个单词组成的命名。


分号

和 C 一样,Go 的正式语法使用分号来结束语句,和 C 不同的是,这些分号并不在源码中出现。 取而代之,词法分析器会使用一条简单的规则来自动插入分号,因此源码中基本就不用分号了。

通常 Go 程序只在诸如 for 循环子句这样的地方使用分号, 以此来将初始化器、条件及增量元素分开。如果你在一行中写多个语句,也需要用分号隔开。

警告:无论如何,你都不应将一个控制结构(ifforswitchselect)的左大括号放在下一行。

Go 中的结构控制与 C 有许多相似之处,但其不同之处才是独到之处。 Go 不再使用 do 或 while 循环,只有一个更通用的 forswitch 要更灵活一点;if 和 switch 像 for 一样可接受可选的初始化语句; 此外,还有一个包含类型选择和多路通信复用器的新控制结构:select。 其语法也有些许不同:没有圆括号,而其主体必须始终使用大括号括住。

1
2
3
4
if err := file.Chmod(0664); err != nil {
log.Print(err)
return err
}

err 在第一条语句中被声明,但在第二条语句中只是被再次赋值罢了。也就是说,调用 f.Stat 使用的是前面已经声明的 err,它只是被重新赋值了而已。

若你想遍历数组、切片、字符串或者映射,或从信道中读取消息, range 子句能够帮你轻松实现循环。

1
2
3
4
sum := 0
for i := 0; i < 10; i++ {
sum += i
}

在满足下列条件时,已被声明的变量 v 可出现在:= 声明中:

  • 本次声明与已声明的 v 处于同一作用域中(若 v 已在外层作用域中声明过,则此次声明会创建一个新的变量 §),
  • 在初始化中与其类型相应的值才能赋予 v,且
  • 在此次声明中至少另有一个变量是新声明的。
1
2
3
for key, value := range oldMap {
newMap[key] = value
}

Go 没有逗号操作符,而 ++ 和 -- 为语句而非表达式。 因此,若你想要在 for 中使用多个变量,应采用平行赋值的方式 (因为它会拒绝 ++ 和 --).

Go 的 switch 比 C 的更通用。其表达式无需为常量或整数,case 语句会自上而下逐一进行求值直到匹配为止。若 switch 后面没有表达式,它将匹配 true,因此,我们可以将 if-else-if-else 链写成一个 switch,这也更符合 Go 的风格。

1
2
3
4
5
6
7
8
9
10
11
func unhex(c byte) byte {
switch {
case '0' <= c && c <= '9':
return c - '0'
case 'a' <= c && c <= 'f':
return c - 'a' + 10
case 'A' <= c && c <= 'F':
return c - 'A' + 10
}
return 0
}

switch 并不会自动下溯,但 case 可通过逗号分隔来列举相同的处理条件。

1
2
3
4
5
6
7
func shouldEscape(c byte) bool {
switch c {
case ' ', '?', '&', '=', '#', '+', '%':
return true
}
return false
}

switch 也可用于判断接口变量的动态类型。如 类型选择 通过圆括号中的关键字 type 使用类型断言语法。若 switch 在表达式中声明了一个变量,那么该变量的每个子句中都将有该变量对应的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var t interface{}
t = functionOfSomeType()
switch t := t.(type) {
default:
fmt.Printf("unexpected type %T\n", t) // %T 打印任何类型的 t
case bool:
fmt.Printf("boolean %t\n", t) // t 是 bool 类型
case int:
fmt.Printf("integer %d\n", t) // t 是 int 类型
case *bool:
fmt.Printf("pointer to boolean %t\n", *t) // t 是 *bool 类型
case *int:
fmt.Printf("pointer to integer %d\n", *t) // t 是 *int 类型
}

函数

Go 与众不同的特性之一就是函数和方法可返回多个值。这种形式可以改善 C 中一些笨拙的习惯: 将错误值返回(例如用 -1 表示 EOF)和修改通过地址传入的实参。

Go 函数的返回值或结果 “形参” 可被命名,并作为常规变量使用,就像传入的形参一样。 命名后,一旦该函数开始执行,它们就会被初始化为与其类型相应的零值; 若该函数执行了一条不带实参的 return 语句,则结果形参的当前值将被返回。

Go 的 defer 语句用于预设一个函数调用(即推迟执行函数), 该函数会在执行 defer 的函数返回之前立即执行。它显得非比寻常, 但却是处理一些事情的有效方式,例如无论以何种路径返回,都必须释放资源的函数。 典型的例子就是解锁互斥和关闭文件。

被推迟函数的实参在 defer 执行时才会被求值


数据

Go 提供了两种分配原语,即内建函数 newmake。 它们所做的事情不同,所应用的类型也不同。它们可能会引起混淆,但规则却很简单

new(T) 会为类型为 T 的新项分配已置零的内存空间, 并返回它的地址

1
2
p := new(SyncedBuffer)  // type *SyncedBuffer
var v SyncedBuffer // type SyncedBuffer

有时零值还不够好,这时就需要一个初始化构造函数,如来自 os 包中的这段代码所示

1
2
3
4
5
6
7
8
9
10
11
func NewFile(fd int, name string) *File {
if fd < 0 {
return nil
}
f := new(File)
f.fd = fd
f.name = name
f.dirinfo = nil
f.nepipe = 0
return f
}
1
2
3
4
5
6
7
func NewFile(fd int, name string) *File {
if fd < 0 {
return nil
}
f := File{fd, name, nil, 0}
return &f
}

返回一个局部变量的地址完全没有问题,这点与 C 不同。该局部变量对应的数据 在函数返回后依然有效

表达式 new(File)&File{} 是等价的

回到内存分配上来。内建函数 make(T,args) 的目的不同于 new(T)。它只用于创建切片、映射和信道,并返回类型为 T(而非 *T)的一个已初始化 (而非置零)的值

对于切片、映射和信道,make 用于初始化其内部的数据结构并准备好将要使用的值

请记住,make 只适用于映射、切片和信道且不返回指针。若要获得明确的指针, 请使用 new 分配内存。

在 Go 中,

  • 数组是值。将一个数组赋予另一个数组会复制其所有元素。
  • 特别地,若将某个数组传入某个函数,它将接收到该数组的一份副本而非指针。
  • 数组的大小是其类型的一部分。类型 [10]int[20]int 是不同的。

数组为值的属性很有用,但代价高昂;若你想要 C 那样的行为和效率,你可以传递一个指向该数组的指针。

切片通过对数组进行封装,为数据序列提供了更通用、强大而方便的接口。 除了矩阵变换这类需要明确维度的情况外,Go 中的大部分数组编程都是通过切片来完成的。

切片保存了对底层数组的引用,若你将某个切片赋予另一个切片,它们会引用同一个数组。 若某个函数将一个切片作为参数传入,则它对该切片元素的修改对调用者而言同样可见, 这可以理解为传递了底层数组的指针。

我们必须返回切片,因为尽管 Append 可修改 slice 的元素,但切片自身(其运行时数据结构包含指针、长度和容量)是通过值传递的。

映射是方便而强大的内建数据结构,它可以关联不同类型的值。其键可以是任何相等性操作符支持的类型, 如整数、浮点数、复数、字符串、指针、接口(只要其动态类型支持相等性判断)、结构以及数组。

映射可使用一般的复合字面语法进行构建,其键 - 值对使用冒号分隔,因此可在初始化时很容易地构建它们。

1
2
3
4
5
6
7
var timeZone = map[string]int{
"UTC": 0*60*60,
"EST": -5*60*60,
"CST": -6*60*60,
"MST": -7*60*60,
"PST": -8*60*60,
}

若试图通过映射中不存在的键来取值,就会返回与该映射中项的类型对应的零值。

需要区分某项是不存在还是其值为零值。如对于一个值本应为零的 "UTC" 条目,也可能是由于不存在该项而得到零值。你可以使用多重赋值的形式来分辨这种情况。

1
2
3
var seconds int
var ok bool
seconds, ok = timeZone[tz]

若仅需判断映射中是否存在某项而不关心实际的值,可使用空白标识符_)来代替该值的一般变量

1
_, present := timeZone[tz] 

要删除映射中的某项,可使用内建函数 delete,它以映射及要被删除的键为实参。 即便对应的键不在该映射中,此操作也是安全的。

映射中的键可能按任意顺序输出。当打印结构体时,改进的格式 %+v 会为结构体的每个字段添上字段名,而另一种格式 %#v 将完全按照 Go 的语法打印值。

1
2
3
4
&{7 -2.35 abc   def}
&{a:7 b:-2.35 c:abc def}
&main.T{a:7, b:-2.35, c:"abc\tdef"}
map[string]int{"CST":-21600, "EST":-18000, "MST":-25200, "PST":-28800, "UTC":0}

若你想控制自定义类型的默认格式,只需为该类型定义一个具有 String() string 签名的方法。对于我们简单的类型 T,可进行如下操作。

1
2
3
4
func (t *T) String() string {
return fmt.Sprintf("%d/%g/%q", t.a, t.b, t.c)
}
fmt.Printf("%v\n", t)

我们的 String 方法也可调用 Sprintf, 因为打印例程可以完全重入并按这种方式封装。不过有一个重要的细节你需要知道: 请勿通过调用 Sprintf 来构造 String 方法,因为它会无限递归你的的 String 方法。如果 Sprintf 调用试图将接收器直接打印为字符串,而该字符串又将再次调用该方法,则会发生这种情况。这是一个常见的错误,如本例所示。

1
2
3
4
5
type MyString string

func (m MyString) String() string {
return fmt.Sprintf("MyString=%s", m) // 错误:会无限递归
}

要解决这个问题也很简单:将该实参转换为基本的字符串类型,它没有这个方法。

1
2
3
4
type MyString string
func (m MyString) String() string {
return fmt.Sprintf("MyString=%s", string(m)) // 可以:注意转换
}
1
2
3
4
type MyString string
func (m MyString) String() string {
return fmt.Sprintf("MyString=%s", string(m)) // 可以:注意转换
}
1
2
3
4
5
6
7
8
9
func Min(a ...int) int {
min := int(^uint(0) >> 1) // 最大的 int
for _, i := range a {
if i < min {
min = i
}
}
return min
}

如果我们要像 Append 那样将一个切片追加到另一个切片中呢? 很简单:在调用的地方使用 ...,就像我们在上面调用 Output 那样。以下代码片段的输出与上一个相同。

1
2
3
4
x := []int{1,2,3}
y := []int{4,5,6}
x = append(x, y...)
fmt.Println(x)

初始化

尽管从表面上看,Go 的初始化过程与 C 或 C++ 差别并不算太大,但它确实更为强大。 在初始化过程中,不仅可以构建复杂的结构,还能正确处理不同包对象间的初始化顺序。

Go 中的常量就是不变量。它们在编译时创建,即便它们可能是函数中定义的局部变量。 常量只能是数字、字符(符文)、字符串或布尔值。由于编译时的限制, 定义它们的表达式必须也是可被编译器求值的常量表达式。例如 1<<3 就是一个常量表达式,而 math.Sin(math.Pi/4) 则不是,因为对 math.Sin 的函数调用在运行时才会发生。

可被编译器求值

1
2
3
4
5
var (
home = os.Getenv("HOME")
user = os.Getenv("USER")
gopath = os.Getenv("GOPATH")
)

每个源文件都可以通过定义自己的无参数 init 函数来设置一些必要的状态。 (其实每个文件都可以拥有多个 init 函数。)而它的结束就意味着初始化结束: 只有该包中的所有变量声明都通过它们的初始化器求值后 init 才会被调用, 而包中的变量只有在所有已导入的包都被初始化后才会被求值。

init 函数还常被用在程序真正开始执行前,检验或校正程序的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
func init() {
if user == "" {
log.Fatal("$USER not set")
}
if home == "" {
home = "/home/" + user
}
if gopath == "" {
gopath = home + "/go"
}
// gopath 可通过命令行中的 --gopath 标记覆盖掉。
flag.StringVar(&gopath, "gopath", gopath, "override default GOPATH")
}

方法

正如 ByteSize 那样,我们可以为任何已命名的类型(除了指针或接口)定义方法; 接收者可不必为结构体。

以指针或值为接收者的区别在于:值方法可通过指针和值调用, 而指针方法只能通过指针来调用。

之所以会有这条规则是因为指针方法可以修改接收者;通过值调用它们会导致方法接收到该值的副本, 因此任何修改都将被丢弃,因此该语言不允许这种错误。


接口与其它类型

Go 中的接口为指定对象的行为提供了一种方法:如果某样东西可以完成这个, 那么它就可以用在这里

是类型转换的一种形式:它接受一个接口,在选择 (switch)中根据其判断选择对应的情况(case), 并在某种意义上将其转换为该种类型

1
2
3
4
5
6
7
8
9
10
11
type Stringer interface {
String() string
}

var value interface{} // Value 由调用者提供
switch str := value.(type) {
case string:
return str
case Stringer:
return str.String()
}
1
2
3
4
5
6
str, ok := value.(string)
if ok {
fmt.Printf("string value is: %q\n", str)
} else {
fmt.Printf("value is not a string\n")
}

空白标识符

我们在 for-range 循环映射中提过几次空白标识符。 空白标识符可被赋予或声明为任何类型的任何值,而其值会被无害地丢弃。它有点像 Unix 中的 /dev/null 文件:它表示只写的值,在需要变量但不需要实际值的地方用作占位符。 我们在前面已经见过它的用法了。

若某次赋值需要匹配多个左值,但其中某个变量不会被程序使用, 那么用空白标识符来代替该变量可避免创建无用的变量,并能清楚地表明该值将被丢弃。 例如,当调用某个函数时,它会返回一个值和一个错误,但只有错误很重要, 那么可使用空白标识符来丢弃无关的值。

1
2
3
if _, err := os.Stat(path); os.IsNotExist(err) {
fmt.Printf("%s does not exist\n", path)
}

你偶尔会看见为忽略错误而丢弃错误值的代码,这是种糟糕的实践。请务必检查错误返回, 它们会提供错误的理由。

1
2
3
4
5
// 很糟糕的代码!若路径不存在,它就会崩溃。
fi, _ := os.Stat(path)
if fi.IsDir() {
fmt.Printf("%s is a directory\n", path)
}

若导入某个包或声明某个变量而不使用它就会产生错误。未使用的包会让程序膨胀并拖慢编译速度, 而已初始化但未使用的变量不仅会浪费计算能力,还有可能暗藏着更大的 Bug。 然而在程序开发过程中,经常会产生未使用的导入和变量。虽然以后会用到它们, 但为了完成编译又不得不删除它们才行,这很让人烦恼。空白标识符就能提供一个工作空间。

要让编译器停止关于未使用导入的包,需要空白标识符来引用已导入包中的符号。 同样,将未使用的变量 fd 赋予空白标识符也能关闭未使用变量错误。 该程序的以下版本可以编译。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
"fmt"
"io"
"log"
"os"
)

var _ = fmt.Printf // 用于调试,结束时删除。
var _ io.Reader // 用于调试,结束时删除。

func main() {
fd, err := os.Open("test.go")
if err != nil {
log.Fatal(err)
}
// TODO: use fd.
_ = fd
}
1
import _ "net/http/pprof" 

为辅助作用导入

若只需要判断某个类型是否是实现了某个接口,而不需要实际使用接口本身 (可能是错误检查部分),就使用空白标识符来忽略类型断言的值:

1
2
3
if _, ok := val.(json.Marshaler); ok {
fmt.Printf("value %v of type %T implements json.Marshaler\n", val, val)
}

内嵌

Go 并不提供典型的,类型驱动的子类化概念,但通过将类型内嵌到结构体或接口中, 它就能 “借鉴” 部分实现。

1
2
3
4
5
// ReadWriter 接口结合了 Reader 接口 和 Writer 接口
type ReadWriter interface {
Reader
Writer
}

ReadWriter 能够做任何 Reader  Writer 可以做到的事情,它是内嵌接口的联合体 (它们必须是不相交的方法集)。只有接口能被嵌入到接口中。

当内嵌一个类型时,该类型的方法会成为外部类型的方法, 但当它们被调用时,该方法的接收者是内部类型,而非外部的。在我们的例子中,当 bufio.ReadWriter 的 Read 方法被调用时, 它与之前写的转发方法具有同样的效果;接收者是 ReadWriter 的 reader 字段,而非 ReadWriter 本身。

type Bank struct { sync.RWMutex saving map[string]int } func NewBank() *Bank { b := Bank{ saving: make(map[string]int), } return &b } //存 func (b *Bank) Deposit(name string, amount int) { // 可以直接调用自身的Lock方法,但真实接收者是 b.RWMutex ,体现了内嵌的便利性 b.Lock() defer b.Unlock() remain, ok := b.saving[name] if !ok { b.saving[name] = amount } else { b.saving[name] = remain + amount } } //取 func (b *Bank) Withdraw(name string, amount int) int { b.Lock() defer b.Unlock() remain, ok := b.saving[name] if !ok { return 0 } if remain < amount { amount = remain } b.saving[name] = remain - amount return amount } //查 func (b *Bank) Query(name string) int { b.RLock() defer b.RUnlock() amount, ok := b.saving[name] if !ok { return 0 } return amount }


并发

并发编程是个很大的论题。但限于篇幅,这里仅讨论一些 Go 特有的东西。

在并发编程中,为实现对共享变量的正确访问需要精确的控制,这在多数环境下都很困难。

不要通过共享内存来通信,而应通过通信来共享内存。

我们称之为 Go 协程是因为现有的术语 — 线程、协程、进程等等 — 无法准确传达它的含义。 Go 协程具有简单的模型:它是与其它 Go 协程并发运行在同一地址空间的函数。它是轻量级的, 所有消耗几乎就只有栈空间的分配。而且栈最开始是非常小的,所以它们很廉价, 仅在需要时才会随着堆空间的分配(和释放)而变化。

Go 协程的设计隐藏了线程创建和管理的诸多复杂性。

在函数或方法前添加 go 关键字能够在新的 Go 协程中调用它。当调用完成后, 该 Go 协程也会安静地退出。

信道与映射一样,也需要通过 make 来分配内存。其结果值充当了对底层数据结构的引用。 若提供了一个可选的整数形参,它就会为该信道设置缓冲区大小。默认值是零,表示不带缓冲的或同步的信道。

1
2
3
ci := make(chan int)            // 整数无缓冲信道
cj := make(chan int, 0) // 整数无缓冲信道
cs := make(chan *os.File, 100) // 指向文件的指针的缓冲信道

若信道是不带缓冲的,那么在接收者收到值前, 发送者会一直阻塞;若信道是带缓冲的,则发送者仅在值被复制到缓冲区前阻塞; 若缓冲区已满,发送者会一直等待直到某个接收者取出一个值为止。

带缓冲的信道可被用作信号量,例如限制吞吐量。在此例中,进入的请求会被传递给 handle,它向信道内发送一个值,处理请求后将值从信道中取回,以便让该 “信号量” 准备迎接下一次请求。信道缓冲区的容量决定了同时调用 process 的数量上限,因此我们在初始化时首先要填充至它的容量上限。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var sem = make(chan int, MaxOutstanding)

func handle(r *Request) {
sem <- 1 // 等待活动队列清空。
process(r) // 可能需要很长时间。
<-sem // 完成;使下一个请求可以运行。
}

func Serve(queue chan *Request) {
for {
req := <-queue
go handle(req) // 无需等待 handle 结束。
}
}

Bug 出现在 Go 的 for 循环中,该循环变量在每次迭代时会被重用,因此 req 变量会在所有的 Go 协程间共享,这不是我们想要的。我们需要确保 req 对于每个 Go 协程来说都是唯一的。有一种方法能够做到,就是将 req 的值作为实参传入到该 Go 协程的闭包中:

1
2
3
4
5
6
7
8
9
func Serve(queue chan *Request) {
for req := range queue {
sem <- 1
go func(req *Request) {
process(req)
<-sem
}(req)
}
}

回到编写服务器的一般问题上来。另一种管理资源的好方法就是启动固定数量的 handle Go 协程,一起从请求信道中读取数据。Go 协程的数量限制了同时调用 process 的数量。Serve 同样会接收一个通知退出的信道, 在启动所有 Go 协程后,它将阻塞并暂停从信道中接收消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
func handle(queue chan *Request) {
for r := range queue {
process(r)
}
}

func Serve(clientRequests chan *Request, quit chan bool) {
// 启动处理程序
for i := 0; i < MaxOutstanding; i++ {
go handle(clientRequests)
}
<-quit // 等待通知退出。
}

这些设计的另一个应用是在多 CPU 核心上实现并行计算。如果计算过程能够被分为几块 可独立执行的过程,它就可以在每块计算结束时向信道发送信号,从而实现并行处理。

我们在循环中启动了独立的处理块,每个 CPU 将执行一个处理。 它们有可能以乱序的形式完成并结束,但这没有关系; 我们只需在所有 Go 协程开始后接收,并统计信道中的完成信号即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
const numCPU = 4 // CPU 核心数

func (v Vector) DoAll(u Vector) {
c := make(chan int, numCPU) // 缓冲区是可选的,但明显用上更好
for i := 0; i < numCPU; i++ {
go v.DoSome(i*len(v)/numCPU, (i+1)*len(v)/numCPU, u, c)
}
// 排空信道。
for i := 0; i < numCPU; i++ {
<-c // 等待任务完成
}
// 一切完成
}
1
var numCPU = runtime.NumCPU() 
1
var numCPU = runtime.GOMAXPROCS(0) 

错误

库函数很多时候必须将错误信息返回给函数的调用者。如前所述,Go 允许函数可以有多个返回值的特性,使得函数的调用者在得到正常返回值的同时,可以获取到更为详细的错误信息

按照约定,错误的类型通常为 error,这是一个内置的简单接口。

1
2
3
type error interface {
Error() string
}

若调用者关心错误的完整细节,可使用类型选择或者类型断言来查看特定错误,并抽取其细节。比如 PathErrors,它你可能会想检查内部的 Err 字段来判断这是否是一个可以被恢复的错误。

1
2
3
4
5
6
7
8
9
10
11
for try := 0; try < 2; try++ {
file, err = os.Create(filename)
if err == nil {
return
}
if e, ok := err.(*os.PathError); ok && e.Err == syscall.ENOSPC {
deleteTempFiles() // 恢复一些空间。
continue
}
return
}
1
2
3
4
5
6
7
var user = os.Getenv("USER")

func init() {
if user == "" {
panic("no value for $USER")
}
}

当 panic 被调用后(包括不明确的运行时错误,例如切片越界访问或类型断言失败), 程序将立刻终止当前函数的执行,并开始回溯 Go 协程的栈,运行任何被推迟的函数。 若回溯到达 Go 协程栈的顶端,程序就会终止。不过我们可以用内建的 recover 函数来重新或来取回 Go 协程的控制权限并使其恢复正常执行。

调用 recover 将停止回溯过程,并返回传入 panic 的实参。 由于在回溯时只有被推迟函数中的代码在运行,因此 recover 只能在被推迟的函数中才有效。

简单方法限流实现-利用缓存通道写满时写阻塞, 为空时读阻塞的特性

简单方法限流实现

利用缓存通道写满时写阻塞, 为空时读阻塞的特性来实现

主要代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

func NowTimeStr() string {
const DateTimePattern = "2006-01-02 15:04:05"
return time.Now().Format(DateTimePattern)
}

type ERateLimiter struct {
c chan interface{}
tk *time.Ticker
}

// initFill 初始的并发数 , 每秒允许的并发数
func NewERateLimiter(initFill, requestPerSeconds int) *ERateLimiter {
//利用缓存通道写满时写阻塞, 为空时读阻塞的特性

limiter := ERateLimiter{}
concurrency := util.Max(initFill, requestPerSeconds)
limiter.c = make(chan interface{}, concurrency)
var i int
for i = 0; i < initFill; i++ {
limiter.c <- 0
}
sleept := 1000 / requestPerSeconds
limiter.tk = time.NewTicker(time.Millisecond * time.Duration(sleept))
limiter.Run()
return &limiter
}

func (receiver *ERateLimiter) Run() {

go func() {
for range receiver.tk.C {
receiver.c <- 0
}
}()
}


func (receiver *ERateLimiter) Stop() {
receiver.tk.Stop()
close(receiver.c)
}

func (receiver *ERateLimiter) Wait() {
<-receiver.c
}

测试

1
2
3
4
5
6
7
8
9
func TestERateLimiter(t *testing.T) {
limiter := NewERateLimiter(10, 10)
for i := 0; i < 50; i++ {
limiter.Wait()
fmt.Println(i+1, NowTimeStr())
}
limiter.Stop()
}

测试输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
=== RUN   TestERateLimiter
1 2021-02-19 11:56:48
2 2021-02-19 11:56:48
3 2021-02-19 11:56:48
4 2021-02-19 11:56:48
5 2021-02-19 11:56:48
6 2021-02-19 11:56:48
7 2021-02-19 11:56:48
8 2021-02-19 11:56:48
9 2021-02-19 11:56:48
10 2021-02-19 11:56:48
11 2021-02-19 11:56:48
12 2021-02-19 11:56:48
13 2021-02-19 11:56:48
14 2021-02-19 11:56:48
15 2021-02-19 11:56:48
16 2021-02-19 11:56:48
17 2021-02-19 11:56:48
18 2021-02-19 11:56:49
19 2021-02-19 11:56:49
20 2021-02-19 11:56:49
21 2021-02-19 11:56:49
22 2021-02-19 11:56:49
23 2021-02-19 11:56:49
24 2021-02-19 11:56:49
25 2021-02-19 11:56:49
26 2021-02-19 11:56:49
27 2021-02-19 11:56:49
28 2021-02-19 11:56:50
29 2021-02-19 11:56:50
30 2021-02-19 11:56:50
31 2021-02-19 11:56:50
32 2021-02-19 11:56:50
33 2021-02-19 11:56:50
34 2021-02-19 11:56:50
35 2021-02-19 11:56:50
36 2021-02-19 11:56:50
37 2021-02-19 11:56:50
38 2021-02-19 11:56:51
39 2021-02-19 11:56:51
40 2021-02-19 11:56:51
41 2021-02-19 11:56:51
42 2021-02-19 11:56:51
43 2021-02-19 11:56:51
44 2021-02-19 11:56:51
45 2021-02-19 11:56:51
46 2021-02-19 11:56:51
47 2021-02-19 11:56:51
48 2021-02-19 11:56:52
49 2021-02-19 11:56:52
50 2021-02-19 11:56:52
--- PASS: TestERateLimiter (4.00s)
PASS

BundleAnalyzer Webpack externals配置,优化chunk-vendor大小,提高初始加载速度

调整了很多次终于见到效果 , 发现最根本的问题在于 被external的包是否依赖Vue, 如果依赖就要把Vue先加external

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
vue.config.js

const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
publicPath: '/saas-manager',

// 只用加以下节点 , main.js当中不用调整
configureWebpack: {
externals: {
// 使用elementui 必须要先中vue(因为elementui依赖vue, 而在html文件中这两个资源也必需写在<div id="app"></div> 之前 ),
vue: 'Vue',
'element-ui': 'ELEMENT',
},
},
// 更改编译输出的文件名增加hashcode , 以强制浏览器无法缓存, 保证每次修改能及时看到效果
chainWebpack: (config) => {
config.output.filename('js/[name].[hash:6].js')
.chunkFilename('js/[name].[hash:6].js')
.end();

// 每次yarn serve 新开浏览器显示 BundleAnalyzer(各资源文件大小占比)
config
.plugin('webpack-bundle-analyzer')
.use(BundleAnalyzerPlugin)
.init((Plugin) => new Plugin());

js文件未做任何调整

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
main.js 

import Vue from 'vue';
import ElementUI from 'element-ui';
import axios from 'axios';
import VueAxios from 'vue-axios';
import elementUtils from 'vue-element-utils';

import App from './App.vue';
import router from './router';
import store from './store';

// 以下两个资料在index.html 中引用csn资源, 以减少打包后的文件大小
// import 'element-ui/lib/theme-chalk/index.css';
// import 'bootstrap/dist/css/bootstrap.min.css';

Vue.config.productionTip = false;

Vue.use(ElementUI, {
size: 'small',
zIndex: 3000,
});


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
index.html
<head>
// 外部css
<link href="https://cdn.bootcdn.net/ajax/libs/element-ui/2.14.1/theme-chalk/index.css" rel="stylesheet">
<!-- bootstrap3 会有graph,所以使用bs3 -->
<link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/3.4.1/css/bootstrap.css">
</head>
<body>
// 外部js
<script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.11/vue.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/element-ui/2.14.1/index.js"></script>
...

<div id="app"></div>
</body>

singleflight在并发场景下保护下游业务

singleflight 使用场景

针对同一业务的同一批请求(需自定义缓存的 key),只放一个请求去执行,其他等待结果(和普通缓存还不一样), 可以在不使用缓存的情况下,保护下游业务;

这是一条测试修改

例如 1:在有缓存的数据读取场景中,缓存过期失效时且大并发场景中,瞬间会有大量请求压到数据库,当设置上缓存后才会恢复.但如果去数据库当中查询数据\内存中计算组装\设置缓存等操作耗时稍长,同样会存在很大的风险,瞬间的巨量数据库访问,可能会使数据库异常。

例如 1:同上,在无缓存的场景中, 如果一个业务完成处理需要 1s, 100 并发情况下, 这 1s 内都会被到服务器执行,会给服务器造成巨大的压力, 用 singleflight 只会有一个请求被真正处理, 其它的会等 1s(第一个请求处理完成),直接取第一个请求的处理结果 .
golang singleflight 用武之地,杨锡坤 2017-09-17如果每个请求都落到下游服务,通常会导致下游服务瞬时负载升高。如果使用缓存,如何判断当前接口请求的内容需要缓存下来?缓存的过期、更新问题?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 实现原理
func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error) {
g.mu.Lock()
if g.m == nil {
g.m = make(map[string]*call)
}
if c, ok := g.m[key]; ok {
g.mu.Unlock()
c.wg.Wait() //其他的请求阻塞
return c.val, c.err
}
c := new(call)
c.wg.Add(1)
g.m[key] = c
g.mu.Unlock()

c.val, c.err = fn() //第一个去执行调用
c.wg.Done() //同一批都返回

g.mu.Lock()
delete(g.m, key)
g.mu.Unlock()

return c.val, c.err
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// sample
func TestDoDupSuppress(t *testing.T) {
var g singleflight.Group
var calls int32
fn := func() (interface{}, error) {
fmt.Printf("inprocess %d\n", calls)
atomic.AddInt32(&calls, 1)
// 模拟耗时
time.Sleep(time.Second * 1)
// 回写返回结果
return "ok", nil
}

const n = 30
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)

go func(j int) { // n个协程同时调用了g.Do,fn中的逻辑只会被一个协程执行
fmt.Printf("before request %d\n", j)
v, err := g.Do("key", fn)

fmt.Printf("after request %d, %#v\n", j, v)
if err != nil {
fmt.Printf("Do error: %v\n", err)
}

wg.Done()
}(i)
}
wg.Wait()
fmt.Printf("done calls= %d\n", calls)

}