bt365体育投注.主頁欢迎您!!

    <acronym id="zvmrr"></acronym>
    <td id="zvmrr"></td>
  • <tr id="zvmrr"><label id="zvmrr"></label></tr>
  • <acronym id="zvmrr"></acronym>
  • Richard_Yi

    Richard_Yi 查看完整档案

    杭州编辑浙江工业大学  |  非计算机 编辑某某有限公司  |  Java开发 编辑 ricstudio.top 编辑
    编辑

    个人主页:https://ricstudio.top

    座右铭:吾辈采石之人,当心怀大教堂之愿景

    佛系求关注。

    技术兴趣:热衷微服务、分布式技术;

    关键词:重度猫瘾、健身、读书、生活;

    Please let me know if my answer helps you out.
    如果我的答案对你有用,请让我知道。

    个人动态

    Richard_Yi 发布了文章 · 10月30日

    Elasticsearch 如何做到快速检索 - 倒排索引的秘密

    "All problems in computer science can be solved by another level of indirection.”

    – David J. Wheeler

    “计算机世界就是 trade-off 的艺术”

    一、前言

    最近接触的几个项目都使用到了 Elasticsearch (以下简称 ES ) 来存储数据和对数据进行搜索分析,就对 ES 进行了一些学习。本文整理自我自己的一次技术分享。

    本文不会关注 ES 里面的分布式技术、相关 API 的使用,而是专注分享下 ”ES 如何快速检索“ 这个主题上面。这个也是我在学习之前对 ES 最感兴趣的部分。


    本文大致包括以下内容:

    • 关于搜索

      • 传统关系型数据库和 ES 的差别
      • 搜索引擎原理
    • 细究倒排索引

      • 倒排索引具体是个什么样子的(posting list -> term dic -> term index)
      • 关于 postings list 的一些巧技 (FOR、Roaring Bitmaps)
      • 如何快速做联合查询?

    二、关于搜索

    先设想一个关于搜索的场景,假设我们要搜索一首诗句内容中带“前”字的古诗,

    用 传统关系型数据库和 ES 实现会有什么差别?

    如果用像 MySQL 这样的 RDBMS 来存储古诗的话,我们应该会去使用这样的 SQL 去查询

    select name from poems where content like "%前%";

    这种我们称为顺序扫描法,需要遍历所有的记录进行匹配。

    不但效率低,而且不符合我们搜索时的期望,比如我们在搜索“ABCD"这样的关键词时,通常还希望看到"A","AB","CD",“ABC”的搜索结果。

    于是乎就有了专业的搜索引擎,比如我们今天的主角 -- ES。

    搜索引擎原理

    搜索引擎的搜索原理简单概括的话可以分为这么几步,

    • 内容爬取,停顿词过滤

      比如一些无用的像"的",“了”之类的语气词/连接词

    • 内容分词,提取关键词
    • 根据关键词建立倒排索引
    • 用户输入关键词进行搜索

    这里我们就引出了一个概念,也是我们今天的要剖析的重点 - 倒排索引。也是 ES 的核心知识点。

    如果你了解 ES 应该知道,ES 可以说是对 Lucene 的一个封装,里面关于倒排索引的实现就是通过 lucene 这个 jar 包提供的 API 实现的,所以下面讲的关于倒排索引的内容实际上都是 lucene 里面的内容。

    三、倒排索引

    首先我们还不能忘了我们之前提的搜索需求,先看下建立倒排索引之后,我们上述的查询需求会变成什么样子,

    这样我们一输入“前”,借助倒排索引就可以直接定位到符合查询条件的古诗。

    当然这只是一个很大白话的形式来描述倒排索引的简要工作原理。在 ES 中,这个倒排索引是具体是个什么样的,怎么存储的等等,这些才是倒排索引的精华内容。

    1. 几个概念

    在进入下文之前,先描述几个前置概念。

    term

    关键词这个东西是我自己的讲法,在 ES 中,关键词被称为 term

    postings list

    还是用上面的例子,{静夜思, 望庐山瀑布}是 "前" 这个 term 所对应列表。在 ES 中,这些被描述为所有包含特定 term 文档的 id 的集合。由于整型数字 integer 可以被高效压缩的特质,integer 是最适合放在 postings list 作为文档的唯一标识的,ES 会对这些存入的文档进行处理,转化成一个唯一的整型 id。

    再说下这个 id 的范围,在存储数据的时候,在每一个 shard 里面,ES 会将数据存入不同的 segment,这是一个比 shard 更小的分片单位,这些 segment 会定期合并。在每一个 segment 里面都会保存最多 2^31 个文档,每个文档被分配一个唯一的 id,从0(2^31)-1

    相关的名词都是 ES 官方文档给的描述,后面参考材料中都可以找到出处。

    2. 索引内部结构

    上面所描述的倒排索引,仅仅是一个很粗糙的模型。真的要在实际生产中使用,当然还差的很远。

    在实际生产场景中,比如 ES 最常用的日志分析,日志内容进行分词之后,可以得到多少的 term?

    那么如何快速的在海量 term 中查询到对应的 term 呢?遍历一遍显然是不现实的。

    term dictionary

    于是乎就有了 term dictionary,ES 为了能快速查找到 term,将所有的 term 排了一个序,二分法查找。是不是感觉有点眼熟,这不就是 MySQL 的索引方式的,直接用 B+树建立索引词典指向被索引的数据。

    term index

    但是问题又来了,你觉得 Term Dictionary 应该放在哪里?肯定是放在内存里面吧?磁盘 io 那么慢。就像 MySQL 索引就是存在内存里面了。

    但是如果把整个 term dictionary 放在内存里面会有什么后果呢?

    内存爆了...

    别忘了,ES 默认可是会对全部 text 字段进行索引,必然会消耗巨大的内存,为此 ES 针对索引进行了深度的优化。在保证执行效率的同时,尽量缩减内存空间的占用。

    于是乎就有了 term index

    Term index 从数据结构上分类算是一个“Trie 树”,也就是我们常说的字典树。这是一种专门处理字符串匹配的数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题。

    这棵树不会包含所有的 term,它包含的是 term 的一些前缀(这也是字典树的使用场景,公共前缀)。通过 term index 可以快速地定位到 term dictionary 的某个 offset,然后从这个位置再往后顺序查找。就想右边这个图所表示的。(怎么样,像不像我们查英文字典,我们定位 S 开头的第一个单词,或者定位到 Sh 开头的第一个单词,然后再往后顺序查询)

    lucene 在这里还做了两点优化,一是 term dictionary 在磁盘上面是分 block 保存的,一个 block 内部利用公共前缀压缩,比如都是 Ab 开头的单词就可以把 Ab 省去。二是 term index 在内存中是以 FST(finite state transducers)的数据结构保存的。

    FST 有两个优点:

    • 空间占用小。通过对词典中单词前缀和后缀的重复利用,压缩了存储空间
    • 查询速度快。O(len(str)) 的查询时间复杂度。
    FST 的理论比较复杂,本文不细讲

    延伸阅读:https://www.shenyanchao.cn/bl...

    OK,现在我们能得到 lucene 倒排索引大致是个什么样子的了。

    四、关于 postings list 的一些巧技

    在实际使用中,postings list 还需要解决几个痛点,

    • postings list 如果不进行压缩,会非常占用磁盘空间,
    • 联合查询下,如何快速求交并集(intersections and unions)

    对于如何压缩,可能会有人觉得没有必要,”posting list 不是已经只存储文档 id 了吗?还需要压缩?”,但是如果在 posting list 有百万个 doc id 的情况,压缩就显得很有必要了。(比如按照朝代查询古诗?),至于为啥需要求交并集,ES 是专门用来搜索的,肯定会有很多联合查询的需求吧 (AND、OR)。

    按照上面的思路,我们先将如何压缩。

    1. 压缩

    Frame of Reference

    在 lucene 中,要求 postings lists 都要是有序的整形数组。这样就带来了一个很好的好处,可以通过 增量编码(delta-encode)这种方式进行压缩。

    比如现在有 id 列表 [73, 300, 302, 332, 343, 372],转化成每一个 id 相对于前一个 id 的增量值(第一个 id 的前一个 id 默认是 0,增量就是它自己)列表是[73, 227, 2, 30, 11, 29]在这个新的列表里面,所有的 id 都是小于 255 的,所以每个 id 只需要一个字节存储

    实际上 ES 会做的更加精细,

    它会把所有的文档分成很多个 block,每个 block 正好包含 256 个文档,然后单独对每个文档进行增量编码,计算出存储这个 block 里面所有文档最多需要多少位来保存每个 id,并且把这个位数作为头信息(header)放在每个 block 的前面。这个技术叫 Frame of Reference

    上图也是来自于 ES 官方博客中的一个示例(假设每个 block 只有 3 个文件而不是 256)。

    FOR 的步骤可以总结为:

    进过最后的位压缩之后,整型数组的类型从固定大小 (8,16,32,64 位)4 种类型,扩展到了[1-64] 位共 64 种类型。

    通过以上的方式可以极大的节省 posting list 的空间消耗,提高查询性能。不过 ES 为了提高 filter 过滤器查询的性能,还做了更多的工作,那就是缓存

    Roaring Bitmaps (for filter cache)

    在 ES 中,可以使用 filters 来优化查询,filter 查询只处理文档是否匹配与否,不涉及文档评分操作,查询的结果可以被缓存。

    对于 filter 查询,es 提供了 filter cache 这种特殊的缓存,filter cache 用来存储 filters 得到的结果集。缓存 filters 不需要太多的内存,它只保留一种信息,即哪些文档与 filter 相匹配。同时它可以由其它的查询复用,极大地提升了查询的性能。

    我们上面提到的 Frame Of Reference 压缩算法对于 postings list 来说效果很好,但对于需要存储在内存中的 filter cache 等不太合适。

    filter cache 会存储那些经常使用的数据,针对 filter 的缓存就是为了加速处理效率,对压缩算法要求更高。

    对于这类 postings list,ES 采用不一样的压缩方式。那么让我们一步步来。

    首先我们知道 postings list 是 Integer 数组,具有压缩空间。

    假设有这么一个数组,我们第一个压缩的思路是什么?用位的方式来表示,每个文档对应其中的一位,也就是我们常说的位图,bitmap。

    它经常被作为索引用在数据库、查询引擎和搜索引擎中,并且位操作(如 and 求交集、or 求并集)之间可以并行,效率更好。

    但是,位图有个很明显的缺点,不管业务中实际的元素基数有多少,它占用的内存空间都恒定不变。也就是说不适用于稀疏存储。业内对于稀疏位图也有很多成熟的压缩方案,lucene 采用的就是roaring bitmaps

    我这里用简单的方式描述一下这个压缩过程是怎么样,

    将 doc id 拆成高 16 位,低 16 位。对高位进行聚合 (以高位做 key,value 为有相同高位的所有低位数组),根据低位的数据量 (不同高位聚合出的低位数组长度不相同),使用不同的 container(数据结构) 存储。

    • len<4096 ArrayContainer 直接存值
    • len>=4096 BitmapContainer 使用 bitmap 存储

    分界线的来源:value 的最大总数是为2^16=65536. 假设以 bitmap 方式存储需要 65536bit=8kb,而直接存值的方式,一个值 2 byte,4K 个总共需要2byte*4K=8kb。所以当 value 总量 <4k 时,使用直接存值的方式更节省空间。

    空间压缩主要体现在:

    • 高位聚合 (假设数据中有 100w 个高位相同的值,原先需要 100w*2byte,现在只要 1*2byte)
    • 低位压缩

    缺点就在于位操作的速度相对于原生的 bitmap 会有影响。

    这就是 trade-off 呀。平衡的艺术。

    2. 联合查询

    讲完了压缩,我们再来讲讲联合查询。

    先讲简单的,如果查询有 filter cache,那就是直接拿 filter cache 来做计算,也就是说位图来做 AND 或者 OR 的计算。

    如果查询的 filter 没有缓存,那么就用 skip list 的方式去遍历磁盘上的 postings list。

    以上是三个 posting list。我们现在需要把它们用 AND 的关系合并,得出 posting list 的交集。首先选择最短的 posting list,逐个在另外两个 posting list 中查找看是否存在,最后得到交集的结果。遍历的过程可以跳过一些元素,比如我们遍历到绿色的 13 的时候,就可以跳过蓝色的 3 了,因为 3 比 13 要小。

    用 skip list 还会带来一个好处,还记得前面说的吗,postings list 在磁盘里面是采用 FOR 的编码方式存储的

    会把所有的文档分成很多个 block,每个 block 正好包含 256 个文档,然后单独对每个文档进行增量编码,计算出存储这个 block 里面所有文档最多需要多少位来保存每个 id,并且把这个位数作为头信息(header)放在每个 block 的前面。

    因为这个 FOR 的编码是有解压缩成本的。利用 skip list,除了跳过了遍历的成本,也跳过了解压缩这些压缩过的 block 的过程,从而节省了 cpu

    五、总结

    下面我们来做一个技术总结(感觉有点王刚老师的味道??)

    • 为了能够快速定位到目标文档,ES 使用倒排索引技术来优化搜索速度,虽然空间消耗比较大,但是搜索性能提高十分显著。
    • 为了能够在数量巨大的 terms 中快速定位到某一个 term,同时节约对内存的使用和减少磁盘 io 的读取,lucene 使用 "term index -> term dictionary -> postings list" 的倒排索引结构,通过 FST 压缩放入内存,进一步提高搜索效率。
    • 为了减少 postings list 的磁盘消耗,lucene 使用了 FOR(Frame of Reference)技术压缩,带来的压缩效果十分明显。
    • ES 的 filter 语句采用了 Roaring Bitmap 技术来缓存搜索结果,保证高频 filter 查询速度的同时降低存储空间消耗。
    • 在联合查询时,在有 filter cache 的情况下,会直接利用位图的原生特性快速求交并集得到联合查询结果,否则使用 skip list 对多个 postings list 求交并集,跳过遍历成本并且节省部分数据的解压缩 cpu 成本

    Elasticsearch 的索引思路

    将磁盘里的东西尽量搬进内存,减少磁盘随机读取次数 (同时也利用磁盘顺序读特性),结合各种压缩算法,用及其苛刻的态度使用内存。

    所以,对于使用 Elasticsearch 进行索引时需要注意:

    • 不需要索引的字段,一定要明确定义出来,因为默认是自动建索引的
    • 同样的道理,对于 String 类型的字段,不需要 analysis 的也需要明确定义出来,因为默认也是会 analysis 的
    • 选择有规律的 ID 很重要,随机性太大的 ID(比如 Java 的 UUID) 不利于查询

    最后说一下,技术选型永远伴随着业务场景的考量,每种数据库都有自己要解决的问题(或者说擅长的领域),对应的就有自己的数据结构,而不同的使用场景和数据结构,需要用不同的索引,才能起到最大化加快查询的目的。

    这篇文章讲的虽是 Lucene 如何实现倒排索引,如何精打细算每一块内存、磁盘空间、如何用诡谲的位运算加快处理速度,但往高处思考,再类比一下 MySQL,你就会发现,虽然都是索引,但是实现起来,截然不同。笼统的来说,b-tree 索引是为写入优化的索引结构。当我们不需要支持快速的更新的时候,可以用预先排序等方式换取更小的存储空间,更快的检索速度等好处,其代价就是更新慢,就像 ES。

    希望本篇文章能给你带来一些收获~

    参考文档

    查看原文

    赞 8 收藏 5 评论 0

    Richard_Yi 赞了文章 · 5月12日

    Apache Dubbo 云原生服务自省架构设计

    原文链接:Apache Dubbo 云原生服务自省架构设计,来自于微信公众号:次灵均阁

    背景

    随着微服务架构的推广和普及,服务之间的耦合度在逐步降低。在演化的过程中,伴随着应用组织架构的变化以及基础设施的衍进,服务和应用之间的边界变得更为模糊。Java 作为一门面向对象的编程语言,Java 接口(interface)作为服务之间通讯的一等公民,配合文档(JavaDoc)便于开发人员理解和维护。基于相同的编程哲学,Apache Dubbo 作为传统的 RPC 服务治理框架,通过接口实现分布式服务。然而对于微服务治理而言,应用(或“服务”)才是基础设施的核心要素。面对云原生(Cloud Native)技术的兴起,传统的 Dubbo 架构不断地面临着新的的挑战。下面内容将以 Apache Dubbo 2.7.5 为基础,介绍全新架构 - Apache Dubbo 服务自省(后文简称“服务自省”),了解 Dubbo 传统架构所面临的现实挑战,以及服务自省架构的设计和解决之道。

    术语约定

    • Service:SOA 或微服务中的“服务”,或称之为“应用”,具有全局唯一的名称
    • Service Name: 服务名称,或应用名称
    • Servce Instance:服务实例,或称为应用实例(Application Instance),表示单个 Dubbo 应用进程
    • Registry:注册中心
    • Dubbo 服务:又称之为“Dubbo 业务服务”,包含 Java 接口、通讯协议,版本(version)和分组(group)等元信息
    • Dubbo 服务 ID:唯一鉴定 Dubbo 服务的元数据,用于 Dubbo 服务暴露(发布)和订阅
    • Provider:Dubbo 服务提供方
    • Consumer:Dubbo 服务消费方
    • Dubbo 服务暴露:也称之为 Dubbo 服务发布,或英文中的“export”、"exported"
    • Dubbo 应用服务:也称之为 Dubbo 业务服务,或业务 Dubbo 服务

    使用场景

    服务自省是 Dubbo 应用在运行时处理和分析 Dubbo 服务元信息(Metadata)的过程,如当前应用暴露 的Dubbo 服务以及各自的通讯协议等。期间会伴随着事件的广播和处理,如服务暴露事件。Dubbo 服务自省架构是其传统架的一种补充,更是未来 Dubbo 架构,它更适合以下使用场景:

    • 超大规模 Dubbo 服务治理场景
    • 微服务架构和元原生应用
    • Dubbo 元数据架构的基石

    超大规模 Dubbo 服务治理场景

    如果 Dubbo 集群规模超过一千以上,或者集群扩缩容已无法自如地执行,如 Zookeeper 管理数万 Dubbo 服务,服务自省可极大化减轻注册中心的压力,尤其在内存足迹、网络传输以及变更通知上体现。

    微服务架构和元原生应用

    如果想要 Dubbo 应用更好地微服务化,或者更接近于云原生应用,那么服务自省是一种不错的选择,它能够提供已应用为粒度的服务注册与发现模型,全面地支持最流行的 Spring Cloud 和 Kubernetes 注册中心,并且能与 Spring Cloud 或 Spring Boot 应用交互。

    Dubbo 元数据架构的基石

    Dubbo 元数据架构是围绕 Dubbo DevOps 而引入,包括 Dubbo 配置元数据(如:属性配置、路由规则等)和结构元数据(如:Java 注解、接口和文档等)。服务自省作为 Dubbo 元数据的基础设施,不仅支持所有元数据的存储和平滑升级,而且不会对注册中心、配置中心和元数据中心产生额外的负担。

    传统架构

    Apache Dubbo 是一款面向接口代理的高性能 RPC 框架,提供服务注册与发现的特性,其基础架构如下图所示:

    image.png

    (图 1)

    • Provider 为服务提供方,提供 Java 服务接口的实现,并将其元信息注册到 Dubbo 注册中心(过程 1.register 所示)
    • Consumer 为服务消费端,从 Dubbo 注册中心检索订阅的 Java 服务接口的元信息(过程 2.subscribe 所示),通过框架处理后,生成代理程序执行远程方法调用(过程 4.invoke 所示)
    • Registry 为注册中心,属于注册元信息中心化基础设施(如 Apache Zookeeper 或 Alibaba Nacos),为 Provider 提供注册通道,为 Cosumer 提供订阅渠道。同时,注册中心支持注册元信息变更通知,通知 Consumer 上游 Provider 节点的变化(如扩容或缩容)。而注册元信息均以 Dubbo URL 的形式存储
    • Monitor 为服务治理平台,提供开发和运维人员服务查询、路由规则、服务 Mock 和测试等治理能力

    综上所述,Dubbo 注册与发现的对象是 Dubbo 服务(Java 接口),而其载体为注册元信息,即Dubbo URL,如:dubbo://192.168.1.2:20880/com.foo.BarService?version=1.0.0&group=default,通常包含必须信息,如服务提供方 IP 和端口、 Java 接口,可选包含版本(version)和分组(group)等。服务 URL 所包含的信息能够唯一界别服务提供方的进程。

    现实挑战

    为了更好地符合 Java 开发人员的编程习惯,Dubbo 以 Java 服务接口作为注册对象,所面临的现实挑战主要有:

    • 如何解决或缓解注册中心压力过载
    • 如何支持以应用为粒度的服务注册与发现
    • 如何精简 Dubbo URL 元数据

    如何解决或缓解注册中心压力过载

    注册中心内存压力

    Dubbo 注册中心是中心化的基础设施,大多数注册中心的实现为内存型存储,比如 Zookeeper、Nacos 或 Consul、Eureka。注册中心的内存消耗与 Dubbo 服务注册的数量成正比,任一 Dubbo Provider 允许注册 N 个 Dubbo 服务接口,当 N 越大,注册中心的负载越重。根据不完全统计,Dubbo 核心 Provider 用通常会暴露 20 ~ 50 个服务接口。注册中心是中心化的基础设施,其稳定性面临严峻考验。尽管微服务架构不断地深化,然而现实情况是,更多开发者仍旧愿意在单一 Provider 上不断地增加 Dubbo 服务接口,而非更细粒度的 Dubbo Provider 组织。

    注册中心网络压力

    为了避免单点故障,主流的注册中心均提供高可用方案。为解决集群环境数据同步的难题,内建一致性协议,如 Zookeeper 使用的 Zab 协议,Consul 采用的 Raft 协议。无论哪种方式,当 Dubbo URL 数量变化频繁时,网络和 CPU 压力也会面临考验。如果注册中心与客户端之间维持长连接状态的话,如 Zookeeper,注册中心的网络负担会更大。

    注册中心通知压力

    假设某个 Dubbo Provider 注册了 N 个 Dubbo 服务接口,当它扩容或缩容 M 个实例(节点)时,N 数量越大,注册中心至少有 M * N 个 Dubbo URL 注册或移除。同时,大多数注册中心实现支持注册变化通知,如 Zookeeper 节点变化通知。当 Dubbo Consumer 订阅该 Provider 的 Dubbo 服务接口数为 X 时,X 数值越大,通知的次数也就越多。实际上,对于来自同一 Provider 的服务接口集合而言,X-1 次通知是重复和无价值的。

    如果 Dubbo 注册实体不再是服务 URL,而是 Dubbo Provider 节点的话,那么上述情况所描述的注册中心压力将得到很大程度的缓解。(负载只有过去的 1/N 甚至更少),然而 Dubbo 如何以应用为粒度来注册又是一个新的挑战。

    如何支持以应用为粒度的服务注册与发现

    尽管 Dubbo 也存在应用(Application)的概念,不过传统的使用场景并非核心要素,仅在 Dubbo Monitor 或 Dubbo Admin 场景下做辨识之用。随着微服务架构和云原生技术的兴起,以应用为粒度的注册模型已是大势所趋,如 Spring Cloud 和 Kubernetes 服务注册与发现模型。注册中心所管理的对象通常与业务无关,甚至不具备 RPC 的语义。在术语上,微服务架构中的“服务”(Services)与云原生中“应用”(Applications)是相同的概念,属于逻辑名称,而它们的成员则以服务实例(Service Instances)体现,服务和服务实例的数量关系为 1:N。

    单个服务实例代表一个服务进程,而多个 Dubbo 服务 URL 可隶属一个 Dubbo Provider 进程,因此,Dubbo URL 与服务实例的数量关系是 N : 1。假设一个 Dubbo Provider 进程仅提供一个 Dubbo 服务(接口)的话,即 N = 1 的情况,虽然以应用为粒度的服务注册与发现能够基于 Dubbo 传统的 Registry SPI 实现,不过对于现有 Dubbo 应用而言,将存在巨大的应用微服务化工作。

    支持 Spring Cloud 服务注册与发现模型

    Spring Cloud 是 VMware 公司(前为 Pivotal)推出的,一套以 Spring 为技术栈的云原生(Cloud-Native)解决方案,在 Java 微服务领域具备得天独厚的优势,拥有超大规模的全球用户。Spring Cloud 官方支持三种注册中心实现,包括:Eureka、Zookeeper 和 Consul,Spring Cloud Alibaba 扩展了 Nacos 注册中心实现。 尽管 Zookeeper、Consul 和 Nacos 也被 Apache Dubbo 官方支持,然而两者的服务注册与发现的机制不尽相同。

    若要 Dubbo 支持 Spring Cloud 服务注册与发现模型,Dubbo 则需基于 Dubbo Registry SPI 实现,否则底层的变化和兼容性存在风险。

    支持 Kubernetes 服务注册与发现模型

    Kubernetes 源自 Google 15 年生产环境的运维经验,是一个可移植的、可扩展的开源平台,用于管理容器化的工作负载和服务。Kubernetes 原生服务发现手段主要包括:DNS 和 API Server。DNS 服务发现是一种服务地址的通用方案,不过对于相对复杂 Dubbo 元数据而言,这种服务发现机制或许无法直接被 Dubbo Registry SPI 适配。相反,API Server 所支持相对更便利,毕竟 Spring Cloud Kubernetes 同样基于此机制实现,并已在生产环境得到验证。换言之,只要 Dubbo 支持 Spring Cloud 服务注册与发现模型,那么基于 Kubernetes API Server 的支持也能实现。

    兼容 Dubbo 传统服务注册与发现模型

    所谓兼容 Dubbo 传统服务注册与发现模型,包含两层含义:

    • 基于 Dubbo Registry SPI 同时支持 Spring Cloud 和 Kubernetes 服务注册与发现模型
    • 传统和新的 Dubbo 服务注册与发现模型之间能够相互发现

    如何精简 Dubbo URL 元数据

    Dubbo 从 2.7.0 开始增加了简化 URL 元数据的特性,被“简化”的数据存放至元数据中心。由于 Dubbo 传统服务注册与发现模型并未减少 Dubbo 服务 URL 注册数量。因此,精简后的 URL 并未明显地减少注册中心所承受的压力。同时,Dubbo URL 元数据精简模式存在一定的限制,即所有的 Dubbo Provider 节点必须是无状态的,每个节点中的 URL 元信息均是一致的,现实中,这个要求非常难以保证,尤其在同一 Provider 节点存在不同的版本或配置的情况下。综上所述,Dubbo URL 元数据需要进一步精简,至少压力应该避免聚集在注册中心之上。

    架构设计

    架构上,Dubbo 服务自省不仅要解决上述挑战,而且实际场景则更为复杂,因此,架构细节也将循序渐进地展开讨论,整体架构可由以下子架构组成:

    • 服务注册与发现架构
    • 元数据服务架构
    • 事件驱动架构

    服务注册与发现架构

    Dubbo 服务自省首要需求是减轻注册中心的承载的压力,同时,以应用为粒度的服务注册与发现模型不但能够最大化的减少 Dubbo 服务元信息注册数量,而且还能支持 Spring Cloud 和 Kubernetes 环境,可谓是一举两得,架构图如下所示:

    image.png

    (图 2)

    注册实体

    图中所示,从 Provider 和 Consumer 向注册中心注册的实体不再是 Dubbo URL,而是服务实例(Service Instance),一个服务实例代表一个 Provider 或 Consumer Dubbo 应用进程。服务实例属性包括:

    • 服务名(Service Name):该名称必须在注册中心全局唯一
    注:名称规则架构上不做约束,不过不同注册中心的规则存在差异
    • 主机地址(Host/IP):能够被解析的主机名或者 TCP IP 地址
    • 服务端口(Port):应用进程所暴露的 Dubbo 协议端口,如 Dubbo 默认端口 20880
    注:如果应用进程暴露多个 Dubbo 协议端口,如 dubbo 和 rest,那么,服务端口随机挑选其一,架构上不强制检验端口是否可用
    • 元数据(Metadata):服务实例的附加信息,用于存储 Dubbo 元信息,类似于通讯协议头或附件
    • 激活状态(Enabled):用于标记当前实例是否对外提供服务

    上述服务实例模型的支持依赖于注册中心的实现。换言之,并非所有注册中心实现满足服务自省架构的要求。

    注册中心

    除了满足服务实例模型的要求之外,注册中心还得具备以下能力:

    • 服务实例变化通知(Notification):如上图步骤 4 所示,当 Consumer 订阅的 Provider 的服务实例发生变化时,注册中心能够实时地通知 Consumer
    • 心跳检测(Heartbeats):注册中心能够检测失效的服务实例,并且合理地移除它们

    业界主流的注册中心中满足上述要求的有:

    总之,Spring Cloud 与Kubernetes注册中心均符合服务自省对注册中心的要求。不过,在 Dubbo 传统 RPC 使用场景中,Provider 和 Consumer 关注的是 Dubbo 服务接口,而非 Service 或服务实例。假设需要将现有的 Dubbo 应用迁移至服务自省架构,Provider 和 Consumer 做大量的代码调整是不现实的。理想的情况下,两端实现代码均无变化,仅修改少量配置,就能达到迁移的效果。那么,Dubbo 服务接口是如何与 Service 进行映射的呢?

    Dubbo 服务与 Service 映射

    前文曾讨论,单个 Dubbo Service 能够发布多个 Dubbo 服务,所以,Dubbo 服务与 Service 的数量关系是 N 对 1。不过,Dubbo 服务与 Dubbo Service 之间并不存在强绑定关系,换言之,某个 Dubbo 服务也能部署在多个 Dubbo Services 中,因此,Dubbo 服务与 Service 数量关系是 N 对 M(N, M >= 1),如下图所示:

    image.png

    (图 3)

    上图中 P1 Service 到 P3 Service 为 Dubbo Service,com.acme.Interface1 到 com.acme.InterfaceN 则为 Dubbo 服务接口全称限定名(QFN)。值得注意的是,Dubbo 服务的 Java 接口(interface)允许不同的版本(version)或分组(group),所以仅凭 Java 接口无法唯一标识某个 Dubbo 服务,还需要增加通讯协议(protocol)方可,映射关系更新如下:

    image.png(图 4)

    Dubbo 服务 ID 字符表达模式为: ${protocol}:${interface}:${version}:${group} , 其中,版本(version)或分组(group)是可选的。当 Dubbo Consumer 订阅 Dubbo 服务时,构建对应 ID,通过这个 ID 来查询 Dubbo Provider 的 Service 名称列表。

    由于 Dubbo 服务与 Service 的映射关系取决于业务场景,架构层面无从预判。因此,这种映射关系只能在 Dubbo 服务暴露时(运行时)才能确定,否则,Dubbo 服务能被多个 Consumer 应用订阅时,Consumer 无法定位 Provider Service 名称,进而无法完成服务发现。同时,映射关系的数据通常采用配置的方式来存储,服务自省提供两种配置实现,即 “中心化映射配置” 和 “本地化映射配置”。

    中心化映射配置

    明显地,注册中心来扮演动态映射配置的角色并不适合,不然,Dubbo Service 与映射关系在注册中心是平级的,无论在理解上,还是设计上是混乱的。结合 Dubbo 现有基础设施分析,这个存储设施可由 Dubbo 配置中心承担。

    其中 Dubbo 2.7.5 动态配置 API(DynamicConfiguration )支持二级结构,即:group 和 key,其中,group 存储 Dubbo 服务 ID,而 key 则关联对应的 Dubbo Service 名称,对应的 "图 4” 的数据结构则是:

    image.png

    (图 5)

    如此设计的原因如下:

    1. 获取 Dubbo 服务对应 Services

    利用 DynamicConfiguration#getConfigKeys(String group) 方法,能够轻松地通过 Dubbo 服务 ID 获取其发布的所有 Dubbo Services,结合服务发现接口获取服务所部署的 Service 实例集合,最终转化为 Dubbo URL 列表。

    1. 避免 Dubbo Services 配置相互覆盖

    以 Dubbo 服务 ID dubbo:com.acme.Interface1:default 为例,它的提供者 Dubbo Services 分别:P1 Service 和 P2 Service。假设配置 Group 为 "default"(任意名字均可), Key 为 "dubbo:com.acme.Interface1:default",而内容则是 Dubbo Service 名称的话。当 P1 Service 和 P2 Service 同时启动时,无论哪个 Services 最后完成 Dubbo 服务暴露,那么,该配置内容必然是二选其一,无论配置中心是否支持原子操作。即使配置中心支持内容追加的特性,由于两个 Service 服务实例过程不确定,配置内容可能会出现重复,如:“P1 Service,P2 Service,P1 Service”。

    1. 获取 Dubbo 服务发布的 timestamp
    配置中心潜在的压力

    假设当 P1 Service 存在 5 个服务实例,当 Dubbo 服务 dubbo:com.acme.Interface1:default(ID)发布时,配置所关联的 key 就是当前 Dubbo Service 名称,即(P1 Service),而内容则是最后发布该 Dubbo 服务的时间戳(timestamp)。当服务实例越多时,配置中心和网络传输所承受的写入压力也就越大。当然架构设计上,服务自省也希望避免重复推送配置,比如在 DynamicConfiguration API 增加类似于 publishConfigIfAbsent 这样的方法,不过目前大多数配置中心产品(如:Nacos、Consul)不支持这样的操作,所以未来服务自省架构会有针对性的提供支持(如:Zookeeper)。

    注册中心作为配置中心

    由于服务自省架构必须依赖注册中心,同时动态映射配置又依赖配置中心的话,应用的架构复杂度和维护成本均有所提升,不过 Apache Dubbo 所支持的部分注册中心也可作为配置中心使用,情况如下所示:

    基础软件注册中心配置中心
    Apache Zookeeper??
    HashiCorp Consul??
    Alibaba Nacos??
    Netflix Eureka??
    Kubernetes API Server??

    其中,ZookeeperConsulNacos 是目前业界流行的注册中心,这对于大多数选择开源产品的应用无疑是一个福音。

    本地化映射配置

    如果开发人员认为配置中心的引入增加了架构的复杂性,那么,静态映射配置或许是一种解决方案。

    该特性并未在最新 Dubbo 2.7.6 全面发布,部分特性已在 Dubbo Spring Cloud 中发布
    接口映射配置

    在 Dubbo 传统的编程模型中, 常以 Java 注解 @Reference 或 XML 元素 ` 订阅目标 Dubbo 服务。服务自省架构在此基础上增加 service` 属性的映射一个或多个 Dubbo Service 名称,如:

    <reference services="P1 Service,P2 Service" interface="com.acme.Interface1" />

    @Reference(services="P1 Service,P2 Service") 
    private com.acme.Interface1 interface1;

    如此配置后,Dubbo 服务 com.acme.Interface1 将向 p1-servicep2-service 订阅服务。如果开发人员认为这种方式会侵入到代码,服务自省还提供外部化配置方式配置映射。

    外部化映射配置

    服务自省架构支持外部化配置的方式声明“Dubbo 服务与 Service 映射”,配置格式为 Properties ,以图 4 为例,内容如下:

    dubbo\:com.acme.Interface1\:default = P1 Service,P2 Service
    thirft\:com.acme.InterfaceX = P1 Service,P3 Service
    rest\:com.acme.interfaceN = P1 Service
    应用级别映射配置

    除此之外,Dubbo Spring Cloud 提供应用级别的 Dubbo 服务映射配置,即 dubbo.cloud.subscribed-services ,例如:

    dubbo:
        cloud:
        subscribed-services: P1 Service,P3 Service

    总之,无论是映射配置的方式是中心化还是本地化,服务 Consumer 依赖这些数据来定位 Dubbo Provider Services,再通过服务发现 API 结合 Service 名称(列表)获取服务实例集合,为合成 Dubbo URL 做准备:

    image.png

    (图 6)

    不过,映射关系并非是一种强约束,Dubbo Provider 的服务是否可用的检验方法是探测目标 Dubbo Service 是否存在,并需确认订阅的 Dubbo 服务在目标 Services 是否真实暴露,因此,服务自省引入了 Dubbo 元数据服务架构,来完成 Dubbo 服务 URL 的存储。

    元数据服务架构

    Dubbo 元数据服务是一个常规的 Dubbo 服务,为服务订阅端提供 Dubbo 元数据的服务目录,类似于 WebServices 中的 WDSL 或 REST 中的 HATEOAS,帮助 Dubbo Consumer 获取订阅的 Dubbo 服务的 URL 列表。元数据服务架构无法独立于服务注册与发现架构而存在,下面通过“整体架构”的讨论,了解两者之间的关系。

    整体架构

    架构上,无论 Dubbo Service 属于 Provider 还是 Consumer,甚至是两者的混合,每个 Dubbo (Service)服务实例有且仅有一个 Dubbo 元数据服务。换言之,Dubbo Service 不存在纯粹的 Consumer,即使它不暴露任何业务服务,那么它也可能是 Dubbo 运维平台(如 Dubbo Admin)的 Provider。不过出于行文的习惯,Consumer 仍旧被定义为 Dubbo 服务消费者(应用)。由于每个 Dubbo Service 均发布自身的 Dubbo 元数据服务,那么,架构不会为不同的 Dubbo Service 设计独立的元数据服务接口(Java)。换言之,所有的 Dubbo Service 元数据服务接口是统一的,命名为 MetadataService

    微观架构

    从 Dubbo 服务(URL)注册与发现的视角, MetadataService 扮演着传统 Dubbo 注册中心的角色。综合服务注册与发现架构(Dubbo Service 级别),微观架构如下图所示:

    image.png

    (图 7)

    **
    **

    对于 Provider(服务提供者)而言,Dubbo 应用服务暴露与传统方式无异,而 MetadataService 的暴露时机必须在它们完成后,同时, MetadataService 需要收集这些 Dubbo 服务的 URL(存储细节将在“元数据服务存储模式“ 小节讨论)。假设某个 Provider 的 Dubbo 应用服务暴露数量为 N,那么,它所有的 Dubbo 服务暴露数量为 N + 1。

    对于 Consumer(服务消费者)而言,获 Dubbo 应用服务订阅 URL 列表后,Dubbo 服务调用的方式与传统方式是相同的。不过在此之前,Consumer 需要通过 MetadataService 合成订阅 Dubbo 服务的 URL。该过程之所以称之为“合成”,而非“获取,是因为一次 MetadataService 服务调用仅在其 Provider 中的一台服务实例上执行,而该 Provider 可能部署了 N 个服务实例。具体“合成”的细节需要结合“宏观架构”来说明。

    宏观架构

    元数据服务的宏观架构依赖于服务注册与发现架构,如下图所示:

    image.png

    (图 8)

    图 8 中 p 和 c 分别代表 Provider 和 Consumer 的执行动作,后面紧跟的数字表示动作的次序,从 0 开始计数。执行动作是串行的,并属于 Fast-Fail 设计,如果前阶段执行失败,后续动作将不会发生。之所以如此安排是为了确保 MetadataService 能够暴露和消费。首先从 Provider 执行流程开始说明。

    Provider 执行流程
    • p0:发布所有的 Dubbo 应用服务,声明和定义方式与传统方式完全相同。
    • p1:暴露 MetadataService ,该步骤完全由框架自行处理,无论是否 p0 是否暴露 Dubbo 服务
    • p2:在服务实例注册之前, 框架将触发并处理事件(Event),将 MetadataService 的元数据先同步到服务实例(Service Instance)的元数据。随后,执行服务实例注册
    • p3:建立所有的 Dubbo 应用服务与当前 Dubbo Service 名称的映射,并同步到配置源(抽象)
    Consumer 执行流程
    • c0:注册当前 Dubbo Service 的服务实例,可选步骤,架构允许 Consumer 不进行服务注册
    • c1:通过订阅 Dubbo 服务元信息查找配置源,获取对应 Dubbo Services 名称(列表)
    • c2:利用已有 Dubbo Service 名称(可能存在多个),通过服务发现 API 获取 Provider 服务实例集合。假设 Service 名称 P,服务实例数量为 N
    • c3:
      1. 随机选择 Provider 一台服务实例 Px,从中获取 MetadataService 的元数据
      2. 将元数据组装 MetadataService Dubbo 调用客户端(代理)
      3. 发起 MetadataService Dubbo 调用,获取该服务实例 Px 所暴露的 Dubbo 应用服务 URL 列表
      4. 从 Dubbo 应用服务 URL 列表过滤出当前订阅 Dubbo 应用服务的 URL
      5. 理论上,步骤 c 和 d 还需要执行 N-1 次才能获取 P 所有服务实例的 Dubbo URL 列表。为了减少调用次数,步骤 d 的结果作为模板,克隆其他 N-1 台服务实例 URL 列表
      6. 将所有订阅 Dubbo 应用服务的 URL 同步到 Dubbo 客户端(与传统方式是相同的)
    • c4:发起 Dubbo 应用服务调用(与传统方式是相同的)

    不难看出,上述架构以及流程结合了“服务注册与发现”与“元数据服务”双架构,步骤之间会触发相关 Dubbo 事件,如“服务实例注册前事件”等。换言之,三种架构综合体也就是服务自省架构。

    至此,关于 Dubbo 服务自省架构设计方面,还存在一些细节亟待说明,比如:

    1. 不同的 Dubbo Service 的 MetadataService 怎样体现差异呢?
    2. MetadataService 作为一个常规的 Dubbo 服务,它的注册元信息存放在何处?
    3. MetadataService 作为服务目录,它管理的 Dubbo 应用服务 URL 是如何存储的?
    4. 在 Consumer 执行流程的 c3.e 中,克隆 N - 1 条 URL 的前提是该 Provider 的所有服务实例均部署了相同 Dubbo 应用服务。如果 Provider 处于升级的部署过程,同一 Dubbo 应用服务接口在不同的服务实例上存在差异,那么该服务的 URL 应该如何获取?
    5. 除了 Dubbo 服务 URL 发现之外,元数据服务还支持哪些元数据类型呢?

    元数据服务 Metadata

    元数据服务 Metadata,称之为“元数据服务的元数据”,主要包括:

    • inteface:Dubbo 元数据服务所暴露的接口,即 MetadataService
    • serviceName : 当前 MetadataService 所部署的 Dubbo Service 名称,作为 MetadataService 分组信息
    • group:当前 MetadataService 分组,数据使用 serviceName
    • version:当前 MetadataService 的版本,版本号通常在接口层面声明,不同的 Dubbo 发行版本 version 可能相同,比如 Dubbo 2.7.5 和 2.7.6 中的 version 均为 1.0.0。理论上,version 版本越高,支持元信息类型更丰富
    • protocol: MetadataService 所暴露协议,为了确保 Provider 和 Consumer 通讯兼容性,默认协议为:“dubbo”,也可以支持其他协议。
    • port:协议所使用的网络端口
    • host:当前 MetadataService 所在的服务实例主机或 IP
    • params:当前 MetadataService 暴露后 URL 中的参数信息

    不难得出,凭借以上元数据服务的 Metadata,可将元数据服务的 Dubbo 服务 ID 确定,辅助 Provider 服务暴露和 Consumer 服务订阅 MetadataService 。不过对于 Provider,这些元信息都是已知的,而对 Consumer 而言,它们直接能获取的元信息仅有:

    • serviceName:通过“Dubbo 接口与 Service 映射”关系,可得到 Provider Service 名称
    • interface:即 MetadataService ,因为 Provider 和 Consumer 公用 MetadataService 接口
    • group:即 serviceName

    不过 Consumer 合成 MetadataService Dubbo URL 还需获取 version、host、port、protocol 以及 params:

    • version:尽管 MetadataService 接口是统一接口,然而 Provider 和 Consumer 可能引入的 Dubbo 版本不同,从而它们使用的 MetadataService version 也会不同,所以这个信息需要 Provider 在暴露MetadataService 时,同步到服务实例的 Metadata 中,方便 Consumer 从 Metadata 中获取
    • host:由于 Consumer 已得到 serviceName,可通过服务发现 API 获取服务实例对象,该对象包含 host 属性,直接被 Consumer 获取即可。
    • port:与 version 类似,从 Provider 服务实例中的 Metadata 中获取
    • params:同上

    通过元数据服务 Metadata 的描述,解释了不同 Dubbo Services 是怎样体现差异性的,并且说明了 MetadataService 元信息的存储介质,这也就是服务自省架构为什么强依赖支持 Metadata 的注册中心的原因。下个小节将讨论 MetadataService 所存储 Dubbo 应用服务 URL 存放在何处。

    元数据服务存储模式

    Dubbo 2.7.5 在引入 MetadataService 的同时,也为其设计了两种存储方式,适用于不同的场景,即“本地存储模式”和“远程存储模式”。其中,本地存储模式是默认选项。

    元数据服务本地存储模式

    本地存储模式又称之为内存存储模式(In-Memory),如 Dubbo 应用服务发现和注册场景中,暴露和订阅的 URL 直接存储在内存中。架构上,本地存储模式的 MetadataService 相当于去中心化的 Dubbo 应用服务的注册中心。

    元数据服务远程存储模式

    远程存储模式,与去中心化的本地存储模式相反,采用 Dubbo 元数据中心来管理 Dubbo 元信息,又称之为元中心化存储模式(Metadata Center)。

    选择存储模式

    为了减少负载压力和维护成本,服务自省中的元数据服务推荐使用地存储模式”

    回顾前文“Consumer 执行流程”中的步骤 c3.e,为了减少 MetadataService 调用次数,服务自省将第一次的调用结果作为模板,再结合其他 N-1 服务实例的元信息,合成完整的 N 台服务实例的 Dubbo 元信息。假设,Dubbo Service 服务实例中部署的 Dubbo 服务数量和内容不同,那么,c3.e 的执行步骤是存在问题的。因此,服务自省引入“Dubbo 服务修订版本”的机制来解决不对等部署的问题。

    尽管“Dubbo 服务修订版本”机制能够介绍 MetadataService 整体消费次数,然而当新修订版本的服务实例过少,并且 Consumer 过多时,如新的版本 Provider 应用分批部署,每批的服务实例为 1 台,而其 Consumer 服务实例成千上万。为了确保这类场景的稳定性,Provider 和 Consumer 的 MetadataService 可选择“远程存储模式”,避免消费热点的发生。

    Dubbo 服务修订版本

    当业务出现变化时,Dubbo Service 的 Dubbo 服务也会随之升级。通常,Provider 先行升级,Consumer 随后跟进。

    考虑以下场景,Provider “P1” 线上已发布 interface 为 com.acme.Interface1,group 为 group , version 为 v1 ,即 Dubbo 服务 ID 为:dubbo:com.acme.Interface1:v1:default 。P1 可能出现升级的情况有:

    1. Dubbo 服务 interface 升级

    由于 Dubbo 基于 Java 接口来暴露服务,同时 Java 接口通常在 Dubbo 微服务中又是唯一的。如果 interface 的全类名调整的话,那么,相当于 com.acme.Interface1 做下线处理,Consumer 将无法消费到该 Dubbo 服务,这种情况不予考虑。如果是 Provider 新增服务接口的话,那么 com.acme.Interface1 则并没有变化,也无需考虑。所以,有且仅有一种情况考虑,即“Dubbo interface 方法声明升级”,包括:

    • 增加服务方法
    • 删除服务方法
    • 修改方法签名
    1. Dubbo 服务 group、version 和 protocol 升级

    假设 P1 在升级过程中,新的服务实例部署仅存在调整 group 后的 Dubbo 服务,如 dubbo:com.acme.Interface1:v1:test ,那么这种升级就是不兼容升级,在新老交替过程中,Consumer 仅能消费到老版本的 Dubbo 服务。当新版本完全部署完成后,Consumer 将无法正常服务调用。如果,新版本中 P1 同时部署了 dubbo:com.acme.Interface1:v1:default

    dubbo:com.acme.Interface1:v1:test 的话,相当于 group 并无变化。同理,version 和 protocol 变化,相当于 Dubbo 服务 ID 变化,这类情况无需处理

    1. Dubbo 服务元数据升级

    这是一种比较特殊的升级方法,即 Provider 所有服务实例 Dubbo 服务 ID 相同,然而 Dubbo 服务的参数在不同版本服务实例存在差异,假设 Dubbo Service P1 部署 5 台服务,其中 3 台服务实例设置 timeout 为 1000 ms,其余 2 台 timeout 为 3000 ms。换言之,P1 拥有两个版本(状态)的 MetadataService

    综上所述,无论是 Dubbo interface 方法声明升级,还是 Dubbo 服务元数据升级,均可认为是 Dubbo 服务升级的因子,这些因子所计算出来的数值称之为“Dubbo 服务修订版本”,服务自省架构将其命名为“revision”。架构设设计上,当 Dubbo Service 增加或删除服务方法、修改方法签名以及调整 Dubbo 服务元数据,revision 也会随之变化,revision 数据将存放在其 Dubbo 服务实例的 metadata 中。当 Consumer 订阅 Provider Dubbo 服务元信息时,MetadataService 远程调用的次数取决于服务实例列表中出现 revision 的个数,整体执行流程如下图所示:

    image.png

    (图 9)

    1. Consumer 通过服务发现 API 向注册中心获取 Provider 服务实例列表
    2. 注册中心返回 6 台服务实例,其中 revision 为 1 的服务实例为 Instance 1 到 3, revision 为 2 的服务实例是 Instance 4 和 Instance 5,revision 为 3 的服务实例仅有 Instance 6
    3. Consumer 在这 6 台服务实例中随机选择一台,如图中 Instance 3
    4. Consumer 向 Instance 3 发起 MetadataService 的远程调用,获得 Dubbo URL 列表,并建立 revision 为 1 的 URL 列表缓存,用 cache = { 1:urls(r1) } 表示
    5. (重复步骤 4)Consumer 再从剩余的 5 台服务实例中随机选择一台,如图中的 Instance 5,由于 Instance 5 与 Instance 3 的 revision 分为为 2 和 1,此时缓存 cache = { 1:urls(r1) } 未命中,所以 Consumer 将再次发起远程调用,获取新的 Dubbo URL 列表,并更新缓存,即 cache = { 1:urls(r1) , 2:urls(r2) }
    6. (重复步骤 4)Consumer 再从剩余的 4 台服务实例中随机选择一台,假设服务实例是 Instance 6,由于此时 revision 为3,所以缓存 cache = { 1:urls(r1) , 2:urls(r2) } 再次未命中,再次发起远程调用,并更新缓存 cache = { 1:urls(r1) , 2:urls(r2) , 3:urls(r3) }
    7. (重复步骤 4)由于缓存 cache = { 1:urls(r1) , 2:urls(r2) , 3:urls(r3) } 已覆盖三个 revision 场景,如果该步骤选择服务实例落在 revision 为 1 的子集中,只需克隆 urls(r1),并根据具体服务实例替换部分 host 和 port 等少量元信息即可,组成成新的 Dubbo URL 列表,依次类推,计算直到剩余服务实例为 0。

    大多数情况,revision 的数量不会超过 2,换言之,Consumer 发起 MetadataService 的远程调用不会超过 2次。无论 revision 数量的大小,架构能够保证获取 Dubbo 元信息的正确性。

    当然 MetadataService 并非仅支持 Dubbo URL 元数据,还有其他类型的支持。

    元数据类型

    架构上,元数据服务(MetadataService)未来将逐步替代 Dubbo 2.7.0 元数据中心,并随着 Dubbo 版本的更迭,所支持的元数据类型也将有所变化,比如 Dubbo 2.7.5 元数据服务支持的类型包括:

    • Dubbo 暴露的服务 URL 列表
    • Dubbo 订阅的服务 URL 列表
    • Dubbo 服务定义

    Dubbo 暴露的服务 URL 列表

    当前 Dubbo Service 暴露或发布 Dubbo 服务 URL 集合,如:[ dubbo://192.168.1.2:20880/com.acme.Interface1?group=default&version=v1 , thirft://192.168.1.2:20881/com.acme.InterfaceX , rest://192.168.1.2:20882/com.acme.interfaceN ]

    Dubbo 订阅的服务 URL 列表

    当前 Dubbo Service 所有订阅的 Dubbo 服务 URL 集合,该元数据主要被 Dubbo 运维平台来收集。

    Dubbo 服务定义

    Dubbo 服务提供方(Provider)在服务暴露的过程中,将元信息以 JSON 的格式同步到注册中心,包括服务配置的全部参数,以及服务的方法信息(方法名,入参出参的格式)。在服务自省引入之前,该元数据被 Dubbo 2.7.0 元数据中心 存储,如:

    {
     "parameters": {
      "side": "provider",
      "methods": "sayHello",
      "dubbo": "2.0.2",
      "threads": "100",
      "interface": "org.apache.dubbo.samples.metadatareport.configcenter.api.AnnotationService",
      "threadpool": "fixed",
      "version": "1.1.1",
      "generic": "false",
      "revision": "1.1.1",
      "valid": "true",
      "application": "metadatareport-configcenter-provider",
      "default.timeout": "5000",
      "group": "d-test",
      "anyhost": "true"
     },
     "canonicalName": "org.apache.dubbo.samples.metadatareport.configcenter.api.AnnotationService",
     "codeSource": "file:/../dubbo-samples/dubbo-samples-metadata-report/dubbo-samples-metadata-report-configcenter/target/classes/",
     "methods": [{
      "name": "sayHello",
      "parameterTypes": ["java.lang.String"],
      "returnType": "java.lang.String"
     }],
     "types": [{
      "type": "java.lang.String",
      "properties": {
       "value": {
        "type": "char[]"
       },
       "hash": {
        "type": "int"
       }
      }
     }, {
      "type": "int"
     }, {
      "type": "char"
     }]
    }

    更多元数据类型支持

    在架构上,元数据服务(MetadataService)所支持元数据类型是不限制的,如下图所示:

    image.png

    (图 10)

    除上文曾讨论的三种元数据类型,还包括“Dubbo 服务 REST 元信息” 和 “其他元信息”。其中,Dubbo 服务 REST 元信息包含 Dubbo 服务 与 REST 映射信息,可用于 Dubbo 服务网关,而其他元信息可能包括 Dubbo 服务 JavaDoc 元信息,可用于 Dubbo API 文档。

    元数据服务升级

    考虑到 Dubbo Provider 和 Consumer 可能依赖不同发行版本的 MetadataService ,因此,Provider 提供的和 Consumer 所需要的元数据类型并不对等,如 Provider 使用 Dubbo 版本为 2.7.5,该发行版本仅支持“Dubbo 暴露的服务 URL 列表”,“Dubbo 订阅的服务 URL 列表”和“Dubbo 服务定义”,这三种元数据分别来源于接口的三个方法。当 Consumer 使用了更高的 Dubbo 版本,并需要获取“Dubbo 服务 REST 元信息”时,自然无法从 Provider 端获取。假设 MetadataService 为其新增一个方法,那么,当 Consumer 发起调用时,那么这个调用自然会失败。即使两端使用的版本相同,那么 Provider 仍有可能选择性支持特定的元数据类型。为了确保元数据接口的兼容性,MetadataService 应具备元数据类型支持的判断。如此设计,MetadataService 在元数据类型上支持更具有弹性。

    事件驱动架构

    相较于传统的 Dubbo 架构,服务自省架构的执行流程更为复杂,执行动作之间的关联非常紧密,如 Dubbo Service 服务实例注册前需要完成 Dubbo 服务 revision 的计算,并将其添加至服务实例的 metadata 中。又如当 Dubbo Service 服务实例出现变化时,Consumer 元数据需要重新计算。这些动作被 “事件”(Event)驱动,驱动者被定义为“事件分发器”( EventDispatcher ),而动作的处理则由“事件监听器”(EventListener)执行,三者均为 “Dubbo 事件"的核心组件,同样由 Dubbo 2.7.5 引入。不过,Dubbo 事件是相对独立的架构,不过被服务自省中的“服务注册与发现架构”和“元数据服务架构”依赖。

    Dubbo 内建事件

    Dubbo 内建事件可归纳为以下类型:

    • Dubbo 服务类型事件
    • Dubbo Service 类型事件
    • Dubbo 服务实例类型事件
    • Dubbo 服务注册和发现类型事件

    Dubbo 服务类型事件

    事件类型事件触发时机
    ServiceConfigExportedEvent当 Dubbo 服务暴露完成时
    ServiceConfigUnexportedEvent当 Dubbo 服务下线后
    ReferenceConfigInitializedEvent当 Dubbo 服务引用初始化后
    ReferenceConfigDestroyedEvent当 Dubbo 服务引用销毁后

    Dubbo Service 类型事件

    事件类型事件触发时机
    DubboShutdownHookRegisteredEvent当 Dubbo ShutdownHook 注册后
    DubboShutdownHookUnregisteredEvent当 Dubbo ShutdownHook 注销后
    DubboServiceDestroyedEvent当 Dubbo 进程销毁后

    Dubbo 服务实例类型事件

    事件类型事件触发时机
    ServiceInstancePreRegisteredEvent当 Dubbo 服务实例注册前
    ServiceInstanceRegisteredEvent当 Dubbo 服务实例注册后
    ServiceInstancePreUnregisteredEvent当 Dubbo 服务实例注销前
    ServiceInstanceUnregisteredEvent当 Dubbo 服务实例注销后
    ServiceInstancesChangedEvent当 某个 Dubbo Service 下的服务实例列表变更时

    Dubbo 服务注册和发现类型事件

    事件类型事件触发时机
    ServiceDiscoveryInitializingEvent当 Dubbo 服务注册与发现组件初始化中
    ServiceDiscoveryInitializedEvent当 Dubbo 服务注册与发现组件初始化后
    ServiceDiscoveryExceptionEvent当 Dubbo 服务注册与发现组件异常发生时
    ServiceDiscoveryDestroyingEvent当 Dubbo 服务注册与发现组件销毁中
    ServiceDiscoveryDestroyedEvent当 Dubbo 服务注册与发现组件销毁后

    课程推荐

    查看原文

    赞 9 收藏 4 评论 1

    Richard_Yi 发布了文章 · 4月30日

    Java 应用线上问题排查思路、工具小结

    原文地址:Java 应用线上问题排查思路、工具小结

    原创不易,转载请注明出处。

    前言

    本文总结了一些常见的线上应急现象和对应排查步骤和工具。分享的主要目的是想让对线上问题接触少的同学有个预先认知,免得在遇到实际问题时手忙脚乱。毕竟作者自己也是从手忙脚乱时走过来的。

    只不过这里先提示一下。在线上应急过程中要记住,只有一个总体目标:尽快恢复服务,消除影响。 不管处于应急的哪个阶段,我们首先必须想到的是恢复问题,恢复问题不一定能够定位问题,也不一定有完美的解决方案,也许是通过经验判断,也许是预设开关等,但都可能让我们达到快速恢复的目的,然后保留部分现场,再去定位问题、解决问题和复盘

    在大多数情况下,我们都是先优先恢复服务,保留下当时的异常信息(内存dump、线程dump、gc log等等,在紧急情况下甚至可以不用保留,等到事后去复现),等到服务正常,再去复盘问题。

    好,现在让我们进入正题吧。

    常见现象:CPU 利用率高/飙升

    场景预设:

    监控系统突然告警,提示服务器负载异常。

    预先说明:

    CPU飙升只是一种现象,其中具体的问题可能有很多种,这里只是借这个现象切入。

    注:CPU使用率是衡量系统繁忙程度的重要指标。但是CPU使用率的安全阈值是相对的,取决于你的系统的IO密集型还是计算密集型。一般计算密集型应用CPU使用率偏高load偏低,IO密集型相反。

    常见原因:

    • 频繁 gc
    • 死循环、线程阻塞、io wait...etc

    模拟

    这里为了演示,用一个最简单的死循环来模拟CPU飙升的场景,下面是模拟代码,

    在一个最简单的SpringBoot Web 项目中增加CpuReaper这个类,

    /**
     * 模拟 cpu 飙升场景
     * @author Richard_yyf
     */
    @Component
    public class CpuReaper {
    
        @PostConstruct
        public void cpuReaper() {
            int num = 0;
            long start = System.currentTimeMillis() / 1000;
            while (true) {
                num = num + 1;
                if (num == Integer.MAX_VALUE) {
                    System.out.println("reset");
                    num = 0;
                }
                if ((System.currentTimeMillis() / 1000) - start > 1000) {
                    return;
                }
            }
        }
    }

    打包成jar之后,在服务器上运行。java -jar cpu-reaper.jar &

    第一步:定位出问题的线程

    方法 a: 传统的方法

    1. top 定位CPU 最高的进程

      执行top命令,查看所有进程占系统CPU的排序,定位是哪个进程搞的鬼。在本例中就是咱们的java进程。PID那一列就是进程号。(对指示符含义不清楚的见【附录】)

    2. top -Hp pid 定位使用 CPU 最高的线程

    3. printf '0x%x' tid 线程 id 转化 16 进制

      > printf '0x%x' 12817
      > 0x3211
    4. jstack pid | grep tid 找到线程堆栈

      > jstack 12816 | grep 0x3211 -A 30

    方法 b: show-busy-java-threads

    这个脚本来自于github上一个开源项目,项目提供了很多有用的脚本,show-busy-java-threads就是其中的一个。使用这个脚本,可以直接简化方法A中的繁琐步骤。如下,

    > wget --no-check-certificate https://raw.github.com/oldratlee/useful-scripts/release-2.x/bin/show-busy-java-threads
    > chmod +x show-busy-java-threads
    
    > ./show-busy-java-threads

    show-busy-java-threads
    # 从所有运行的Java进程中找出最消耗CPU的线程(缺省5个),打印出其线程栈
    
    # 缺省会自动从所有的Java进程中找出最消耗CPU的线程,这样用更方便
    # 当然你可以手动指定要分析的Java进程Id,以保证只会显示你关心的那个Java进程的信息
    show-busy-java-threads -p <指定的Java进程Id>
    
    show-busy-java-threads -c <要显示的线程栈数>

    方法 c: arthas thread

    阿里开源的arthas现在已经几乎包揽了我们线上排查问题的工作,提供了一个很完整的工具集。在这个场景中,也只需要一个thread -n 命令即可。

    > curl -O https://arthas.gitee.io/arthas-boot.jar # 下载

    要注意的是,arthas的cpu占比,和前面两种cpu占比统计方式不同。前面两种针对的是Java进程启动开始到现在的cpu占比情况,arthas这种是一段采样间隔内,当前JVM里各个线程所占用的cpu时间占总cpu时间的百分比。

    具体见官网:https://alibaba.github.io/art...

    后续

    通过第一步,找出有问题的代码之后,观察到线程栈之后。我们就要根据具体问题来具体分析。这里举几个例子。

    情况一:发现使用CPU最高的都是GC 线程。

    GC task thread#0 (ParallelGC)" os_prio=0 tid=0x00007fd99001f800 nid=0x779 runnable
    GC task thread#1 (ParallelGC)" os_prio=0 tid=0x00007fd990021800 nid=0x77a runnable 
    GC task thread#2 (ParallelGC)" os_prio=0 tid=0x00007fd990023000 nid=0x77b runnable 
    GC task thread#3 (ParallelGC)" os_prio=0 tid=0x00007fd990025000 nid=0x77c runnabl

    gc 排查的内容较多,所以我决定在后面单独列一节讲述。

    情况二:发现使用CPU最高的是业务线程

    • io wait

      • 比如此例中,就是因为磁盘空间不够导致的io阻塞
    • 等待内核态锁,如 synchronized

      • jstack -l pid | grep BLOCKED 查看阻塞态线程堆栈
      • dump 线程栈,分析线程持锁情况。
      • arthas提供了thread -b,可以找出当前阻塞其他线程的线程。针对 synchronized 情况

    常见现象:频繁 GC

    1. 回顾GC流程

    在了解下面内容之前,请先花点时间回顾一下GC的整个流程。


    接前面的内容,这个情况下,我们自然而然想到去查看gc 的具体情况。

    • 方法a : 查看gc 日志
    • 方法b : jstat -gcutil 进程号 统计间隔毫秒 统计次数(缺省代表一致统计
    • 方法c : 如果所在公司有对应用进行监控的组件当然更方便(比如Prometheus + Grafana)

    这里对开启 gc log 进行补充说明。一个常常被讨论的问题(惯性思维)是在生产环境中GC日志是否应该开启。因为它所产生的开销通常都非常有限,因此我的答案是需要开启。但并不一定在启动JVM时就必须指定GC日志参数。

    HotSpot JVM有一类特别的参数叫做可管理的参数。对于这些参数,可以在运行时修改他们的值。我们这里所讨论的所有参数以及以“PrintGC”开头的参数都是可管理的参数。这样在任何时候我们都可以开启或是关闭GC日志。比如我们可以使用JDK自带的jinfo工具来设置这些参数,或者是通过JMX客户端调用HotSpotDiagnostic MXBean的setVMOption方法来设置这些参数。

    这里再次大赞arthas??,它提供的vmoption命令可以直接查看,更新VM诊断相关的参数。

    获取到gc日志之后,可以上传到GC easy帮助分析,得到可视化的图表分析结果。

    2. GC 原因及定位

    prommotion failed

    从S区晋升的对象在老年代也放不下导致 FullGC(fgc 回收无效则抛 OOM)。

    可能原因:

    • survivor 区太小,对象过早进入老年代

      查看 SurvivorRatio 参数

    • 大对象分配,没有足够的内存

      dump 堆,profiler/MAT 分析对象占用情况

    • old 区存在大量对象

      dump 堆,profiler/MAT 分析对象占用情况

    你也可以从full GC 的效果来推断问题,正常情况下,一次full GC应该会回收大量内存,所以 正常的堆内存曲线应该是呈锯齿形。如果你发现full gc 之后堆内存几乎没有下降,那么可以推断: 堆中有大量不能回收的对象且在不停膨胀,使堆的使用占比超过full GC的触发阈值,但又回收不掉,导致full GC一直执行。换句话来说,可能是内存泄露了。

    一般来说,GC相关的异常推断都需要涉及到内存分析,使用jmap之类的工具dump出内存快照(或者 Arthas的heapdump)命令,然后使用MAT、JProfiler、JVisualVM等可视化内存分析工具。

    至于内存分析之后的步骤,就需要小伙伴们根据具体问题具体分析啦。

    常见现象:线程池异常

    场景预设:

    业务监控突然告警,或者外部反馈提示大量请求执行失败。

    异常说明:

    Java 线程池以有界队列的线程池为例,当新任务提交时,如果运行的线程少于 corePoolSize,则创建新线程来处理请求。如果正在运行的线程数等于 corePoolSize 时,则新任务被添加到队列中,直到队列满。当队列满了后,会继续开辟新线程来处理任务,但不超过 maximumPoolSize。当任务队列满了并且已开辟了最大线程数,此时又来了新任务,ThreadPoolExecutor 会拒绝服务。

    常见问题和原因

    这种线程池异常,一般可以通过开发查看日志查出原因,有以下几种原因:

    1. 下游服务 响应时间(RT)过长

      这种情况有可能是因为下游服务异常导致的,作为消费者我们要设置合适的超时时间和熔断降级机制。

      另外针对这种情况,一般都要有对应的监控机制:比如日志监控、metrics监控告警等,不要等到目标用户感觉到异常,从外部反映进来问题才去看日志查。

    2. 数据库慢 sql 或者数据库死锁

      查看日志中相关的关键词。

    3. Java 代码死锁

      jstack –l pid | grep -i –E 'BLOCKED | deadlock'

    四、常见问题恢复

    这一部分内容参考自此篇文章

    对于上文提到的一些问题,这里总结了一些恢复的方法。

    五、Arthas

    这里还是想单独用一节安利一下Arthas这个工具。

    Arthas 是阿里巴巴开源的Java 诊断工具,基于 Java Agent 方式,使用 Instrumentation 方式修改字节码方式进行 Java 应用诊断。

    • dashboard :系统实时数据面板, 可查看线程,内存,gc 等信息
    • thread :查看当前线程信息,查看线程的堆栈,如查看最繁忙的前 n 线程
    • getstatic:获取静态属性值,如 getstatic className attrName 可用于查看线上开关真实值
    • sc:查看 jvm 已加载类信息,可用于排查 jar 包冲突
    • sm:查看 jvm 已加载类的方法信息
    • jad:反编译 jvm 加载类信息,排查代码逻辑没执行原因
    • logger:查看logger信息,更新logger level
    • watch:观测方法执行数据,包含出参、入参、异常等
    • trace:方法内部调用时长,并输出每个节点的耗时,用于性能分析
    • tt:用于记录方法,并做回放
    以上内容节选自Arthas官方文档

    另外,Arthas里的 还集成了 ognl 这个轻量级的表达式引擎,通过ognl,你可以用arthas 实现很多的“骚”操作。

    其他的这里就不多说了,感兴趣的可以去看看arthas的官方文档、github issue。

    六、涉及工具

    再说下一些工具。

    结语

    我知道我这篇文章对于线上异常的归纳并不全面,还有网络(超时、TCP队列溢出...)、堆外内存等很多的异常场景没有涉及。主要是因为自己接触很少,没有深刻体会研究过,强行写出来免不得会差点意思,更怕的是误了别人??。

    还有想说的就是,Java 应用线上排查实际非常考究一个人基础是否扎实、解决问题能力是否过关。比如线程池运行机制、gc分析、Java 内存分析等等,如果基础不扎实,看了更多的是一头雾水。另外就是,多看看网上一些有实际场景的关于异常排查的经验文章,学习他们解决排查问题的思路和工具。这样即使自己暂时遇不到,但是会在脑海里面慢慢总结出一套解决类似问题的结构框架,到时候真的遇到了,也就是触类旁通的事情罢了。

    如果本文有帮助到你,希望能点个赞,这是对我的最大动力????????。

    参考

    附录

    top 命令显示的指示符的含义

    指示符含义
    PID进程id
    USER进程所有者
    PR进程优先级
    NInice值。负值表示高优先级,正值表示低优先级
    VIRT进程使用的虚拟内存总量,单位kb。VIRT=SWAP+RES
    RES进程使用的、未被换出的物理内存大小,单位kb。RES=CODE+DATA
    SHR共享内存大小,单位kb
    S进程状态。D=不可中断的睡眠状态 R=运行 S=睡眠 T=跟踪/停止 Z=僵尸进程
    %CPU上次更新到现在的CPU时间占用百分比
    %MEM进程使用的物理内存百分比
    TIME+进程使用的CPU时间总计,单位1/100秒
    COMMAND进程名称(命令名/命令行)
    查看原文

    赞 10 收藏 7 评论 0

    Richard_Yi 发布了文章 · 4月27日

    Java 并发编程 ④ - Java 内存模型

    原文地址:Java 并发编程 ④ - Java 内存模型

    转载请注明出处!

    往期文章:

    前言

    Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能得到一致效果的机制及规范

    JMM与Java内存区域是两个容易混淆的概念,这两者既有差别又有联系:

    • 区别

    两者是不同的概念层次Java 内存模型是抽象的,他是用来描述一组规则,通过这个规则来控制各个变量的访问方式,围绕原子性、有序性、可见性等展开的。而Java运行时内存的划分是具体的,是JVM运行Java程序时,必要的内存划分。

    • 联系

    都存在私有数据区域和共享数据区域。一般来说,JMM中的主内存属于共享数据区域,他是包含了堆和方法区;同样,JMM中的本地内存属于私有数据区域,包含了程序计数器、本地方法栈、虚拟机栈。

    在学习Java 内存模型时,我们经常会提到3个特性:

    • 可见性 - Visibility
    • 原子性 - Atomicity
    • 有序性 - Ordering

    Java内存模型就是围绕着在并发过程中如何处理这3个特性来建立的。本文也会按照这三个特性讲述。

    一、Java 共享变量的内存可见性问题

    在讨论之前,需要先重温一下,JVM运行时内存区域:

    线程私有变量不会在线程之间共享,也就不会有内存可见性的问题,也不受内存模型的影响。而在堆中的变量是共享的,这一块的数据也称为共享变量,内存可见性问题针对的就是共享变量。


    好了,弄清楚问题的主体之后,我们再来思考一个问题。

    为什么堆上的变量会存在内存可见性的问题呢?

    JMM对硬件层面缓存访问的抽象

    其实,这就要涉及到计算机硬件的缓存访问操作了。

    现代计算机中,处理器上的寄存器的读写的速度比内存快几个数量级,为了解决这种速度矛盾,在它们之间加入了高速缓存。

    Java的内存访问操作与上述的硬件缓存具有很高的可比性:

    Java内存模型中,规定了:

    • 所有的变量都存储在主内存中。
    • 每个线程还有自己的工作内存,存储了该线程以读、写共享变量的副本。
    • 本地内存(或者叫工作内存)是Java内存模型的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器等。
    • 线程只能直接操作工作内存中的变量,不同线程之间的变量值传递需要通过主内存来完成。

    从抽象的角度来说,JMM定义了线程和主内存之间的抽象关系

    按照上述对于JMM的描述,当一个线程操作共享变量时,它首先从主内存复制共享变量到自己的工作内存,然后对工作内存里的变量进行处理,处理完后将变量值更新到主内存。

    Cache(工作内存)的存在就会带来共享变量的内存不可见的问题(也可以叫做缓存一致性问题),具体可以看下面的例子:

    • 假设现在主内存中有共享变量X=0;
    • 线程A首先获取共享变量X的值,由于Cache中没有命中,所以去加载主内存中变量X的值,把X=0的值缓存到工作内存中,线程A执行了修改操作X++,然后将其写入工作内存中,并且刷新到主内存中。
      Thread-A工作内存中 X=1
      主内存中           X=1
    • 线程B开始获取共享变量,由于Cache没有命中,所以去加载主内存中变量X的值,把X=1的值缓存到工作内存中。然后线程B执行了修改操作X++,然后将其写入工作内存中,并且刷新到主内存中。
      Thread-B工作内存中 X=2
      Thread-A工作内存中 X=1
      主内存中           X=2

    明明线程B已经把X的值修改为了2,为何线程A获取的还是1呢?这就是共享变量的内存不可见问题,也就是线程B写入的值对线程A不可见。

    如何保证内存的可见性

    那么如何保证内存的可见性,主要有三种实现方式:

    • volatile 关键字

      该关键字可以确保对一个变量的更新对其他线程马上可见。当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存

    • sychronized 关键字

      一个线程在获取到监视器锁以后才能进入 synchronized 控制的代码块,一旦进入代码块,首先,该线程对于共享变量的缓存就会失效,因此 synchronized 代码块中对于共享变量的读取需要从主内存中重新获取,也就能获取到最新的值

      退出代码块的时候,会将该线程写缓冲区中的数据刷到主内存中,所以在 synchronized 代码块之前或 synchronized 代码块中对于共享变量的操作随着该线程退出 synchronized 块,会立即对其他线程可见(当然前提是线程会去主内存读取最新值)。

    • final 关键字

      在对象的构造方法中设置 final 属性,同时在对象初始化完成前,不要将此对象的引用写入到其他线程可以访问到的地方(不要让引用在构造函数中逸出)。如果这个条件满足,当其他线程看到这个对象的时候,那个线程始终可以看到正确初始化后的对象的 final 属性。(final 字段所引用的对象里的字段或数组元素可能在后续还会变化,若没有正确同步,其它线程也许不能看到最新改变的值,但一定可以看到完全初始化的对象或数组被 final 字段引用的那个时刻的对象字段值或数组元素。)

      final 的场景比较偏,一般就是前面两种方式

      延伸链接:JSR-133:JavaTM 内存模型与线程规范

    volatile 和 sychronized 是我认为比较重要的内容,会有单独的章节来讲。

    二、原子性

    JMM 内存交互操作

    Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互操作

    • read:把一个变量的值从主内存传输到线程的工作内存中
    • load:在 read 之后执行,把 read 得到的值放入线程的工作内存的变量副本中
    • use:把线程的工作内存中一个变量的值传递给执行引擎
    • assign:把一个从执行引擎接收到的值赋给工作内存的变量
    • store:把工作内存的一个变量的值传送到主内存中
    • write:在 store 之后执行,把 store 得到的值放入主内存的变量中
    • lock:作用于主内存的变量,把一个变量标识成一条线程独占的状态
    • unlock: 作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
    JMM关于内存交互的定义规则非常的严谨和繁琐,为了方便理解,Java设计团队将Java内存模型的操作简化为read、write、lock和unlock四种,但这只是语言描述上的等价化简,Java内存模型的基础设计并未改变。

    JMM 对于原子性的规定

    所谓原子性操作,是指执行一系列操作时,这些操作要么全部执行,要么全部不执行,不存在只执行其中一部分的情况。

    Java 内存模型保证了 readloaduseassignstorewritelockunlock 操作具有原子性,例如对一个 int 类型的变量执行 assign 赋值操作,这个操作就是原子性的。但是 Java 内存模型允许虚拟机将没有被 volatile 修饰的 64 位数据(longdouble)的读写操作划分为两次 32 位的操作来进行,也就是说基本数据类型的访问读写是原子性的,除了longdouble是非原子性的,loadstoreread write 操作可以不具备原子性。 在《深入理解Java 虚拟机》书中提醒我们只需要知道有这么一回事,真的要用到这个知识点的场景十分罕见。

    共享变量的原子性问题

    这里放一个很经典的例子,并发条件下的计数器自增。

    /**
     * 内存模型三大特性 - 原子性验证对比
     *
     * @author Richard_yyf
     */
    public class AtomicExample {
    
        private static AtomicInteger atomicCount = new AtomicInteger();
    
        private static int count = 0;
    
        private static void add() {
            atomicCount.incrementAndGet();
            count++;
        }
    
        public static void main(String[] args) {
            final int threadSize = 1000;
            final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
            ExecutorService executor = Executors.newCachedThreadPool();
            for (int i = 0; i < threadSize; i++) {
                executor.execute(() -> {
                    add();
                    countDownLatch.countDown();
                });
            }
            System.out.println("atomicCount: " + atomicCount);
            System.out.println("count: " + count);
    
            ThreadPoolUtil.tryReleasePool(executor);
        }
    }

    输出结果:

    atomicCount: 1000
    count: 997

    可以看到,虽然有1000个线程执行了count++操作,最终得到的结果却不是预期的1000。

    至于原因呢,就是因为count++这行代码,并不是一个原子性操作。可以借助下图帮助理解。

    count++这个简单的操作根据上面的原理分析,可以知道内存操作实际分为读写存三步;因为读写存这个整体的操作,不具备原子性,count被两个或多个线程读入了同样的旧值,读到线程内存当中,再进行写操作,再存回去,那么就可能出现主内存被重复set同一个值的情况,如上图所示,两个线程进行了count++,实际上只进行了一次有效操作。

    如何保证原子性

    想要保证原子性,可以尝试以下几种方式:

    • CAS:使用基于CAS实现的原子操作类(例如AtomicInteger)
    • synchronized 关键字:可以使用synchronized 来保证限定临界区内操作的原子性。它对应的内存间交互操作为:lock 和 unlock,在虚拟机实现上对应的字节码指令为 monitorenter 和 monitorexit
    前者是乐观锁(读多写少场景),后者是悲观锁(读少写多场景)

    三、有序性

    重排序

    计算机在执行程序时,为了提高性能,编译器和处理器会对指令做重排。

    重排序由以下几种机制引起:

    • 编译器优化重排

      编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

    • 指令并行重排

      现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。

    • 内存系统重排

      由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

    如何保证有序性

    Java内存模型允许编译器和处理器对指令重排序以提高运行性能,并且只会对不存在数据依赖性的指令重排序。意思就是说,在Java内存模型的规定下,对编译器和处理器来说,只要不改变程序的执行结果(单线程程序和正确同步了的多线程程序),编译器和处理器怎么优化都行。 在单线程下,可以保证重排序优化之后最终执行的结果与程序顺序执行的结果一致(我们常说的as-if-serial语义),但是在多线程下就会存在问题。

    重排序在多线程下会导致非预期的程序执行结果,想要保证可见性,可以考虑以下实现方式:

    • volatile

      volatile产生内存屏障,禁止指令重排序

    • synchronized

      保证每个时刻只有一个线程进入同步代码块,相当于是让线程顺序执行同步代码。

    小结

    Java内存模型的一系列运行规则看起来有点繁琐,但总结起来,是围绕原子性、可见性、有序性特征建立。归根究底,是为实现共享变量的在多个线程的工作内存的数据一致性,是的在多线程并发、指令重排序优化的环境中程序能如预期运行。

    本文介绍了Java内存模型,以及其围绕的有序性、内存可见性以及原子性相关的知识。不得不说,关于Java内存模型,真的要深究估计可以写出一本小书,有兴趣的读者可以参阅其他资料做更深的了解。

    上文中提到的valotilesynchronized,是比较重要的内容,会有单独的章节。

    参考

    • 《Java 并发编程之美》
    • 《深入理解Java虚拟机》
    • JSR133中文
    查看原文

    赞 5 收藏 4 评论 0

    Richard_Yi 发布了文章 · 4月24日

    读书日,谈谈读书

    undefined

    4月23号是国际读书日,借着这个时机就想谈谈自己对读书的感悟。

    其实回顾前大半个学生生涯,我都不是一个喜欢读书的人。

    因为什么?我后来仔细想了想,因为我觉得“无用”。

    原来从很久很久以前,我都是站在一种很纯粹的实用主义立场,去看待读书这件事情,这和每个人家庭的成长环境有关,从小到大,周围的人都给我灌输着一种成功主义的价值观。“你要成功,你要出名,你要吃得苦中苦,吃得苦中苦的目的是赚大钱,成为人上人”。“你说你看这些闲书有什么用”。

    然而,站在纯粹的实用主义立场,用处其实不大,甚至有反作用,你的眼界、审美水平、认知能力会被局限在一个小小的圈子中,精神世界会变得匮乏,甚至有可能越来越“愚蠢”。

    所幸在一些良心UP主,良心公众号的推荐之下,我慢慢开始阅读。到现在差不多有两年了,读的书越多就越后悔。后悔什么呢?后悔没有早点阅读。

    《财富自由之路》、《富爸爸与穷爸爸》刷新了我对金钱、财富、工作的理解,让我有意识地去学习理财相关的知识,培养理财思维。让我懂得不要为短期的金钱工作,更看重个人能力的增值。

    《社会性动物》、《非暴力沟通》、《关键沟通》让我用一种全新的视角去看待人与人之间的相互影响、沟通与合作,学会了很多与人沟通的艺术。

    《被讨厌的勇气》、《幸福的方法》让我对幸福有了新的理解,明白了幸福是一个需要长期追求、永不间断的过程,是在自己觉得有意义的生活方式中享受其中点滴。

    是的,这是我现阶段我体会到读书的最大的意义,也是我想说的第一点:

    读书能让你用最低的成本去培养你的眼界、思维和认知。

    读书,尤其是读经典的书,实际上就是和各种各样的伟人、学者进行跨时空的交流和学习。


    其次,读书让我们学会谦卑

    很多时候读书具有一种悖论性:

    我们因为无知才去阅读,而我们越阅读,我们越承认自己的无知。

    牛顿说:一直以来,我就像一个在海边玩耍的小孩,时不时被某个特别光滑的鹅卵石或美丽的贝壳所吸引,然而却对面前那无边无际的真理的海洋浑然无知。苏格拉底说:我唯一知道的就是自己一无所知。

    学习的真谛就在于此,真正的学习不是为了炫耀已有的知识,而是承认自己是如此的无知,发自内心地感恩自己能够获得真理的惊鸿一瞥。在广袤的真理海洋中不断地学会谦卑,对未知的领域保持足够的敬畏。

    读书让我明白了,自认为万事皆知的人只是最大的愚昧,也让我明白了,有些人的傲慢不过是不学无术的另一种表达。


    最后,读书可以提高我们的审美

    我们经常会把书成为精神食粮,那些好的书籍就像我们吃过的饭菜,我们很难记住自己好几天前吃过什么饭菜,但是这些食物为我们的身体提供了能量养分,组成了我们的身体。我们很难记住我们读过的书中的具体的文字,但是这些书籍、以及我们在阅读过程中的思考,都内化到了我们的精神世界,形成了我们独有的品位。随着你的审美水平越来越高,你会为了一些更高水平的快乐,去抛弃一些低下的乐趣。

    想一想,当你乘坐宇宙飞船前往火星,所有的乘客都不住地欣赏着窗外的璀璨星空,而你却埋头只顾看肥皂剧,你不觉得这是一种浪费吗?当你如孩童一般在泥巴潭旁嬉戏游玩,觉得人生快乐,莫过于此。也许有一天,你会在海边漫步,拥有更大的快乐。

    实际上我现在这个阶段,还不敢妄谈“美”,因为我自认为我的审美水平还停留在一个很初级的阶段。但是我非常渴望能够通过读书来提高自己的审美水平。

    写文在此,期待与您共勉。

    查看原文

    赞 0 收藏 0 评论 0

    Richard_Yi 赞了文章 · 4月20日

    Apache毕业贺礼—Apache ShardingSphere跌宕起伏的开源之路

    作者介绍

    潘娟,京东数科高级DBA,Apache ShardingSphere PMC

    张亮,京东数科数据研发负责人,Apache ShardingSphere VP,Apache Dubbo PMC,人气开源项目Elastic-Job作者

    前序

    从Sharding-JDBC到Apache ShardingSphere;
    从轻量级的分库分表中间件到完整闭环的分布式数据库中间件平台;
    从2016年1月的第一行代码到现今的300K+行代码;
    从寥寥无几的关注到GitHub 10K+的star;
    从无人问津的社区到100+位贡献者;
    从公司内部的应用类库到100+的采用公司列表;
    从寻找mentor到顺利成为Apache顶级项目。
    ……

    Apache ShardingSphere团队核心初创人员将讲述这其中的跌宕起伏,并以时间轴为线索为你呈现它开源之路背后的故事。

    项目介绍

    Apache ShardingSphere是一套开源的分布式数据库中间件解决方案组成的生态圈,它由3款相互独立,却又能够混合部署配合使用的产品组成。它们均提供标准化的数据分片、分布式事务和数据库治理功能,可适用于如Java同构、异构语言、云原生等各种多样化的应用场景,核心功能如1-1所示。

    image.png
    1-1 ShardingSphere核心功能架构图

    Apache ShardingSphere由三个子项目组成,形成一个完整的数据库解决方案,合称 J.P.S. 生态系统。

    ShardingSphere-JDBC:定位为轻量级Java框架,在Java的JDBC层提供额外服务。 它使用客户端直连数据库,以jar包形式提供服务,无需额外部署和依赖,可理解为增强版的JDBC驱动,完全兼容JDBC和各种ORM框架。

    ShardingSphere-Proxy:定位为透明化的数据库代理端,提供封装了数据库二进制协议的服务端版本,用于完成对异构语言的支持。 目前提供MySQL/PostgreSQL版本,它可以使用任何兼容MySQL/PostgreSQL协议的访问客户端操作数据,对DBA更加友好。

    ShardingSphere-Sidecar(TODO):定位为Kubernetes的云原生数据库代理,以Sidecar的形式代理所有对数据库的访问。 通过无中心、零侵入的方案提供与数据库交互的的啮合层,即Database Mesh,又可称数据网格。

    Apache ShardingSphere的亮点主要包括:

    1. 完整的分布式数据库解决方案:提供数据分片、分布式事务、数据弹性迁移、数据库和数据治理等核心能力。
    2. 独立的SQL解析引擎:支持多SQL方言的完全独立化SQL解析引擎,能够脱离ShardingSphere独立使用。
    3. 可插拔微内核:所有的SQL方言、数据库协议和功能都能够通过SPI的可插拔方式加载或卸载,微内核甚至在未来可以运行于无任何功能的空白环境中。

    为Apache做准备

    找寻mentor是进入Apache基金的最初且最重要的一步。在了解了Apache基金会的运作方式后,我们便踏上了找寻mentor之旅。参加各种与开源相关的分享会或meetup,借此来认识Apache的member。但是,事情却并不顺利。多次的尝试,多次的接触换来的只是口头的认可。这段时间我们确实倍感压力和焦虑,甚至打算以后再说,一切随缘。

    后来一个契机,我们认识了吴晟和华为的姜宁。吴晟是Apache SkyWalking项目的VP,在开源领域有丰富的经验。他和ShardingSphere的前身Sharding-JDBC很有渊源,Sharding-JDBC项目原型也有他参与设计,因此,他最终作为ShardingSphere的PPMC一同建设社区。在参与ShardingSphere社区建设的这一年多的时间里,他又陆续担任了多个Apache孵化项目的Mentor,并在今年被选举为Apache Member;而姜宁同样是一位热心又有经验的老手,是国内最资深的Apache Member之一,在与他交流的过程中,终于让我们看到一些希望,他也最终成为了我们的mentor。再后来,团队VP张亮又前去上海参加HDC大会,认识了我们的另一位mentor—Craig L Russell,Craig当时是Apache的秘书长,所有的SGA、ICLA等法务文件均由他负责签署。在ShardingSphere孵化的过程中,Craig当选了Apache软件基金会的主席。他友善而和气,给予了我们很多有关社区规范的实用建议,也愿意助我们一臂之力;第三位mentor则是由Apache RocketMQ的核心成员冯嘉担任;最后由Roman Shaposhnik担任项目的Champion,为项目寻找导师之旅画上完美句号。

    至今还记得我们当时的欣喜和激动。之前的无助、徘徊、失落在这一瞬间柳暗花明。每个进入Apache基金会的项目,一定都有自己的故事。尤其对于中国的项目来说,语言与地域的障碍让我们雪上加霜。好在有越来越多的来自于中国的项目进入了Apache基金会,也能看到越来越多的华人活跃在Apache的邮件列表里,还有ALC Beijing的建立让参与门槛不断降低,这对想要参与的国内朋友来说,确实是个good news!

    进入Apache孵化器

    为了正式进入Apache孵化器,项目代码、社区、文档等都需要进行一系列的规范和整理。这确实是个琐碎但很重要的事情。

    代码层面,合规操作是首要原则。我们梳理第三方依赖的许可协议, 确保满足Apache软件许可协议(ASL)合规的要求;社区方面,我们开始由中文转变成英文;文档方面则需要我们准备英文文档,并准备相关的proposal。由于项目最开始的目标就是进入Apache基金会,所以在项目初期,依赖就尽可能地简单,社区相对规范,文档在不断翻译。不打无准备之仗,这些提前的准备让这部分工作进展顺利,而项目获得Apache域名的那一刻,大家才真切感受到所有付出得到了最有价值的回报。

    除了学习写规范代码,团队成员也开始学习Apache的规范、运作方式、英文沟通渠道等细节。我们开始了解到如何关注社区,什么是consensus decision,如何用异步方式进行邮件沟通。特别是邮件列表的学习非常重要,你可以在其中找到历史问题记录、合规的解决方案、优秀的案例等。

    Apache way的探索

    很多人认为只要代码开放,就叫做开源。但其实,这仅仅只是开源旅程的第一步。如何构建一个活跃的社区,如何理解Apache way,是一个更为重要的话题。ShardingSphere在进入Apache孵化器初期并未能完全理解Apache way,并且由于过度注重代码风格,以至于参与门槛较高、社区活跃度平平。起初,我们并不知道问题出在哪里,迷茫了很长一段时间,直到在跟Apache的member不断交流的过程中才渐渐意识到问题所在,因此社区发起了有关committer bar的讨论,见图1-2。这是社区建设之路的转折点,因为从此community over code的理念开始逐渐渗入人心,并指导我们的行动。

    image.png
    1-2 Committer bar讨论邮件

    仔细阅读Apache way的关注点:Earned Authority, Community of Peers, Open Communications, Consensus Decision Making, Responsible Oversight。你会发现它一直在强调合规、开放、平等、协作,为的就是建立合规且活跃的项目社区,尽可能地做到让更多的人参与,平等沟通,推动项目发展,促进个人成长。

    秉持这个理念,ShardingSphere开始在多维度进行调整,

    • 代码:规整代码结构,划分模块功能,提供项目可插拔能力,从而允许用户局部参与某一模块的同时,尽量不破坏整体代码结构。
    • 心态:开放的心态,编制社区任务,鼓励社区朋友参与,相关PPMC或Committer积极提供指导和帮助。
    • 规范:梳理文档和代码规范,并提供详细的订阅、参与指南,大范围促进用户自主进行社区贡献。
    • 交流:鼓励社区尽可能使用邮件和Issue进行讨论从而公开讨论内容,同时针对较为细节的讨论则放在微信群里进行。此外,官方公众号还会介绍社区的进展、Release、刊登技术文章等。
    • 合作:与其他Apache社区建立联系、增加沟通,从合作交流中进行学习和发展。

    在孵化期间,Apache ShardingSphere先后与Apache SkyWalking、Apache ServiceComb进行项目的合作与集成,不仅彼此的产品功能更加完善,还增加了社区成员之间的交流。此外,还与Apache DolphinScheduler(Incubating)和Apache IoTDB(Incubating)举办了co-meetup,详见图1-3。还与Apach pulsar和Apache APISIX(Incubating)的核心成员们进行了多次交流和探讨。

    image.png
    1-3 co-meetup

    经过时间的积累,社区已有了质的变化。从社区的邮件讨论、GitHub的数据展示中,你会发现ShardingSphere的社区开始真正变得活跃与多元化。图1-4展示了ShardingSphere在Apache孵化器一年多的社区数据变化。

    image.png
    1-4 社区数据变化

    社区与贡献者之间的依赖和互赢也在整个过程中体现的淋漓尽致。对于贡献者来说,他们会在这个开源社区中与其他人交流、协作。而这个持续的过程,将带来以下成果,

    • 扩大人际交友圈
    • 不断学习与成长
    • 提高自己的技术影响力
    • 拓宽职业渠道
    • 结合兴趣,享受过程

    而对于社区来说,这个相互帮助和沟通的过程则会,

    • 拓展项目的功能
    • 收获活跃多元化的生态圈
    • 增加项目知名度
    • 获得社区的可持续发展

    从这个角度来看,不断探索Apache way不也是希望出现这样一种共赢而互助的局面吗?Please remember community over code。

    从孵化器毕业

    所有孵化器的项目最终都希望能走向TLP(Top Level Project)。在mentor的指导、PPMC的探索、committer和contributor的支持与付出下,ShardingSphere开始筹备Apache孵化器毕业。依据Apache的成熟度评估模型图1-5,在以下几个方面评估社区和项目是否成熟。其实在Apache项目社区的初建阶段,我们建议大家就在这几个方面发力,因为这是官方给予的毕业标准及指导方针。以此为方向,探索属于各自项目的独特社区运作方式,也可谓是百花齐放。

    image.png
    1-5 Apache项目成熟度评估模型

    经历Release、社区建设、Apache member的指导、meetup举办等一系列事件,ShardingSphere终于在社区发起了毕业讨论,开始接受Apache member及所有Apache成员的指导和评估。虽然最终以10 +1 binding votes,6 +1 non-binding votes和 no -1 or +/-0 votes通过毕业投票,但过程也是一波三折。

    即便是经过1年多的社区建设,项目基本成熟,但面对毕业还是有很多工作要合乎毕业规范。例如确认商标是否可使用、完成项目官网有关Apache brand和trademark的陈述、网站符合Apache way等。在这个投票期间,由于官网存在fork me on github的slogan,而这一问题一直频繁出现并且没有结论,所以其他Apache成员借此单独开辟了thread来讨论这一问题,查看Email List了解详情。虽说这一举让ShardingSphere被成功推到前台,间接提高了项目的曝光,却也能看出Apache对于第三方独立、禁止参与商业行为的重视和严苛。可喜可贺的是,2020年4月16日,Apache ShardingSphere最终通过基金会董事会决议,加入了TLP行业!

    未来的路

    从Apache孵化器毕业成为TLP,对ShardingSphere来说,并不是一个结束,而是另一个开始。在产品功能上,ShardingSphere将继续在分布式数据库中间件平台上深耕,打磨出以“分布式”为核心的数据库中间件生态圈,从而提供完整的解决方案,如图1-6所示。从社区角度讲,ShardingSphere仍将继续活跃社区,鼓励更多朋友成为社区的committer和contributor。所以,我们欢迎大家关注ShardingSphere,并加入到社区来,与更多知己结伴前行。

    image.png
    1-6 Apache ShardingSphere生态圈

    未来之路不可预测,但立足当下,眺望未来,初心未改,即便亦步亦趋,也愿一苇以航!

    Apache ShardingSphere committer列表

    Mentor

    Craig L Russell
    冯嘉,阿里巴巴
    姜宁,华为

    PMC

    张亮,京东数科
    潘娟,京东数科
    赵俊,京东数科
    张永伦,京东数科
    陈清阳,翼支付
    曹昊,海南新软
    马晓光
    杜红军,领创智信
    杨翊,京东数科
    吴晟,tetrate.io
    高洪涛,tetrate.io

    Committer

    李亚,九个小海豹
    颜志一,DaoCloud
    董宗磊,京东零售
    孙海生,瓜子
    王奇,京东零售
    欧阳文,一卡易
    蒋晓峰,阿里巴巴
    王光远
    秦金卫,京东数科
    岳令
    赵亚楠

    官网:https://shardingsphere.apache.org/
    查看原文

    赞 18 收藏 4 评论 1

    Richard_Yi 收藏了文章 · 4月13日

    一键导出微信读书的书籍和笔记

    简介


    全民阅读的时代已经来临,目前使用读书软件的用户数2.1亿,日活跃用户超过500万,其中19-35岁年轻用户占比超过60%,本科及以上学历用户占比高达80%,北上广深及其他省会城市/直辖市用户占比超过80%。本人习惯使用微信读书,为了方便整理书籍和导出笔记,便开发了这个小工具。




    部分截图










    代码思路

    1. 目录结构

    首先,我们先看一下整体目录结构

    Code
    ├─ excel_func.py                   读写excel文件
    ├─ pyqt_gui.py                     PyQt GUI界面
    └─ wereader.py                     微信读书相关api
    
    • excel_func.py

    使用xlrd和xlwt库对excel文件进行读写操作

    • pyqt_gui.py

    使用PyQt绘制GUI界面

    • wereader.py

    通过抓包解析获得相关api


    2. excel_func.py

    def write_excel_xls(path, sheet_name_list, value):
        # 新建一个工作簿
        workbook = xlwt.Workbook()
    
        # 获取需要写入数据的行数
        index = len(value)
    
        for sheet_name in sheet_name_list:
    
            # 在工作簿中新建一个表格
            sheet = workbook.add_sheet(sheet_name)
    
            # 往这个工作簿的表格中写入数据
            for i in range(0, index):
                for j in range(0, len(value[i])):
                    sheet.write(i, j, value[i][j])
    
        # 保存工作簿
        workbook.save(path)
    

    该函数的代码流程为:

    1. 创建excel文件
    2. 创建表格
    3. 往表格写入数据




    3. pyqt_gui.py

    class MainWindow(QMainWindow):
    
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
    
            self.DomainCookies = {}
    
            self.setWindowTitle('微信读书助手') # 设置窗口标题
            self.resize(900, 600) # 设置窗口大小
            self.setWindowFlags(Qt.WindowMinimizeButtonHint) # 禁止最大化按钮
            self.setFixedSize(self.width(), self.height()) # 禁止调整窗口大小
    
            url = 'https://weread.qq.com/#login' # 目标地址
            self.browser = QWebEngineView() # 实例化浏览器对象
    
            QWebEngineProfile.defaultProfile().cookieStore().deleteAllCookies() # 初次运行软件时删除所有cookies
    
            QWebEngineProfile.defaultProfile().cookieStore().cookieAdded.connect(self.onCookieAdd) # cookies增加时触发self.onCookieAdd()函数
            self.browser.loadFinished.connect(self.onLoadFinished) # 网页加载完毕时触发self.onLoadFinished()函数
    
            self.browser.load(QUrl(url)) # 加载网页
            self.setCentralWidget(self.browser) # 设置中心窗口

    该函数的代码流程为:

    1. 新建QT窗口
    2. 实例化QWebEngineView对象
    3. 绑定self.onCookieAdd事件
    4. 绑定self.onLoadFinished事件
    5. 加载网页




        # 网页加载完毕事件
        def onLoadFinished(self):
    
            global USER_VID
            global HEADERS
    
            # 获取cookies
            cookies = ['{}={};'.format(key, value) for key,value in self.DomainCookies.items()]
            cookies = ' '.join(cookies)
            # 添加Cookie到header
            HEADERS.update(Cookie=cookies)
    
            # 判断是否成功登录微信读书
            if login_success(HEADERS):
                print('登录微信读书成功!')
    
                # 获取用户user_vid
                if 'wr_vid' in self.DomainCookies.keys():
                    USER_VID = self.DomainCookies['wr_vid']
                    print('用户id:{}'.format(USER_VID))
    
                    # 关闭整个qt窗口
                    self.close()
    
            else:
                print('请扫描二维码登录微信读书...')

    该函数的代码流程为:

    1. 当网页加载完毕时,检测是否成功登录微信读书
    2. 如果成功登录微信读书,则关闭QT窗口,开始进行数据导出
    3. 如果失败登录微信读书,则继续等待用户扫描二维码




        # 添加cookies事件
        def onCookieAdd(self, cookie):
            if 'weread.qq.com' in cookie.domain():
                name = cookie.name().data().decode('utf-8')
                value = cookie.value().data().decode('utf-8')
                if name not in self.DomainCookies:
                    self.DomainCookies.update({name: value})

    该函数的代码流程为:

    1. 保存微信读书网址的cookies,以便后续操作




        books = get_bookshelf(USER_VID, HEADERS) # 获取书架上的书籍
        books_finish_read = books['finishReadBooks']
        books_recent_read = books['recentBooks']
        books_all = books['allBooks']
        write_excel_xls_append(data_dir + '我的书架.xls', '已读完的书籍', books_finish_read) # 追加写入excel文件
        write_excel_xls_append(data_dir + '我的书架.xls', '最近阅读的书籍', books_recent_read)  # 追加写入excel文件
        write_excel_xls_append(data_dir + '我的书架.xls', '所有的书籍', books_all)  # 追加写入excel文件
    
        # 获取书架上的每本书籍的笔记
        for index, book in enumerate(books_finish_read):
            book_id = book[0]
            book_name = book[1]
            notes = get_bookmarklist(book[0], HEADERS)
    
            with open(note_dir + book_name + '.txt', 'w') as f:
                f.write(notes)
            print('导出笔记 {} ({}/{})'.format(note_dir + book_name + '.txt', index+1, len(books_finish_read)))
    
    

    该函数的代码流程为:

    1. 调用write_excel_xls_append函数,保存书籍,并且导出笔记




    4. wereader.py

    def get_bookshelf(userVid, headers):
        """获取书架上所有书"""
        url = "https://i.weread.qq.com/shelf/friendCommon"
        params = dict(userVid=userVid)
        r = requests.get(url, params=params, headers=headers, verify=False)
        if r.ok:
            data = r.json()
        else:
            raise Exception(r.text)
    
        books_finish_read = set() # 已读完的书籍
        books_recent_read = set() # 最近阅读的书籍
        books_all = set() # 书架上的所有书籍
    
    
        for book in data['recentBooks']:
            if not book['bookId'].isdigit(): # 过滤公众号
                continue
            b = Book(book['bookId'], book['title'], book['author'], book['cover'], book['intro'], book['category'])
            books_recent_read.add(b)
    
        books_all = books_finish_read + books_recent_read
    
        return dict(finishReadBooks=books_finish_read, recentBooks=books_recent_read, allBooks=books_all)
    

    该函数的代码流程为:

    1. 获取最近阅读的书籍、已经读完的书籍、所有书籍
    2. 过滤公众号部分
    3. 将书籍数据保存为字典格式




    def get_bookmarklist(bookId, headers):
        """获取某本书的笔记返回md文本"""
        url = "https://i.weread.qq.com/book/bookmarklist"
        params = dict(bookId=bookId)
        r = requests.get(url, params=params, headers=headers, verify=False)
    
        if r.ok:
            data = r.json()
            # clipboard.copy(json.dumps(data, indent=4, sort_keys=True))
        else:
            raise Exception(r.text)
        chapters = {c['chapterUid']: c['title'] for c in data['chapters']}
        contents = defaultdict(list)
    
        for item in sorted(data['updated'], key=lambda x: x['chapterUid']):
            # for item in data['updated']:
            chapter = item['chapterUid']
            text = item['markText']
            create_time = item["createTime"]
            start = int(item['range'].split('-')[0])
            contents[chapter].append((start, text))
    
        chapters_map = {title: level for level, title in get_chapters(int(bookId), headers)}
        res = ''
        for c in sorted(chapters.keys()):
            title = chapters[c]
            res += '#' * chapters_map[title] + ' ' + title + '\n'
            for start, text in sorted(contents[c], key=lambda e: e[0]):
                res += '> ' + text.strip() + '\n\n'
            res += '\n'
    
        return res

    该函数的代码流程为:

    1. 获取某一本书籍的笔记
    2. 将返回的字符串改写成markdown格式并输出






    如何运行

    # 跳转到当前目录
    cd 目录名
    # 先卸载依赖库
    pip uninstall -y -r requirement.txt
    # 再重新安装依赖库
    pip install -r requirement.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
    # 开始运行
    python pyqt_gui.py




    补充

    完整版源代码存放在github上,有需要的请点击这里下载

    项目持续更新,欢迎您star本项目




    License

    The MIT License (MIT)

    查看原文

    Richard_Yi 关注了专栏 · 4月8日

    技术也能说人话

    很多技术看起来复杂,其实可以说的很简单。

    关注 6

    Richard_Yi 赞了文章 · 4月8日

    为什么要用Go语言?

    本文章创作于2020年4月,大约6000字,预计阅读时间15分钟,请坐和放宽。

    logo.png

    前言

    Go 是一个开源的编程语言,它能让构造简单、可靠且高效的软件变得容易[1]。

    Go 语言被设计成一门应用于搭载 Web 服务器,存储集群或类似用途的巨型中央服务器的系统编程语言。对于高性能分布式系统领域而言,Go语言无疑比大多数其它语言有着更高的开发效率。它提供了海量并行的支持,这对于游戏服务端的开发而言是再好不过了[1]。

    其实早在2018年前,我就已经有在国内的程序员环境中断断续续地听到Go语言的消息,Go语言提供的方便的并发编程方式,十分适合我当时选择的毕业设计选题,但是受限于导师的语言选择、项目的进度追赶、考研的时间压榨,一直没有机会来好好地学习这门语言。

    在进入研究生阶段后,尽管研究的方向和算法相关,但未来的职业方向还是选择了以后端为主,主要是因为想做更多和业务相关的工作。为了能在有限的时间里给予自己足够深的知识底蕴,选择了一些让自己去深入了解的方向,Go语言自然也在其中,今天终于有机会来开始研究这门语言。

    为什么要用Go语言?

    撰写此文的初衷,是本文的标题,也是我作为初学者一直以来的疑问:

    “我为什么要用Go语言?”

    为了回答这个问题,我翻阅了很多Go语言相关的文档、书籍和教程,我发现我很难在它们之中找到非常明显直接的答案,书上和教程只会说,“是的,Go语言好用”

    对于部分人来说,这个问题的答案或许很“明显”,比如选择Go语言是因为Google设计的语言、Go开发赚的钱多、XX公司使用Go语言等等,如果想要了解这门语言更加本质的东西,仅仅这些答案我认为是还不够的。

    部分Go的教徒可能会说,他们选择的理由是和语言本身相关的,比如:

    • Go编译快
    • Go执行快
    • Go并发编程方便
    • Go有垃圾回收(Garbage Collection, GC)

    的确,Go是有这些特点,但这并非都是Go独有的

    • 运行时解释的脚本语言(比如Python)几乎不需要时间编译
    • C、C++甚至是汇编,基本上能够榨干一台机器的大部分性能
    • 大部分语言都有并发编程的支持库
    • 大部分语言都不需要程序员主动关注内存情况

    一些Go的忠实粉丝把这种All in One的特性作为评价语言的标准,他们认为至少在这些方面,Go是可以完美的代替其他语言的。

    那么,Go真的能优秀到完全替代另一个语言么?

    其实未必,我始终认为银弹是不存在的[2],无论是在这次调查前,还是在这次调查后。

    本文从Go语言被设计的初衷出发,深入互联网各种角落,调查Go所具有的那些特性是否足够优秀,同时和其他语言进行适当的比较,你可以选择性的阅读、接受或者反对我的内容,毕竟有交流才能传播知识。

    我的最终目的是让更多的初学者看到Go没有轻易暴露出的缺点,同时也能看到Go真正优秀的地方

    设计Go的初衷

    Go语言的主要目标是将静态语言的安全性和高效性与动态语言的易开发性进行有机结合,达到完美平衡,从而使编程变得更加有乐趣,而不是在艰难抉择中痛苦前行[3]。

    Google公司不可能无缘无故地设计一个新语言(一些特性相比于其他语言也没有新到哪里去),这一切肯定是有原因的。

    设计Go语言是为了解决当时Google开发遇到的一些问题[4]:

    • C++编译慢、没有现代化(入门级友好的)的内存管理
    • 数以万计行的代码,难以维护
    • 部署的平台各式各样,交叉编译困难
    • ......

    joke.png

    找不到什么合适的语言,想着反正都是弄来自己用,Google选择造个轮子试试。

    Go 语言起源 2007 年,并于 2009 年正式对外发布。它从 2009 年 9 月 21 日开始作为谷歌公司 20%兼职项目,即相关员工利用 20% 的空余时间来参与 Go 语言的研发工作。该项目的三位领导者均是著名的 IT 工程师:Robert Griesemer,参与开发 Java HotSpot 虚拟机;Rob Pike,Go 语言项目总负责人,贝尔实验室 Unix 团队成员,参与的项目包括 Plan 9,Inferno 操作系统和 Limbo 编程语言;Ken Thompson,贝尔实验室 Unix 团队成员,C 语言、Unix 和 Plan 9 的创始人之一,与 Rob Pike 共同开发了 UTF-8 字符集规范。自 2008 年 1 月起,Ken Thompson 就开始研发一款以 C 语言为目标结果的编译器来拓展 Go 语言的设计思想[3]。

    go-designers.png

    Go 语言设计者:Griesemer、Thompson 和 Pike [3]

    当时Google的很多工程师是用的都是C/C++,所以语法的设计上接近于C,Go的设计师们想要解决其他语言使用中的缺点,但是仍保留他们的优点[5]:

    • 静态类型和运行时效率
    • 可读性和易用性
    • 高性能的网络和多进程
    • ...

    emmm,这些听起来还是比较玄乎,毕竟设计归设计,实现归实现,我们回顾一下现在Go的几个主要特点,编译速度、执行速度、内存管理以及并发编程。

    Go的编译为什么快

    当然,设计Go语言也不是完全从零开始,最初Go的团队尝试设计实现一个Go语言的编译前端,由基于C的gcc编译器来编译成机器代码,这个面向gcc的前端编译器也就是目前的Go编译器之一的gccgo。

    与其说Go的编译为什么快,不如先说说C++的编译为什么慢,C++也可以用gcc编译,编译速度的大部分差异很有可能来源于语言设计本身。

    在讨论问题之前,其中需要先说明的一点是:这里比较的编译速度都是在静态编译下的

    静态编译和动态编译的区别:

    • 静态编译:编译器在编译可执行文件时,要把使用到的链接库提取出来,链接打包进可执行文件中,编译结果只有一个可执行文件。
    • 动态编译:可执行文件需要附带独立的库文件,不打包库到可执行文件中,减少可执行文件体积,在执行的时候再调用库即可。

    两种方式有各自的优点和缺点,前者不需要去管理不同版本库的兼容性问题,后者可以减少内存和存储的占用(因为可以让不同程序共享同一个库),两种方式孰优孰弱,要对应到具体的工程问题上,Go默认的编译方式是静态编译

    回到我们要讨论的问题:C++的编译为什么慢?

    C++编译慢的主要两个大头原因[6]

    • 头文件的include方式
    • 模板的编译

    C++使用include方式引用头文件,会让需要编译的代码有乘数级的增加,例如当同一个头文件被同一个项目下的N个文件include时,编译器会将头文件引入到每一份代码中,所以同一个头文件会被编译N次(这在大多数时候都是不必要的);C++使用的模板是为了支持泛型编程,在编写对不同类型的泛型函数时,可以提供很大的便利,但是这对于编译器来说,会增加非常多不必要的编译负担。

    当然C++对这两个问题有很多后续的优化方法,但是这对于很多开发者来说,他们不想在这上面有过多时间和精力开销。

    大部分后来的编程语言在引入文件的方式上,使用了import module来代替include 头文件的方式,import解决了重复编译的问题,当然Go也是使用的import方式;在模板的编译问题上,由于Go在设计理念上遵循从简入手,所以没有将泛函编程纳入到设计框架中,所以天生的没有模版编译带来的时间开销(没有泛型支持也是很多人不满Go语言的理由)。

    在Go 的1.5 版本中,Go团队使用Go语言来编写Go语言的编译器(也叫自举),相比于gccgo来说:

    • 提高了编译速度,但执行速度略有下降(性能细节优化还不如gcc)
    • 增加了可编译的平台类型(以往受限于gcc)

    在此之外,Go语言语法中的关键字也是非常少的(Go1.11版本里只有25个)[7],这也可以减少编译器花费在语法解析上的时间开销。

    keywords.png

    所以在我看来,Go编译速度快,主要出于四个原因

    • 使用了import的引用管理方式;
    • 没有模板的编译负担;
    • 1.5版本后的自举编译器优化;
    • 更少的关键字。

    所以为了加快编译速度、放弃C++而转入Go的同时,也要考虑一下是否要放弃泛型编程的优点。

    注:泛型可能在Go 2版本获得支持。

    Go的实际性能如何

    Go的执行速度,可以参考一个语言性能测试数据网站 —— The Computer Language Benchmarks Game[8]。

    这个网站在不同的算法上对每个语言进行测试,然后给出时间和内存上的开销数据比对。

    比较的语言有C++、Java、Python。

    首先是时间开销:

    time-cost.png

    注意时间开销的单位是s,并且Y轴为了方便进行不同跨度上的比较,所以选取的是对数轴(即非线性轴,为1-10-100-1000的比较跨度)。

    然后是内存开销:

    mem-cost.png

    注意Y轴为了方便进行不同跨度上的比较,所以选取的是对数轴(即非线性轴,为1000-10000-100000-1000000的比较跨度)。

    需要注意的是,语言本身的性能只决定了一个程序的最高理论性能,程序具体的性能还要取决于这个程序的实现方法,所以当各个语言的性能并没有太大的差异时,性能往往只取决于程序实现的方式。

    通过两个图的数据可以分析:

    • Go虽然还无法达到C++那样的极致性能,但是在大部分情况下已经很接近了
    • Go和Java在算法的时间开销上难分伯仲,但在内存的开销上Java就要高得多了;
    • Go在上述的绝大部分情况下,至少时间和内存开销都比Python要优秀得多;

    Go的并发编程

    Go的并发之所以比较受欢迎,网络上的很多内容集中在几个方面:

    • 天生并发的设计
    • 轻量化的并发编程方式
    • 较高的并发性能
    • 轻量级线程Goroutines、并发通信Channels以及其他便捷的并发同步控制工具

    由于Go在设计的时候就考虑到了并发的支持,或者说很多特性都是为了并发而设计,这和一些后期库支持并发和第三方库支持并发的语言不同。

    所以Go的并发到底有多方便?在Go中使用并发,只需要在普通的函数执行前加上一个go关键字,就可以新建一个线程让函数在其中执行:

    func main() {
        go loop() // 启动一个goroutine
        loop()
    }

    这样带来的好处不仅仅是让并发编程更方便了,在一些特定情况下,比如Go引用一些使用了并发的库时,这些库所使用的并发也是基于Go本身的并发设计,不会存在库使用另一套并发实现的情况,这样Go调度器在处理程序中的各种并发线程时,可以有更加统一化的管理方式。

    不过Go的并发对于程序的实现要求还是比较高的,在使用一些通信Channel的场合,稍有疏忽就可能出现死锁的问题,比如:

    fatal error: all goroutines are asleep - deadlock!

    Go的并发量可以比大部分语言里普通的线程实现要高,这受益于轻量级的Goroutine,轻量化主要是它所占用的空间要小得多,例如64位环境下的JVM,它会默认固定为每个线程分配1MB的线程栈空间,而Goroutines大概只有4-8KB,之后再按需分配。足够轻量化的线程在相同的内存下也就可以有更高并发量(服务器CPU还没有饱和的情况下),同时也可以减少很多上下文切换的时间开销[9]。但是如果你的每个线程占用空间都非常大时(比如10MB,当然这是非常规需求的情况下),Go的轻量化优势就没有那么明显了。

    Go在并发上的优点很明显,也是Go的功能目标,从语言设计上支持了并发,提供了统一便捷的工具,复杂的并发业务也需要在Go的一整套并发规范体系下进行编程,当然这肯定会牺牲部分实现自由度,但可以获得性能的提高和维护成本的下降。

    PS:关于Go调度器的内容在这里并没有被提及,因为很难用简单的文字向读者说明该调度方式和其他调度方式的优劣,将在未来的某一篇中会细致地介绍Go调度器的内容。

    Go的垃圾回收

    垃圾回收(英语:Garbage Collection,缩写为GC),在计算机科学中是一种自动的存储器管理机制。当一个计算机上的动态存储器不再需要时,就应该予以释放,以让出存储器,这种存储器资源管理,称为垃圾回收。垃圾回收器可以让程序员减轻许多负担,也减少程序员犯错的机会[10]。

    在使用Go或者其他支持GC的语言时,不用再像C++一样,手动地去释放不需要的变量占用的内容空间(free/delete)

    的确,这很方便(对于懒人和容易忘记主动释放的人),但是也多了一些限制(暗箱操作的不透明性以及在GC处理上的性能开销)。GC也不是万能的,当遇到一些对性能要求较高的场景,还是需要记得进行一些主动释放或优化操作(比如说自定义内存池)。

    PS:将在未来的某一篇中会细致地介绍Go垃圾回收的细节(如果你们也觉得有必要的话)。

    什么时候可以选择Go?

    Go有很多优点,编译快、性能好、天生并发以及垃圾回收,很多比较有特色的内容也还没有说到(比如gofmt)。

    Go语言也有很多缺点,比如第三方库支持还不够多(相比于Python来说就少的太多了)、支持编译的平台还不够广、还有被称为噩梦的依赖版本管理(已经在改善了,但是还没有达到完全可靠的程度)。

    所以到底Go适合做什么,不适合做什么?

    分析了这么多后,这个问题其实很难回答,但我们可以选择先从不适合的领域把Go剔除掉,看看我们会剩下什么。

    Go不适合做什么

    • 极致高性能优化的场景,你可能需要使用C/C++,甚至是汇编;
    • 简单流程的脚本工具、数值分析、深度学习,可能Python更适合(至少目前是);
    • 搭一个博客或网站,PHP何尝不是天下第一的语言呢;
    • 如果你想比较方便找到一份的后端工作,绝大部分公司的Java岗一直缺人(在实际生产过程中,目前Go仍没有比Java表现得好太多,至少没有好到让一个部门/公司将核心业务重新转向Go来进行重构);
    • ...

    你可以找到类似上面那样的很多场景,你可能会发现Go并不能那么完美地替代掉谁。

    Go适合做什么

    最后,到了我们的终极问题,Go到底适合做什么?

    读到这里你可能会觉得,好像是我把Go的特性吹了一遍,然后突然告诉你可能Go不适合你。

    Go天生并发,面向并发,所以Go的定位一直很清楚,从最浅显的视角来看,至少Go作为一个有较高性能的并发后端来说,是具有非常大的诱惑力的。

    尤其对于后端相关的程序员而言,在某些业务功能的初步实现上,简洁的语法、内置的并发、快速的编译,都可以让你更加高效快速地完成任务(前提是Go的内容足以完成你的任务),不用再去担忧编译优化和内存回收、不用担心过多的时间和内存开销、不用担心不同版本库之间的冲突(静态编译)以及不用担心交叉编译平台适配问题。

    大部分情况下,编写一个服务,你只需要:实现、编译、部署、运行

    高效快速,足够敏捷,这在企业的绝大部分项目的初期都是适用的,这也是大部分项目对开发初期的要求。当一个项目或者服务真的可以发展下去,需求的确触碰到Go的天花板时,再考虑使用更加好的语言或方法去优化也为时不晚。

    简而言之,尽管Go的过于简洁带来了很多问题(有些人说的难听点叫过于简单),Go所具有的优点,可以让大部分人用编程语言这种工具,来解决对他们而言更加重要的问题。

    Go语言不是银弹,但它的确能有效地解决这些问题。

    参考文章

    扩展阅读

    在调查Go的过程中,发现了一些比较有意思、或者比较实用的文章,一并附在这里。

    • 我为什么选择使用 Go 语言?,该文写于2016年,在我的文章基本构思完成的时候,偶然看到了这篇文章,作者有很多早期Go版本的开发经验,里面有更多的细节都是出自于工程师的经验之谈,我发现其中的部分想法和我不谋而合,你可以把这篇文章当作本文的后续扩展阅读,不过要注意文章的时效,可能提及到的一些Go的缺点现在已经被改进了。
    • C/C++编译器的工作过程,主要是供不熟悉C系的朋友了解一下编译器的工作过程。
    • The Computer Language Benchmarks Game,一个对各个语言进行性能测试的网站,里面的算法具有一定的代表性,但是不能代表所有工程可能遇到的情况,仅供参考。
    • 为什么 Go 语言在某些方面的性能还不如 Java?,这是知乎上一个2017年开始有的问题,你可以看到很多人对于这个问题的分析,从多个角度来理解语言之间的性能差异。
    • go-wiki WhyGo,Go的Github仓库上维护的Wiki中,有一篇关于WhyGo的文章整理,不过大部分是英文,里面主要是很多关于“为什么我要选择Go”的软硬稿。
    • 为什么要使用Go语言,Go语言的优势在哪里,这个知乎的提问更早,是来自2013年的Yvonne YU用户,在Go的早期其实是具有很大的争议的,你可以看到大家在各个问题上的博弈。
    • 哪些公司在使用Go,Go的Github仓库上维护的Wiki中,有一篇关于全球都有哪些公司在使用Go,不过提供的信息大部分只有一个公司名,比如国内有阿里巴巴(而人家大部分都招Java),可以看看但参考性不大。
    • Go 语言的优点,缺点和令人厌恶的设计,这是Go语言中文网上一篇2018年的文章,如果你对语言本身的一些特性的设计感兴趣,你可以选择看看,作者从很多语法层面上介绍了Go的优点和缺点。
    • Ruby China - 瞎扯淡 真的没必要浪费心思在 Go 语言上,这是我无意中找到的一篇有名的帖子,这个问题始于2013年,在Ruby China上,其中也是大佬们(可能)从各个角度来辩论Go是否值得学习,可以当作武侠小说观看。
    • The way to Go - 3.8 Go性能说明,《The way to Go》这本书上为数不多关于Go性能问题的说明。
    • C++开发转向go开发是否是一个好的发展方向?,2014年知乎上关于C++和Go的一个讨论,其实我觉得“如果选择一个并不意味着就要放弃另一个”,程序员不是研究语言的,也不应该是只靠某一门语言吃饭。
    • 我为什么放弃Go语言 Liigo,嗯,2014年,仍旧是Go争议很大的时候,CSDN上一篇阅读数很高的文章,作者从自己的角度对Go进行批判(Go早期的确是有不少问题),你可以看到早期Go的很多问题,也可以斟酌这些问题对你是否重要以及到底在2020年的Go中有没有被解决。
    • Golang 本身是用什么语言写的?,一个关于编译的有趣的问题,可以适当了解。
    • 搞懂Go垃圾回收,一篇还算比较新的分析Go垃圾回收问题的文章。
    • 有趣的编程语言:Go 语言的启动时间是 C 语言的 300 多倍,C# 的关键字最多,这篇InfoQ文章其实算是一个典型的标题党,主要使用的是一个Github上关于各个语言HelloWorld程序启动时间的测试数据(https://github.com/bdrung/sta...,使用gccgo编译的Go程序的启动时间非常地长,的确是C的300多倍,但使用GC编译的Go程序启动时间只是C的2倍。
    • Go 语言的历史回顾,我一直在寻找一个整理Go的版本变动细节的文章,在Go的官方文档和各种书籍上寻找无果时,在InfoQ上找到了一篇还算跟踪地比较新的(Go 1.0 - Go 1.13)文章,对于初学者而言,知道语言的变化也是很重要的(比如方便的知道哪些问题解决了,哪些还没有被解决),可能之后会拓展性的写一篇关于这个的文章。
    查看原文

    赞 19 收藏 8 评论 3

    Richard_Yi 发布了文章 · 4月4日

    《clean code》 阅读笔记

    编者寄语:

    这是一本真正的好书,不过如果读者没有一定的经验,以及缺乏对编程境界的追求的话,可能认为这本书很一般。当然,对于有心人来说,这本书里面的部分东西可能都已经习以为常了。

    那么,你是怎样的呢?

    另外我为什么写的是《clean code》而不是《代码整洁之道》,因为这本书很多地方你需要看原版的文字才能get到作者真正想表达的意思。如果有能力还是看原版吧。

    看原版书,你能学到很多术语表达,在你看外文技术文章的时候更容易帮助你理解全文。如increase cohesion - 增加内聚性,decrease coupling - 减少耦合,separate concerns - 关注点分离,modularize system concerns - 模块化系统关注点,这些都是很经典的表达。

    I sincerely and strongly recommend u to read 《clean code》 rather than 《代码整洁之道》

    原文地址:《clean code》 阅读笔记

    转载请注明出处!

    一、整洁代码 ?

    关键词:优雅
    1. 代码逻辑直接了当,让缺陷难以隐藏
    2. 尽量减少依赖关系,使之便于维护
    3. 依据某种分层策略完善错误处理代码
    4. 性能调至最优,省得引诱别人做没规矩的优化
    5. 整洁的代码只做一件事
    6. 简单直接,具有可读性
    7. 有单元测试和验收测试
    8. 有意义的命名
    9. 代码应在字面上表达其含义
    10. 尽量少的实体:类、方法、函数
    11. 没有重复代码
    整洁的代码读起来令人愉悦

    二、有意义的命名 ??

    • 使用带有语义的命名,能 够让维护代码的人更容易理解和修改代码
    • 编程本来就是一种社会活动(大部分的编程活动都是人与人协作的过程)
    • 避免思维映射,明确才是王道
    • 尽可能要做到“顾名思义”,看到名称就能知道这个变量、函数、类、包的意义、用途。

    具体规则

    1. 名副其实:名称不需要注释补充就可见其含义、用途
    2. 不要写多余的废话或者容易让人混淆的命名。

      比如"customerObject"和"customer", "ProductInfo"和"ProductData";这种就是意义混杂的废话。如果真的有区别,就用特定的可以区分的命名来描述它。

    3. 使用读得出来的名称。
    4. 使用可搜索的名称。

      MAX_CLASSES_PER_STUDENT很容易,但想找数字7就麻烦了。

    5. 类名和对象名应该是名词或名词短语。
    6. 方法名应当是动词或动词短语。

      如postPayment、deletePage或save。属性访问器、修改器和断言应该根据其值命名,并依Javabean标准加上get、set和is前缀。

    7. 每个抽象概念选一个词,并且一以贯之

      我的理解中,在同个领域模型中,就应该只有一个命名,比如订单号,同个系统中不应该出现TradeNo、OrderNo等多个命名。

    8. 尽量用术语(CS术语,算法,数学术语)命名

      尽管用那些计算机科学(Computer Science,CS)术语、算法名、模式名、数学术语。

    9. 上一条无法做到的情况下,尽量使用源自所涉问题领域的名称。

      如果不能用程序员熟悉的术语来给手头的工作命名,就采用从所涉问题领域而来的名称。

    10. 添加富有意义的语境,例如利用UserInfo类封装各种个人信息

    三、函数 ??

    编程就像讲故事,要用准确、清晰、富有表达力的语句(代码)
    • 好的函数应该做到自顶向下阅读代码时,像是在阅读报刊文章。
    • 写代码很像是写文章。先想怎么写就怎么写,然后再打磨:分解函数、修改名称、消除重复
    • 编程其实是一门语言设计艺术,大师级程序员把程序系统当做故事来讲。使用准确、清晰、富有表达力的代码来帮助你讲故事。

    具体规则

    1. 短小!短小!短小

      重要的事情说3遍。

    2. 函数应该做一件事。做好这件事。只做这一件事
    3. 每个函数一个抽象层级!!!

      这个是编者认为非常重要的一点,也是本人在开发过程当中看到最多的问题。应该处于不同抽象层级的代码混乱在一起时,阅读和理解起来会很痛苦。

      引原文描述:

      函数中混杂不同抽象层级,往往让人迷惑。读者可能无法判断某个表达式是基础概念还是细节。更恶劣的是,就像破损的窗户,一旦细节与基础概念混杂,更多的细节就会在函数中纠结起来。

      但是,就像作者说的,这条规则很难

    4. 使用描述性的名称

      长而具有描述性的名称,要比短而令人费解的名称好。长而具有描述性的名称,要比描述性的长注释好。

      为只做一件事的小函数取个好名字。函数越短小、功能越集中,就越便于取个好名字。

    5. 拒绝boolean型标识参数。

      例: CopyUtil.copyToDB(isWorkDB) --> CopyUtil.copyToWorkDB(), CopyUtil.copyToLiveDB()

      (但是编者阅读很多源码里面也没有遵守,手动狗头...)

    6. 如果一定需要多个参数,那么可能需要对参数进行封装
    7. 使用异常代替返回错误码,错误处理代码就能从主路径代码中分离出来得到简化。
    8. Don't Repeat Yourself(经典的DRY原则)
    9. 先把函数写出来,再规范化

    四、注释

    这节实际上内容不多,尽量避免注释
    • 别给糟糕的代码加注释(专家建议不如重写)
    • 把力气花在写清楚明白的代码上,直接保证无需编写注释。
    • 好的注释:

      • 法律信息
      • 提供信息
      • 解释意图
      • 警示
      • TODO注释

    五、格式 ?

    • 代码格式很重要。代码格式关乎沟通,而沟通是专业开发者的头等大事。
    • 向报纸格式学习代码编写。

    具体规则

    1. 垂直距离

      1. 变量声明应该尽可能靠近使用位置,本地变量应该在函数顶部出现
      2. 实体变量应该放在类的顶部声明
      3. 相关的函数应该放在一起
      4. 函数的排列顺序保持其相互调用的顺序
    2. 水平位置

      1. 一行代码尽量短,不超过100 - 120 个字符。

        这个在常见的IDE中可以设置提示线。下图是IDEA的配置位置。

        效果:

      2. 用空格将相关性弱的分开
      3. 声明和赋值不需要水平对齐
      4. 注意缩进
    3. 团队之间形成一致的代码格式规范(Checkstyle 插件了解一下?)

      不要使用不同的风格来编写源代码,会增加其复杂度。

    六、对象与数据结构

    这块有两个我比较在意的概念
    • 要弄清楚数据结构和对象的差异:对象把数据隐藏于抽象之后,曝露操作数据的函数。数据结构曝露其数据,没有提供有意义的函数
    • The Law of Demeter:模块不应了解它所操作对象的内部情形。

      更准确更白话地说:方法不应调用由任何函数返回的对象的方法。只跟朋友谈话,不与陌生人谈话。

      反例:

      final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();

    七、错误处理 ??

    • 一个原则:错误处理很重要,但是如果它搞乱了代码逻辑,就是错误的做法
    • 整洁代码是可读的,但也要强固。可读与强固并不冲突。如果将错误处理隔离看待,独立于主要逻辑之外,就能写出强固而整洁的代码。做到这一步,我们就能单独处理它,也极大地提升了代码的可维护性。

    具体规则

    1. 使用异常而非返回码
    2. 使用不可控异常(这点深有体会,checked Exception的代价很大)

      这里作者想说明的是,在使用受检异常时,你首先要考虑这样是否能值回票价。因为受检异常违反了开闭原则,当你在一个方法内抛出了受检异常时,你就得在catch语句和抛出异常之间的方法调用链中的每个方法签名中声明这个异常。

      这意味着,你对软件较低层级的修改,会涉及到较高层级的签名。封装被打破了,因为在抛出路径中的每个函数都要去了解下一层级的异常细节。既然异常旨在让你能在较远处处理错误,可控异常以这种方式破坏封装简直就是一种耻辱

      如果你在编写一套关键代码库,则可控异常有时也会有用:你必须捕获异常。但对于一般的应用开发,其依赖成本要高于收益。
    3. 给出异常发生的环境说明(这个也很重要)

      创建信息充分的错误消息,并和异常一起传递出去。在消息中,包括失败的操作和失败类型。如果你的应用程序有日志系统,传递足够的信息给catch块,并记录下来。

      良好的日志和异常机制,是不应该出现调试的。打日志和抛异常,一定要把上下文给出来,否则,等于在毁灭命案现场,把后边处理问题的人,往歪路上带。

      需要调试来查找错误时,往往是一种对异常处理机制的侮辱

    4. 使用通用异常类打包第三方API包的异常(如调用一些第三方支付SDK等)
    5. 尝试使用特例模式(SPECIAL CASE PATTERN),将异常行为封装到特例对象中。

      很巧妙高级的一种设计模式。

      // 修改前
      try {
          MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
          m_total += expenses.getTotal();
      } catch(MealExpensesNotFound e) {
          m_total += getMealPerDiem();
      }
      
      
      // 优化之后,当没有餐食消耗(即上述代码抛出MealExpensesNotFound的情况),返回特例对象
      MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
      m_total += expenses.getTotal();
      
      // 特例对象
      public class PerDiemMealExpenses implements MealExpenses {
          public int getTotal() {
          // return the per diem default
          }
      }
    6. 不要返回null,不要传递null

      相信不少程序员都深受null 的困扰。返回null值,基本上是在给自己增加工作量,也是在给调用者添乱。只要有一处没检查null值,应用程序就会失控。

      在大多数编程语言中,没有良好的方法能对付由调用者意外传入的null值。事已如此,恰当的做法就是禁止传入null值。

    八、边界

    边界这一章个人读起来比较难懂。感觉像是翻译的问题。

    原书这一章节的名字叫做"Boundaries"。

    这一章篇幅较短,意义有点难懂,这里简单总结:作者的意思是让我们自己的代码和第三方库的代码不要耦合太紧密,需要有清晰的Boundaries。

    同时也给出了第三方类库的学习建议:探索性地学习测试,以此熟悉类库,写出良好的代码。

    九、单元测试

    • 测试代码和生产代码一样重要。它可不是二等公民。

      它需要被思考、被设计和被照料。它该像生产代码一般保持整洁。

      测试代码需要随着生产代码的演进而修改,如果测试不能保持整洁,只会越来越难修改。

    • 整洁的测试有什么要素?有三个要素:可读性,可读性和可读性
    • 每个测试一个断言,每个测试一个概念。

    单测本身也应该成为Code Review的一部分,单测写的好,bug一定少。

    TDD 三定律

    • 定律一 在编写不能通过的单元测试前,不可编写生产代码。
    • 定律二 只可编写刚好无法通过的单元测试,不能编译也算不通过。
    • 定律三 只可编写刚好足以通过当前失败测试的生产代码。
    任何一种迭代和增量的交付方式,都会遇到一个严肃的灵魂拷问:频繁对软件做修改,如何保障软件不被改坏?这个问题,用人肉测试解决不了。交付越频繁,人肉测试就越不可能跟上节奏。自动化的、快速且可靠的、覆盖完善的测试必不可少。这种要求,后补式的、黑盒的测试方法不可能达到,必须在开发软件的过程中内建。

    当团队被迫采用迭代和增量的需求管理和项目管理方式,对应的配置管理和质量保障手段就必须跟上。TDD不是锦上添花,而是迭代和增量交付不可或缺的基石

    F.I.R.S.T.

    整洁的测试应该遵循以下5条规则:

    • 快速(Fast)

      测试应该够快。测试应该能快速运行。测试运行缓慢,你就不会想要频繁地运行它。如果你不频繁运行测试,就不能尽早发现问题,也无法轻易修正,从而也不能轻而易举地清理代码。最终,代码就会腐坏。

    • 独立(Independent)

      测试应该相互独立。某个测试不应为下一个测试设定条件。你应该可以单独运行每个测试,及以任何顺序运行测试。当测试互相依赖时,头一个没通过就会导致一连串的测试失败,使问题诊断变得困难,隐藏了下级错误。

    • 可重复(Repeatable)

      测试应当可在任何环境中重复通过。你应该能够在生产环境、质检环境中运行测试,也能够在无网络的列车上用笔记本电脑运行测试。如果测试不能在任意环境中重复,你就总会有个解释其失败的接口。当环境条件不具备时,你也会无法运行测试。

    • 自足验证(Self-Validating)

      测试应该有布尔值输出。无论是通过或失败,你不应该查看日志文件来确认测试是否通过。你不应该手工对比两个不同文本文件来确认测试是否通过。如果测试不能自足验证,对失败的判断就会变得依赖主观,而运行测试也需要更长的手工操作时间

    • 及时(Timely)

      测试应及时编写。单元测试应该恰好在使其通过的生产代码之前编写。如果在编写生产代码之后编写测试,你会发现生产代码难以测试。你可能会认为某些生产代码本身难以测试。你可能不会去设计可测试的代码

    十、类 ??

    类应该尽量短小

    对于衡量类的大小,这里书中提出了一个不同的衡量方法:计算权责。我理解的意思就是,一个类承担了太多的权责之后,这个类就算大了。

    所以书中随即提出了SRP - 单一权责原则(也叫单一职责原则)

    单一权责原则

    单一权责原则(SRP)认为,类或模块应有且只有一条加以修改的理由。该原则既给出了权责的定义,又是关于类的长度的指导方针。类只应有一个权责——只有一条修改的理由。

    作者还提到了,系统应该由许多短小的类而不是少量巨大的类组成。每个小类封装一个权责,只有一个修改的原因,并与少数其他类一起协同达成期望的系统行为。

    内聚

    同时,作者提出了保持内聚性就会得到许多短小的类。

    类的高内聚的含义是:类的实体变量应尽可能少,类中方法尽可能多地使用到这些变量。(如果一个类中的每个变量都被每个方法所使用,则该类具有最大的内聚性)

    组织类时考虑代码的修改

    在整洁的系统中,我们对类加以组织,以降低修改的风险。

    • 开放-闭合原则(OCP)

      类应当对扩展开放,对修改封闭。通过子类化手段,类对添加新功能是开放的,而且可以同时不触及其他类。

    • 依赖倒置原则(Dependency Inversion Principle,DIP)

      DIP认为类应当依赖于抽象而不是依赖于具体细节。通过这种抽象隔离了系统之间的元素,使得系统每个元素的理解变得更加容易,使用起来更加灵活、更加可复用。

    十一、系统

    系统构造与使用分开。

    这里我理解就是将一些对象实例的初始化和使用分离解耦,将构建实例的逻辑交给一个公共的模块/类/框架来做。这里作者也介绍了开发中常见的两种方式,体现了这种思想:

    • 工厂:使用工厂方法自行决定何时创建实例,但是构造细节却在其他地方
    • 依赖注入:当A对B有依赖时,A中不负责B的实例化(这就是类的权责单一原则

    后半章主要讲的是AOP的思想和具体的框架实现。就是说将一些重复性、功能性的代码(如:性能监视、日志记录、事务管理、安全检查、缓存等)进行关注面切分,模块化,成就了分散化管理和决策。最终的效果也显而易见,减少了重复代码,关注面的分离也使得设计、决策更加清晰容易。

    十二、Emergence (迭进)

    这一节主要是讲了四个简单的设计规则(design rules),通过遵循这四个规则,你可以编写出很好的代码,深入了解代码的结构和设计,继而以一种更简单的方式来学习掌握SRP和DIP之类的设计原则。

    Four rules of Simple Design are of significant help increating well-designed software
    • 运行所有的测试

      全面测试并持续通过所有测试。遵循SRP的类,测试起来较为简单。测试编写得越多,就越能持续走向编写较易测试的代码。所以,确保系统完全可测试能帮助我们创建更好的设计。

      有了全面的测试保驾护航之后,我们就有条件一步一步地去重构完善我们的代码,目的是为了得到“高内聚,低耦合”的系统。书中也提出了下面三条简单的规则。

    • 不要重复(DRY)
    • 写出能清晰表达编码者意图的代码(Expressive)
    • 尽量减少类和方法(Mininal Classes and Methods)

      当你在重构时,按照SRP、代码可读性等规则遵守,是有可能创建出比原来更多的细小的类。但这不在本条的针对范围之内。

      这里的尽量减少,作者举例了一种情况,就是毫无意义的教条主义会导致编码人员无意识的创建很多的类和方法。不知道你有没有类似的经历,我拿我亲身体会举个例子,我很难理解在某个项目中,对一个领域对象(如User),在构建对应的Service层和Dao层的时候,一定要为每个类创建接口,即使这些接口根本不可能有其他的实现类。

    十三、并发

    “Objects are abstractions of processing. Threads are abstractions of schedule.”

                                                            —James O. Coplien

    这一节作者讨论了并发编程的需求和难点,并且给出了一些解决这些困难和编写整洁并发代码的建议。因为关于并发编程有更好的资料可以学习,所以这里我就简单总结一下。

    并发防御原则

    • 单一权责原则(SRP):方法/类/组件应当只有一个修改的理由
    • 限制数据作用域:严格限制对可能被共享的数据的访问
    • 使用数据复本:这点很好理解,避免数据的共享。(Java 中的ThreadLocal)
    • 线程应尽可能独立:不与其他线程共享数据。每个线程处理一个客户端请求,从不共享的源头接纳所有请求数据,存储为本地变量

    小结

    其他未提到的章节,是我觉得相较来说非重点的章节。还有可能会有一些内容的遗漏,因为这本书中的精华,我觉得我还需要学习领会。

    好书常读常新,这本书就在我的工位上,我希望在经历一段时间的工作实践之后,再次打开这本书,我能有更多更新的一些感悟。

    如果本文有帮助到你,希望能点个赞,这是对我的最大动力????????。
    查看原文

    赞 5 收藏 4 评论 0

    认证与成就

    • 获得 459 次点赞
    • 获得 29 枚徽章 获得 2 枚金徽章, 获得 6 枚银徽章, 获得 21 枚铜徽章

    擅长技能
    编辑

    开源项目 & 著作
    编辑

    (??? )
    暂时没有

    注册于 2018-01-05
    个人主页被 4.5k 人浏览

    bt365体育投注