Rome是一个比较有历史的RSS解析工具,主要提供了以下功能:
看到这里,不自觉会产生几个问题:
该项目依赖第三方较少,git拉下来代码后,单测直接可以跑,很方便调试,这里提供一个testcase
1 | public class TestReadWriter extends FeedTest { |
该项目的核心流程:读取RSS源数据 → 解析成标准格式 → 按照指定格式输出。因此按照上述的testcase,跟踪源代码绘制即可,我的绘制结果如下:
该项目包划分不是很细致,主要有以下几个包:
综合复杂度以及依赖关系:uitl → feed → io → opml → module,因此源码阅读顺序就按照这个来。
该包都是一些简单工具类方法,看了一遍后,略过。
该包主要是定义实体类模型,比如针对RSS定义了Channel类,针对ATOM定了Feed类,以及公共抽象SyndFeed也都在这个包下面。
impl包
该包是利用反射能力,包装了一套bean clone能力,其核心类有:
该包这些功能实际上可以单独给封装到另外的包,在feed下面我认为最主要的原因是这些功能都是给feed实体准备的,因为要将不同的格式统一到同一种结构上,那么势必会带来很多属性值copy。
atom & rss包
module包
module在这里使用的并不多,更多的则是接口定义,至于有什么用处,暂时还看不出来,因此这部分遗留到module再处理。这里大致可以看出来,module是根据namespace做出来的扩展,对于一个namespace可以指定module解析,并将结果放入到Feed实例中。
synd包
copyInto
:将WireFeed转换为SyndFeedcreateRealFeed
:将SyndFeed转换为WireFeed这里第一次碰到了 PluginManager
这个类,这个类类似于策略模式的策略管理器,Converters实现了这个类,从而有了多种Convert的管理能力,因此可以抉择最终使用哪一个Convert。PluginManager的解析,放到接下来的IO包中在看。
IO包
该包主要定义解析规则,即XML怎么到WireFeed,以及WireFeed又怎么写回RSS,同时还有WireFeed与SyndFeed之间的转换逻辑。
~ xml解析
~ PluginManager加载
PluginManager是类似与Java的serviceload机制写的一套扩展策略管理系统,在配置中存在 rome.properties 配置文件,里面会按照如下指定了涉及到的class全类名,然后PluginManager要做的就是将这些类实例化,管理起来。
至于为什么这样设计?这样设计可以做到很灵活,因为用户也可以自己指定这个配置,来扩展自己想要的解析策略。于现在的做法,我更加倾向于简单点的一个静态Config类来管理这部分的配置。
在OSGI多类加载器下,这样的模式有点问题,用户指定的以及系统自带的可能不是一个类加载器,因此提供了 ConfigurableClassLoader 这个接口,可以指定加载使用的类加载器信息。
1 | Converter.classes=com.rometools.rome.feed.synd.impl.ConverterForAtom10 \ |
~ 解析流程
解析流程还是复用上述代码,主要分析build里面做了哪些事情。
1 | Reader reader = getFeedReader(); |
SAXBuilder
,通过xml解析得到Document
实例boolean isMyType(final Document document)
方法,该方法判断一个实例当前是否支持解析,判断依据是root元素,version,namespace等信息。rss
,version为2.0
,version不存在则默认2.0
~ 输出流程
输出指的是针对SyndFeed转换为xml结构。
1 | SyndFeedOutput output = new SyndFeedOutput(); |
Opml是基于上述体系扩展出来的一个格式支持,与RSS,ATOM同等级。
module的核心是ModuleParser与ModuleGenerator两个类,两者都可以在properties中配置,然后嵌入到xml解析以及输出中,用于定制相关的能力,这里就不详细看了,因为觉得这种定制方式并不是很友好。
首先回答下最初的疑问:
Rome的模型结构以及module扩展方式,我个人觉得使用上不是很友好,定制能力也不够强,比如针对时间字段的不同格式使用不同的解析,这个在rome中只能以自定义module来实现,但消费module结果又不那么方便。至于什么样子的既能符合使用上的直觉,有具备很强的定制能力,这个还没想好怎么处理。我的大致思路是将主体保留到实体类中,比如title,author,description,其他字段都以策略枚举的方式扩展出去,这样能够解决扩展性问题,但实用性上还没想好怎么处理,大概思路是定义特定类型访问接口,让策略枚举字段主动支持上述格式的解析。
此外这种通用源码阅读方式,针对这种简单的小项目非常适合,这样的步骤可以轻易将小项目拆解,从而完整的了解到项目全貌。
]]>最早我阅读Mybatis3源码时,大概工作已经1年左右,此时经验很不成熟,也没有过多的规划,日常中使用多了就想着开始看看了。现在回想起来主要有以下两个问题:
TypeHandler
阅读时,扣的就太细,缺少全局性把控,阅读源码最重要的是学习设计,具体实现是次要。在本书中,作者在阅读前,会先做很多辅助性功能,比如搭建好调试环境,理清楚框架脉络,以及最重要的将功能拆分,作者始终认为需要事先有一个上帝视角,然后再深入细节,这样事半功倍。接下来讲下本文的阅读流程,以及中间自己的理解。
在开始一个项目的源码阅读前,首先需要对整个项目有较为全面的了解,需要了解项目的产生背景以及演进过程,使用方法,主要目地是勾勒出项目的整体轮廓,了解了项目的轮廓,便于更好的构建上帝视角。
借助对项目的了解,搭建可以调试的本地开发环境,调试是非常重要,如果无法调试就意味着无法去验证项目的处理逻辑,只靠看代码,这个过程会痛苦万份。不是必要的话,针对不可调试项目,阅读源代码的必要性需要再斟酌。
借助调试,把项目的核心流程给梳理出来,这个阶段只需要分析出项目的一个核心流程主要分成了哪些部分,之间如何配合,最好能产出每一部分的核心模块。相对复杂的逻辑,切忌不要深入,跳过就好,这个阶段重要的是形成项目的全局视角。
以Mybatis为例,核心流程如下:
这一点我认为作者的想法很好,从包结构就直接将项目的整体架构给描述的清清楚楚,能够确定哪些是核心,哪些是外围,以及之间的层次关系,前面的骨架梳理更多的目地也是产出这张模块图。以Mybatis为例,作者产出了如下的图,可以很容易看清楚里面包含了哪些模块。
这个其实变相的对所看项目提出了要求,如果所看的项目在模块上规划杂乱无章,那么自然就很难理出来这个图,留给阅读者更多痛苦,至于这种项目该不该深入研究,那就看实际所需了。
源码有很多,一上来就跟着主流程看,势必很快就晕掉,因此合理的做法是先看外围包,比如Mybatis的基础功能包。先看外围包,相当于在积累一定的项目知识储备,等到了一定程度再看核心包,难度自然会降低很多。作者用了个剥洋葱的比喻,由外及内,逐层深入。
eg:基础功能包 → 配置解析包 → 核心操作包
从外围包开始看起,可能会遇到很多设计不知道为什么,因此不了解核心逻辑。此时就需要思考和记录,等到最后看核心逻辑时,再回过来看这些,有很大可能触发恍然大悟,从而带来个人理解的质变,这样能够更好的深入理解设计原理。
主要针对一个接口,多种实现情况下,比如MyBatis的TypeHandler
,本质是映射,但存在非常多的映射实现,这些其实没必要看完,只需要看顶层或者抽1-2个实现来看即可。
有些包可能很大,包含了很多功能实现,那么最好的做法是先按照功能拆分,然后按照功能分别看各自相关代码逻辑。比如Mybatis的mapping包,主要完成以下功能:
这样拆分后,看起来会轻松很多。
]]>Command | 解释 |
---|---|
spawn | 通常用来启动给定程序进程,并开始与之交互,比如 spawn ssh user@host 启动ssh进程 |
expect | expect命令会等待程序输出,匹配规则为正则表达式,停止条件为匹配到指定输出,程序输出结束仍然未匹配,或者达到超时时间 |
expect_user | 对用户输入进行匹配,该指令会等待用户输入信息,然后按照指定模式将数据暂存到$expect_out数组变量中 |
send | 将字符串输入到当前进程,该命令是交互式核心,用于模拟用户输入信息 |
interact | 将当前进程的交互控制权转交给用户,转交给用户后,脚本不再继续执行 |
send_user | 将信息发送到stdout,用于给用户信息提示 |
set | 该指令既可以修改全局变量,比如set timeout 10 修改超时时间,也可以获取命令行参数并赋值set username [lindex $argv 0] 获取脚本参数 |
close | 关闭当前进程 |
[lindex $argv 0] | 获取脚本参数,0代表第一个参数,一般常配合set指令,这样后续脚本可以直接是用$xxx访问 |
一般一个简单的expect脚本通常是下列形式,首先指定shebang为expect程序,然后使用spawn启动交互式程序,使用expect确定启动成功,最后使用send发送要执行的命令。
1 |
|
Expect脚本更多的是通过实例学习,博主现在掌握的实例并不多,因此本文后续会将遇到的案例追加上来,以此作为样板,读者可以根据样例实现自己的自动化逻辑
本案例从How to Learn The Basics of Expect Script?中摘抄出来,作为入门案例,描述了spawn,expect,send等指令的基本用法,详细分析写到注释中。
1 |
|
这个案例是大多数程序的前提,我们假设要实现一个自动化脚本,该脚本需要用户输入 host,user,password三个变量,当用户没有输入user或者password的时候,需要主动提醒,让其输入。
该脚本的核心为 set 指令获取用户参数,以及使用 expect_user 匹配用户输入
1 |
|
该脚本相较于第一个登录案例,增加了变量,以及异常情况判断,主要用到的是expect多分支匹配。这三个案例加起来,博主觉得足以满足绝大多数情况了,况且还可以将shell和expect配合使用,在shell中调用expect脚本,以达到更加灵活的操作。
1 |
|
访问者模式的定义为:表示一个作用于某对象结构中的各元素的操作,它可以使你在不改变元素的类的前提下定义作用于这些元素的新操作。定义是比较拗口的,简单点来说,就是在面对复杂数据结构时,可以在对应结构不感知的情况下,为该结构增加一系列的功能,比如我们平常会定义Domain类,然后在Domain Service为Domain扩展一系列的方法,这其实也算是符合访问者模式的定义,Doamin类作为输入的复杂数据结构,DomainService在不改变Domain类的情况下,给Domain增加CRUD等方法。
博主举出的这种沾亲式的案例,其实也想表达本来没有设计模式,但大家把一种策略当成模板后,设计模式就自然而然的诞生了。设计模式是编程设计原则的体现,很多人使用往往都会生搬硬套,但博主认为设计模式需要了解模式背后要解决的问题是什么,了解本质目地后,那么各种约束规则便不再是设计束缚。
访问者模式就涉及两个关键的类,Element与Visitor,其中Element是复杂数据结构,Visitor是想要为Element增加的功能实现。
Visitor
上图中Visitor定义为一个访问者接口,其中含有visitor(ConcreteElement1)
,visitor(ConcreteElement2)
两个方法, 该接口本身不具有明确意义,只是提供了针对具象元素的访问通道,实现上具体什么含义,取决于ConcreteVisitor1
和ConcreteVisitor2
的实现逻辑。
Element
本身就是一个数据结构模型,可以是一个Model,也可以是多个Model组合而成的复杂结构。往往在不同的模型上有着差别的方法,并且需要很灵活的扩展。比如ConcreteElement1
可能只需要分析(analysis)功能,ConcreteElement2
则不需要分析,需要保存(save)功能。按照传统思路,要么直接在ConcreteElement1
中增加analysis方法,要么就专为ConcreteElement1
新建一个Service,这两种方法都存在扩展性不足的问题,因此visitor模式是为了对这两种方式进行改进而诞生的设计。
简单的访问者模式是我自己起的名字,简化一些不必要的扩展,看看最简单的情况下访问者模式是什么样子,然后再由这种最简单的模式扩展到下面的复杂形式。这里的简化是将Element的多态给去除,假设Element就是一个实现类,那么此时每一个Visitor就相当于一个Element内部方法的迁移,接下来看具体案例。
如下图所示,假设当前Element是Dog,狗,然后我们想要给他增加健康评估(Health)和耐力预测(Endurance)技能。
首先是定义Visitor接口以及Element实体类,并分别实现通道方法visit
和accept
。
1 | /** |
接下来是实现具体访问者的逻辑,访问者主要是获取Element的属性,然后按照自己的逻辑实现计算,变相的为Element增加对应的能力。
1 | /** |
那么定义以及实现都搞定后,想要使用什么功能,如下所示,直接初始化对应的访问者,然后用访问者调用主体类。
1 | public static void main(String[] args) { |
看完上述实现,我们可以分析下这个简单案例。如果不使用访问者模式,那么可以新建一个DogDomainService,然后在Service中实现健康评估(Health)和耐力预测(Endurance)技能,这种方式也是可以的,当功能更加复杂后,将其用访问者模式分离开功能,每个复杂功能单独实现,像是插件一样,想要扩展时,也只需要新增加一个Visitor的实现策略,也是合理的。因此访问者模式的本质目地之一我们可以简单的认为就是将原本的属于Element的功能给拆散到Visitor中,便于后续灵活扩展。当然这样的简单案例发挥不出访问模式的优势,这种扩展一般策略模式就足以了,接下来看下多主体类下的访问者模式。
与上述访问者模式不同的是,多主体模式下的Element是多继承结构,比如Animal下面分为了狗(Dog)以及猫(Cat)还有鹦鹉(Parrot)等等,每个不同的Element具有特殊的功能,比如猫(Cat)的攀爬(scramble)能力,鹦鹉(Parrot)的飞行(flight)能力,那么此时Visitor接口本身还是具有通用通道,访问者的具体实现类就根据自身需要,具有针对性的实现对应方法,一般依赖多态来分别实现区分不同的功能。
如上图所示:此时Element被分为了Animal接口,以及三个实现类:Dog,Cat,Parrot。Visitor接口中分别增加了对三类主体的访问方法:visit(Dog),visit(Cat),visit(Parrot)。其中实现类HealthVisitor,EnduranceVisitor可以同时对三者进行健康评估和耐力评估,ScrambleVisitor只针对Cat做攀爬能力分析,FlightVisitor则只针对Parrot做飞行能力分析。
案例很好理解,就是上面的变种,因此就不展示具体代码了,这里我们分析下引入多主体类后,会产生哪些问题。
问题一:Visitor中对每个类都有个visitor方法,目地是什么?
这个问题想必是很多人的疑问,Visitor接口承担的是一个通道的作用,重载方法的定义是为了方法调用的一致性,只需要visit(xxx)以及accept(xxx)。当然也可以直接定义一个visit(Animal),但这样子类由需要instance of感知具体的Element是什么,才能进行单独的逻辑,反而增加复杂性,直观性也不足够,比如想找到所有给Dog增加的方法,就比较麻烦,不像有单独接口,可以直接定位到子类,得不偿失。
问题二:Visitor子类如何方便的选择自己针对的Element?
上述方式带来的弊端是子类感知到了所有的方法,像FlightVisitor这种实现类,只针对Parrot,他就不需要感知visit(Dog/Cat),此时一般会在Visitor接口下面增加一个VisitorAdapter抽象类,来实现所有的方法,只不过实现的逻辑都是Throw UnSupportException,然后子类再选择自己想要覆盖的实现。在或者将visit(Dog),visit(Cat),visit(Parrot)分别拆分到三个Visitor接口:DogVisitor,CatVisitor,ParrotVisitor,然后实现类利用多重继承,选择实现对应的接口,也是一种合理方式。
问题三:Visitor与Element之间的耦合关系是什么样子?
耦合关系决定了使用形式,从上述关系图来看,Element是不感知Visitor的,也就是具体有哪些Visitor,Element毫不关心,但反过来Visitor是强感知Element的,Visitor需要知道自己针对的是哪个Element,不针对哪些Element。这样的关系决定了我们在使用访问者模式时,是需要知道当下业务到底想要什么样子的Visitor。比如当前就需要对鹦鹉(Parrot)做飞行评估,那么就需要主动实例化出来FlightVisitor。
这个是在实际开发过程中用到的最多的一种情况了,因此基于上面两个案例的铺垫,这里会直接使用实际案例来进行分析。
在SQL解析中,一般会经过词法分析,语法/语义分析(生成AST语法树),各种业务自定义逻辑(比如分库分表表名替换)这几个步骤,参考美团文章中的图,针对如下SQL会生成对应AST语法树:
1 | select username, ismale from userinfo where age > 20 and level > 5 and 1 = 1 |
这种树形结构,在应用中一般以组合模式形式构建,以Druid为例,解析后结构如下图所示,应用对外展示的则是最顶层的SQLStatement
,其本质是SQLSelectStatement
。
1 | String sql = "select username, ismale from userinfo where age > 20 and level > 5 and 1 = 1"; |
运用组合模式提供的嵌套能力,可以很轻松的将这个AST语法树给构建出来,但问题是怎么方便的访问?比如从上述语句中提取出来表名,就需要从顶层Select节点遍历到From节点,然后获取表名,如果再嵌套子查询,那么情况更加复杂。因此实际情况下,更多时候使用Visitor模式做组合对象的功能扩展,接下来我们使用Druid提供的Visitor接口,实现一个表名提取器。
1 | // 定义一个表名提取的visitor |
该Visitor实现了SQLASTVisitor
接口,这个是Druid预留的扩展,里面针对每一个组合中的实体类Element提供了Visitor通道,比如这里访问表名,只需要实现visit(SQLExprTableSource x)
来访问表来源相关的语法节点即可。接下来使用该Visitor遍历语法树:
1 | public static void main(String[] args) { |
遍历的过程只需要调用SQLStatement.accept(visitor),该节点会自动顺着语法树的顶层,一直遍历,直到每一个叶子节点。
到这里,针对Visitor模式的本质基本上差不多了,Visitor模式的复杂性来源博主认为主要有两点:1)主体类Element本身是多重继承结构,或者是组合模式这种复合型结构,不符合人的直观思维,增加理解难度。2)Visitor的实现类是分散开的,且都是一个个独立的功能,不能很直观的展示一个对象究竟有哪些能力,也增加理解成本。
大多数时候,使用Visitor模式扩展必要性是不大的,策略模式就能满足了。但在最后一个案例中,如果没有Visitor模式,笔者还真的想象不到有什么好的方式能够解决组合模式的扩展性问题,这大概也是在实际开发中看到的Visitor模式都是和组合模式一起出现的原因。
${user.home}/.m2/repository
http://repo1.maven.org/maven2
这里很核心的一点:中央仓库(id=central)是一个特有的概念和定位,他是Maven资源的首要来源,central的配置在超级pom中,因此其等级是优先于聚合仓库低于本地仓库。
整个查找流程如下图所示:
从上面流程来看,Maven的配置逻辑本身很简单,但在一些公司中,Maven配置的复杂性主要来源于仓库众多,以蚂蚁为例,官方的仓库就有7个左右,新同学接手时,就很容易出现错误,那么怎么配置呢?
1. 选定中央仓库代理
中央仓库自然优先级最高,默认的http://repo1.maven.org/maven2
由于网络原因,拉取常常出现中断,因此中央仓库一般使用mirrors方式定向到国内镜像,而不是复写repository配置,比如下方我使用的阿里云仓库。
这里需要注意下<mirrorOf>
,国内很多加速库会推荐设置为*,代表代理所有仓库,这种当然是不负责的推荐配置,阿里云的public库只是central以及jcenter的聚合,并不能代替spring,gradle,jetbrain等仓库。
1 | <mirror> |
2. 使用Profile划分其他仓库
profile用于圈定一批生效仓库,比如下方我定义了一个rdc profile作为默认生效的配置,其中的repositories分别配置了私有的releases&snapshot库,如果有多个release或者snapshot,那么只需要在该配置中增加即可。如果独立环境的仓库,那么可以新建一个profile圈选,在IDE中做快捷切换。
1 | <profile> |
SSD (Solid State Drive):即固态硬盘,是一种以半导体(NAND Flash)为介质的存储设备。从物理结构上来看,有以下几个组件:
因此可以大致想象整个工作方式如下图所示:主机通过接口将数据提交到SSD,SSD主控首先决定数据到底存储在闪存的什么位置,然后再将数据写入到具体的闪存位置中。
SSD使用的闪存芯片其内部由许许多多存储单元构成,每一个存储单元是浮栅晶体管结构,对于该结构我们只需要知道其可以存储电子,且断电后电子不会消失,也因此SSD为非易失性存储器。从数据的角度,所谓的写数据,是控制电子数量来标识状态,读数据则是加电压,获取对应的状态。随着SSD不断的发展,一个存储单元能够标识的bit位数据在不断增加,将SSD颗粒分为SLC,MLC,TLC,QLC,分别对应1bit,2bit,3bit,4bit,其速度以及寿命随着bit位数增加而减少,但容量不断增大,当然成本也在逐渐降低,时至今日,SSD已经很接近与HDD的价格了。
那么如何理解这发展现象?举个例子,当一个存储单元只需要表示1bit位数时,可以很简单的认为电子数大于0,标识1,小于等于0标识0,那么无论是加电压判断还是清空电子逻辑都方便很多。当需要表示2bit时,可以认为当电子数>=3时,为11,3>电子数>=2时标识10,2>电子数>=1时标识01,电子数为0时标识0,那么要准确判断数据到底是什么自然就麻烦了很多,因此随着bit表示增加,实际情况只会更加复杂,因此性能是逐渐下降的。
但随之带来的好处是成本的下降,在1bit存储单元时代,大容量的SSD只能依赖工艺的提升,在一个闪存芯片中塞入更多的存储单元,但带来的容量却很有限,然而牺牲部分速度前提下,一个存储单元标识2bit,相同面积下容量直接翻倍,这是SSD能够普及消费者市场的重要推力。
大容量的SSD,一般由多个NAND Flash所组成,这里单独看一个NAND Flash。
最外层被称为DIE/LUN,该部分是接收与执行闪存命令的基本单元,一个LUN又由多个Plane所构成,Plane是真正执行数据读写单元,因此具备独立的Cache Register以及Page Register,一个Plane由多个Block所构成,Block是数据擦除的基本单位,由于擦除需要较高电压,因此在Block层面是比较妥协的做法,Block由多个Page构成,一个Page一般4KB,Page是最基本的数据读写单位。这里直接借用书中的图,能够很清晰的展示上述关系。
先不考虑操作系统的影响,当主控接收到读写命令后,会将其下发到对应的LUN,LUN再下发到Plane上。正常情况下读写流程只需要Cache Register参与即可。举个例子,写入时,数据先写到Cache Register,然后再由Cache Register写入到闪存介质中,读取时,数据先读取到Cache Register,然后再传输到主控,这里的读写单位都是Page。
那么Page Register有什么用?闪存除了上述正常读写流程外,还支持Cache读写,在Cache读情况下,当主控读取Cache Register时,Page Register可以开始读取下一份数据,也就是预读,从而减少等待时间。同理,Cache写情况下当一份数据正在写入时,主控可以将另一份数据传输到Cache Register中。这里博主是类比计算机设计,一个SSD看作是小型计算机系统,主控作为CPU属于高速设备,存储介质自然是低速设备,两者之间速度不匹配带来了巨大的CPU性能损耗,因此Cache Register以及Page Register承担的是寄存器的作用,尽可能降低这种速度不匹配带来的影响。
上述读写流程中,并没有出现Block这一结构,那么为什么需要Block?
在SSD中,由于NAND Flash的特性,每次写入都是以Page维度,并且只能写入到空闲的Page,无法覆写原本有内容的Page,因此产生了擦除。由于擦除本身需要高电压,让电子转移,也因此Block的存在是为了应对数据擦除,对于SSD来说数据的擦除是在对应存储单元上加高电压,经过足够长的时间后,电子会丢失,整个存储单元回到初始状态。
可以认为Block是SSD的基本管理单位,由于闪存块的寿命限制,整个Block的管理算法并没有想象中那么简单,常见的有以下几个问题:
上述问题的解决全靠FTL (Flash Translation Layer),FTL最基础的功能是将主机的逻辑地址空间翻译成闪存的物理地址空间,除此之外好的FTL还会实现上述的垃圾回收,磨损平衡,坏块管理,数据保持等问题。
操作系统访问SSD时,是通过LBA(Logical Block Address)进行寻址,LBA的大小由具体的文件系统所决定,一般为4KB。SSD内部维护了一个逻辑页到物理页的映射关系表,用户每写入一个逻辑页,就会产生一条新的映射关系。
SSD会在内部开辟一个单独的空间用于存储映射表,以256G大小的SSD,映射表大概256MB,SSD板载的DRAM其主要目地是为了缓存该映射表,这样,映射关系的查找就会变得非常迅速,从而提高整个SSD的访问效率。
由于闪存无法覆盖写,那么数据变更时,变更部分会直接写入到新的区域。FTL会控制一个块上垃圾页的比例,垃圾页大于一定阈值时,触发垃圾回收,主要流程是将正常页搬迁到其他块,然后对整个块进行擦除达到回收目地。如下图所示,回收到x,y两个块的可用数据迁移到z,同时擦除x,y。
这里额外提下Trim指令,Trim是SSD提供的指令,用于主机上删除文件时,主动通知SSD对应文件已经被删除,这样可以方便对SSD做垃圾回收,而不是写入时才垃圾回收。按理来说这个是本来就该有的指令,为什么单独提起呢?我想大概原因还是SSD近些年才开始大范围普及,早起的HDD是支持覆写的,不需要垃圾回收,操作系统给定指令直接写即可,而SSD需要擦除后才能写,因此多了这一步骤。现在的SSD基本都默认开启了trim,我们也无需太关注这个。
每个闪存块的寿命有限,想要保证SSD最佳寿命,即需要保证每个闪存块的擦除数保持平衡。从数据的更新频次来看,主要有冷数据以及热数据,比如操作系统的很多文件,写入后基本不再更新。而日常应用日志却更新频繁。那么FTL需要将冷的数据搬迁到写入次数比较多的闪存块,这叫静态磨损平衡。此外FTL还需要将热数据写到磨损次数比较少的块上,这叫动态磨损平衡。
数据保持主要解决两个问题,其一是读次数限制,读次数主要因为读取时是加一定电压,相当于轻微写入,次数多了自然会造成数据不准确,FTL的做法很简单,针对读大于一定次数的块,主动搬迁数据到新的块,然后擦除该块。其二是电子自然流失导致数据不准确,FTL则会每隔一段时间对块进行扫描,发现其中bit翻转大于一定阈值,则主动进行搬迁刷新数据。
整个SSD设计还有很多其他的问题需要处理,本文根据书籍内容只是做了一些简单总结,有兴趣的建议看参考中的书籍,写的很详细,值得阅读。
另外一块单独的SSD我们可以理解是一块未被开垦的荒地,想要实际存储数据还需要搭配文件系统+操作系统,这部分内容后续文章会分析总结,敬请期待。
《深入浅出SSD-固态存储核心技术》- SSDFans
]]>先说说解决的场景是什么?
入职新公司后,第一件事情要做的就是熟悉环境,熟悉各种平台,要记住非常多的地址,这个时候大部分人会选择使用收藏夹,但问题是找起来麻烦,而且遇到一个平台分成DEV,TEST,PRE,PROD四个环境时,收藏夹中会出现大量冗余,带来搜索不便,该插件就是解决这个问题场景。
该workflow把命令分为key -> values形式,如下所示,key属于大分类,匹配到key后会显示其下全部value.
目前支持yaml格式(推荐)以及json配置,这里以yaml为例介绍字段含义
1 | - key: 用于搜索指令 |
eg:yaml格式
1 | - key: ss |
eg:json格式
1 | [ |
主要功能:
支持获取粘贴板,使{clip_0}
来代替,最终渲染时会自动进行粘贴板数据替换。比如我选择了https://www.baidu.com/s?wd={clip_0}
,此时我粘贴板数据假设为 张三
,那么最终打开浏览器的地址为https://www.baidu.com/s?wd=张三
。
该插件对应命令数据支持外置的(便于云端保存,丢到同步盘中即可),因此自己指定一个路径后,以参数形式传入即可.
变量的支持依赖于alfred,可以在自己的脚本中配置多个变量,在后面使用Utils
工具替换。
https://cors-anywhere.herokuapp.com/https://github.com/login/oauth/access_token
是cors-anywhere所提供的示例服务,由于访问量大,cors-anywhere这个地址做出了响应的限制。找到了原因,稳定的解决方案就是自建,恰好自己有域名以及服务器,因此开放出来给其他人使用,希望帮助到你。CORS本质上是请求一个地址能够接受跨域,也就是header需要有Access-Control-Allow-Origin "*";
,但往往大多数地址为了安全不支持跨域,因此诞生了CORS Proxy,也就是跨域地址 + 反向代理。
针对Nginx的配置如下,大概思路是用户访问https://cors.mrdear.cn/https://github.com/login/oauth/access_token
地址时,nginx需要解析出真正的地址https://github.com/login/oauth/access_token
,然后使用proxy_pass
给代理过去。
1 | location ~* "/(.*):/(.*)" { |
自己搭建一个其实很简单,不过如果闲麻烦的话,可以使用我自建的服务,域名续费了10多年,服务器也一直有,只要不被封,会一直提供下去。
1 | https://cors.mrdear.cn/想要访问的地址 |
主要业务是发起DAG图的执行,如下图所示,用户发起一个DAG任务请求,该请求到达Planner后,异步去启动一个DAG任务,当执行到DAG时,该DAG会异步遍历整个图,然后阻塞的获取最后结果。
原有业务系统太过于复杂,因此我将相关逻辑提取了出来,简述为以下代码表示,其中关键链路如下:
1 | public class Test { |
该代码在运行一段时间后,发生了死锁,具体现象为:ThreadPool中queue在不停的累计,但所有的core thread全部处于WAITING状态。也就是说线程池中的每一个线程都在等待某一个信号,从而导致queue中的任务无法消费。
从现象来看,问题的原因是线程池中core线程执行的任务被阻塞了,一直无法完成,所以新的任务不停的往queue中累积。那为什么线程池中的core线程会被阻塞?
首先找出与线程池相关的代码,确定线程池中执行了哪些任务:
1 | // 线程池执行了Test::work这个任务 |
比较特殊的是 Test::work 这个任务的完成依赖于Test::workInnerTask,那么需要Test::workInnerTask执行完毕Test::work才能完成,然而线程池是先将Test::work放入到queue,再将Test::workInnerTask放入到queue,那么只要前者足够多到将core线程池全部占满,就会导致后者一直无法完成,前者由于等待后者也无法完成,造成死锁。
原因定位到后,解决思路就很清晰了。
第一种方式,将Test::work放入到另一个独立的线程池中执行。两边线程池互不影响,那么在一个queue上就不会产生阻塞。
第二种方式,去除Test::work中的get()阻塞,让其返回CompletableFuture,也就是异步调用就全链路执行异步,没必要中间出现同步代码。
这个简化版案例代码,可以很容易找到具体原因,但是在复杂业务系统中,调用链路错综复杂,由于线程池的复用很容易引发类似问题,如何才能避免这种问题呢?
博主想了许久,没有找到靠谱的结论,不过有两点准则在日常开发中可以参考:
1.业务系统尽量不要使用公共线程池,不同的业务使用不同的线程池隔离
2.阻塞操作想要变异步时,使用单独线程池,而不是公共线程池
如果您有更好的建议,欢迎分享。
]]>粘包与半包是应用层协议在对接TCP/IP网络协议时,对所遇到的对接问题定义的概念,所以在使用Netty编写网络通信框架时,这一概念经常被提起。那么什么是粘包和半包问题呢?
在网络编程中,客户端往服务端发送消息是以消息为单位,TCP/IP传输时,消息会被拆分为多个数据包,该层是以数据包为单位,接着三次握手之后,服务端通过accept()函数获取到该连接,然后开始读取数据,读取的是一个个数据包,此时客户端的消息相当于失真,需要服务端将这些数据包还原成对应的消息,在还原过程中就可能出现如下情况:
在Time1时刻,服务端只拿到了数据包1,此时并不能完整的还原出消息A,这种现象被称为半包,对于服务端的影响是需要判断一个包是否完整,从而才能决定是否反序列化成应用层消息体。
在Time2时刻,服务端又拿到了数据包2,但数据包2中除了消息A还有消息B的部分内容,这种现象称为粘包,对服务端的影响是需要感知消息A的结束,以及消息B的起始位置。
本文接下来会从整个链路角度,来详细解释产生这种问题的本质原因,以及介绍一些经典应用层协议的优秀解法。
粘包/半包现象与TCP关联最多,要详细了解产生这类问题的本质原因,需要对整个链路传输情况有个大致的了解,关键点是每个阶段其认为的消息传输最小单位是什么,这个最小单位决定了该层对消息将如何拆分。
应用层是面向业务的一层,以浏览器访问百度为例,会发送如下HTTP协议完整消息,包含起始行,头部,空行(CRLF),实体(payload),那么完整的HTTP协议请求格式是该层认为的最小传输单位。
1 | curl 'https://www.baidu.com/' \ |
应用层到达TCP层后,TCP并不关心对应的业务,在TCP看来要发送的数据就是一定长度的二进制序列而已。数据提交过来时,会先进入到一个发送缓冲区,而不是立马发送,因为TCP不知道应用层是一次性写入还是分多段写入。那么TCP什么时候开始发送呢?
TCP会根据MSS大小进行判断,MSS到底多大,受限于MTU,MTU表示一个网络包的最大长度,是数据链路层的限制,在网卡处可以设置,路由器处也可以设置,一般为1500字节,MSS是一般为1460 (MTU(1500) - IP头部(20) - TCP头部(20))。
在三次握手时,通讯双方为了最大效率会协商MSS大小,确定最优MSS,一般是最小的一个值,就像木桶效应一样,盛水量取决于最短的一块木板。当TCP收到的数据长度超过或者接近MSS时,再发送数据,避免大量小包问题,从而提高网络效率。
正是因为这样,所以应用层的消息到TCP层后,如下图所示会拆分成多个数据段,每个数据段长度小于或者等于MSS,数据块 + TCP头部,组成了TCP层的数据段,因此数据段是TCP层数据传输的最小单位。
IP层主要与数据链路层打交道,也就是路由器的端口,由于路由器的端口对接不同线路,每个线路最大传输包长度不尽相同,遇到这种情况需要IP层的定义的分片功能对较大的数据段进行拆分。
分片的过程首先是获取MTU,也就是最大网络包长度限制,MTU一般是物理端口支持最大包长度(1518) - MAC头部(14) - 尾部校验+FCS(4) = 1500,知道MTU后,拆包就按照MTU长度拆即可,拆出来的小包不需要有TCP头了,直接附属上IP头即可,如下图所示,数据块1被拆分为数据包1.1和数据包1.2,其中1.2部分只需要IP头部即可,因此数据包是IP层的最小传输单位。
经过上面的分析,应用层一个请求传输过程中,会被两次拆分,其中第一次TCP拆分时,通过MSS参数尽量避免IP层而二次拆分,即使IP层数据拆分后,在服务端IP层也会将数据合并起来,完整的将数据段交给TCP层,然后转交给应用层,那么粘包与半包自然而然就是TCP分段传输带来的问题了。
具体分析半包,半包是由于应用层数据太大,到TCP层后会被分段传输,到达服务端,应用层看到的是一段一段的数据,此时需要服务端等待全部数据到达后,才能还原出具体的应用层消息,也因此无论长连接还是短连接,都会出现半包问题。
具体分析粘包,粘包的发生是因为应用层使用同一个TCP连接传输了消息A和消息B,也就是长连接情况下会发生的问题,长连接下,TCP通道会被复用,顺序传输多次请求,由于TCP发送缓存队列存在,就会导致两个请求在一个数据段中。短链接情况下,每一次建立连接只会传输一个请求,传输完毕就关闭连接,这种情况下自然不存在粘包现象。
UDP是仅仅是为了传输开发的协议,UDP在接收到应用层消息体后,是直接将全部消息体丢给IP层,依赖IP层的分片,自身传输仍然是以消息为单位,那么服务端接收到的自然也是IP层重组合并之后的消息,因此不会出现粘包以及半包现象。
TCP只是在保证可靠性的前提下,尽可能提高网络利用率,粘包与半包是应用层需要解决的问题,解决方案的主要思路是增加消息边界描述,应用层在解析时能够感知到消息边界,具体做法则有很多黑科技可以讨论了,接下来分析下一些主流协议使用的解决方案。
HTTP/1.1目前仍然在广泛使用,在HTTP/1.1当中,开启keep-alive后,TCP连接会被复用,也就是长连接,此时粘包和半包问题都会出现,为了解决类似问题,HTTP对于消息格式有一个强制性要求,借用极客时间的图,第一行是请求行,三个字段使用空格分割,以CRLF结尾,中间部分是请求头,以:号分割,CRLF结尾,以单独一个空行CRLF标识请求头结束,接着是请求体内容。
服务端解析时,针对请求行和请求头则逐个字节扫描,当发现是LF标识结尾时,即可解析已读取内容,当检测到CRLF之后又一个CRLF则标识请求头解析完毕,接着解析请求体。请求体解析有两种形式,第一种是已知请求体长度,在表单请求中比较常见,其会在Header中声明Content-Length,服务端根据该字段确定接下来再读取多少个字节作为请求体。第二种是不知道请求体长度,比如文件上传,HTTP提供了Transfer-Encoding: chunked
这一header标识,开启之后,HTTP会分块传输数据,每一块大小是指定的,终止块是一个长度为0的块。有了这些标准,在粘包以及半包情况下,服务端就知道是该拆分还是该等待。
举个实际案例,在Netty的io.netty.handler.codec.http.HttpObjectDecoder
中,Netty定义了一套状态机流转方式来解析HTTP消息,博主画了一个图,基本模式就是先读取请求行,然后解析请求头,在解析请求头的过程中,判断接下来状态是读取payload还是直接结束,其中黄色框的读取完后,会重置状态,解析下一个HTTP消息,有兴趣的可以翻阅相关源代码查阅。
简单总结一下,本质上还是使用那个CRLF这一特殊标识定义了消息在各种状态的结束符号,服务端在解析流程中,根据结束符号进行状态流转。
HTTP/2.0相比1.1版本,增加了HTTP连接的多路复用,怎么理解呢?在HTTP1.1时期,虽然有keep-alive
机制的长连接,但浏览器在获取一个资源时,会独占一个HTTP请求,只有当该资源获取完毕后,该HTTP才能给其他的资源获取使用。但是HTTP2.0时代,一个HTTP请求可以同时获取多个资源,HTTP层面资源不再排队等待,极大的提升了网络利用率。在这种模式下,HTTP协议是怎么解决粘包和半包问题的呢?
在HTTP2中为了支持多路复用,引入了Stream Frame这一结构,当多个HTTP请求使用同一个连接时,HTTP2会给每一个请求分配一个ID,然后将请求数据包装成一个个Stream Frame,丢给TCP连接传输。对于服务端,同一个HTTP请求的数据还是顺序传输的,当接收到一个Stream Frame后,根据头部的Length判断该包的大小,当读取结束时,开始进行整个包的解析。
那么还有个问题,怎么判断一个HTTP请求数据发送完毕了呢?该问题分为两部分,一是解析Header结束,二是整个请求结束,针对这种情况,Stream Frame的flag属性针对Header结束定义了END_HEADERS标识,针对整个流则特别定义了一个END_STREAM,因此服务端根据flag标识,能够确定下一步是该读取payload还是结束数据解析。
也简单总结下,因为是定长结构,处理简单了许多,当半包出现时,等就好了,当粘包出现时,因为长度存在也不会导致读错包,服务端接收到多个Stream Frame后,按照id将对应请求的数据进行合并,当接收到结束帧时,完成整个HTTP请求解析,应用链路可以继续往下调用进行。
实际上只要基于TCP/IP实现应用层协议,不可避免的都会遇到应用层数据被拆分现象,粘包与半包的概念提出是为了解决这个问题,让人比较容易理解,我倒是觉得这两个词相当形象得描绘了这一问题,没必要过度反感与抵制。
]]>在讨论之前,需要了解下Java中WeakReference
作用,感兴趣的可以参考Java中的四种引用。简单点来说在GC工作时,如果WeakReference
对象没有被强引用所关联,那么就会被GC回收,这个回收是ThreadLocal泄漏原因的根源。
ThreadLocal主要实现依赖ThreadLocalMap
类,该类使用开放地址法解决hash冲突,当put数据时,ThreadLocalMap
会将对应的数据封装为java.lang.ThreadLocal.ThreadLocalMap.Entry
对象,填充hash槽,该对象是一个WeakReference
子类,被跟踪对象则是ThreadLocal本身,如下图所示,其中ThreadLocal
标红代表被弱引用所跟踪。
清单一:Entry对象
1 | static class Entry extends WeakReference<ThreadLocal<?>> { |
当对应的Entry中ThreadLocal被回收后,Entry中value又是强引用,导致此时Entry无法被释放,就会出现内存泄漏,如下图所示。此时Entry对象永远无法被访问到,也无法被回收,但仍然占用着内存。本质问题是生命周期短的对象引用了生命周期长的对象,导致自身无法释放。
以下内容只考虑日常开发中使用习惯,不过多考虑极端情况。
泄漏的本质是弱引用被回收,换句话说不让弱引用被回收即可以解决泄漏,这也是日常开发下建议ThreadLocal对象声明为static final
全局变量的好处,当这样声明后,关系图如下所示,此时ThreadLocal由于存在强引用,除非对应的ClassLoader被回收,否则不会被GC回收。
那么声明为static final
可以高枕无忧吗?当然不行,此时虽然不会因为弱引用问题导致内存泄漏,但是会出现一些线程池中线程分配了部分ThreadLocal对象,但却一直没有使用该对象,那么这些分配未使用过的对象则无法回收,一直处于占坑状态,需要等线程生命周期结束后才能释放,这种也算一种内存泄漏。这种没有比较好的解决方案,常见的是调整线程生命周期,避免线程持续时间太长,二是养成开发意识,在对应行为处使用ThreadLocal需要回收对应内存,对于大部分业务中ThreadLocal的使用来说,所幸的是一般不会造成大问题,顶多是耗费多一点内存。
再者就是合并部署下可能出现内存泄漏,比如Tomcat服务器可以部署多个web应用,这些web应用是共用一套Tomcat的线程池服务,这种情况比较复杂,比如ThreadLocal中引用了webA的类,webA服务下线时,由于强引用存在,导致ClassLoader无法被回收,此时可能造成内存泄漏。在JDK7时代,Tomcat热部署机制就很容易造成 OOM。如今大多数项目都使用Spring Boot单体部署方式,这种内存泄漏越来越少了。
由于Entry对key是弱引用,当key也就是ThreadLocal本身被回收后,无法通过key访问该hash槽,造成内存泄漏。ThreadLocalMap在java.lang.ThreadLocal.ThreadLocalMap#expungeStaleEntry
方法中实现类对该类数据的回收,该方法遍历对应的hash槽,当发现key为null的数据后,回收对应的槽,如清单二所示,然后ThreadLocalMap类在get以及remove等方法中直接或者间接调用了expungeStaleEntry
,因此当出现内存泄漏后,大多数情况能够主动回收该部分泄漏内存。
清单二:ThreadLocal回收key
1 | private int expungeStaleEntry(int staleSlot) { |
问题二就比较常见了,使用了ThreadLocal,却没有清理,导致第二次重用了旧数据。这种错误是ThreadLocal犯的最多,且最致命的问题,举个博主之前写过的bug,场景如下:
后端提供了一个查询系统所拥有数据的API,由于系统拥有很多不同类型数据,不如枚举信息、用户权限信息、系统状态等,数据都分布在不同的表,因此该接口会并发查询,简略示意图如下,后台使用了策略模式,每一种信息的查询是一个单独的策略接口,前端传入要获取的信息类型,后端根据类型进行parallelStream
并发获取。
博主当时想也没想,就直接把用户权限的获取写成了一个策略实现类,踩了坑。本质问题是获取用户信息的RequestUserHolder
本质上是从ThreadLocal中获取,而parallelStream
底层实现为forkjoin,会根据当前负载情况拆分任务到CommonPool线程池中执行。由于存在线程复用,因此用户信息在请求线程ThreadLocal,调用到parallelStream后,第一次创建CommonPool线程池时,是能够传递ThreadLocal到子线程,之后线程复用,无法传递ThreadLocal,造成数据混乱使用。
讲了那么多,根据上述缺陷,博主总结了以下使用点:
1 | private static final ThreadLocal<StringBuilder> LOCAL = ThreadLocal.withInitial(StringBuilder::new); |
现象一:MetaSpace频繁引起full gc?
应用A,线上机器运行一段时间后频繁触发full gc,根据监控发现是MetaSpace达到设定的-XX:MaxMetaspaceSize=512m,每次full gc后还会占用450M+,之后很快达到512M,再次触发full gc,如此反复。
现象二:MetaSpace无限增大,最终导致OS kill掉应用
应用B是一个新申请的应用,运行一段时间后,个别机器内存会被java进程占据90%以上,最终由于内存占用过高,被OS kill掉。
两者系统的共同点是都用了https://github.com/killme2008/aviatorscript,以及大量groovy脚本,想必大概原因是出在这里,为了搞清楚上述两个现象背后的原因,需要对MetaSpace的原理有一定了解,博主带着一堆问题开始了google。。。
MetaSpace被称为元数据空间,在JDK8版本中退出代替JDK7的perm(永久代)。关于为什么会替代,说法众说纷纭,博主觉得比较合理的解释是为了解决OOM问题。
在JDK7时代,不少人经历过Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
,比如Tomcat7的热部署,频繁触发的话,很快就导致系统挂掉,究其原因是PermGen利用了堆内存,而生产环境堆内存一般会设置最大值,那么就带来该给PermGen分配多少的问题,分配多了有点浪费,分配少了则频繁Full gc。相比PermGen,MetaSpace最大的变化就是使用直接内存,因此最大占用不受堆内存限定,而取决于操作系统,其次是JDK8下lambdas新增表达式,大量的运用会为该区域带来不确定性。但从上述两个问题来看,该有的问题还是一个都不少。。。
MetaSpace主要存放class metadata,class metadata可以理解为记录了Java类在JVM中的静态信息,主要包含:
常用参数主要有以下三个:
MetaSpaceSize
默认20.8M大小,主要控制metaspaceGC发生的初始阈值,也是最小阈值。
MaxMetaSpaceSize
默认无穷大,一旦到达这个值就会触发full gc,该值设定后,不会在JVM一开始就分配该部分内存,而是随着使用不断申请,直到达到这个值。
CompressedClassSpaceSize
默认1G,设置Klass MetaSpace的大小,该参数生效前提是开启压缩指针,达到该参数大小后也会触发full gc
那么MetaSpace是如何扩容的呢?
以参数 -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m为例,当JVM启动时,会经历以下步骤:
到这一步,上述问题就很好解释了。
无论是aviator还是groovy其都是JVM上的动态语言,动态语言的特点是动态Classloader以及随时随地新建类,那么metaspace内存一直增长就是由这些带来的。博主所在公司提供了JDK的metaspace dump功能,导出后由分析工具也能看到一堆的aviatorClassLoader以及groovyClassLoader,也可以使用jmap -clstats PID
定位。
问题一:问题一设置了XX:MaxMetaSpaceSize=512m参数,所以最大值不会突破512M,由于使用了动态脚本引擎,因此会不断的加载新的类以及新建ClassLoader,因此导致MetaSpace不断的达到阈值,触发full gc,解决方案是增加缓存,对相同的表达式不再触发编译操作。
问题二:问题二由于是一个新应用,配套的JVM参数在走应用上线流程时并没有生效,因此其MetaSpace会不断的增加,直到达到系统最大值,被操作系统kill。解决方案增加上述参数即可。
感谢网友Rain-Chen的建议,上述方案虽然也能够解决当前问题,但治标不治本,该业务的表达式随时间变化,而不是一个静态表达式,因此缓存也只能有一段时间效果,从而降低full gc频率,根本的解决方案是替换表达式引擎,这种频繁场景下使用更加轻量级的解决方案,不过这是另一个话题了,本文不多讨论。
对JVM了解不多,如有错误还请指出。
]]>无论什么语言,二进制操作一般都支持以下指令:
以下案例均是这些操作的组合。
枚举场景下,使用二进制能够快速判断当前对象是否拥有该枚举状态,举个例子:
1 | enum |
当判断用户是否有哪几个权限时,使用X & U = X
,其中X代表要判断的权限,U代表用户当前权限,举个例子:
1 | 判断小明是否拥有B:2 & 3 = 2 拥有 |
该案例引申下,可以在任意有限数据基础上判断包含以及不包含,解决相应业务问题。
分布式ID,即在分布式系统下生成不重复且有序增长的ID序列。已Snowflake算法为例,该算法将64个bit位的Long类型数据进行拆分。首位是符号位,不用,中间41位时间戳,接着是10位的机器id,最后是12位序列号,因此每毫秒最大能产生2的12次方,即4096个序列号。
那么回到二进制上,对应bit位的数据该怎么填充?这里填充的实现主要依赖与位移操作。举个例子:
1 | 时间戳:1000000000 (1597996131000-1596996131000) |
使用位移以及|操作完成了数据的拼接。这种想法除了分布式ID外,也可以在业务中很高性能的生成各种ID。博主曾经在设计事件系统ID生成逻辑时,使用过类似方案:1bits + 8bits(ip最后一位) + 12bits(事件码编号) + 2bits(时戳类型,毫秒,秒,分钟) + 41bits(时间戳,简单去重可自定义)
,因为系统压力不大,所以去重也不需要考虑很复杂,可以说很实用了。
Java的ReentrantReadWriteLock是一个可重入读写锁,即写锁,读锁可以多次被获取,且拥有读读不互斥,读写互斥,写写互斥等特性,实现这些特性则需要标记当前写锁获取次数,读锁获取次数。JDK在这里的实现巧妙的使用了32位INT类型数据,其中高16位标识读写数量,低16位标识写锁数量。比如下图,由于写锁存在,所以必然有一个线程获取到了该锁,并且写锁重入2次,读锁也被获取,且重入1次。
按照这种形式定下来后,JDK需要提供一些快速执行四则运算的方法,如下所示,位移以及位运算结合。
1 | int state = xxx ,标识当前锁状态 |
JDK线程池里面也有类似做法,这种有点黑科技的感觉,业务代码还是不要学习这种写法,业务追求的是简单,好理解。
Bloom Filter是利用二进制0,1两个真假属性来判断对应值是否包含的一种策略。以下图为例,key1经过三个不同hash函数(f,g,h)分别映射到三个bit槽,key2同样也是。因为hash冲突以及计算bit槽时取余操作,所以Bloom Filter存在不一定真的存在,但不存在则一定不存在,利用该特性可以实现白名单,黑名单等业务需求,并且存在判断也可以作为一些底层存储的前置拦截,减少穿透请求。
不过这些都不是今天讨论的内容,Bloom Filter给二进制操作上带来的问题是如何构造超长的bit位?这个需要我们了解下。
JDK的做法使用long[]数组来标识bit set,每一个long位64个bit位,那么一个BitSet初始化后,就是N*64长度,通过取余定操作可以先定位到对应long位置,然后再使用位运算修改bit位。
Redis与Java实现类似,不过内部使用的是char[]数组,在C语言中一个char占一个字节,相当于8bit。每一段变小,会造成全遍历统计时数据量庞大,比如统计一共多少个1,Redis在此基础上做了个优化,将对应char[]数组分组,加入128个char一组进行遍历,大大提高了统计效率。
上述两种做法一般应对于数据经常变化的场景,因为要涉及旧数据的更新操作,不过当使用场景下数据比较稀疏时,还是会造成内存的浪费,因此使用时合理选择映射到bit位的id能够节省不少内存消耗。
倒排索引中,倒排表一般会使用跳表或者bitset实现,这里只分析bitset实现方式遇到的挑战以及解法。以ElasticSearch为例,当倒排索引建立后,假设结构如下所示:其中termA,termB,termC为分词之后的词典表,倒排表0则代表当前id不存在,1则代表存在,因此termA有文档4,5,6,7;termB有文档1,2;termC有文档1,4,5。
1 | termA :[0,0,0,1,1,1,1] |
那么要解决的第一个问题,数据怎么存?
倒排索引不同与上述Bloom Filter场景,在ES中数据往往是以亿位单位的,因此倒排表会非常大,倒排词典数据量一多,磁盘占用就会飞速上涨,另外ES底层使用的Luence是写入后不再变更,也就不存在更新场景,因此使得数据压缩有了很好的前置条件。
压缩的思路很简单,100个0存储(100,0)即可,而不是真实的存储100个0。Luence在存储中,使用的是位图压缩结构(Roaring Bitmap)来实现优化。如下图所示:
如上图所示这一结构以65536为一块区域,进行分块存储,每一块最多标识65536个文档,当文档数小于4096时,Lucene会使用short[]数组存储,short占的空间:2bytes(65535 = 2^16-1 是2bytes 能表示的最大数),当超过4096时,则使用bitmap 占的空间: 65536/8 = 8192bytes,换句话说一个块最大占用内存8192bytes,也就是8kb。
同样一亿数据量,bitmap结构,必须保存1亿个bit位,但往往这一亿中存在大量不命中的文档id,也就是存在很多个0,此时该算法就能起到很好的压缩效果了。
第二个问题是快速取交集,由于是倒排表是对齐的bit操作,因此可以直接使用&|指令快速求的交集或者并集。
以上内容是对工作至今遇到的案例进行总结,如有错误或者遗漏,欢迎留言指正。
]]>由上面可以总结为两种形式,一种是快捷键做到在各个窗口之间,一种是alfred定位程序,两种分别使用不同软件定制即可。
快捷键切换是使用最频繁的场景,因此为了达到完美体验,我总结了以下流程图,流程图的原则是定位到不同的应用,不存在则启动应用,存在则切换到该应用,同时应对多屏幕下多窗口,多次相同快捷键能够在窗口之间互相切换。
我选择的软件是hammerspoon,该程序提供了lua脚本与mac os交互的能力,定制脚本如下所示,需要的可以根据自己需要改造。
1 | --123 |
Alfred切换不需要配置什么了,下载Alfred后,唤醒窗口,输入关键字即可快速匹配跳转。
有更好的方式,欢迎分享
]]>notion嵌套不了iframe,因此直接贴地址了。
]]>从使用角度,可以将Mockito分为插桩以及验证,使用者只需要关心模块提供的能力,不需要太过于深入了解。
想要在开发中随心所欲的使用Mockito达到单测目的,了解Mockito原理是必须的。当我们在用Mockito时,经常写出以下类似代码,从逻辑上可以分为四部分:定义Mock对象,定义方法返回值,调用单测方法(这里直接调用mock方法,方便阐述原理),验证业务结果。那么每一步骤对于Mockito分别做了什么呢?
1 | UserService mockService = Mockito.mock(UserService.class); |
简单来看,我们可以猜想到所谓的Mock测试技术原理应是预先定义好该方法返回值,使用AOP技术拦截对应的方法执行,当拦截直接返回对应的值,从而达到Mock效果,如下图所示:
问题回到Mockito,可以提出以下三个问题为思考的切入点:
Mockito AOP对象的创建,对应代码的第一行Mockito.mock(UserService.class)
,贴一下相关代码
1 | # Mockito.mock(UserService.class) |
上述代码中,Mockito
会使用全局静态变量MOCKITO_CORE
创建代理对象,核心逻辑都在MockitoCore
中,我们不必关心很细节,所以仍然按照带着问题的方式去探索。
**1.**创建中的MockSettings
可以做什么?
MockSettings
可以针对一个mock对象创建做额外的配置,比如使用指定构造函数进行初始化,设置mock的一些调用监听器,以及mock拦截后默认返回值,一般创建时不指定,系统默认为new MockSettingsImpl().defaultAnswer(RETURNS_DEFAULTS)
。
**2.**代理对象创建使用的是什么技术?对应拦截器实现是什么?
对应细节都在createMock(...)
方法中,这里就不贴代码,直接说结论。Mockito内部有一套插件机制,其中生成代理类对应MockMaker
扩展点,默认实现为org.mockito.internal.creation.bytebuddy.SubclassByteBuddyMockMaker
,即用ByteBuddy技术生成对应代理类,ByteBuddy
博主了解的不是很多,但根据SubclassBytecodeGenerator
处的源码可以发现其本质是使用继承动态创建需要代理类的子类,然后复写对应方法达到拦截目的,因此也说明了Mockito不支持final类,不支持static,private等方法mock的原因,不过这个算不上缺点,private方法外部根本不用关心,因此无需考虑mock,static方法作为全局使用的工具类型方法,如果也需要mock那么说明存在坏代码的味道,最好的做法是重构,而不是想方设法的mock。另外Mockito提供了InlineByteBuddyMockMaker
实现类,该类利用Instrumentation API特性实现了静态方法,私有方法,final方法等拦截,更加强大,该特性还在试验中,感兴趣的可以尝试。
ByteBuddy
代理类默认拦截器为org.mockito.internal.creation.bytebuddy.MockMethodInterceptor
类。该类面向ByteBuddy提供的调用入口,内部会将参数包装后,给到真正的Mock拦截器org.mockito.invocation.MockHandler
,进而决定返回或者插桩定义。MockHandler
的实现可以理解为下图结构,
**3.**Mockito如何保证线程安全
在测试中会开启多线程测试,而Mockito又是一个静态调用形式,内部MockitoCore是全局共享变量,如果没有一定处理措施,必然会导致并发冲突。Mockito的解决方案是使用ThreadLocal,其提供MockingProgress
类存储当前mock进度信息,提供ThreadSafeMockingProgress
使其与ThreadLocal进行绑定,线程安全和mock本身原理关系不是很大,这里不多做分析,具体细节感兴趣的可以去了解下。
常用的预定义方法返回值主要有两种形式when() thenXXX
,doXXX when
,很多同学不理解这两个的区别,事实不然,两者的存在都很有必要,按照上述拦截后执行逻辑,分别分析两者的不同点。
**1.**when() thenXXX是如何预定义值的
还是以上述为例Mockito.when(mockService.queryUser(Mockito.eq("xxx"))).thenReturn(new User("屈定"));
,Java是顺序执行代码的,那么针对该部分代码执行顺序可以拆解为以下几个步骤,下面分别分析:
MockingProgress
添加一个equal的ArgumentMatcher,MockingProgress
使用Stack结构存储ArgumentMatcher,因此可以推测出,这里默认规则在方法拦截前所有的ArgumentMatcher都会按照执行顺序入栈。org.mockito.internal.handler.MockHandlerImpl#handle
中,此时MockHandler发现该是一个方法调用,接着去查找对应的ArgumentMatcher与其绑定,存到Stub Container中,mockContainer结构可以看作为UserService+queryUser+eq(xxx) -> NULL
MockingProgress
的mock进度MockingProgress
中当前mock进度,thenReturn会创建Returns
对象,该对象会与上一个方法调用进行绑定,因此执行完毕后,mockContainer结构可以看作UserService+queryUser+eq(xxx) -> User(“屈定”)
**2.**doXXX when()是如何预定义的
在doXXX when()模式下,一般这样Mockito.doReturn(new User("屈定")).when(mockService).queryUser(Mockito.eq("xxx"));
定义mock,按照同样步骤进行拆解:
StubberImpl
对象,与上述流程是不一样的。该类有成员变量List<Answer<?>> answers
,此方法执行会将对应Answer对象暂存到该集合中MockingProgress
获取当前mock进度,之后再把成员变量List<Answer<?>> answers
与当前mock对象进行绑定,此时mockContainer结构可以看作NULL -> User(“屈定”)
MockingProgress
添加一个equal的ArgumentMatcherUserService+queryUser+eq(xxx) -> User(“屈定”)
**3.**两者的不同点带来什么不同
按照上述流程分析,无论是when() thenXXX
还是doXXX when()
,最终都能得到UserService+queryUser+eq(xxx) -> User(“屈定”)
的结构,那两者的不同点是为了什么呢?
在Mock代理中,因为Mock都会被拦截掉,并不会有任何真实调用,两者所产生的效果是没有区别的。doXXX when()
主要是用在Spy和void返回形式上,在Spy模式下,UserService.queryUser
会产生真实调用,doXXX when()
的做法是将调用放在最后一步,调用时,已经知道对应的Answer了,而when() thenXXX
则是在最后一步才知道Answer,无法做拦截。
第二个问题分析过程中实际上已经回答了这个问题,在Mock构建完毕后,对应的方法调用Invocation以及Answer是一一对应绑定起来的,因此只需要找到对应的stub,然后发现有Answer直接返回即可
上文主要来源于源码翻查以及网上一些资料,如有错误,还请指出。
]]>事件机制很好理解,发布者发布了一个事件,经过一些操作,投递到了订阅者,订阅者根据接收到相应的事件,执行对应业务逻辑,在这个流程中,可以分析出事件模型的三个必要条件
也因此事件机制主流实现方式是发布订阅模式,MQ可以理解为是分布式下的事件机制设计实现,观察者模式可以理解为进程内的事件机制设计实现,那么使用事件机制的优势是什么?我总结了以下几点:
接下来会分析Spring ApplicationListener以及Guava EventBus设计。
Spring ApplicationListener的使用很方便,对应的Spring Bean只需要实现ApplicationListener
接口,并指定对应的事件类型即可,Spring在启动中会通过IOC容器将该订阅任务添加到ApplicationEventMulticaster
中,如下代码所示
1 |
|
背后原理是什么样子呢?Spring的事件机制是典型的观察者模式,其主要目地是在Spring框架初始化生命周期过程中提供各阶段的通知能力,当然也支持自定义事件,用于业务系统。参考发布订阅模式,其中发布者为ApplicationEventMulticaster
,订阅者为ApplicationListener
,事件模型ApplicationEvent
,进程内通知,因此发布者直接持有了全部的订阅者,基本流程如下:
其中ApplicationEventMulticaster
在Spring容器ApplicationContext创建时会一并创建,ApplicationEventMulticaster
唯一实现类为SimpleApplicationEventMulticaster
,如名称所示,就是一个简单的执行Listener的类,虽然提供了taskExecutor
变量,但默认情况下为同步调用。Spring这一套事件机制设计的目地更多的考虑是内部事件分发,比如你的类感兴趣Spring容器刷新事件,则可以订阅ContextRefreshedEvent
事件,较少的考虑到业务中事件处理,因此不建议业务中直接使用该模块作为进程内通信方式。
Guava的EventBus使用也很简单,以下面单测为例,使用@Subscribe
注解声明一个订阅者,然后创建对应的EventBus,并注册订阅者,接着发布事件,即可完成投递动作。
1 | public class EventBusTest { |
相比Spring事件设计,Guava将进程内的事件驱动机制该有的组件更加细分模块,如下所示:
总体实现比较简单,EventBus
作为入口提供事件注册以及发布能力,这里也可以使用AsyncEventBus
提供的异步能力进行事件处理。当注册一个事件时,EventBus
会将其转交到SubscriberRegistry
,SubscriberRegistry
根据定义逻辑解析出来事件处理方法,比如guava会解析提交类中被@Subscribe
标注的方法作为事件订阅者,其第一个入参为其感兴趣的事件。当产生一个事件后,EventBus
会将从SubscriberRegistry
中获取到对应的订阅者,然后一并转交给Dispatcher
进行处理,guava中Dispatcher
的实现主要是依赖队列,当直接同步执行时,为直接调用,当异步执行时,则需要一个共享队列进行排队,当需要顺序执行时,则需要队列绑定到当前ThreadLocal,对类似场景Guava有着不同的封装实现。
Guava的这一套API一直标准为BETA状态,但代码逻辑比简单,业务中直接使用也是很推荐的。
理解了观察者模式后,事件机制实现原理也很容易理解,事件机制最大的优势是彻底解耦,合理使用会让代码设计上更加高内聚低耦合。
]]>享元模式很好理解,即针对不可变对象的复用。
对象不可变指的是对象构建成功后,不能通过访问方法改变对象属性,因为享元模式下,对象会被多处使用,如果可变则造成不一致现象,这一点很好理解。另外怎么复用呢?常见的手段是使用一个Map存下来已经产生的对象,当新建对象时,如果Map中已经存在需要的对象,则直接返回已存在对象地址,达到复用目地。
享元模式本身很简单,个人认为需要掌握的是这种对象复用思想,在实现对应业务时,能敏感的发现可复用场景,下面从几个案例中来感受下其威力。
熟悉Java的同学,或多或少都遇到过下面问题,按理来说,Integer
属于对象,每一次创建都会开辟新的内存,所以即使相同的大小,其内存地址不一致,会被==
判定为两个对象,但实际情况中[-128,127]之间的数字,JDK使用了享元模式,复用了这部分的对象,JDK实现者认为[-128,127]之间的数字一般为编程中高频数字,如果每次都new产生新对象,比较浪费内存,如果是复用情况下,即使多次声明,内存中只会有一份对象存在,能够节省大量无谓的内存消耗。
1 | Integer var1 = 1; |
JDK中提供了字符串常量池,也就是字符串缓存,在Java中动态创建的字符串可以使用intern()
方法让其进入常量池,关于更多常量池分析可以参考我另一篇文章Java – 字符串常量池介绍。那么常量池机制本身就是享元模式思想,针对重复字符串对象达到复用目地,从而节省内存消耗。
Twitter曾分享过利用字符串常量池享元方式优化内存案例,该案例中Twitter用户登录后需要在session中保存用户地理位置信息,有国家,省份,城市等,当网站日活上去后,session中的地理位置字符串信息将占据大量内存。简单分析下,地理位置信息为字符串格式,具备不可变属性,且在该业务中重复度很高,因此可以利用字符串常量池复用相应的字符串,这本质上也是享元模式的一种运用。
享元模式虽然为应用代码设计的产物,但在数据库表结构设计上也经常有类似思想运用。比如要设计一款RSS阅读器,用户可以自定义订阅列表,那么怎么做?
做法一
做法一是记录每一个用户的地址,然后后台定时任务为每一个用户更新对应的RSS信息,订阅表如下树形结构所示。这样做有什么坏处?考虑到2-8原则,即80%用户都会订阅常见的一些RSS,那么这张表中RSS地址重复度就很高,针对每一个用户更新对应RSS信息则相当于做了很多重复的订阅拉取动作。
做法二:使用享元思想
做法二是针对RSS地址,单独维护一张表,用户订阅时只需要关联到RSS id,RSS订阅则不需要考虑用户维度,定时去更新RSS源地址中所有地址,两者完全解耦开来。此时RSS源地址表
相当于享元思想中被共享的单元,之所以可以这样设计,因为无论用户订阅怎么变化,RSS地址不会变化,因此具备不可变性,且用户订阅中RSS地址重复度很高,具备高重复度这一特点。
享元模式很简单,很好理解,关键时刻能发挥出巨大作用,但什么时候使用享元模式或者说需要考虑享元模式呢?根据上面案例,我觉得可以总结为以下两点
这两点情况下,大多数情况都能够使用享元模式进行优化。
首先两者本身就很类似,尤其是JDK8之后,接口增加了default方法,允许接口中有部分实现逻辑,这个阶段虽然大多数接口与抽象类都可以互相替换,但他们的侧重点不同。
了解上述区分点后,对于该问题,应该要从实现考虑,当你描述的关系为is - a时,那么抽象类更加合适,如果has - a时,那么接口合适。往往大多数时候两者都具备,所以经常见到三层继承结构,最上层是接口,中间抽象类,叶子节点为实现类,比如Mybatis中Executor实现,最上层接口Executor标识has -a 关系,表示Executor是一个执行器,有着这些操作方法。中间BaseExecutor侧重于代码复用,描述的是is - a关系,表示这一类实现都是Executor,方便子类实现。
这种问题要从接口的意义来讨论,接口的意义在于解耦,解耦的是什么呢?自然是规范与实现,接口本身可以认为是稳定的规范,实现逻辑则认为是不稳定的实现,面向接口编程本质目地是依赖稳定,屏蔽不稳定。
面向接口编程,对于开发人员有一定要求,接口作为稳定的存在,则要求开发人员定义接口时,要充分屏蔽相关实现细节,比如要定义一个上传文件接口,那么upload(target),比uploadAliyun()更加合适,因为其没有包含细节,开发人员需要具有接口意识。
常见的编程原则中有组合优于继承这一说法,在《Effective Java》中也有类似说法,作者举出了一个继承HashMap的例子,因为实现类不了解HashMap中addAll方法调用了add方法,导致复写时出现重复计算,来表明继承的缺陷,近而引出了组合设计思路。在复杂多层次的继承关系描述上,组合能够让结构更加简单,没有了复杂的对象关系存在,开发人员更加容易理解业务,这是组合优于继承的一点。
但是继承真的该被抛弃吗?当然不是,我们使用的都是面向对象语言,写的都是面向对象代码,继承能很好的描述对象之间的关系,组合却很难做到这一点,这是继承的优势,那问题到底出在哪里?问题的本质原因是继承的滥用,比如存在复杂的继承关系树,甚至关系网,继承本意是让你可以更快的了解对象关系,但是这种关系网的存在反而带来的理解障碍,那么此时继承就是不合理的实现方式。再比如类似HashMap这种不是为继承设计的类,但有业务去继承,一不小心就会因为对父类中实现不了解而踩坑。因此对于继承,一般策略是要么专门为继承设计,要么不使用继承,如果使用继承,那么层次不要超过3层。
这一点很好理解,在现在框架中有很多类似的做法,比如Abstract/Base开头的类在Spring中经常出现,如下图所示,AbstractThemeResolver
就是专门为继承设计的类,这种的好处很明显,结构简单,叶子节点都是实现类,非叶子节点都是抽象类或者接口,关系也简单明了。因此我们在设计时,应尽量遵循树根为接口,中间非叶子节点为抽象类,叶子节点永远是具体实现类方式来设计,让自己的代码变得更加清晰。
编程没有统一的标准,如果有什么想法欢迎评论区讨论
]]>日志包虽然很多,但大体上分为三类
那么最佳实践自然是一套门面,一套实现,其他都为桥接,如下图所示,这种方式下结构非常清晰,且日志实现类可以随时更换,不会影响到现有应用,目前主流组合有 slf4j + logback + 各种桥接,slf4j + log4j2 + 各种桥接,配置时可以作为参考。
也因此日志该如何配置即变成了如何保证应用中有且仅有一套门面,一套实现日志框架。
在分析之前,我们大致可以想象到,门面日志相当于定义了一套输出日志的标准API,桥接类相当于复写了对应实现类,然后在内部将对应日志行为转接到slf4j,接下来以slf4j+log4j2为例,描述这一流程。
如下代码所示,在slf4j中org.slf4j.LoggerFactory#bind
方法会使用StaticLoggerBinder.getSingleton()
完成实现类日志绑定,而StaticLoggerBinder
由对应实现类日志提供,比如使用log4j2实现时,则由log4j-slf4j-impl
jar提供该类。
清单一: slf4j日志绑定
1 | Set<URL> staticLoggerBinderPathSet = null; |
桥接的目地是获取到ILoggerFactory
,由其提供对应日志类Logger
,完成绑定输出。
不同的类桥接方式不太一样,以JUL为例,其桥接包jul-to-slf4j
提供了SLF4JBridgeHandler
类,该类继承了java.util.logging.Handler
,针对JUL日志的日志输出会转到org.slf4j.Logger
输出,从而实现了桥接。另外log4j-over-slf4j
的实现方式,则是完全复写log4j库,提供一样的Class接口,但是内部日志输出使用的是org.slf4j.Logger
,从而完成了桥接。
原理实际上很简单,这其中容易配错的就是桥接方向,比如同时引入了 jcl-over-slf4j 与 slf4j-jcl,前者是桥接jcl到slf4j,后者是桥接slf4j到jcl,那么必然死循环,造成内存溢出。
按照一套门面,一套实现,其他都为桥接的标准,日志配置可以分为三个步骤:第一步,引入门面日志框架;第二步,排除多余的日志实现类框架;第三步,引入相关桥接包,排除多余桥接包。按照简单的三个步骤策略,可以轻松配置日志,另外在整个过程中可以画图辅助,这样能够快速帮助你定位到问题所在。
解决日志配置问题后,管理也是个大问题,随着应用迭代增多,如果不加以控制,日志文件实际上会越来越多,因此收口到一个工具类是非常必要的选择。比如针对报警事件,可以收口到一个alarm.log的日志,使用不同的marker区分,针对监控日志可以收口到monitor.log,使用marker区分,错误日志则全部收口到error.log,防止多处打印。
工具类实现,首推策略枚举模式,管理方便,调用简单,还便于在日志上增加各种属性配置,如清单二代码实例所示,该LogUtils不但提供了枚举调用方式,还提供了静态方法调用方式,方便外部存在 logger对象时调用,另外还可以通过匿名类方式,为单一日志对象提供额外方法,灵活性可以说极其自由。
清单二:策略枚举实现日志工具类
1 | public enum LogUtils { |
日志配置实际上并没有很多可以说的东西,只要理解了日志体系,配置是很简单的事情,希望本文对你有帮助。
]]>