数据可视化过程不完全指南

数据集犹如世界历史状态的快照,能帮助我们捕捉不断变化的事物,而数据可视化则是将复杂数据以简单的形式展示给用户的良好手段(或媒介)。结合个人书中所学与实际工作所学,对数据可视化过程做了一些总结形成本文供各位看客"消遣"。

个人以为数据可视化服务商业分析的经典过程可浓缩为:从业务与数据出发,经过数据分析与可视化形成报告,再跟踪业务调整回到业务,是个经典闭环。

image

本文主题为数据可视化,将重点讲解与数据可视化相关的环节,也即上图中蓝色的环节。

一、理解 DATA

进行 DATA 探索前,我们需先结合业务去理解 DATA,这里推荐运用 5W1H 法,也即在拿到数据后问自身以下几个问题:

  • Who:是谁搜集了此数据?在企业内可能更关注是来自哪个业务系统。
  • How:是如何采集的此数据?尽可能去了解详细的采集规则,采集规则是影响后续分析的重要因素之一。如:数据来自埋点,来自后端还是前端差异很大,来自后端则多是实时的,来自前端则需更近一步了解数据在什么网络状态会上传、无网络状态下又是如何处理的。
  • What:是关于什么业务什么事?数据所描述的业务主题。
  • Why:为什么搜集此数据?我们想从数据中了解什么,其实也就是我们此次分析的目标。
  • When:是何时段内的业务数据?
  • Where:是何地域范围内的业务数据?

通过回答以上几个问题,我们能快速了解:数据来源是什么?它的可信度有多少?它在描述何时发生的怎样的业务(问题)?我们为什么要搜集此数据?等等。从而快速了解数据与业务开展近一步的探索与分析。

二、探索 DATA

之前的文章中,我们曾经分享过如何快速地探索 DATA (「如何成为一名数据分析师:数据的初步认知」),其中有谈到如何通过诸如平均数/中位数/众数等描述统计、通过相关系数统计快速探索 DATA 的方法。本文主要讲解可视化,所以将从可视化的角度去介绍如何通过可视化方法进行数据探索。

在探索、研究阶段,更重要的是要从不同的角度去观察数据,并逐步深入到对业务更重要的事情上。在这个阶段,我们不必去过多地追求图表美化,而应该尽可能快速地尝试更多个角度。下面我们根据数据/主题类型的差异分开阐述:

1. 分类数据的探索

在业务分析中,我们常常将人群、地点和其他事物进行分类,分类能为我们带来结构化,能让我们快速掌握信息。

在分类数据可视化中,我们最多使用的是条形图;但当试图观察分类中的比例时,我们可能也会选择饼图、瀑布图;当不仅关心一级分类还关心子分类时候,我们可能会选择树形图。通过对分类数据的可视化,我们能快速地获取最大、最小值,同时也能方便地了解到数据集的范围,因为它在一定程度上还反映了数据分布特征。下图展示了可视化分类数据的一些选择:

a. 条形图,用长度作为视觉暗示,利于直接比较。

image

b. 使用饼图、柱形堆叠图、瀑布图等,能在分类数据中对比占比情况。

image

c. 使用树形图,能在展示一级分类的子类统计,可实现维度的又一层下钻。

image

2. 时序数据的探索

业务分析中,我们常常关心事物随着时间的变化趋势,以及数据随时间变化的规律(时间周期下的规律)。所以,对时序数据的探索,主要有两种模式:其一为随着时间线索向右延伸的时序图,诸如:折线图、堆积面积图等;其二为根据时间周期,统计汇总的柱形图、日历图、径向图等。

a. 用于观察事物随时间线索变化的探索。

image

b. 用于发现事物随时间周期变化规律的探索。

image
image

3. 空间数据的探索

空间数据探索主要是期望展现或者发现业务事件在地域分布上的规律,即区域模式。全球数据通常按照国家分类,而国内数据则按照省份去分类,对于省份数据则按照市、区分类,以此类推,逐步向细分层次下钻。空间数据探索最常用为等值热力图,如下:

image

4. 多元变量的探索

数据探索过程中,有时候我们需要对比多个个体多个变量,从而寻找数据个体间的差异或者数据变量间的关系。在这种情况下,我们推荐使用散点图、气泡图,或者将多个简单图表组合生成“图矩阵”,通过对比“图矩阵”来进行多元变量的探索。其中,散点图和气泡图适合变量相对较少的场景,对于变量5个及以上的场景我们更多地是推荐“图矩阵”。

a. 变量相对较少(5个以下)的场景我们采用散点图与气泡图。

image

b. 变量多(5个及以上)的场景我们采用多个简单图表组成的“图矩阵”,下图为最简单的“图矩阵”多元热力图:

image

5. 数据分布的探索

探索数据的分布,能帮助我们了解数据的整体的区间分布、峰值以及谷值以及数据是否稳定等等。

之前在分类数据探索阶段曾提到分类清晰的条形图在一定程度上向我们反映了数据的分布信息。但,之前我们是对类别做的条形图,更多时候我们是需查看数据“坐落区间”,这里我们推荐直方图以及直方图的变型密度曲线图(密度曲线图,上学时代学的正态分布就常用密度曲线图绘制)。此外,对数据分布探索有一个更为科学的图表类型,那就是:箱线图。
image

三、图表清晰

1. 合理"搭配"可视化的组件

所谓可视化,其实就是根据数据,用标尺、坐标系、各种视觉暗示以及背景信息描述进行组合来表现数据。下图为可视化组件的“框架图”:

image

a. 视觉暗示

可视化最基本的形式就是简单地将数据映射成图形,大脑可以在数字与图形间来回切换从而寻找模式。所以我们必须选择合适的视觉暗示来保证数据的本质没有在大脑地来回切换中丢失,并且尽可能让大脑能轻松获得信息。

image

从上到下,对人脑而言视觉暗示清晰程度逐渐降低。

位置

使用位置作视觉暗示时,大脑是在比较给定空间或者坐标系中数值的位置。它的优势在于占用空间会少于其他视觉暗示,但劣势也很明显,我们很难去辨别每一个点代表什么。所以,应用位置作为视觉暗示主要用于发现趋势规律或者群集分布规律,散点图是位置作为视觉暗示的典型运用。

长度

使用长度作为视觉暗示,大脑的理解模式是条形越长,绝对值越大。优点非常明显人眼对于长度的“感受”往往是最准确的。条形图是长度作为视觉暗示的最常见图表。

角度

使用角度作为视觉暗示,大脑的理解模式为两向量如何相交,相交角度是否大于90度或180度。角度作为视觉暗示的最常见图表式饼图。

方向

使用方向作为视觉暗示,大脑的理解模式为坐标系中一个向量的方向。在折线图中显示为斜率,在迁徙图中显示为箭头所指方向。

形状

使用形状作为视觉暗示,对大脑而言往往代表着不同的对象或者类别。可用于在散点图中区分不同群集。

面积/体积

使用面积/体积作为视觉暗示,面积大则绝对值大。需要注意的一点是,用面积显示2倍关系时,应该是面积乘倍而不是边长乘倍。

色相与饱和度

不同的颜色通常用来表示分类数据,每个颜色代表一个分组;不同的色相通畅用来表示连续数据,常见模式是颜色越深代表数值越大。

b. 坐标系

  • 直角坐标系:绝大多数的图表都在直角坐标系中完成,它是最常用的坐标系。在直角坐标系中,关注的两个点之间的距离,距离是欧式距离。
  • 极坐标系:极坐标系是显示角度的坐标系,如果用过饼图那么就已经接触过极坐标系了。
  • 地理坐标系:简单点理解,它由经纬度组成,将世界各地的位置显示在图表中,因与现实世界直接相关而倍受喜爱。

c. 标尺

标尺的重要性在于与坐标系一起决定了图形的投影方式。

  • 线性标尺:间距处处相等,无论处于什么位置,是大众最熟悉、最容易接受的标尺,不容易产生误解;
  • 分类标尺:分类数据往往采用分类标尺,如:年龄段、性别、学历等等,值得注意的一点是,对于有序的分类,我们应尽量对分类标尺做排序以适应读者的阅读模式;
  • 百分比标尺:其实仍旧是线性标尺,只是刻度值为百分比;
  • 对数标尺:指按照对数化将坐标轴压缩,适合数值跨度非常大的场景。但需考虑读者是否能够适应对数标尺,毕竟它并不常见。

d. 背景信息

背景信息,所指即我们在理解 DATA 通过 “5W1H” 法回答的问题。包括数据背景与业务背景。

基本的原则是,如果信息在图形元素中没有得到巧妙地暗示,我们久需要通过标注坐标轴、注明度量单位,添加额外说明等方法来告诉读者图表中每一个数据及其视觉暗示代表什么。

2. 美化,让可视化更为清晰

在研究阶段,我们重点尝试从各种不同的角度切入去观察数据,没有过多地考虑表达是否准确,图形是否美观。
但,当我们进展到准备将分析报告呈现给业务方或领导时,必须对可视化图表进行优化使其是清晰易读的。否则,我们很可能要挨批了。

image

上图为,数据可视化与现实世界的连接关系。清晰易读的可视化一定是在尽可能地减少读者从可视化图表理解转换为现实世界的难度。而增强数据比较、合理注解引导、减少读者理解步骤是达成这一目的的良好手段,下面为大家详细展开介绍:

a. 增强数据比较,降低大脑进行信息比较的难度

当我们在阅读可视化图表时,我们的大脑会自然地进行比较从而获取信息。增强数据比较,可有效降低信息比较难度,使大脑更容易抓住关键信息,减少模凌两可,使大脑获取信息更具确定性。

建立视觉层次,用醒目的颜色突出数据,淡化其他元素

有层次感的图表更易读,用户能更快地抓住图表中的重点信息。相反,扁平图则缺少流动感,读者相对较难理解。建立视觉层次,我们可以用醒目的颜色突出显示数据,并淡化其他元素使其作为背景,淡化元素可采用淡色系或虚线。

散点图的目标是为寻找规律与模式,拟合数据线是下图的关键。弱化数据点、强化拟合趋势线使其形成鲜明的2个层次。

image

高亮显示重点内容

高亮显示可以帮助读者在茫茫数据中一下找到重点。它既可以加深人们对已看到数据的印象,也可以让人们关注到那些应该注意的东西。需要注意的是,使用“高亮”突出显示时,我们应尽可能使用当前图表中尚未使用的视觉暗示。

下面为常见的电商转化漏斗,其中下单步骤是最应当关注的环节,使用红色高亮能会使读者的目光快速落在这一关键步骤中。

image

其他技巧

除了以上介绍两大增强比较技巧,我们可以通过以下一些小技巧来增强数据比较:

  • 提升色阶跨度,倘若图表中所用颜色色阶跨度太小,我们将难以区分差异,合理提升色阶跨度能有效增强比较;
  • 合理增大标尺跨度,有时候我们只需要对标尺做合理地放大,数据差异将清晰好几倍;
  • 添加参考线(建议采用虚线),参考线作为对比基准,可有效增强数值与基准的比较。

b. 合理注解与引导,使读者快速理解图表信息并抓住信息重点

仅通过图形元素,我们很难向读者展示充分的信息,合理增加注解能有效帮助读者理解图表;增加适当的箭头等符号引导能帮助读者快速抓住关键信息。

合理注解:背景信息、分析结论以及统计学概念

如果报表的读者对数据、业务背景并不十分熟悉,我们应考虑在标题或其他报告文字中直接说明背景。

如果是结论性图表,我们可在主标题中直接说明结论。如果结论得出的过程较复杂,我们还可以在副标题中辅助说明是如何推导得到的结论。

如果图表中,有大部分读者都不熟悉的统计学概念,我们应适当地进行注解,以帮助读者了解相关概念。

下图,主标题数据背景注解让读者快速了解业务背景,副标题说明结论能有效引导读者朝着什么方向去阅读图表

image

合理增加引导:增加适当的箭头指向

分析阶段,我们是报表的制作者;汇报阶段,我们是报告的讲解者。我们可以将自身作为报告的导游,引导读者按照我们的期望去阅读图表。而增加箭头等符号的引导是最直接有效的方式。

c. 通过引入计算、视觉暗示直接符合读者“背景暗示”等方法可有效降低读者理解步骤

创造性地从不同角度进行计算

有时,我们只需在图表上先做一个图表计算就可以让图表离结论更近一个层次,从而减少读者从可视化图表到现实世界的理解步骤。常见的可用计算包括:平均值计算、环比增长率、基准点上下、累加统计等。

示例1:将员工销售业绩与团队均值做差值,快速辨别员工的销售表现

image

示例2:将2个采购商的采购成本按照一年累计汇总后可使采购成本差异更显著

image

选择符合读者“背景期望”的视觉暗示

人在世界上生存久了都会形成一定的潜意识,有一些潜意识是“人群通用的”,在可视化过程中,我们应该合理运用。比如:在失业、就业统计中,失业用负数表示,就业用正数表示,就是一种符合大多数人“背景期望”的一种场景。

示例1: 之前在一本书中看到的一个关于伊拉克战争可视化。此图的主题在于批判战争的残酷造成了巨大的伤亡,所以作者采用了与血液相同的红色作为主色调,倒挂的柱形也能给人以压抑感,同样符合“背景期望”。

image

示例2: 之前一位同事分享的一个关于美国一些互联网平台网红收入的可视化。在色彩上它直接采用对应互联网平台自身logo的色系。符合人的“背景期望”阅读过程将非常轻松。

image

四、适应读者

别忘了,我们的可视化是为读者进行的,我们应考虑目标读者的特点制作他们易于、乐于理解的可视化。尤其要避免的一个陷阱是:过分追求新颖图表,反而使得图表难以理解,结果违背了可视化的初衷。

为读者而可视化,要求我们试图去了解读者,了解他们对可视化的偏好,尤其是能够接受新颖的图表类型,以及他们对业务的理解程度等等。

此外,还有一个非常关键且通用的建议:让我们的报告以讲故事的方式展开,我们自身则作为这个报告的导游,合理有效地引导读者看完你创造的“分析故事”。


好,以上即为个人对数据可视化服务商业分析的过程所有总结。

0

小型大写字母的用武之处

对英文字体排版稍有了解的话,就会知道在大写字母和小写字母之外,还存在着一种特殊的类型:小型大写字母(Small Caps)。

大写字母:HELLO WORLD
小写字母:hello world
小型大写字母:Hᴇʟʟᴏ ᴡᴏʀʟᴅ

这样看可能并不能明显地突显出小型大写字母的特征。那么,如果你有看过英文版的圣经的话,在全书中提到「ʟᴏʀᴅ」的地方,都使用的是小型大写字母。

image

可以看到,这些「ʟᴏʀᴅ」在字形上是大写字母,然而,在字高上,却与小写字母相同。小型大写字母被创造出来的初衷有两点,其一,是为了使标题更具有装饰性和艺术感;其二,是为了在部分排版中(尤其是密集的正文排版),替代大写字母的存在,从而减弱对视觉的干扰。

这么说可能有些抽象,那我们就一起来看看小型大写之母经常出没的地方。需要注意的是,下文中提到的一些示例,很多只是出于通用的实践或大多数设计师个人的喜好,而并非强制性的规范。

减弱大写字母对视觉的冲击

capitals_small_v_big

英文中存在着许多首字母缩写(acronyms,例如 WTO 是 World Trade Organization 的首字母缩写)和简写(abbreviations,例如 TV 是 Television 的简写)。这些缩写或简写往往需要大写,然而,你会发现当它们出现在密集排版的正文中,对视觉有很大的干扰,使阅读的重点完全落在了这些词上。

而将这些大写字母转化为小型大写字母后,视觉上的冲击就被明显减弱了,人的阅读注意力也能重新回到文本和内容上。不过,在实践中,许多设计师仅对三个字母以上的缩写使用小型大写字母。但这并不是约定成俗的,在一部分出版社的排版规范中,也规定了文中出现的「ᴀᴅ」、「ʙᴄ」、「ᴀᴍ」、「ᴘᴍ」皆采用小型大写字母。

capitals_time_smallcaps1

装饰性的封面和标题

51+ERtqCF9L

在许多书的封面设计(往往是副标题和作者署名),以及正文中的章节标题处,都会使用小型大写字母。一种更为常见的用法是,同时搭配大写字母与小型大写字母,单词的首字母使用大写字母,其后跟随小写字母,往往可以使字体的设计显得更具有装饰感。

capitals_headlines

正文中的引用

在一些规范中,如果在正文内引用了本书的其它部分章节内容,或在法律文书中援引一些人名和书籍时,对被引用的部分不使用斜体或双引号,而使用小型大写字母。如《世界图书百科全书》中提到「See Nᴏ-Fᴀᴜʟᴛ Iɴsᴜʀᴀɴᴄᴇ」,意即让读者去参阅「Nᴏ-Fᴀᴜʟᴛ Iɴsᴜʀᴀɴᴄᴇ」这个章节。

指代姓氏或非西文顺序的姓氏

在一些语言中,如果一个人的姓氏非常长,往往会用小型大写字母来表示后文会经常使用的简写。如西班牙语写作的《堂吉诃德》中,「Don Qᴜɪxᴏᴛᴇ de La Mancha」意为「来自曼查的骑士吉诃德大人」,在后文中仅用「Qᴜɪxᴏᴛᴇ」来代指。而对于像中文这样,将姓氏排在名前面的顺序,有时也会使用小型大写字母,来特意标注出哪部分是姓氏,如「Mᴀᴏ Zedong」。

用于头部引起视觉注意

capitals_plays

在剧本中,不同角色的台词,其另起一行的人物名称往往会使用小型大写字母,来引起视觉上的注意,而又不显得过于突兀。

capitals_chapter_start_small

在章节的起首,英文字体往往也会有多样的处理。除了纵向上横跨数行的首字母放大,另一种常见的处理,则是使用小型大写字母。根据设计师的个人喜好,采用小型大写字母处理的范围也不尽相同,从首个单词,到首句话,再到首行,都有可能。

Reference:
- http://www.bergsland.org/2012/07/book-production/typography/the-use-of-small-caps-is-required/
- https://ilovetypography.com/2008/02/20/small-caps/
- http://theworldsgreatestbook.com/book-design-part-5/
- https://en.wikipedia.org/wiki/Small_caps

0

React Native 项目整合 CodePush 之完全指南

本文使用的环境:

  • React@16.3.1
  • React Native@0.55.4
  • react-native-code-push@5.3.4
  • Android SDK@23
  • Android Build Tool@23.0.3
  • Gradle@2.14.1
  • Android Gradle Plugin@2.2.3

Why CodePush?

CodePush 是微软提供的一个热更新前后台方案,它对 React Native 项目有很好的支持。

目前针对 React Native 的 hot update 方案有许多,但是 CodePush 是最成熟稳定的方案,它最大的特点是提供了完整的后台工具。它主要的优点是:

  • 微软出品,大厂保证
  • 良好的多环境支持(Testing,Staging, Production)
  • 灰度发布、自动回滚等等特性
  • 良好的数据统计支持:下载、安装、出错一目了然
  • 强大的 CLI 工具,一个终端搞定全部流程

由于 React Native 执行的是脚本 js 文件,对热更新有天然的亲和,有余力的团队可以尝试实现自己的框架,一个简单的实现思路是:

  • 修改加载 jsBundle 的代码,转而从指定的本地存储位置去加载。如果没有,下载 bundle, 并且本次打开使用 app 包中的 bundle。
  • 如果找到 jsBundle 文件,调用 api 比较版本号,如果不一致,则从指定服务器下载最新的 bundle 进行替换。
  • 通过反射调用私有方法,在下载完成的回调中更新运行时资源,从而能立即看到更新的效果。
  • 使用类似 google-diff-match-patch 的 diff 工具,生成差异化补丁,不必下载完整 bundle,从而大大减小补丁包体积。

网上有很多资料和源码,这里就不细述了。

后台配置

为了使用 Code Push 发布热更新,我们需要向微软服务注册我们的应用。这部分工作微软提供了强大的命令行工具:CodePush CLI

CodePush CLI

安装 cli 工具

npm 全局安装:

npm install -g code-push-cli

关联账号

使用命令

code-push register

注册一个账号,可以直接使用 GitHub 账号授权,完成后将 token 复制回命令行中。

授权返回的token

使用 whoami 查看登录状态:

code-push whoami

注册应用

登录成功后,我们注册一个app:

code-push app add 你的App名称 android react-native

注意一定要为 Android 和 iOS 分别注册,两者的更新包内容会有差异。

注册成功

查询状态

每个 App 有不同的运行时环境,比如 Production,Staging等,我们也可以配置自己的环境。查看 App 的不同环境和部署状况:

code-push deployment ls 注册的app名称

查询状态

目前我们还没有发布任何更新,所以表中的状态是空的。

到这里就完成了后端的基本配置。

App端配置

版本兼容

安装 Code Push 环境前首先要 check 版本的兼容性问题,不同的RN版本需要使用不同的 Code Push,原则上我们建议将 RN 和 CodePush 都升级到最新版本。

下表是官方文档中的兼容性说明:

React Native version(s) Supporting CodePush version(s)
<0.14 Unsupported
v0.14 v1.3 (introduced Android support)
v0.15-v0.18 v1.4-v1.6 (introduced iOS asset support)
v0.19-v0.28 v1.7-v1.17 (introduced Android asset support)
v0.29-v0.30 v1.13-v1.17 (RN refactored native hosting code)
v0.31-v0.33 v1.14.6-v1.17 (RN refactored native hosting code)
v0.34-v0.35 v1.15-v1.17 (RN refactored native hosting code)
v0.36-v0.39 v1.16-v1.17 (RN refactored resume handler)
v0.40-v0.42 v1.17 (RN refactored iOS header files)
v0.43-v0.44 v2.0+ (RN refactored uimanager dependencies)
v0.45 v3.0+ (RN refactored instance manager code)
v0.46 v4.0+ (RN refactored js bundle loader code)
v0.46-v0.53 v5.1+ (RN removed unused registration of JS modules)
v0.54-v0.55 v5.3+ (Android Gradle Plugin 3.x integration)

安装包

使用命令:

npm info react-native-code-push

来查看包相关信息。

我们建议始终将RN、React以及一些相关库升级到最新版本。在根目录下使用命令:

npm install --save react-native-code-push

来安装最新版本的 CodePush。

也可以参照上面的兼容性表格,安装指定版本:

npm install --save react-native-code-push@5.1.4

工程配置(Android)

如果工程创建的时候比较早,可能是使用命令create-react-native-app来创建的,则需要在根目录执行:

npm run eject

来改变工程结构,防止后面的兼容性问题。

配置安卓工程,官方提供了两种途径:

  • 使用命令行工具rnpm(现在已经被整合到React Native CLI工具中了)。执行
react-native link react-native-code-push
  • 手动配置

如果你是新手,或者对 gradle、安卓工程结构不了解,我们强烈建议执行一次手动配置,帮助理解到底发生了什么。

手动配置

step 1

android/settings.gradle文件中添加:

include ':app', ':react-native-code-push'
project(':react-native-code-push').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-code-push/android/app')

这个文件定义了哪些 module 应该被加入到编译过程,对于单个 module 的项目可以不用需要这个文件,但是对于 multiModule 的项目我们就需要这个文件,否则 gradle 不知道要加载哪些项目。这个文件的代码在初始化阶段就会被执行。

我们添加的内容告诉 gradle:去 node_modules 目录下的 react-native-code-push 加载 CodePush 子项目。

step 2

android/app/build.gradle 中的 dependencies 方法中添加依赖:

...
dependencies {
    ...
    compile project(':react-native-code-push')
}

这样就能在主工程中引用到 CodePush 模块了。

step 3

继续在 android/app/build.gradle 中,添加在编译打包阶段 CodePush 需要执行的 task 引用:

...
apply from: "../../node_modules/react-native-code-push/android/codepush.gradle"
...

这段代码其实就是调用了 project 对象的 apply 方法,传入了一个以 from 为 key 的 map。完整写出来就是这样的:

project.apply([from: '../../node_modules/react-native-code-push/android/codepush.gradle'])

apply fromapply plugin的区别在于,前者是从指定 url 去加载脚本文件,后者则用是从仓库拉取 plugin id 对应的二进制执行包。

step 4

CodePush 发布有各种环境(deployment),默认有 Staging 和 Production,我们需要在 buildType 中配置对应的环境,并且设置 PushKey,从而让 App 端的 CodePush RunTime 根据不同的健值来下载正确的更新包。

查询各个环境 Key 的方法是使用上文安装的 CLI 工具:

code-push deployment ls App名称 -k

查询CodePushKey

上表中的 Deployment Key 就是对应环境的 Key 值了。

android/app/build.gradle 中,配置 buildTypes:

buildTypes {

    // 对应Production环境
    release {
        ...
        buildConfigField "String", "CODEPUSH_KEY", '"从上述结果中复制的production值"'
        ...
    }

    // 对应Staging环境
    releaseStaging {
        // 从 release 拷贝配置,只修改了 pushKey
        initWith release
        buildConfigField "String", "CODEPUSH_KEY", '"从上述结果中复制的stagingkey值"'
    }

    debug {
        buildConfigField "String", "CODEPUSH_KEY", '""'
    }
}

注意这里不同 buildType 的命名,Staging 环境对应的 buildType 就叫 releaseStaging,要符合这样的命名规范。

Debug 环境虽然用不到 CodePush, 但是也要配置空的 Key 值,否则会报错。

step 5

处理完引用关系后,我们修改 MainApplication.java,在 App 执行时启动 CodePush 服务:

// 声明包
import com.microsoft.codepush.react.CodePush;

public class MainApplication extends Application implements ReactApplication {

    private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {
        ...
        // 重写 getJSBundleFile() 方法,让 CodePush 去获取正确的 jsBundle
        @Override
        protected String getJSBundleFile() {
            return CodePush.getJSBundleFile();
        }

        @Override
        protected List<ReactPackage> getPackages() {
            return Arrays.<ReactPackage>asList(
                new MainReactPackage(),
                // 创建一个CodePush运行时实例
                new CodePush(BuildConfig.CODEPUSH_KEY, MainApplication.this, BuildConfig.DEBUG)
                ...
            );
        }
    };
}

js端引入 Code Push

配置完项目工程后,我们将 CodePush 引入到 js 端。

首先将 App 的根组件包裹在 CodePush 中:

import codePush from "react-native-code-push";

AppRegistry.registerComponent('BDCRM', () => codePush(App));

CodePush 会在 App 启动后自动去 check 和更新最新的版本,我们可以添加一些配置,让它在进入后台的时候也执行检查:

let codePushOptions = { checkFrequency: codePush.CheckFrequency.MANUAL };
AppRegistry.registerComponent('BDCRM', () => codePush(codePushOptions)(App));

CodePush js端的 api 不多,我们可以用这些 api 控制更新的一系列流程,常用的有:

// 检测是否有更新包可用
codePush.checkForUpdate(deploymentKey: String = null, handleBinaryVersionMismatchCallback: (update: RemotePackage) => void): Promise<RemotePackage>;

// 获取本地最新更新包的属性
codePush.getCurrentPackage(): Promise<LocalPackage>;

// 重启app(即使不用在 Hot Updating,也挺有用的)
codePush.restartApp(onlyIfUpdateIsPending: Boolean = false): void;

// 手动进一次更新
codePush.sync(options: Object, syncStatusChangeCallback: function(syncStatus: Number), downloadProgressCallback: function(progress: DownloadProgress), handleBinaryVersionMismatchCallback: function(update: RemotePackage)): Promise<Number>;

更多详细信息见文档

使用 CodePush CLI 发布更新

完成前后端的配置,打包发布应用后,后续的改动我们就能通过 CLI 工具来发布啦!

升级前首先要 check:

  • 应用的版本号要有更新(app/build.gradle: defaultConfig/versionName)
  • js bundle 要有改动,Code Push 会 diff 前后版本,如果代码一致会认为是无效的更新包

打开终端,进入到工程目录,完整发布命令是:

code-push release-react <appName> <platform>
[--bundleName <bundleName>]
[--deploymentName <deploymentName>]
[--description <description>]
[--development <development>]
[--disabled <disabled>]
[--entryFile <entryFile>]
[--gradleFile <gradleFile>]
[--mandatory]
[--noDuplicateReleaseError]
[--outputDir <outputDir>]
[--plistFile <plistFile>]
[--plistFilePrefix <plistFilePrefix>]
[--sourcemapOutput <sourcemapOutput>]
[--targetBinaryVersion <targetBinaryVersion>]
[--rollout <rolloutPercentage>]
[--privateKeyPath <pathToPrivateKey>]
[--config <config>]

命令参数很多,但用途都一目了然,嫌每次打麻烦的话,做成脚本也可以。

一般来说,我们发布应用首先会在测试环境进行稳定性测试,通过后再发布到生产环境中:

  • 打包发布 Staging 环境
code-push release-react 应用名 --platform android --deploymentName Staging --description "修复一些bug"

这样,我们 Staging 环境就可以收到更新推送啦,具体加载新 bundle 的实际,和我们在应用中配置的策略有关,上文已经介绍过了。

  • 测试 ok 后,提升(Promoting)到 Production 环境,并且进行灰度20%发布
code-push promote 应用名 Staging Production --rollout 20%
  • 在生产环境验证 ok,使用 patch 将灰度修改为100%,进行全网发布:
code-push patch 应用名 Production -rollout 100%

以上就是按照 测试 - 灰度 - 全部发布 步骤的一个典型 CodePush 发布工作流。

总体来说,CodePush 能满足我们灰度发布 React Native 应用的大部分需求了,由微软提供的服务器端支持可以节省很多工作,是一个成熟可靠的方案。如果要说缺点,可能有几个需要考虑一下:

  • 服务器速度,国内网络状况可能会影响下发的成功率和效率。
  • 污染代码,在 js 端必须将根节点包裹到 CodePush 模块中去,污染了代码。
  • 冗余,如果只是想要简单的下发小体积的 js bundle,CodePush 显得太“重”,过于冗余了,这时候用轻量化的方案更好。

总之,我们根据自己项目的需要去进行选型就好了!

更多细节,可以参考文档

0

震惊!JavaScript 竟然可以类型推断!

作为弱类型的 JavaScript 写起来爽,维护起来更

—— 鲁迅·沃梅硕果

近几年,前端技术的发展可以用 Big Boom 来形容,因此 JavaScript 也被大规模的运用在项目中,由此也产生了代码的维护问题,所谓 动态类型一时爽,代码重构火葬场

其实不仅仅是代码重构,在日常开发中也能感受到弱类型语言的不足所带来的不便之处。举个例子,现在有个函数 renderUserList , 作用是将用户列表显示在界面上

function renderUserList(el, userList) {
  let html = '';
  for (const user of userList) {
    html += `<div>
    姓名:${user.age}
    年龄:${user.age}
    </div>`
  }
  el.innerHTML = html;
}

我敢打赌,大家在写这种类型函数的时候,都是在盲写,因为我们不知道传入的 eluserList 到底是什么类型。更不知道 el 下面有哪些方法,写的时候都如此费劲,跟别谈维护了。其实我们可以通过一些简单的操作,让这个函数写起来更轻松,就像下面一样:

类型提示1

类型提示2

那么,到底是怎么实现的呢?接下来就要介绍本文的主角 JSDocVSCode

JSDoc是一个根据 JavaScript 文件中注释信息,生成 JavaScript 应用程序或库、模块的 API 文档 的工具。你可以使用他记录如:命名空间,类,方法,方法参数等。

通俗的讲,JSDoc 是 JavaScript 注释规范的一种,VSCode 利用 JSDoc 规范的特点,配合 typescript 实现了“类型提示”,所以在 VSCode 中基本上是 开箱即用 的,而对于非内置对象,比如 jQuery 的 $,lodash 的 _ 等,则需要单独下载对应的声明文件。

不过实际开发中,在 window 和 mac 上,还是有些差别的,mac 版的 VSCode 会去检查代码,然后自动下载对应的声明文件存放在 ~/Library/Caches/typescript/ (猜测是自动下载的),而 windows 则需要开发者手动通过 npm 去安装需要的声明文件,文末也会提到如何使用声明文件。

另外在 .jsx 中也可以使用 JSDoc,webstorm 也支持通过 JSDoc 实现类型提示, sublime 貌似还不支持。

在 VSCode 中会自动根据 JSDoc 的标注对变量、方法、方法参数等进行类型推断,通过 TypeScript 来进行智能提示,因此从编写注释开始学习 TypeScript 也是一个不错的选择,下面就来一一列举 JSDoc 在代码中的用法。

变量

@type 标注变量的类型

基础类型

/**
 * @type {number}
 */
let n;

/** @type {boolean} */
let flag;

/** @type {string} */
let str;

联合类型

如果一个变量可能是多种类型,则可以使用联合类型

/** 
 * @type {string | boolean}
 */
let x;

自定义类型

我们经常用到自定义类型,也就是 JavaScript 中的对象,对于简单的对象,可以用下面的写法

/**
 * @type {{name: string, age: number}}
 */
let user;

对于键值对比较多的复杂对象,可以使用 @typedef 来定义复杂类型,用 prop 或者 property 来定义对象的属性。

/**
 * @typedef {Object} goods
 * @property {string} name
 * @prop {number} code
 * @prop {string=} thumbnail 用 = 表示该属性是可能存在,也可能不存在
 * @prop {string} [introduction] 也可以给属性名加上 [] 表示这是一个可选属性
 * @prop {string[]} label
 */

 /**
  * @type {goods}
  */
 let phone;

数组

可以使用 [] 或者 Array 表示数组

/**
 * @type {number[]}
 */
let numList;

/**
 * @type {Array<string>}
 */
let strList;

对于已经定义的类型或者已经声明的变量,也是可以直接使用,下面分别声明一个 user 数组和 goods 数组

/**
 * @type {user[]}
 */
let userList;

/**
 * @type {goods[]}
 */
let goodsList;

如果不确定数组的每一项具体类型,可以使用 any * 或者交叉类型

/**
 * @type {any[]}
 */
let arr1;

/**
 * @type {*[]}
 */
let arr2;

/**
 * @type {(user | goods)[]}
 */
let arr3

泛型

/**
 * @template T
 * @param {T} p1
 * @return {T}
 */
function gen(p1) { return p1 }

函数

@name 表示函数的名称

@param 表示函数的参数

@return@returns 表示函数的返回值

一般函数的写法大致分为两种:声明式函数和函数表达式。

函数表达式

/**
 * @type {function (number, number): number}
 */
var getSum = (n1, n2) => n1 + n2;

声明式函数

/**
 * @name fn
 * @param {string} str
 * @param {boolean} flag
 * @returns {*[]}
 */
function fn(str, flag) {
  return [];
}

通过上面的注释写法,便可以在函数 fn 内部正确的识别出两个参数的类型,并且可以知道该函数返回值类型为数组。

对于函数参数的类型,写法和上面的变量写法一致,区别是将 @type 换成了 @param,函数的返回值也是同样的道理。

对象的方法

对函数的注释同样适用于对象的方法

var o = {
  /**
   * @param {string} msg
   * @returns {void}
   */
  say(msg) {
    console.log(msg);
  }
}

内置类型和其它类型

上面的例子只是简单的用到了一些常见的类型,然而在实际开发中,我们用到的不止这些,比如开始文章开头的例子中,有用到了 DOM 对象,那该怎么编写注释呢?其实 VSCode 已经为我们提供了很多的类型了,比如 DOM 对象对应的类型是 HTMLElement , 事件对象对应的类型是 Event,同时 DOM 对象还可以更细化,比如 HTMLCanvasElementHTMLImageElement 等等。

同时,我们在开发中也会用到第三方的类库或框架,通常情况下,这些类库都会有一份以 d.ts 结尾的声明文件,该声明文件中包含了所用到类型的所有提示,以最为经典的 jQuery 为例,如果在时在 webpack 环境下,在通过 npm 安装 jQuery 后,需要再单独安装对应的声明文件 @types/jquery ,这样 VSCode 就可以正确的识别 $ 符号,也可以在 JSDoc 中使用 JQuery, JQueryStatic 等这都类型了,就像下面这样

/**
 * @type {JQuery}
 */
var $btn = $('button');

/**
 * @param {number} userId
 * @returns {JQuery.jqXHR} 
 */
function getUser(userId) {
  return $.get(`/user/${userId}`);
}

大部分情况下,通过 npm 发布的包,都会包含其对应的声明文件,如果没有的话,可以通过这个地址 TypeSearch 来搜索一下并安装 ,如果感兴趣可以到这个仓库 DefinitelyTyped 看看。当然你也可以提供一些仓库内目前还没有声明文件,别人会非常感谢你的!

当然并不是所有的项目都用到了 npm ,仍有很多项目在使用 script 这种方式从 cdn 来引入 .js 文件,这种情况下用不到 webpack ,也用不到 npm ,那这个时候就要从上面所提到的仓库地址 DefinitelyTyped 来下载对应的声明文件了,然后通过 /// <reference path="" /> 这种形式来引入声明文件,就像下面这样

/// <reference path="./node_modules/@types/jquery/index.d.ts"/>

个人建议:即使是通过 cdn 方式来引入 .js 文件,也可以通过 npm 来安装 @types/ ,这样和在每个文件中通过 /// <reference path="" /> 引入声明文件相比,还是方便很多的。

总结

以上便是关于利用 JSDoc 实现 JavaScript 的类型提示。当然还有一些更深入的用法,比如全局模板文件,命名空间等,但是这些和 TypeScript 关系更大一些。当有一天你发现 JSDoc 已经不能满足你的时候,便是向着 TypeScript 大举进攻的时候了。

0

OpenResty 不完全指南

OpenResty 简介

OpenResty® 是一个基于 Nginx 与 Lua 的高性能 Web 平台。我们知道开发 Nginx 的模块需要用 C 语言,同时还要熟悉它的源码,成本和门槛比较高。国人章亦春把 LuaJIT VM 嵌入到了 Nginx 中,使得可以直接通过 Lua 脚本在 Nginx 上进行编程,同时还提供了大量的类库(如:lua-resty-mysql lua-resty-redis 等),直接把一个 Nginx 这个 Web Server 扩展成了一个 Web 框架,借助于 Nginx 的高性能,能够快速地构造出一个足以胜任 10K 乃至 1000K 以上单机并发连接的高性能 Web 应用系统。

Nginx 采用的是 master-worker 模型,一个 master 进程管理多个 worker 进程,worker 真正负责对客户端的请求处理,master 仅负责一些全局初始化,以及对 worker 进行管理。在 OpenResty 中,每个 worker 中有一个 Lua VM,当一个请求被分配到 worker 时,worker 中的 Lua VM 里创建一个 coroutine(协程) 来负责处理。协程之间的数据隔离,每个协程具有独立的全局变量 _G

ngx_lua works.png

OpenResty 处理请求流程

由于 Nginx 把一个请求分成了很多阶段,第三方模块就可以根据自己的行为,挂载到不同阶段处理达到目的。OpenResty 也应用了同样的特性。不同的阶段,有不同的处理行为,这是 OpenResty 的一大特色。OpenResty 处理一个请求的流程参考下图(从 Request start 开始):

image

指令 使用范围 解释
int_by_lua* init_worker_by_lua* http 初始化全局配置/预加载Lua模块
set_by_lua* server,server if,location,location if 设置nginx变量,此处是阻塞的,Lua代码要做到非常快
rewrite_by_lua* http,server,location,location if rewrite阶段处理,可以实现复杂的转发/重定向逻辑
access_by_lua* http,server,location,location if 请求访问阶段处理,用于访问控制
content_by_lua* location, location if 内容处理器,接收请求处理并输出响应
header_filter_by_lua* http,server,location,location if 设置 heade 和 cookie
body_filter_by_lua* http,server,location,location if 对响应数据进行过滤,比如截断、替换
log_by_lua http,server,location,location if log阶段处理,比如记录访问量/统计平均响应时间

更多详情请参考官方文档

配置 OpenResty

OpenResty 的 Lua 代码是提现在 nginx.conf 的配置文件之中的,可以与配置文件写在一起,也可以把 Lua 脚本放在一个文件中进行加载:

内联在 nginx.conf 中:

server {
    ...
    location /lua_content {
         # MIME type determined by default_type:
         default_type 'text/plain';

         content_by_lua_block {
             ngx.say('Hello,world!')
         }
    }
    ....
}    

通过加载 lua 脚本的方式:

server {
    ...
    location = /mixed {
         rewrite_by_lua_file /path/to/rewrite.lua;
         access_by_lua_file /path/to/access.lua;
         content_by_lua_file /path/to/content.lua;
     }
    ....
} 

OpenResty 变量的共享范围

全局变量

在 OpenResty 中,只有在 init_by_lua*init_worker_by_lua* 阶段才能定义真正的全局变量。因为在其他阶段,OpenResty 会设置一个隔离的全局变量表,以免在处理过程中污染了其他请求。即使在上述两个阶段可以定义全局变量,也尽量避免这么做。全局变量能解决的问题,用模块变量也能解决,而且会更清晰,干净。

模块变量

这里将定义在 Lua 模块中的变量称为模块变量。Lua VM 会将 require 进来的模块换成到 package.loaded table 里,模块里的变量都会被缓存起来,在同一个 Lua VM下,模块中的变量在每个请求中是共享的,这样就可以避免使用全局变量来实现共享了,看下面一个例子:

nginx.conf

worker_processes  1;

...
location {
    ...
    lua_code_cache on;
    default_type "text/html";
    content_by_lua_file 'lua/test_module_1.lua'
}

lua/test_module_1.lua

local module1 = require("module1")

module1.hello()

lua/module1.lua

local count = 0
local function hello() 
    count = count + 1
    ngx.say("count: ", count)
end

local _M  = {
    hello = hello
}   

return _M

当通过浏览器访问时,可以看到 count 输出是一个递增的,这也说明了在 lua/module1.lua 的模块变量在每个请求中时共享的:

count: 1
count: 2
.....

另外,如果 worker_processes 的数量大于 1 时呢,得到的结果可能就不一样了。因为每个 worker 中都有一个 Lua VM 了,模块变量仅在同一个 VM 下,所有的请求共享。如果要在多个 Worker 进程间共享请考虑使用 ngx.shared.DICT 或如 Redis 存储了。

本地变量

跟全局变量,模块变量相对,我们这里姑且把 *_by_lua* 里定义的变量称为本地变量。本地变量仅在当前阶段有效,如果需要跨阶段使用,需要借助 ngx.ctx 或者附加到模块变量里。

这里我们使用了 ngx.ctx 表在三个不同的阶段来传递使用变量 foo

location /test {
     rewrite_by_lua_block {
         ngx.ctx.foo = 76
     }
     access_by_lua_block {
         ngx.ctx.foo = ngx.ctx.foo + 3
     }
     content_by_lua_block {
         ngx.say(ngx.ctx.foo)
     }
 }

额外注意,每个请求,包括子请求,都有一份自己的 ngx.ctx 表。例如:

 location /sub {
     content_by_lua_block {
         ngx.say("sub pre: ", ngx.ctx.blah)
         ngx.ctx.blah = 32
         ngx.say("sub post: ", ngx.ctx.blah)
     }
 }

 location /main {
     content_by_lua_block {
         ngx.ctx.blah = 73
         ngx.say("main pre: ", ngx.ctx.blah)
         local res = ngx.location.capture("/sub")
         ngx.print(res.body)
         ngx.say("main post: ", ngx.ctx.blah)
     }
 }

访问 GET /main 输出:

main pre: 73
sub pre: nil  # 子请求中并没有获取到父请求的变量 $pre
sub post: 32
main post: 73

性能开关 lua_code_cache

开启或关闭在 *_by_lua_file(如:set_by_lua_file, content_by_lua_file) 指令中以及 Lua 模块中 Lua 代码的缓存。

若关闭,ngx_lua 会为每个请求创建一个独立的 Lua VM,所有 *_by_lua_file 指令中的代码将不会被缓存到内存中,并且所有的 Lua 模块每次都会从头重新加载。在开发模式下,这给我们带来了不需要 reload nginx 就能调试的便利性,但是在生成环境下,强烈建议开启。 若关闭,即使是一个简单的 Hello World 都会慢上一个数量级(每次 IO 读取和编译消耗很大)。

但是,那些直接写在 nginx.conf 配置文件中的 *_by_lua_block 指令下的代码不会在你编辑下实时更新,只有发送 HUP 信号给 Nginx 才能能够重新。

小案例

通过 OpenResty + Redis 实现动态路由

Nginx 经常用来作为反向代理服务器。通常情况下,我们将后端的服务配置在 Nginx 的 upstream 中,当后端服务有变更时就去修改 upstream 中的配置再通过 reload 的方式使其生效。这个操作如果在后端服务经常发生变更的情况下,操作起来就会显得有些繁琐了。现在利用 Lua + Redis 的方式将 upstream 中的配置放在 Redis 中,以实现动态配置的效果。

架构图

image

原理:

在求请求访问阶段处理(access_by_lua*)通过指定的规则(这个规则根据自己的需求去设计)从 Redis 中去获取相对应的后端服务地址去替换 Nginx 配置中的 proxy_pass 的地址。

流程:

  1. 在 Nginx 配置中创建后端服务地址的变量 $backend_server
    server {
        listen 80;
        server_name app1.example.com;

        location / {
            ...
            set $backend_server '';
        }
    }

同时在 Redis 中存入后端服务的地址。

set app1 10.10.10.10:8080
  1. 使用 ngx_redis2 模块来实现一个读取 Redis 的接口。
    # GET /get?key=some_key
    location = /get {
        internal;                        # 保护这个接口只运行内部调用
        set_unescape_uri $key $arg_key;  # this requires ngx_set_misc
        redis2_query get $key;
        redis2_pass foo.com:6379;        # redis_server and port
    }
  1. 在求请求访问阶段处理利用 ngx.location.capture 模块请求去上个阶段定义的 Redis 接口,并将结果替换 $backend_server
    location / {
        ...
        access_by_lua_block {
            local rds_key = "app1"
            # 从 redis 中获取 key 为 app1 对应的 server_ip
            local res = ngx.location.capture('/get', { args = {key = rds_key}})
            # 解析 redis 结果
            local parser = require("redis.parser")
            local server, typ = parser.parse_reply(res.body)
            if typ ~= parser.BULK_REPLY or not server then
                ngx.log(ngx.ERR, "bad redis response: ", res.body)
                ngx.exit(500)
            end

            ngx.var.backend_server = server
        }
    }
  1. Nginx 转发阶段将请求转发至后端服务。
    location / {
        ...
        access_by_lua_block {...};
        proxy_pass http://$backend_server;
    }

最后,推荐两个基于 OpenResty 的比较实用的两个开源项目:

参考

1+

ConcurrentHashMap 的 size 方法原理分析

前言

JAVA 语言提供了大量丰富的集合, 比如 List, Set, Map 等。其中 Map 是一个常用的一个数据结构,HashMap 是基于 Hash 算法实现 Map 接口而被广泛使用的集类。HashMap 里面是一个数组,然后数组中每个元素是一个单向链表。但是 HashMap 并不是线程安全的, 在多线程场景下使用存在并发和死循环问题。HashMap 结构如图所示:

线程安全的解决方案

线程安全的 Map 的实现有 HashTable 和 ConcurrentHashMap 等。HashTable 对集合读写操作通过 Synchronized 同步保障线程安全, 整个集合只有一把锁, 对集合的操作只能串行执行,性能不高。ConcurrentHashMap 是另一个线程安全的 Map, 通常来说他的性能优于 HashTable。 ConcurrentHashMap 的实现在 JDK1.7 和 JDK 1.8 有所不同。

在 JDK1.7 版本中,ConcurrentHashMap 的数据结构是由一个 Segment 数组和多个 HashEntry 组成。简单理解就是ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 Segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。

JDK1.8 的实现已经摒弃了 Segment 的概念,而是直接用 Node 数组 + 链表 + 红黑树的数据结构来实现,并发控制使用 Synchronized 和 CAS 来操作,整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本。 通过 HashMap 查找的时候,根据 hash 值能够快速定位到数组的具体下标,如果发生 Hash 碰撞,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。为了降低这部分的开销,在 Java8 中,当链表中的元素超过了 8 个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。

如何计算 ConcurrentHashMap Size

由上面分析可知,ConcurrentHashMap 更适合作为线程安全的 Map。在实际的项目过程中,我们通常需要获取集合类的长度, 那么计算 ConcurrentHashMap 的元素大小就是一个有趣的问题,因为他是并发操作的,就是在你计算 size 的时候,它还在并发的插入数据,可能会导致你计算出来的 size 和你实际的 size 有差距。本文主要分析下 JDK1.8 的实现。 关于 JDK1.7 简单提一下。

在 JDK1.7 中,第一种方案他会使用不加锁的模式去尝试多次计算 ConcurrentHashMap 的 size,最多三次,比较前后两次计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的。 第二种方案是如果第一种方案不符合,他就会给每个 Segment 加上锁,然后计算 ConcurrentHashMap 的 size 返回。其源码实现:

public int size() {
  final Segment<K,V>[] segments = this.segments;
  int size;
  boolean overflow; // true if size overflows 32 bits
  long sum;         // sum of modCounts
  long last = 0L;   // previous sum
  int retries = -1; // first iteration isn't retry
  try {
    for (;;) {
      if (retries++ == RETRIES_BEFORE_LOCK) {
        for (int j = 0; j < segments.length; ++j)
          ensureSegment(j).lock(); // force creation
      }
      sum = 0L;
      size = 0;
      overflow = false;
      for (int j = 0; j < segments.length; ++j) {
        Segment<K,V> seg = segmentAt(segments, j);
        if (seg != null) {
          sum += seg.modCount;
          int c = seg.count;
          if (c < 0 || (size += c) < 0)
            overflow = true;
        }
      }
      if (sum == last)
        break;
      last = sum;
    }
  } finally {
    if (retries > RETRIES_BEFORE_LOCK) {
      for (int j = 0; j < segments.length; ++j)
        segmentAt(segments, j).unlock();
    }
  }
  return overflow ? Integer.MAX_VALUE : size;
}

JDK1.8 实现相比 JDK 1.7 简单很多,只有一种方案,我们直接看 size() 代码:

    public int size() {
        long n = sumCount();
        return ((n < 0L) ? 0 :
                (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
    }

最大值是 Integer 类型的最大值,但是 Map 的 size 可能超过 MAX_VALUE, 所以还有一个方法 mappingCount(),JDK 的建议使用 mappingCount() 而不是 size()mappingCount() 的代码如下:

   public long mappingCount() {
        long n = sumCount();
        return (n < 0L) ? 0L : n; // ignore transient negative values
    }

以上可以看出,无论是 size() 还是 mappingCount(), 计算大小的核心方法都是 sumCount()sumCount() 的代码如下:

    final long sumCount() {
        CounterCell[] as = counterCells; CounterCell a;
        long sum = baseCount;
        if (as != null) {
            for (int i = 0; i < as.length; ++i) {
                if ((a = as[i]) != null)
                    sum += a.value;
            }
        }
        return sum;
    }

分析一下 sumCount() 代码。ConcurrentHashMap 提供了 baseCount、counterCells 两个辅助变量和一个 CounterCell 辅助内部类。sumCount() 就是迭代 counterCells 来统计 sum 的过程。 put 操作时,肯定会影响 size(),在 put() 方法最后会调用 addCount() 方法。

addCount() 代码如下:
- 如果 counterCells == null, 则对 baseCount 做 CAS 自增操作。

  • 如果并发导致 baseCount CAS 失败了使用 counterCells。

  • 如果counterCells CAS 失败了,在 fullAddCount 方法中,会继续死循环操作,直到成功。

然后,CounterCell 这个类到底是什么?我们会发现它使用了 @sun.misc.Contended 标记的类,内部包含一个 volatile 变量。@sun.misc.Contended 这个注解标识着这个类防止需要防止 "伪共享"。那么,什么又是伪共享呢?

缓存系统中是以缓存行(cache line)为单位存储的。缓存行是2的整数幂个连续字节,一般为32-256个字节。最常见的缓存行大小是64个字节。当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。

CounterCell 代码如下:

    @sun.misc.Contended static final class CounterCell {
        volatile long value;
        CounterCell(long x) { value = x; }
    }

总结

  • JDK1.7 和 JDK1.8 对 size 的计算是不一样的。 1.7 中是先不加锁计算三次,如果三次结果不一样在加锁。
  • JDK1.8 size 是通过对 baseCount 和 counterCell 进行 CAS 计算,最终通过 baseCount 和 遍历 CounterCell 数组得出 size。
  • JDK 8 推荐使用mappingCount 方法,因为这个方法的返回值是 long 类型,不会因为 size 方法是 int 类型限制最大值。
0

从 ThreadLocal 的实现看散列算法

引子

最近在看 JDK 的 ThreadLocal 源码时,发现了一段有意思的代码,如下所示。

    private final int threadLocalHashCode = nextHashCode();
    /**
     * The difference between successively generated hash codes - turns
     * implicit sequential thread-local IDs into near-optimally spread
     * multiplicative hash values for power-of-two-sized tables.
     */
    private static final int HASH_INCREMENT = 0x61c88647;

    /**
     * Returns the next hash code.
     */
    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }

可以看到,其中定义了一个魔法值 HASH_INCREMENT = 0x61c88647, 对于实例变量 threadLocalHashCode, 每当创建 ThreadLocal 实例时这个值都会 getAndAdd(0x61c88647)

0x61c88647 转化成二进制即为 1640531527,它常用于在散列中增加哈希值。上面的代码注释中也解释到:HASH_INCREMENT 是为了让哈希码能均匀的分布在2的N次方的数组里。

那么 0x61c88647 是怎么起作用的呢?

什么是散列?

ThreadLocal 使用一个自定的的 Map —— ThreadLocalMap 来维护线程本地的值。首先我们先了解一下散列的概念。

散列(Hash)也称为哈希,就是把任意长度的输入,通过散列算法,变换成固定长度的输出,这个输出值就是散列值。

在实际使用中,不同的输入可能会散列成相同的输出,这时也就产生了冲突。通过上文提到的 HASH_INCREMENT 再借助一定的算法,就可以将哈希码能均匀的分布在 2 的 N 次方的数组里,保证了散列表的离散度,从而降低了冲突几率.

哈希表就是将数据根据散列函数 f(K) 映射到表中的特定位置进行存储。因此哈希表最大的特点就是可以根据 f(K) 函数得到其索引。

HashMap 就是使用哈希表来存储的,并且采用了链地址法解决冲突。

简单来说,哈希表的实现就是数组加链表的结合。在每个数组元素上都一个链表结构,当数据被 Hash 后,得到数组下标,把数据放在对应下标元素的链表上。

散列算法

先来说一下散列算法。散列算法的宗旨就是:构造冲突较低的散列地址,保证散列表中数据的离散度。常用的有以下几种散列算法:

除法散列法

散列长度 m, 对于一个小于 m 的数 p 取模,所得结果为散列地址。对 p 的选择很重要,一般取素数或 m

公式:f(k) = k % p (p<=m)

因为求模数其实是通过一个除法运算得到的,所以叫“除法散列法”

平方散列法(平方取中法)

先通过求关键字的平方值扩大相近数的差别,然后根据表长度取中间的几位数作为散列函数值。又因为一个乘积的中间几位数和乘数的每一位都相关,所以由此产生的散列地址较为均匀。

公式:f(k) = ((k * k) >> X) << Y。对于常见的32位整数而言,也就是 f(k) = (k * k) >> 28

斐波那契(Fibonacci)散列法

和平方散列法类似,此种方法使用斐波那契数列的值作为乘数而不是自己。

  1. 对于 16 位整数而言,这个乘数是 40503。
  2. 对于 32 位整数而言,这个乘数是 2654435769。
  3. 对于 64 位整数而言,这个乘数是 11400714819323198485。

具体数字是怎么计算得到的下文有介绍。

为什么使用斐波那契数列后散列更均匀,涉及到相关数学问题,此处不做更多解释。

公式:f(k) = ((k * 2654435769) >> X) << Y。对于常见的32位整数而言,也就是 f(k) = (k * 2654435769) >> 28

这时我们可以隐隐感觉到 0x61c88647 与斐波那契数列有些关系。

随机数法

选择一随机函数,取关键字的随机值作为散列地址,通常用于关键字长度不同的场合。

公式:f(k) = random(k)

链地址法(拉链法)

懂了散列算法,我们再来了解下拉链法。拉链法是为了 HashMap 中降低冲突,除了拉链法,还可以使用开放寻址法、再散列法、链地址法、公共溢出区等方法。这里就只简单介绍了拉链法。

把具有相同散列地址的关键字(同义词)值放在同一个单链表中,称为同义词链表。有 m 个散列地址就有 m 个链表,同时用指针数组 T[0..m-1] 存放各个链表的头指针,凡是散列地址为 i 的记录都以结点方式插入到以 T[i] 为指针的单链表中。T 中各分量的初值应为空指针。

对于HashMap:

HashMap

除法散列(k=16):

HashMap

斐波那契散列:

HashMap

可以看出用斐波那契散列法调整之后会比原来的除法散列离散度好很多。

ThreadLocalMap 的散列

认识完了散列,下面回归最初的问题:0x61c88647 是怎么起作用的呢?

先看一下 ThreadLocalMap 中的 set 方法

private void set(ThreadLocal<?> key, Object value) {
            Entry[] tab = table;
            int len = tab.length;
            int i = key.threadLocalHashCode & (len-1);
            ...
}

ThreadLocalMapEntry[] table 的大小必须是 2 的 N 次方(len = 2^N),那 len-1 的二进制表示就是低位连续的 N 个 1, 那 key.threadLocalHashCode & (len-1) 的值就是 threadLocalHashCode 的低 N 位。

然后我们通过代码测试一下,0x61c88647 是否能让哈希码能均匀的分布在 2 的 N 次方的数组里。

public class MagicHashCode {
    private static final int HASH_INCREMENT = 0x61c88647;

    public static void main(String[] args) {
        hashCode(16); //初始化16
        hashCode(32); //后续2倍扩容
        hashCode(64);
    }

    private static void hashCode(Integer length){
        int hashCode = 0;
        for(int i=0; i< length; i++){
            hashCode = i * HASH_INCREMENT+HASH_INCREMENT;//每次递增HASH_INCREMENT
            System.out.print(hashCode & (length-1));
            System.out.print(" ");
        }
        System.out.println();
    }
}

结果:

7 14 5 12 3 10 1 8 15 6 13 4 11 2 9 0 
7 14 21 28 3 10 17 24 31 6 13 20 27 2 9 16 23 30 5 12 19 26 1 8 15 22 29 4 11 18 25 0 
7 14 21 28 35 42 49 56 63 6 13 20 27 34 41 48 55 62 5 12 19 26 33 40 47 54 61 4 11 18 25 32 39 46 53 60 3 10 17 24 31 38 45 52 59 2 9 16 23 30 37 44 51 58 1 8 15 22 29 36 43 50 57 0 

产生的哈希码分布确实是很均匀,而且没有任何冲突。再看下面一段代码:

public class ThreadHashTest {
    public static void main(String[] args) {
        long l1 = (long) ((1L << 32) * (Math.sqrt(5) - 1)/2);
        System.out.println("as 32 bit unsigned: " + l1);
        int i1 = (int) l1;
        System.out.println("as 32 bit signed:   " + i1);
        System.out.println("MAGIC = " + 0x61c88647);
    }
}

结果:

as 32 bit unsigned: 2654435769
as 32 bit signed:   -1640531527
MAGIC = 1640531527

Process finished with exit code 0
16进制 10进制 2进制 补码
0x61c88647 1640531527 01100001110010001000011001000111 10011110001101110111100110111001

可以发现 0x61c88647 与一个神奇的数字产生了关系,它就是 (Math.sqrt(5) - 1)/2。也就是传说中的黄金比例 0.618(0.618 只是一个粗略值),即 0x61c88647 = 2^32 * 黄金分割比。同时也对应上了上文所提到的斐波那契散列法。

黄金比例与斐波那契数列

最后再简单介绍一下黄金比例,这个概念我们经常能听到,又称黄金分割点。

黄金分割具有严格的比例性、艺术性、和谐性,蕴藏着丰富的美学价值,而且呈现于不少动物和植物的外观。现今很多工业产品、电子产品、建筑物或艺术品均普遍应用黄金分割,展现其功能性与美观性。

对于斐波那契数列大家应该都很熟悉,也都写过递归实现的斐波那契数列。

斐波那契数列又称兔子数列:

  • 第一个月初有一对兔子
  • 第二个月之后(第三个月初),它们可以生育
  • 每月每对可生育的兔子会诞生下一对新兔子
  • 兔子永不死去

转化成数学公式即:

  • f(n) = f(n-1) + f(n-2) (n>1)
  • f(0) = 0
  • f(1) = 1

当n趋向于无穷大时,前一项与后一项的比值越来越逼近黄金比

最后总结下来看,ThreadLocal 中使用了斐波那契散列法,来保证哈希表的离散度。而它选用的乘数值即是2^32 * 黄金分割比

0

理清 Promise 的状态及使用

为什么会有 promise?

因为要解决回调函数的嵌套,也就是所谓的回调地狱,回调地狱长啥样大家应该有点数吧?

doSomethingA((res) =>{
  if (res.data) {
    doSomethingB(res.data, (resB) => {
      if (resB.data) {
        doSomethingC(resB.data)
      }
    })
  }
})

这样的代码不太美观,还得依靠缩进来分清层级。那解决这种回调地狱的方式有很多,最简单方式是定义好一堆具名函数直接调用。那进阶一点方式便是 promise。

promise 是什么?

通过 Promise 构造函数可以创建 promise 对象,promise 是一种通过链式调用的方式来解决回调函数传递的异步方案。
promise 对象具有状态,pending、fulfilled、rejected。状态改变之后就不会再变化。

promise 实例

通过 new 关键字初始化得到新的 promsie 对象。

const promise = new Promise(function(resolve, reject) {
    // ... do something
    if (/* 异步操作成功 */){
        resolve(value);
    } else {
        reject(error);
    }
})

promise 对象创建便会立即执行,但 promise 会保存状态。

基本用法

Promise 定义了一些原型方法来进行状态处理。最常用的有:
- Promise.prototype.then

const pm = new Promise(function(resolve) {
    setTimeout(function() {
        resolve(100)
    }, 2000)
})

pormise 对象通过内部 resolve 函数完成状态的 pending -> fulfilled。此后这个promise 将保留这个状态。可以通过 then 方法去处理状态。

pm.then((val) => {
    console.log(val); // 100
})

then 方法也可以用来处理 rejected 状态,then 方法可以接收 2 个函数参数。

new Promise(function( resolve, reject ) {
    setTimeout(() => {
        reject(new Error('err'))
    }, 2000)
}).then(null, (error) => {
    console.log(error); // error: err 
})

但用 then 的第二个参数去处理错误不是最好的选择。因为大多数情况下我们会用到链式调用。类似:promise.then().then().then()。所以在每个 then 方法去处理错误显得代码很多余,而且也真的没必要。

  • Promise.prototype.catch

catch 方法就是用来做错误统一处理。这样链式调用中我们只需要用 then 来处理 fulfilled 状态,在链的末尾加上 catch 来统一处理错误。

new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve(100);
    }, 2000)
}).then(result => {
    return result * num // 这里模拟一个错误,num 未定义
}).then(result => {
    return result / 2;
}).catch(err => {
    console.log(err); // num is not defined
})

这里举得例子比较简单,在 then 方法里面没有去 return 新的 promise。可以看到第一个 then 发生了错误,最后的 catch 会捕捉这个错误。catch 实际上是.then(null, rejection)的别名。

  • Promise.prototype.finally()

这个 api 一般用来放在结尾,因为它不管前面的 promise 变为什么,它都会执行里面的回调函数。

new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve(100);
    }, 2000)
}).then(result => {
    return result * num // 这里模拟一个错误,num 未定义
}).catch(err => {
    console.log(err); // num is not defined
}).finally(() => {
   console.log('complete'); // complete 
})

这便是一个完整的状态处理流程,then() 处理 fulfilled、catch() 处理 rejected、finally 两种都处理。但 finally 目前还只是提案阶段。

  • Promise.all

这个 api 在开发中也比较实用,如果 C 行为依赖于 A、B 行为,A、B 之间又没有依赖关系,而且只有当 A、B 的状态都为 fulfilled,或者有一个变为 rejected 时才会开始 C 行为。这时用 Promise.all() 就显得比较合适。

Promise.all([A, B]).then((resA, resB) => {
    ... // do something
}).catch(() => {
    ... // do something
})

如果不用 Promise.all 也能做到 A、B 完成之后再调用 C,一般会这么写:

promsiseA.then(() => {
  return promsiseB;
}).then(() => {
    ... // do something
}).catch(() => {
    // do something
})

这样就好比强行将 A、B 置为串行,所以对比 Promise.all() 显得效率不高。

  • Promise.race

用法和 Promise.all 一样,C 依赖于 A、B,但只要 A,B 的任一状态改变了,C 便开始执行。

Promise.race([A, B]).then((res) => {
    ... // do something
}).catch(() => {
    ... // do something
})

链式调用

之所以能在 promise 的使用中进行链式调用,是因为 then、catch、finally 方法都会返回新的 promise 对象。链式调用在 js 中也很常见。

[1, 2, 3].map(num => num*2).join('-')  // "2-4-6"

$('#box').find('.info').text('hello world') // jquery 链式

其实链式调用原理都一样,让方法始终返回新的对象就好了。
promise 中的 then、catch、finally 方法也是如此,总是返回新的 promise。这里都能理解,但有趣的是 promise 具有了状态。这让链式变得稍微复杂了些。

  • 状态变化

then、catch、finally 返回 promise 但这些 promise 的状态由谁决定呢?

答案是如果处理了状态那新得到的 promise 的状态由处理函数的具体内容决定,如果没处理状态那得到 promise 的状态直接继承前面 promise 的状态。

假设 promiseA 的状态为 resolved

const promiseB = promiseA.then(() => {
    return 1
}) // resolved 1

const promiseB = promiseA.then(() => {
    const num = 100 
}) // resolved undefined

const promiseB = promiseA.then(() => {
    throw 1
}) // rejected 1

const promiseB = promiseA.then(() => {
    return promiseC
}) // 状态由 promiseC 决定

假设 promiseA 的状态为 rejected

const promiseB = promiseA.then(() => {
    // do anything 
}) // 状态沿用 promiseA: rejected
  • 错误处理
const pm = new Promise((resolve, reject) => {
    reject('err') // 直接将 pm 对象的状态变为 rejected
})

var pm1 = pm.then(result => {
  console.log('hello, then 1');  // 不会执行
  return 100; // 不会执行
});

这里由于pm 的状态是 rejected, 所以 .then 继续将 rejected 状态向下传递,这样我们就能通过末尾的 catch 操作处理异常。

pm
.then(result => {
    return result * 2
}) // rejected
.then(result => {
    return result * 4
}) // rejected
.catch(err => {
    console.log(err)
})

配合 async await 使用

如果能比较熟练的使用 promsie 再过渡到 async、await 也没什么问题。

  • 怎么使用
async function f() {
    await 100;
}

f().then((num) => {
    console.log(num); // 100
})

简单的例子似乎看不出 async await 到底是干什么的,又有什么用。但可以知道 async 返回的是 promise, await 相当于改变状态的操作。

  • 对比 promise 优势在哪?

aysnc 、await 最大的好处是简化 promise 的 .then 操作,让代码更加同步。

pmA.then((dataA) => {
    ...
    return dataB
}).then((dataB) => {
    ...
    return dataC
}).then((dataC) => {
    ...
    console.log('end')
})

换做是 async、await 就可以写成

async f() {
    const dataA = await pmA;
    const dataB = await fb(dataA);
    const dataC = await fc(dataB);
    ...
}

所以不管 await 后面去接什么样的异步操作,整体上还是会保持同步的方式,这样看起来结构清晰。

总结

从 promsie 到 async、await,JS 提供的这些新的特性,都让 JS 的使用体验变得越来越灵活方便。从 ES6 的出现到现在的 ES7、ES8,也都能感受到 JS 在拥有更大的能力去做好更多的事情。关于 promise 的使用还是要多用多体会,不然用 promise 写出回调地狱一样的代码也不是没有可能。最后还是贴出经典的 promise 问答题!据说能回答正确这些写法区别的 promise 掌握的肯定没问题。

// 1
doSomething().then(function () {
  return doSomethingElse()
}).then(/* ... */)

// 2
doSomething().then(function () {
  doSomethingElse()
}).then(/* ... */)

// 3
doSomething().then(doSomethingElse()).then(/* ... */)

// 4
doSomething().then(doSomethingElse).then(/* ... */)
0

逻辑思维:理清思路,表达自己的技巧

为什么要讲逻辑思维

逻辑思维一直是职场的重要技能之一。当遇到某个问题时,你可以运用逻辑思维去梳理问题、分析问题,从而找到问题的本质与解决方案;当需要向他人陈述结论时,你可以运用逻辑思维去梳理即将要表达的内容,划分清晰沟通中的结论、背景、论点,从而将你的结论条理清晰的传达给对方。

几乎可以这么说,一个人的逻辑思维越强,他解决问题的能力就越强,沟通表达就越清晰。

接下来,文章将分别从逻辑思维如何运用在思考过程中、如何运用在沟通表达中去展开介绍。这两种情况的核心区别在于是已有结论对外陈述,还是未有结论对内分析挖掘。因为现在大多数逻辑思维的讲解都是更注重教给我们如何对外陈述,所以在这里我们也从沟通表达的场景开始介绍。

表达框架:金字塔图组织语言,快速表达

在问题被梳理清晰的前提下,运用逻辑思维去沟通表达,一般是遵循 论点/背景--->结论--->理由--->行动 的整体框架。接下来逐个介绍各个环节的关键点:

1. 论点/背景

论点,一般指接下来谈话的中心内容。论点阐述时经常包含背景介绍,它们往往不可分割。

  • 阐述论点,应尽量从对方了解的信息开始阐述;
  • 我们可以使用 5W2H 法来细化论点:When、Where、Who、What、Why、How、How much,通过 5W2H 方法将来各事件元素(时间、地点、人物、事件、原因、如何进展、进展如何等)梳理清楚。

举一个简单例子,你想找 Boss 聊聊员工加班的事情,你不能说 “Boss,关于加班我想找你聊一聊“ 。这样显然没有将事件元素陈述清晰,Boss 会无法判断你谈话的内容,他只能去猜测你将要表达内容是关于加班的哪个方面。正确的论点阐述应该是 “最近,年轻员工加班时间增加过多了,我们是不是应该做一些调整?”。这样论点就描述清楚了。

2. 结论

结论,指在接下来谈话中你最想陈述的内容,即便删除所有其他内容你也想保留的内容。结论的陈述需要注意以下三点:

  • 结论是解决问题,而不是阐述事实;
  • 结论和论点不能偏离,也就是说结论与论点间的联系要科学易被听众接受:尽可能做到是/否问题回答是/否;原因问题回答原因;怎么做问题回答怎么做。比如,Boss 问你销售为什么不输入商品的预估数据?你却回答,输入的方式应该设计的简单一些,就是有问题的。Boss 问的是原因,你回答的解决方案,跳跃太大,造成 Boss 难以理解。
  • 遵循金字塔原则,大多数时候结论先行,否则倾听者容易产生疲倦。当希望给倾听者以准备时间或者倾听者自行得出结论的时候,我们才将结论放后面。

3. 理由

理由的陈述关键点在于做到符合 MECE 分析法,筛选“合格”理由作为结论的支撑,避免将一个相似的理由分成3个理由来说。那么如何做到符合MECE原则?如何筛选“正确合格”理由?接下来我们详细介绍下:

MECE分析法(Mutually Exclusive Collectively Exhaustive),理由间相互独立,完全穷尽。

通俗地说,就是在没有重复没有遗漏的状态下进行思考或表达。当然,真实的情况往往是我们难以做到真正的穷尽,所以只是力求不遗漏,覆盖 8~9成 即可。

MECE 的分析方法,主要有以下三种:

  • 日常分类模式:投入 vs 产出、生产 vs 消费、人力 vs 物力 vs 财力、过去 vs 现在 vs 未来...
  • 通过公式推导思维解决遗漏:销售额 = 用户数 * 转化率 * 客单价,因子一目了然;
  • 套用经典商业/业务模型:
    • 战略模型:3C-Customer/Competior/Company、SWOT-Strength/Weakness/Opportunity/Threat
    • 营销模型:4Ps-Product/Price/Place/Promotion、4Cs-Customer/Cost/Communication/Convenient
    • 生产管理模型:QCD-Quantity/Cost/Delivery

常见分析方法为我们提供了 MECE 分析的切入点,那么我们要如何选择 MECE 的理由?一个是参考下面将介绍的筛选合适理由的原则,另一个是注意从反向思考去检查MECE分析所得理由的缺点。

选择“正确合格”的理由作为支撑,遵循以下几个原则:

  • 从对方角度设想,选择能让对方信服的理由,所以面对不同的听众,要有不同的侧重;
  • 对于认同感不强的理由,采用“理由的理由”去支持它。“理由的理由”可以是:数据证明、一般常识/规律、事例的累积、已决断的策略、公司规定/制度等;
  • 有大量事实,但没有结论的场景,我们可以先陈列大量理由,从理由中推导出结论;
  • 已经有结论,但理由支撑没有思路的时候,我们同样可以先陈列大量理由,再整理分类理由。

此外,在理由的陈述中还需注意理由与结论的关联。因为理由是结论的支撑,理由推导得到结论的逻辑被听众理解是你的结论被理解的重要前提。下面介绍两种主要的理由推导结论的逻辑:

  • 归纳并举型:列举理由,通过理由的共通点推测结论,这种推理方式的缺点是显得主观,所以要格外注意考虑例外的情况。
  • 演绎推理型:三段式,"大前提 + 小前提" -> 结论。如果大前提、小前提是错误的,那么演绎推理的结论也是无意义的,所以要注意确保大、小前提是正确的。

4. 行动

有结论还应该有行动,行动应该是明确自身和他人的具体任务,并加以Deadline限制。即人、事、时间都是划分清楚的,在此同样可以采用5W2H来确认行动计划是否清晰。

思考框架:梳理问题,逻辑推导解决问题

在还没有将梳理清晰问题时,我们运用逻辑思维去梳理问题,它一般遵循 Problem-->Why-->How 的框架,接下来分开阐述各环节(因为思考过程中运用的逻辑思维方法与沟通表达所运用的方法类似,所以这里不会再陈述的非常详细):

1. 明确问题

明确问题主要有两个方法:

  • 设定想要的状态,即设定目标或设定参照物。当问题很明确,那么设定“理所当然”的目标即可,如:公司连续两年赤字,那么设定目标为公司盈利即可;当问题不明确,那么需要设定理想目标,如:公司连续10年保持全国第四,那么设定目标可考虑5年内成为全国第一。
  • 把问题具体化到能够思考原因的大小,列举具体事例,从事例中归纳问题。如,年轻员工没有朝气,那么我们可以通过列举具体事例:打招呼有气无力、写资料错误率高、辞职率高等,通过这样的细化,我们就知道具体的问题是什么了。

2. 寻找原因

寻找原因的关键在于,不停地询问为什么,逐步深入挖掘。如何有逻辑地深入挖掘原因?这里仍旧建议 MECE 分析法,与表达框架中介绍的理由陈述相似,毕竟在表达中陈述的理由就是在思考中挖掘得到的原因。

挖掘原因的另外一个重要命题是,我们需要深挖到什么程度?各种行业书籍给出的答案是,追问5次,直到能找到具体的解决方案。对于没有可能性的部分中途就可以停止深挖,不必要刻意追问5次,只对有真正可能性的原因进行探究。

3. 检讨解决方案

深挖直到能想象具体怎么做为止,在探讨解决方案的过程中,先不要过多考虑方案的可行性。这里,对日常生活中常见的两类人给出总结与建议:

  • 对于不思考就行动的人,建议多思考在行动,采用根本原因分析、从零开始思考等方法;
  • 对于思考过多而不行动的人,建议一边行动一边思考,采用假设思考法。

总结

以上,为关于逻辑思维整理思路,表达自身技巧的总结与介绍。但仍有一点是需被注意的:逻辑思维虽然有用,但也并非所有的场合都可以用逻辑思维去解决问题。比如,女朋友大发雷霆和你大吵特吵,你如果试图用逻辑思维去解决,那估计是要凉凉了(手动微笑脸)。。。大多数人建议在需要寻找问题总结结论或者进行结论表达的时候才使用逻辑思维!

0

Actor 模型及 Akka 简介

前提

随着业务的发展,现代分布式系统对于垂直扩展、水平扩展、容错性的要求越来越高。常见的一些编程模式已经不能很好的解决这些问题。

解决并发问题核心是并发线程中的数据通讯问题,一般有两种策略:

  1. 共享数据
  2. 消息传递

共享数据

基于 JVM 内存模型的设计,需要通过加锁等同步机制保证共享数据的一致性。但其实使用锁对于高并发系统并不是一个很好的解决方案:

  1. 运行低效,代价昂贵,非常限制并发。
  2. 调用线程会被阻塞,以致于它不能去做其他有意义的任务。
  3. 很难实现,比较容易出现死锁等各种问题。

消息传递

与共享数据方式相比,消息传递机制的最大优点就是不会产生竞争。实现消息传递的两种常见形式:

  1. 基于 Channel 的消息传递
  2. 基于 Actor 模型的消息传递

常见的 RabbitMQ 等消息队列,都可以认为是基于 Channel 的消息传递模式,而本文主要会介绍 Actor 模型相关内容。

Actor 模型

Actor 的基础就是消息传递,一个 Actor 可以认为是一个基本的计算单元,它能接收消息并基于其执行运算,它也可以发送消息给其他 Actor。Actors 之间相互隔离,它们之间并不共享内存。

Actor 本身封装了状态和行为,在进行并发编程时,Actor 只需要关注消息和它本身。而消息是一个不可变对象,所以 Actor 不需要去关注锁和内存原子性等一系列多线程常见的问题。

所以 Actor 是由状态(State)、行为(Behavior)和邮箱(MailBox,可以认为是一个消息队列)三部分组成:

  1. 状态:Actor 中的状态指 Actor 对象的变量信息,状态由 Actor 自己管理,避免了并发环境下的锁和内存原子性等问题。
  2. 行为:Actor 中的计算逻辑,通过 Actor 接收到的消息来改变 Actor 的状态。
  3. 邮箱:邮箱是 Actor 和 Actor 之间的通信桥梁,邮箱内部通过 FIFO(先入先出)消息队列来存储发送方 Actor 消息,接受方 Actor 从邮箱队列中获取消息。

模型概念

Actor

可以看出按消息的流向,可以将 Actor 分为发送方和接收方,一个 Actor 既可以是发送方也可以是接受方。

另外我们可以了解到 Actor 是串行处理消息的,另外 Actor 中消息不可变。

Actor 模型特点

  1. 对并发模型进行了更高的抽象。
  2. 使用了异步、非阻塞、高性能的事件驱动编程模型。
  3. 轻量级事件处理(1 GB 内存可容纳百万级别 Actor)。

简单了解了 Actor 模型,我们来看一个基于其实现的框架。

Akka Actor

Akka 是一个构建在 JVM 上,基于 Actor 模型的的并发框架,为构建伸缩性强,有弹性的响应式并发应用提高更好的平台。

ActorSystem

ActorSystem 可以看做是 Actor 的系统工厂或管理者。主要有以下功能:

  • 管理调度服务
  • 配置相关参数
  • 日志功能

Actor 层次结构

Akka 官网展示的 Actor 层次结构示意图

Akka 有在系统中初始化三个 Actor:

  1. / 所谓的根监护人。这是系统中所有 Actor 的父亲,当系统被终止时,它也是最后一个被停止的。
  2. /user 这是所有用户创建的 Actor 的父亲。不要被 user 这个名字所迷惑,他与最终用户没有关系,也和用户处理无关。你使用 Akka 库所创建的所有 Actor 的路径都将以/user/开头
  3. /system系统监护人

我们可以使用 system.actorOf() 来创建一个在 /user 路径下的 Actor。尽管它只是在用户创建的层次的最高级 Actor,但是我们把它称作顶级 Actor。

Akka 里的 Actor 总是属于其父母。可以通过调用 context.actorOf() 创建一个 Actor。这种方式向现有的 Actor 树内加入了一个新的 Actor,这个 Actor 的创建者就成为了这个 Actor 的父 Actor。

Actor 生命周期

Akka Actor 生命周期示意图
生命周期

Actor 在被创建后存在,并且在用户请求关闭时消失。当 Actor 被关闭后,其所有的子Actor 都将被依次地关闭.

AKKA 为 Actor 生命周期的每个阶段都提供了钩子(hook)方法,我们可以通过重写这些方法来管理 Actor 的生命周期。

Actor 被定义为 trait,可以认为就是一个接口,其中一个典型的方法对是 preStart()postStop(),顾名思义,两个方法分别在启动和停止时被调用。

ActorRef

在使用 system.actorOf() 创建 Actor 时,其实返回的是一个 ActorRef 对象。

ActorRef 可以看做是 Actor 的引用,是一个 Actor 的不可变,可序列化的句柄(handle),它可能不在本地或同一个 ActorSystem 中,它是实现网络空间位置透明性的关键设计。

ActorRef 最重要功能是支持向它所代表的 Actor 发送消息:

ref ! message

Dispatcher 和 MailBox

ActorRef 将消息处理能力委派给 Dispatcher,实际上,当我们创建 ActorSystem 和 ActorRef 时,Dispatcher 和 MailBox 就已经被创建了。

Dispatcher 从 ActorRef 中获取消息并传递给 MailBox,Dispatcher 封装了一个线程池,之后在线程池中执行 MailBox。

因为 MailBox 实现了 Runnable 接口,所以可以通过 Java 的线程池调用。

流程

通过了解上面的一些概念,我们可以 Akka Actor 的处理流程归纳如下:

  1. 创建 ActorSystem
  2. 通过 ActorSystem 创建 ActorRef,并将消息发送到 ActorRef
  3. ActorRef 将消息传递到 Dispatcher中
  4. Dispatcher 依次的将消息发送到 Actor 邮箱中
  5. Dispatcher 将邮箱推送至一个线程中
  6. 邮箱取出一条消息并委派给 Actor 的 receive 方法

简略流程图如下:
Akka Actor 流程图

EventBus

接下来我们看一个 Actor 的应用:EventBus。在异步处理场景下,运用最为广泛的消息处理模式即是 Pub-Sub 模式。基于 Pub-Sub 模式,还可以根据不同的场景衍生出特殊的模式,例如针对一个 Publisher 和多个 Subscriber,演化为 Broadcast 模式和 Message Router 模式。

EventBus 则通过引入总线来彻底解除 Publisher 与 Subscriber 之间的耦合,类似设计模式中的 Mediator 模式。总线就是 Mediator,用以协调 Publisher 与 Subscriber 之间的关系。对于 Publisher 而言,只需要把消息发布给 EventBus 即可;对于 Subscriber 而言,只需要在 EventBus 注册需要处理的事件并实现处理流程即可。

在没有使用 EventBus 的时候,Publisher 必须显式的调用 Subscriber 的方法。例如订单支付成功后,必须在订单处理模块调用积分模块处理积分,调用服务号模块进行通知。而且这样的显示调用会越来越多,每次都要去修改订单模块加一个调用。这样订单处理模块和那些模块就都紧密耦合在一起了。我们看看 EventBus 怎么解决这个问题。

EventBus 定义

要使用 Akka EventBus, 首先要实现一个 EventBus 接口。

trait EventBus {
  type Event
  type Classifier
  type Subscriber

  //#event-bus-api
  def subscribe(subscriber: Subscriber, to: Classifier): Boolean

  def unsubscribe(subscriber: Subscriber, from: Classifier): Boolean

  def unsubscribe(subscriber: Subscriber): Unit

  def publish(event: Event): Unit
  //#event-bus-api
}

如上所示:

  1. Event 就是需要发布到总线上的事件
  2. Classifier 分类器用于对订阅者进行绑定和筛选
  3. Subscriber 注册到总线上的订阅者。

所幸的是,我们不需要要从头实现 EventBus 接口,Akka 提供了一个 LookupClassification 帮助我们实现 Pub-Sub 模式,我们要做的最主要就是实现 publish 方法。

class XrEventBus extends EventBus with LookupClassification {
  type Event = XrEvent
  type Classifier = XrEventType
  type Subscriber = ActorRef

  override protected def publish(event: Event, subscriber: Subscriber): Unit = {
    subscriber ! event
  }
  // 其他方法...
}

可以看到:

  1. Event 的类型是我们自己定义的 XrEvent。
  2. 分类起是基于 XrEventType,也就是事件类型的。我们系统中定义了很多时间类型,例如 XrEventType.ORDER_PAID 是订单支付事件,XrEventType.DOC_REGISTERED 是用户注册事件。
  3. Subscriber 其实就是一个 Actor。
  4. Publisher 只是简单的将 Event 作为一个消息发布给所有 Subscriber。

事件发布和订阅

Subscriber 这边则需要实现对事件的处理。

class ScoreEventHandler extends Actor with Logging {
  override def receive = {
    // 订单支付成功
    case XrEvent(XrEventType.ORDER_PAID, order: OrderResponse) =>
      // 处理订单支付成功事件

    // 处理其他事件
  }
}

然后我们通过调用 EventBus.subscribe 进行事件订阅。

  val eventBus = new XrEventBus

  // 积分事件处理模块
  val scoreEventHandler = XingrenSingletons.akkaSystem.actorOf(
    Props[ScoreEventHandler], name = "scoreEventHandler"))
  eventBus.subscribe(scoreEventHandler, XrEventType.ORDER_PAID)
  // 订阅其他事件..

  // 微信服务号事件处理模块
  val weixinXrEventHandler = XingrenSingletons.akkaSystem.actorOf(
    Props[WeixinXrMessageActor], name = "weixinXrEventHandler"))
  eventBus.subscribe(weixinXrEventHandler, XrEventType.ORDER_PAID)
  // 订阅其他事件..

最后,我们的订单处理模块只需要调用 EventBus.publish 发布订单支付事件就好了。至于那些需要处理该事件的模块,自然会去订阅这个事件。上面 XrEventBus 的实现里可以看到,发布其实就是用 Actor 的消息发送机制,将消息发布给了所有的 Subscriber。

XrEventBus.publish(XrEventType.ORDER_PAID, new OrderResponse(order, product))

至此,我们的订单处理模块和积分处理模块、微信服务号模块就安全解耦了,很漂亮不是吗?

总结

当然 Actor 还有其他很多应用场景。例如并发流式处理,甚至我们系统中的定时任务,也是通过 Actor 实现的。

总之,Actor 为我们提供了更高层次的并发抽象模型,让我们不必关心底层的实现,只需着重实现业务逻辑。对于一些并发的场景,是很值得尝试的一种方案。

0