-
Notifications
You must be signed in to change notification settings - Fork 3
/
content.json
1 lines (1 loc) · 288 KB
/
content.json
1
{"pages":[],"posts":[{"title":"Vue社区的路由解决方案:vue-stack-router","text":"vue-router 是 Vue 官方的路由管理器,用法简单、功能强大。但在移动端场景中,特别是 hybrid 项目,我们在使用中遇到了一些问题。 vue-router原理我们先理顺一下客户端路由管理器的通常特点,可以打开手机的设置 App 感受一下: 大部分页面都有栈的概念,比如:PageA -> PageB,这个时候 PageA 和 PageB 的实例都是存在的,只是绘制了 PageB ,当我们从 PageB 返回 PageA 时,PageB 销毁,PageA 展示; 同一个页面可能会有多个实例,比如: PageA -> PageA ->Page A,像是商品 A 跳转到商品 B,其实都是商品页面,这个时候同一个页面会有多个实例存在; 大部分页面都支持手势返回,比如说左滑返回。 使用困境因此在面向移动端的产品中,用户的操作习惯、产品的设计理念也大多趋同于以上几点。但我们从 vue-router 的角度再来审视这几个点,就会发下有这几个问题: vue-router 中所有注册的路由都是单例的,当出现 PageA 跳转到 PageA 的时候,并不是产生一个新的 PageA,而是当前的 PageA 重新渲染。当需要两个 PageA ,并且两个 PageA 都需要有自己不同的状态时,这个场景用 vue-router 解决会比较麻烦。 vue-router 遵循 Web 的规范,整个路由的路径是线性的,组件实例的存活与路由无关,而是取决于是否使用了 keep-alive 组件。而在移动端,大部分栈式路由的场景,PageA 跳转到 PageB,A 和 B 实例都是存活的,当 PageB 返回 PageA,A 存活而 B 被销毁,显然 vue-router 无法满足这个场景。 vue-router 的路由是只能 A 到 B ,没有中间态,我们无法基于 vue-router 还原原生的左滑返回功能。 于是我们开始在社区中寻找解决方案,但遗憾的是大多方案都是基于 vue-router 的二次开发,并且都不满足需求和有一些 Bug。因此我们基于栈的理念开发了,针对移动端应用开发了 vue-stack-router。 vue-stack-router效果先放上效果图以及基于它实现的滑动返回。 特点相较于 vue-router, vue-stack-router 有以下特点: 基于栈的路由管理 路由间数据传递 细粒度、可定制的路由过渡效果 完善而独立的路由导航钩子,不需要处理组件复用的逻辑(vue-router 的 beforeRouteUpdate) 支持预渲染模式 具体文档见 vue-stack-router , 相较于 vue-router ,vue-stack-router 的功能在一些方面依然不够完善和强大,也希望感兴趣的同学能一起来完善这个库。放上 github 地址 https://github.com/luojilab/vue-stack-router ,欢迎 pr/Issues/star。 后续文章会解析整个 vue-stack-router 设计和实现过程,欢迎大家关注。","link":"/2019/09/10/fontend/vue-stack-router/"},{"title":"Elasticsearch 插件详解及实践","text":"背景介绍现在 ElasticSearch 大量应用在搜索领域,开发者可以通过其提供的多样的查询api达到希望的搜索效果,而且Elasticsearch版本也一直在不断迭代,以满足开发者的需要。但是,实际开发过程中,可能需要将搜索和自己的业务场景进行结合,来达到自定义的排序、搜索规则。Elasticsearch针对这种情况,提供了插件的功能,可以这么说,如果能够学会使用插件,那我们就有了自由扩充ELasticsearch功能的手段,对搜索的掌控力就能提升一个档次。 Es插件分类插件作为ES的架构中的重要一环,ES为其开放了足够多的接口使开发者可以实现自定义的功能需求,其共支持下面十种插件,AnalysisPlugin,ScriptPlugin,SearchPlugin这三个常用插件我们在后面会更详细的讲解 1、AnalysisPlugin分析插件,用于开发者开发额外的分析功能来增强Elasticsearch自身分析功能的不足,medcl大佬的ik分词插件相信大家都用过. 2、ScriptPlugin脚本插件.会调用用户的脚本,其中主要是用在function_score查询中,使用自定义方法进行打分,我们熟知的painless脚本就是ScriptPlugin脚本 3、SearchPlugin查询插件,扩展Elasticsearch的查询功能,es 的search功能功能十分强大,有了SearchPlugin我们可以在search中增加更多查询方法,我们后续可能会在此基础上增加很多令人兴奋的查询。例如根据用户购买的书籍查询与用户相似的其他用户,例如结合模型对搜索词进行expanding。 4、ActionPlugiRestful API命令请求插件,如果Elasticsearch内置的命令如_all,cat,/cat/health等rest命令无法满足需求,开发者可以自己开发需要的rest命令,例如希望看到某个分词器的词表的命令。 5、ClusterPlugin集群管理插件,用于加强自定义对集群的管理功能,该插件可以用来扩展allocation机制,例如在进行分片选择的时候如果我们可能倾向于一些机器, 6、DiscoveryPlugin自定义发现插件,目前是使用zen协议来进行。 7、IngestPlugin预处理插件,在数据索引之前进行预处理,例如根据用户ip来增加地理信息的geoip Processor Plugin 8、MapperPlugin映射插件,加强ES的数据类型.比如增加一个attachment类型,里面可以放PDF或者WORD数据 9、NetworkPlugin网络传输插件插件, 10、RepositoryPlugin存储插件,提供快照和恢复 ES插件加载过程 插件的加载时机是在节点启动创建的时候中会扫描Elasticsearch的安装目录下的plugins和module的插件列表,并通过PluginService进行解析插件。 PluginFilter是用来识别plugin类别的一个方法,通过每个插件实现的接口将所有插件分类并分发给Elasticsearch不同的服务组件进行注册。 在node不同的服务启动过程中会读取每个和自己相关的组件进行扩展,最终插件都会形成服务提供给集群使用(比如ScriptPlugin最终在ScriptService提供服务,SearchPlugin最终会在searchTransportService中提供服务) plugin-descriptor.properties PluginService 加载插件的元信息会从该文件中进行读取,所有插件都需要这个文件,下面两个配置比较重要,如果es版本不一致会加载失败。 1name=${project.name}2description=${project.description}3version=${project.version}4classname=${elasticsearch.plugin.classname} 插件入口5java.version=1.86elasticsearch.version=${elasticsearch.version} 插件对应es版本 Example:AnalysisPlugin这里我们就使用十分流行的ik分词来解释,ik是一款十分流行的中文分词器,其能支持粗细力度的中文分词,其就是一款基于AnalysisPlugin实现的插件 IK分词类图 我们可以看到ik分词主要实现了接口中的getTokenizers()和getAnalyzers(),其调用流程如下: 其在Node初始化时就会将pluginService的中的AnalysisPlugin插件加载到AnalysisModule中。 在AnalysisModule中进行分词器和分词器的注册。 在注册的过程其实就是将AnalysisIkPlugin的getTokenizers和getAnalyzers返回的分析器和分词器放入key是名称,value是工厂类的map中。 我们这里只看分词器,实际上被注册到分词组中的是一个工厂类,其返回一个继承自Tokenizer的IK Tokenizer,这里最核心的就是incrementToken(),其会进行循环词语切分,最终将词语切分完毕,如果自定义分词器,此处就是决定分词的方法。 自定义分词器步骤如果我们要实现我们自己的分词器的话其实只要进行如下几步 继承AnalysisPlugin接口和Plugin接口,实现其中获得工厂类的getTokenizers。 实现自定义分词的工厂类方法,其要继承自AbstractTokenizerFactory,实现create来返回自定义测分词类。 实现自定义分词类其继承自Lucene的Tokenizer抽象类,将实现incrementToken方法。 最终将程序中加入plugin-descriptor.properties组价描述文件,打包放入plugin文件中即可。 我们的实践ScriptPlugin现状容错在搜索中十分常见,但我们经过对搜索无结果日志分析发现对于有很大一部分错误都发生在拼音相同但字写错的了情况。 无结果日志 1事件 总数 平均 20190624 20190625 20190626 2A->总次数,马徐俊 8 1.1429 0 0 0 搜索词日志 1搜索关键词 搜索次数 搜索人数2逻辑思维 168 137 问题及解决所以我们希望能够实现拼音级别的容错,然后又不希望字错的字太多,就使用如下DSL 1{\"query\":{2 \"bool\":{3 \"filter\":[4 {5 \"multi_match\":{6 \"query\":\"{{.Query}}\",7 \"analyzer\":\"standard\",8 \"fields\":[9 \"title.standard\",10 \"author.standard\"11 ],12 \"minimum_should_match\":\"50%\"13 }14 },15 {16 \"bool\":{17 \"should\":[18 {19 \"match_phrase\":{20 \"author.pinyin\":{21 \"query\":\"{{.Query}}\",22 \"analyzer\":\"pinyin\"23 }24 }25 },26 {27 \"match_phrase\":{28 \"title.pinyin\":{29 \"query\":\"{{.Query}}\",30 \"analyzer\":\"pinyin\"31 }32 }33 }34 ]35 }36 }37 ]}38 } 39} 但如此便存在一个问题,其匹配到了_dujia_的拼音,又匹配到了其中一半的字家,所以其能被命中返回,如下所示 所以我们新增了一个组件用以限制查询词的长度,太短的词不应进行容错,而且在词变长就不会出现上述问题。其继承自ScriptPlugin,并且实现了自定义的打分逻辑,如果限制的查询语句超过少于限制的长度则直接返回-1分,否则根据配置返回固定的分或者ES打出的分。 主要代码逻辑 1public SearchScript newInstance(LeafReaderContext context) throws IOException {2 return new SearchScript(p, lookup, context) {3 public double runAsDouble() {4 if(query.length()<length){5 return -1d;6 }7 return Integer.MIN_VALUE==constant_score?getScore():constant_score;8 }9 };10} 1// 新增语句2{3 \"script_score\": {4 \"script\": {5 \"source\": \"limit_query\",6 \"lang\": \"limit_query_length\",7 \"params\": {8 \"query\": \"{{.Query}}\",9 \"length\": \"3\",10 \"constant_score\": \"1\"11 }12 }13}14} 思考其实这里我们可以做的更多 在lookup中我们可以拿到每个doc的_source字段 在context中我们可以拿到全局的mapping,setting等信息 在score中可以拿到本来的分数 SearchPlugin现状 我们在实现长句搜索的时候可以使用 more-like-this,其原理大体就是将like的语句进行分词后然后依照BM25 选出在该字段中得分最高的n个词语,然后将原本查询的长语句变成了多个重要词的查询。 问题及解决从morelike中提取出来的词相距距离太长依旧可以召回,相信熟悉Es的同学都知道ES有match_phrase的语法,其中的slop可以限制词的距离,所以我们希望能够实现一个增加词距离的morelike语句,我们称其为more_like_this_phrase,要使es能够识别我们的组件实现SearchPlugin接口,并返回build的类和解析查询的方法就可以了。 1public class MoreLikeThisPharseSearchPlugin extends Plugin implements SearchPlugin {23 @Override4 public List<QuerySpec<?>> getQueries() {5 return singletonList(new QuerySpec<>(MoreLikeThisPharseQueryBuilder.NAME, MoreLikeThisPharseQueryBuilder::new, MoreLikeThisPharseQueryBuilder::fromXContent));6 }78} 由于我们是基于more_like_this进行的修改,所以主要修改的解析体和创建lucene query的逻辑 解析方法 MoreLikeThisPharseQueryBuilder::fromXContent 加入解析slop的方法,将slop存到MoreLikeThisPharseQuery对象中 创建lucene查询 createQuery more_like_this_phrase 在原基础上进行了修改,从多个term query抽取出最少需要匹配到的个数(如果minishouldmatch有配置则使用minishouldmatch的个数,只需匹配任意一个即可),将所抽出的m个 数的词中任意挑选 n个词进行match_phrase+slop的查询原lucene 查询结构 总结插件是解决复杂自定义打分排序逻辑的利器,后面我们会依赖插件实现更多的打分召回策略,为用户提供更好的搜索服务。 [1]: ElasticSearch Plugins and Integrations[2]: Elasticsearch源码分析 Plugin组件加载","link":"/2019/08/11/big-data/elasticsearch/"},{"title":"【八里庄技术沙龙-12 期】如何从零实现一个高性能的API网关","text":"API网关是什么?要回答这个问题我们需要先了解下我们得到的架构变迁。 我们公司最早的时候都是PHP实现的单体应用,比如生活作风的H5商城,得到的V3。这张图就是我们得到的早期架构,当时所有的业务逻辑实现全部在V3当中,然后DCAPI封装了与数据库的交互。这就是一个典型的单体应用架构。 然后到17年的时候,随着公司人员越来越多以及微服务的兴起,我们也开始进行服务化的改造。但是在进行服务化的时候首先面临的一个问题就是:当我们把一个单体应用拆成众多微服务之后,每一个服务如何与客户端进行通信?原来客户端只需要和V3进行对接,现在难道要让客户端分别与这么多服务进行对接么? 这显然是不可行的。 所以,实际上这时候我们与所有进行微服务落地工作的团队一样面临微服务的一些痛点。那么解决这些痛点的方式,一般业界通用的是引入一个叫做API网关的组件。也就是这样的微服务架构。 现在我们就可以来回答网关是什么? API网关一般作为系统与外界联通的入口,在微服务架构中,所有的客户端和消费端都通过统一的网关接入微服务,在网关层处理所有的非业务功能。 网关的核心功能 路由转发 路由重写 访问控制 流量控制 负载均衡 健康检查 服务发现 熔断降级 网关的设计目标 具备Cloud-Native特性 部署支持无限水平扩展 高性能,高可用 开箱即用,功能易扩展 支持多机房部署 网关的抽象模型 应用:一组路由的集合,抽象为应用,一般为一个业务或一条业务线 路由:归属于应用,定义了可以通过网关访问的路由规则及限流策略 服务:对应一个真实的后端服务,可以绑定在多个应用的不同路由上 节点:每个节点即一个后端服务节点,支持监控检查及负载均衡 这四个模型的关系如下: 网关的高可用方案 我们采用etcd来存储配置,通过ETCD的高可用来保证网关集群的高可用和水平无限扩展。 网关的易扩展设计 我们还是希望整个系统能够具备一定的扩展能力,为了避免调度系统过于复杂,所以我们想采用类似Gin框架中间件的形式来实现易扩展。这样每个请求进来之后会穿过所有的中间件,中间件也就可以在这个过程中对其进行操作控制。 Gin框架中间件的巧妙之处主要在于通过Context的Next方法进行自调用实现了一个拦截器。这样,不论在中间件中是否调用了Next都不会影响中间件的执行顺序。如果看代码不好理解,可以看下面这两张图。 网关的内部组件 最终,在易扩展、高可用的基础上,我们在系统内部实现了上图这样的内部结构。 APIServer负责提供配置变更的接口 当它收到配置变更请求时将配置数据写入ETCD 由Watcher组件对ETCD进行watch并把最新的数据映射到Model上,同时编译中间件的HandlerChain 代理服务器负责接收需要转发的请求,开始执行HandlerChain Golang高性能系统实战** 工具:** Jmeter:用于发起压测流量 runtime/pprof:采集程序(非 Server)的运行数据进行分析 net/http/pprof:采集 HTTP Server 的运行时数据进行分析 go tool pprof bin/server http://10.2.0.2:8088/debug/pprof/profile go-torch -u http://10.2.0.2:8088/debug/pprof/profile -f slice.svg ** 环境:** 压测机:8C16G API网关:4C8G 系统:Centos7 反向代理反向代理是网关的基础功能,所以我们首先就对Golang实现的反向代理做了压测,如上图采用Golang官方的net.http包实现。经过多次压测验证,在4C8G的服务器上只能压到19000+,将近两万QPS。这结果令我们很愕然,因为大家都知道Golang的并发处理能力是不弱的,所以我们又单独对Golang实现的HTTPServer进行了压测,实现代码如下图。 最终压出了88000+的QPS,这结果才令人满意。那么我们就分析,Golang的http.Client性能可能远低于Server。我们都知道Golang社区除了官方的net.http包还有一个fasthttp,那么能不能使用fasthttp来替代反向代理中的Client部分呢。见过反复验证,也是可以的。最终我们压出了这两张火焰图。 正则匹配 Tips: 在热点代码中避免使用正则,如果无法避免,那么一定要进行预编译。 字符串拼接 Tips1:单次调用时,操作符+ > strings.Join >= bytes.Buffer > fmt.Sprintf Tips2:多次调用时,bytes.Buffer >= strings.Join > 操作符+ > fmt.Sprintf 频繁创建对象 Vs 复用对象 可以看到明显的提升,所以在热点代码中如果有创建对象的操作,要尽量进行复用。 Slice查询 Vs Map查询 Map查询的时间复杂度为O1,而Slice查询的时间复杂度为On,我们直观上理解Map肯定是比Slice快的。但是实际的情况并不是那么绝对,可以看到上面的例子中,当成员数量为10时我们遍历整个slice的速度都比map的一次查询快。经过研究map底层代码,我们发现这是因为map底层还有hash的操作。 所以,最终经过我们测试,在不考虑key大小的情况下,成员数量小于25时slice的性能要好于map。 高性能总结 避免反射和锁的使用 避免创建过多的对象(避免GC) 尽量复用已经创建的对象(sync.Pool) 避免进行[]byte和string的转换 设置GOGC用内存换CPU时间 对于精度不高的时间自己实现时钟 数据量较少时用slice替代map","link":"/2019/08/14/dd-technical/ddgw/"},{"title":"【八里庄技术沙龙-13 期】进度服务重构之路","text":"进度服务是什么要说明进度服务是什么,首先要说明进度是什么。得到app主要提供内容服务,用户在使用内容服务的时候,就会产生进度。如图,进度的元素无处不在,收听百分比,已听完等。 进度服务是提供进度数据上报和进度信息查询的服务。一般流程如下图: 为什么重构了解了什么是进度服务。下面来说明一下为什么要重构: 不完善的重构 不明确的边界。 不完善的重构在做本次重构之前,实际上已经进行过一次重构了。但是由于第一次重构不完善,导致将变得更加复杂了。首先,经过第一次重构之后,进度项目变得更多了。在原有生产者、消费者和查询服务的基础上,新增了新的消费者和查询者。因此当时除了重新设计新的项目,还需要维护额外的5个老项目。同时,库表也增多了。由于采用分库分表,数据库多达6个,表多达5千个。如此多的库表,在刚接手时,维护服务异常痛苦。第三个是上报的接口本来一个就可以满足需要,最终采用的是不同业务不同接口,导致上报多达5个。 不明确的边界进度服务该包含哪些东西,什么是进度服务的边界,之前是完全没有概念的。一有需求就加进来,导致服务越来越臃肿。 最后,更可怕的是,之前维护老进度项目的人都走了。 如何重构根据进度服务的流程从三个部分进行解决: 简化内部服务 统一上报接口 简化第三方打点。 如下图,绿色的框表示进度上游客户端上报,红色的表示进度服务内部系统,蓝色是针对下游服务打点。 简化内部服务针对以上三个部分的工作,首先选择简化内部服务。只有一个稳定的内部服务,才能保证系统的稳健。简化内部服务主要分为四步: 1)抽象数据结构和流程 2)双写 3)迁移数据 4)切换服务。 抽象数据结构和流程首先是抽象数据结构和流程。要抽象数据结构先来看看得到服务业务和资源的关系。听书和电子书只包含一个资源,而课程包含文章和音频资源。产品要求学完任意一个就算学完该类资源。因此抽象出面向资源的子资源进度,面向业务的主资源进度。如下图 针对于包含两种资源的业务,如何按照要求进行合并呢?一般分为两种方案,一种是查询的时候,进行合并,另一种是写入的时候合并。如下图: 查询合并的好处在于只需要记录子资源,在查询的时候进行合并处理进度。缺点是针对多个资源查询翻倍,批量时更是需要处理多对数组的合并工作,比较复杂。写入合并的好处是查询方便,缺点是需要写入两张表。由于考虑到后者比较简单,且进度状态不可回退,采用写入合并的方案。 下面看看进度服务的流程。对于资源进度,无论什么业务,都会按照三个阶段的方式记录数据。因此抽象出统一的上报流程。如下图: 上报流程经历的流程比较长,任何阶段都可能出现异常。需要采用重试,以保证数据的一致性。如果采用全流程重试就需要更大范围的事务,会影响性能。因此采用阶段重试的方法,阶段方法内部进行事务保证。那么就需要一个灵活的流程控制。 流程代理就是来完成这个工作的。如下图,红色的部分是阶段方法的接口标准,需要根据通用参数完成对应的阶段业务处理。其中分别包含三个阶段方法的具体实现。绿色的部分是通用参数的封装,整个过程中通过通过参数传递数据。下面的蓝色部分是流程代理,其中包含一个通用方法的map,处理函数主要包含方法数组和通用参数。处理流程是通过循环执行传入的阶段函数,完成流程灵活处理。 双写通过抽象数据结构和流程,完成内部处理的简化。这样就可以写数据了。由于之前的重构导致项目比较复杂,不可能一次性替换,因此采用双写的方式。双写有两种方案: 消费topic 双写到新接口。 消费topic主要用于新的数据结构与老的结构基本一致,这样可以更加快速的接入数据。 双写到新接口。由于新的数据结构与老的结构差异比较大。因此在原有的消费者里面进行数据适配到新服务的接口,最终完成双写的目的。 通过双写完成了新数据的写入,要保证进度数据的完成性,就要导入老数据了。 3迁移数据首先进行存储选型。原有的mysql存在6个库和5千多张表,实在是无法继续维护。因此采用mongo数据库。首先mongo比较便捷,代码无需做分表分库的处理,通过mongo的自动分片完成数据的切分。另外是mongo的高并发,写入最高可达到20wqps。第三点是可弹性扩容,再也不用担心容量的问题了。最后是有mongo大佬。选择mongo作为数据库,针对于大约100亿数据,要进行迁移就需要选择工具了。由于需要进行一个业务类型的合并以及数据的补全,最终采用跑脚本的方式。 脚本的流程分为三个部分,首先是根据环境和数据库参数初始化环境,然后是根据开始分区和结束分区进行表级的并发处理。并发处理中主要是在限定时间段按照id进行循环批量扫描的方式进行。如果扫描到数据则写入mongo,没有就表明表已经扫描完毕直接退出。如此循环至所有分区处理完毕。 迁移数据最重要的是断点续传的问题,主要有一下几个办法。 打印扫描的id,中断时可根据最后一个id作为起始id进行继续执行。 标记数据来源。 记录自增id。 另一个问题是先双写还是先导数据。双写的好处在于不需要增量更新数据,缺点在于对于有状态的数据无法批量处理。先导数据的好处在于可以批量新增数据,但是双写后,需要增量更新导入数据时间节点到双写开始节点的数据。 迁移数据最重要的是数据的验证。 脚本记录累计查询数和写入数 对比数据总数 抽样对比数据 大数据校验趋势 最终,通过抽象数据,双写,迁移数据和验证完成了简化内部服务的目标。 统一上报接口内部服务稳定之后,开始处理上游上报。进度服务提供各个业务统一的上报接口,并完成数据上报。同时客户端上报架构变更为业务负责数据的组装,上报组件上报数据,并完成打包、重试等工作。如下图所示: 与客户端上报统一之后,上报数据的准确性和可追溯有了保障,这样查询问题就更加简单了。 第三方打点客户端上报不仅仅记录进度数据,同时需要及时触发第三方效益,也称为第三方打点。首先了解一下调用关系。当用户上报时,通过主动通知,下游方可以及时完成效益处理,通过回调获取进度数据。用户还可以在具体的业务页面进行被动处理。 第三方打点流程主要包括三个部分,检验条件、组装参数和调用接口。 随着第三方打点越来越多,不想每次重复开发,因此需要通过配置开发简化打点工作。根据对打点流程进行分析,从其中三个方面抽象出配置化所需要的数据结构。 对于判断条件是根据进度内部相关的数据进行判断。这些内部数据就是进度服务的数据结构,也是进度服务的领域数据。所有的判断只能是进度服务领域内的数据,例如进度大小、资源类型等。 调用接口主要包含服务名和请求地址。请求参数是进度模块所有包含对外的通用数据结构,这样能够保证进度参数更加灵活。 根据上面三个过程的说明,抽象出对应的配置化数据结构如下。其中事件表示不同类型的进度上报模块。控制条件表示频率的控制,由于进度服务请求比较大,需要对频率进行控制,防止下游被打挂。 下游服务配置好配置数据后,程序就可以根据配置数据进行逻辑处理。配置化的处理流程如下图,首先服务启动时会初始化配置管理器。配置管理器通过查询配置化数据,实例化对应事件的检测器、条件解析器、调用器等组件。当有上报事件发生时,会从配置管理器中获取对应的处理器,然后通过检测器判断是否执行调用,如果需要执行则调用对应的调用器发送请求,如果不需要则返回,执行其他的处理器继续执行。调用器会调用一个新的调度服务,完成重试等调用工作。 最终,通过抽象数据结构和流程、双写、迁移数据完成了简化内部服务的目标,保证了内部服务的稳定。通过统一接口简化上游上报,保证了数据上报的准确性和简单。通过配置化开发简化了第三方打点流程。通过以上三部,完成进度服务的最终重构。 经验重构中遇到很多问题,下面从mongo超时、kafka积压和海亮数据处理说说遇到的问题。 mongo超时问题mongo超时问题主要数据均衡、IO限制和索引命中。 由于使用的是mongo集群。需要手动设置分片键,这样数据才会根据分片键决定存储在集群的具体物理机器上。如下图,假设以user_id为分片键,按照范围来存放到chunk,如上图6个chunk盒子。那么对应范围的user_id的所有数据会落到对应的chunk上。配置服务会记录分片键和chunk的关联,以及chunk与物理机器的关联。默认情况不做分片,所有的chunk会写到主节点上,即所有的数据会写到主节点。很悲剧,行为数据所有的数据都写到了主节点。很快1T的硬盘不够用了。很明显,上图这种情况,数据就发生了倾斜。如果开启均衡,数据就会从a机器上转移到b或c机器上。此时机器io可能会被打满,导致超时。 另外阿里云选择的搭建mongo的ECS配置比较低,IO不够,导致容易打满。同时与其他服务混合部署,其他服务发生大查询时影响进度服务。最后采用高配阿里云高配ECS独立部署服务集群。 索引选择问题。mongo索引是通过采样的⽅方式选择的。因此在数据写入量比较⼤且数据可能出现倾斜的情况下,采样不不准确导致索引选择不合适。最终采⽤hint强制走具体索引的⽅方式解决。如下图: kafka积压重构的过程中,也发生kafka积压的问题。kafka的partition只能被群组中的一个消费者消费。阿里云的partition是有限制的,因此不能够通过增加partition来解决。最后通过加入线程池的方式解决该问题。虽然一个partition只能被一个消费者连接。但是可以有多个消费线程去执行具体业务。因此如下图: 主线程连接kafka获取消息,同时新建一定数量的goroutine去等待消费。主线程获取到消息后写入到内部channel。多个goroutine从内部channel获取消息并发执行。通过这种方式解决消息积压的问题。 海量数据处理另一个头疼的问题是接近55亿的行为表,需要修改双写10天的数据。采用导数据的方式id大于某个值,遍历数据修改。但是执行的时候总是出现超时,更新数据脚本总是无法执行完毕。最后采用分而治之的方式,将10天的数据分别写入10张新表,并附加自增id。然后通过并发的方式,更新原始表。通过这种方式解决了海量数据处理的问题。 总结最后总结一下,做服务明确边界,多做抽象;海量数据处理分而治之,并发处理。","link":"/2019/09/20/dd-technical/process/"},{"title":"【八里庄技术沙龙-15 期】得到安卓客户端的工程架构实践","text":"大家好,我是刘硕,来自得到安卓客户端。主要负责业务架构方向的工程效能提升相关工作。我们希望,通过对工程架构的改造升级,践行工程化方面的一些通用实践。使安卓团队在研发效率和研发体验上得到整体提升,提高app稳定性。 最近两年,我们在工程架构方面有了一些成果,主要围绕着工程架构,开发架构相关方面做了很多工作,大概分为两部分内容:组件化和mvvm开发架构。 组件化拆分组件化的概念其实理解上很简单,所谓组件化,就是把一个功能完整的app拆分成多个子模块,每个子模块可以独立编译和运行,也可以将这些子模块任意组合成一个新的app,子模块之间不互相依赖,但可以相互交互。 单体工程架构一般app的开发早期,团队的重心并不在开发架构的选型上。主要也是因为早期的项目比较小,大家更关注多快好省的完成任务,对于如何复用,如何解耦没有过度考虑。如下图是得到早期的工程架构。 随着版本迭代,app的功能越来越多,项目结构逐渐也演变成了一个庞大的单体工程,内部依赖错综复杂。 当然,这会带来很多很多问题。 第一、逻辑复杂,不易理解。想要熟悉掌握所有功能需要耗费大量的时间和精力,不仅如此,对于新人熟悉业务来说,也会给他们带来巨大的挑战。 第二、不同的业务功能耦合严重。导致面对一个修改,我们无法界定它的影响范围,牵一发,动全身。 第三、构建时间越来越长,降低了研发效率。 单体工程组件化拆分为了解决单体工程存在的这些问题,我们开始了工程的组件化改造。优先梳理各个业务模块,将不同业务模块的代码和资源放到不同的业务子工程中,这些作为组件化工程中的业务组件。对于不同业务组件之间公用的代码和资源,下沉到基础子工程中,作为业务子工程的依赖。独立壳工程app,它的主要任务是负责组件的集成打包,所以尽量不要包含业务逻辑。组件化拆分如下图: 组件间通信工程组件化拆分结束后,我们遇到的另一个问题是如何进行组件间的方法调用,因为业务组件之间是完全解耦的,所以不能简单的通过引用的方式进行调用。 我们为业务组件之间通信提供了两种方式。页面路由和服务调用。从实现上来说,这两种方式都是遵循协议下沉的原则,将服务协议下沉到通信组件。 app启动后,组件工程分别将自己提供的服务和页面路由注册到通信组件。如果某个组件希望调用其他组件提供的服务或路由到其他组件,就可以通过查询通信组件中的注册信息,完成组件间通信的任务。 组件单独调试运行如果我们集成所有组件构建项目,时间会很长,平均大概10分钟左右,为了解决这个问题。我们提出了单组件调试运行的方案。 通过自定义组件构建脚本,每个业务组件都可以作为壳工程独立运行。并且可以集成其他依赖的组件并进行组件间通信。通过这种方式,编译效率得到了很大的提升,我们运行单组件工程,构建时间只需要40秒左右。 组件化2.0组件化用了大概一年时间,我们遇到了新的问题。大家有时希望全组件集成运行app,正如之前的解决方案,我们并没有针对这种情况的优化手段,所以就导致了每天还是浪费了很多时间在编译构建上。 其实,gradle在构建项目时,确实是支持增量编译的,但有时改动一个文件,会导致项目构建时间超过10分钟。我们需要尽快解决这个问题,不然每天团队所有人在构建上浪费的总时间,我粗略的算了下 7个人5次10分钟,超过5个小时,还是比较吓人的。 全组件集成构建流程分析下图简要的描述了我们集成构建app的时候需要执行的核心环节。通过分析,我们发现构建时的一些问题。每次编译项目,所有组件工程都会重新执行同样的编译构建流程,所以,如果组件集成可以直接使用aar的方式,那么这些执行的重复构建就可以节省下来。编译时间上会有很大的提升 组件打包aar所以,顺着前面的思路,我们构建了一套完整的组件打包体系。 ci 负责实时监控git仓库代码变动,当开发人员提交了代码,CI 自动开始执行组件打包脚本。打包脚本会分析出所有包含代码变动的组件,并计算出组件对应的版本和maven仓库信息。使用这些信息,执行每个组件的gradle打包任务,并将打包成功后的产出物 aar 上传到组件仓库。 组件化2.0集成构建组件有了aar的管理方式,我们的全组件集成构建逻辑就可以进一步得到升级。 执行全组件集成构建时,首先解析工程下的组件化配置文件。该配置文件中明确标明了某个组件的依赖方式是aar还是源码,及aar的依赖版本和仓库信息。然后构建脚本就会根据这些信息,灵活的配置组件依赖并集成构建app。 使用aar的集成方式,避免了组件的再次编译,全量编译时间从之前的10分钟降低为现在的2分半左右 MVVM 开发架构这部分,主要包含一些我们在选型开发架构上的心得和实践。如下图描述,工程组件化架构搭建完成后,我们的编译效率和项目管理方式得到了很大的改进。 但是,组件内的业务开发还在采用比较粗放的模式。Controller作为业务功能组织的核心,完成了大概80%的工作量,内部耦合十分严重,极大的限制了代码的复用能力,导致研发效率底下。 MVC VS MVP 为了解决目前以Controller为核心的开发模式带来的代码复用问题,我们对比了常见的三种MVX 架构。 其中,MVC 是开发gui应用程序的经典架构。但是,由于Controller直接持有了View的引用并使用这些引用组织展现逻辑,导致展现逻辑不能很好的被复用。 MVP的出现很好的 解决了MVC中展现逻辑不易复用的问题。MVP中展现交互逻辑完全由Presenter负责,并通过View接口与View通信。 但是MVP也存在一些问题。展现逻辑的复用粒度由View接口的力度决定,而且,当展现逻辑非常复杂,就会造成Presenter与View联系过于紧密,限制了复用能力。 MVP VS MVVM 相对于MVP,MVVM中展现逻辑的复用更为彻底。MVVM 中创新的提出了抽象View的概念 ViewModel,ViewModel封装了View的一切状态和行为,但与具体的显示框架,布局规则没有任何关系。 这就使得ViewModel可以满足几乎任何场景下的被复用需求。基于传统的MVVM概念和google 推出的AAC 架构组件,我们开发了一套更符合自己实际情况的MVVM方案. 下面开始详细的介绍我们的MVVM 实践。 MVVM 中的依赖原则MVVM 遵循单向依赖原则,依赖关系从上向下 依次为 View 依赖 ViewModel,ViewModel依赖 Model,不允许跨层依赖。这样的好处是可以使调用依赖关系更加清晰。 沿着依赖方向的通信方式以直接方法调用为主。由于不能违背依赖原则,从下向上的通信主要借助观察者模式实现,上层注册观察者,下层需要通信时,触发观察者回调。 MVVM 中的类层次如下图,MVVM 中View ViewModel Model 都有自己的类层次结构。 其中,View 需要 承载 布局渲染等逻辑,所以Activity, Fragment,ViewHolder 及其子类属于View的角色范畴。 ViewModel作为View的抽象表示,分别针对页面和列表item提供了不同的子类实现。 Model中BaseModel类主要封装了网络库相关的方法调用,具体子类可以根据不同场景,实现不同的需求。 MVVM 在首页的实践 首页算是得到app中比较特殊的页面。最外层结构是一个列表,列表中每个item 独立请求需要显示的业务数据。 我们在使用mvvm架构整个页面的过程中,确实遇到了一些问题。这些问题,大概包含了三个方面的内容。 问题1:如何复用逻辑面向对象开发中,复用的主要手段包括组合还有继承 那么,mvvm中,展现逻辑和数据逻辑的复用,也不外乎这两种手段。 例如,得到app 首页中 推荐课程,推荐听书都包含 负反馈和底部推荐标签功能,我们将这两个功能抽象到TagsItemVM 中,课程,听书VM分别继承TagsItemVM,这样就可以非常容易的实现这两个展现交互逻辑的复用。 问题2:ViewModel如何感知View的生命周期变化 在mvvm中,View直接持有ViewModel的引用,所以,当View的生命周期发生变化,ViewModel对应的生命周期函数会立即被调用。通过这种方式,我们确保ViewModel与View的生命周期能够保持同步 但由于页面Activity,Fragment和列表ItemViewHolder具有不同的生命周期形式,所以他们对应的ViewModel会有不同的生命周期回调。 ViewModel内部使用一个对象维护自身的生命周期状态,当ViewModel与View绑定后,ViewModel的生命周期 活跃,当ViewModel与View解除绑定后,ViewModel的生命周期不活跃。 此外,ViewModel生命周期的活跃状态受其parent ViewModel的生命周期影响,当parent ViewModel 不活跃,当前ViewModel的生命周期同样已经不活跃。 通过感知ViewModel生命周期的活跃状态,在生命周期不活跃时,执行某些资源的清理操作,可以有效防止内存泄露。 code block 问题3:ViewModel之间如何互相通信 ViewModel之间通信主要依赖于LifecycleBus,这是一种特殊的事件总线。 ViewModel不活跃时,由于会断开与总线的链接,所以不会收到总线上的事件。 这样的设计主要考虑到 viewmodel 已经不在与View有绑定关系,ViewModel继续关注View中的事件通知是没有意义的,还可能带来其他未知的问题。 这中方案还带来了另外的好处,使event的派发效率更好,因为事件只会派发到活跃的ViewModel 消除模板代码,简化开发 如上图,传统的MVVM实现中,如果我们希望实现一个列表效果,至少需要新创建四个文件,view adapter,item view hodler,item view model,layout file。但是,view adapter和view holder中主要是一些模板代码,几乎没有有效的业务逻辑。 所以,我们为了解决这个问题,在MVVM framework中提供三个基础设施类,通用的view adapter,通用的view holder,bindItemVH注解。 这样,我们再实现同样的列表效果,只需要创建两个文件 item view model和layout file。 然后使用注解关联这两个文件。运行时,通用的view adapter 根据注解指定的关联关系,就可以将相关的ui渲染到屏幕上。 我们的收获目前我们已经上线首页,已购的mvvm改造,消息中心,问答,搜索,课程的mvvm方案也已经完成。 通过mvvm开发架构的升级,我们的程序结构更加清晰,代码可读性更高,通过运行时注解的支持,彻底消除了不必要的模板代码,使我们的开发更加顺畅。 未来规划未来,我们想尝试的方向有组件平台化,插件化,跨平台,希望通过这些手段,进一步提升团队协作效能,提高app研发效率,和用户体验 以上就是我们最近两年在工程架构上的努力,谢谢大家","link":"/2019/11/07/dd-technical/ddmvvm-android/"},{"title":"Chrome扩展开发科普","text":"chrome 扩展是什么chrome 扩展是用传统的 HTML、CSS、JS、图片等静态资源开发并最终打包成后缀为 .crx 的一个压缩包。所以,它和我们平常开发的页面没有多大的区别,所以如果你想引入前端开发所用的各种框架,组件库,构建工具也都是可以的。主要区别只有 2 个: 扩展的页面、js 和普通的页面运行位置不同 扩展可以调用 chrome 提供的更多的 API 来增强我们扩展的能力 chrome 扩展的安装方式扩展的安装方式有 3 种: 通过 chrome 扩展商店,下载安装 在其他网站下载打包好的 .crx 压缩包,把压缩包直接拖拽到 chrome 的扩展管理页面 如果是自己开发的扩展,可以在扩展管理页面,打开开发者模式,手动加载已解压的扩展程序,进行本地调试 chrome 扩展的展现形式这里只简单地介绍几种经常见到的,还有更多的展现形式,大家感兴趣可以去官方文档详细了解 点击地址栏右侧 icon 会有页面弹出,这个大多数扩展都会有,主要是扩展的设置或者功能的入口 页面修饰内容:通过添加 DOM 对页面赋予新功能,比如 Octotree (对 gitub 项目页面做导航) 页面右键菜单:定制在页面内右键弹出的菜单,很多划线翻译的扩展都利用了这个功能 覆盖 chrome 默认页面: chrome 有的页面支持开发者自定义,比如 Momentum 就覆盖了默认的 New Tab 页面 devtool 工具:这个是开发者经常用到的 比如 vue-devtool 等框架提供的调试工具 开发介绍具体扩展各个组成部分的学习,我们以一个很简单的例子为基础进行介绍,这个扩展是一个为页面添加回到顶部功能的扩展。 配置文件每一个扩展都必须要有的一个名字为 manifest.json 的配置文件,这个文件声明了此扩展用到了哪些功能,及各个功能需要用到的静态资源 这个 backToTop 扩展的配置已经在途中说明,已经有了基本的说明,下面对一些配置项做下额外的说明,全部配置可以在官网查看: browser_action 指定了 popup 页面相关的 icon、html、tooltip 文字等配置,相似的还有一个 page_action,它的配置参数和 browser_aciton 是相同的 但是它可以通过 chrome.pageAction API 来动态的设置扩展在某些页面的行为 icons 配置不用每个尺寸都给出,chrome 会自己选出效果最合适的 icon permissions 声明扩展需要用到的 chrome 特性 常用 API chrome.runtime chrome.tabs 对标签页进行操作、与对应标签页内容通讯 chrome.storage 扩展的存储,类似 localstorage chrome.contextMenus chrome.extension 核心 JS这部分我们说下扩展开发核心的几种 JS popuppopup 页面生命周期是点击弹出时,初始化,关闭时,页面也跟着销毁, 并且这个页面没有任何跨域的限制。它在我们扩展里的作用是配置页面里 backToTop icon 的样式,并存入storage。 1chrome.storage.local.get(['right', 'bottom', 'icon'], function(result) {2 if(result) {3 // 初始化4 right.value = result.right || '';5 bottom.value = result.bottom || '';6 if(result.icon) {7 img.src = result.icon;8 img.dataset.uploaded = true;9 }10 }11}) 页面初始化时,从 storage 取出存储的值,初始化页面。保存参数时,再将相关参数存入 storage 1chrome.storage.local.set({right: rightVal, bottom: bottomVal});2 if(img.dataset.uploaded) {3 chrome.storage.local.set({icon: img.src});4 } 注: storage API 有 2 种 storage.sync 和 storage.local 他们的区别 sync 会将存储的数据定时发到 chrome 的服务器,进行数据的同步,local 就只是将数据存储在本地 尺寸的不同:local 和 localstorage 是一样的 5M. 而 sync 存储的大小只有 100K 而且对于单个 key 的值大小,以及写入的频率也有限制,毕竟要同步服务端,所以如果开发的扩展只是用于个人使用的效率提升,不打算发布,可以直接用 local 就好了 content-scriptcontent-script 是我们用来定制化页面,实现页面内扩展逻辑的地方。它的特点是: 因为在页面内,当然可以访问 DOM 但是和页面的 js 是完全独立的,不能互相访问 无法对页面内的 DOM 事件绑定 扩展里的回调,这种情况可以通过 content-script 创建一个 script 标签插入到 DOM 里 这个新的 script 里的函数是可以绑定的 因为在页面里运行,所以是会收到跨域限制滴 运行时机是随着页面的加载而运行,页面关闭也就卸载了,所以说 content-script 会在每一个 tab 页面里都有一份代码在运行 因为在页面初始化才会运行,所以在初始加载插件时,需要刷新页面,content-script 才会开始运行。 注入页面的 css 优先级非常高,一定要注意好类名 ID 名的设置 那我们的 backToTop 里 content-script 都干了什么呢首先,初始化我们页面里的 icon 并根据页面 scrollTop 判断当前是否需要展示 icon 1const el = document.createElement('div');2el.show = true; // 控制icon是否显示3el.classList.add('ce-btt-container');456el.style.opacity = target.scrollTop > visibleHeight ? 1 : 0;78const img = document.createElement('img');9img.classList.add('ce-btt-icon');1011el.appendChild(img);121314chrome.storage.local.get(['right', 'bottom', 'icon'], function(result) {15 el.style.right = result && result.right ? result.right + 'px' : right;16 el.style.bottom = result && result.bottom ? result.bottom + 'px' : bottom;17 img.src = result && result.icon ? result.icon : chrome.runtime.getURL('icons/backToTop.png');18 document.body.appendChild(el);19}); 第二步, 要想实现返回顶部,当然要给我们的 icon 绑定点击事件,以及监听 scroll 事件判断什么时候该隐藏展示 1el.addEventListener('click', function(e) {2 let step = 20;3 let timer = setInterval(() => {4 if(target.scrollTop <= 0) {5 clearInterval(timer);6 } else {7 step += 20;8 target.scrollTop -= step;9 }10 }, 20);11});1213const handleScroll = function() {14 if(!el.show) return false; // icon不显示时,不处理15 if(target.scrollTop > visibleHeight) {16 el.style.opacity = 1;17 } else {18 el.style.opacity = 0;19 }20};2122container.addEventListener('scroll', throttle(handleScroll, 300)); 第三步, 如果 popup 页面有配置的变更, content-script 都需要立刻进行更新 1chrome.storage.onChanged.addListener(function(changes, namespace) {2 if(changes.bottom) {3 el.style.bottom = `${changes.bottom.newValue}px`;4 }5 if (changes.right) {6 el.style.right = `${changes.right.newValue}px`;7 }8 if (changes.icon) {9 img.src = changes.icon.newValue;10 }11}); 注: 这里需要注意的是,因为我们要把扩展里的 icon 插入到页面,所以需要在 manifest.json 里配置 web_accessible_resources 赋予页面可以访问我们指定的扩展静态资源的权限。这是因为页面里 icon 的 src 属性是这样的 backgroundbackground 可以理解为是扩展在后台一直运行的一个 JS(实际并不是), 它在整个浏览器里只会有一个 js 在运行。在 background 的配置里,有一个 persistent 的配置, 当它为 true 时,background 才会一直运行,false 时,浏览器会检测长时间不活动时,自动卸载调,只有监听的事件发生时,才会重新执行,官方的说明是 The only occasion to keep a background script persistently active is if the extension uses chrome.webRequest API to block or modify network requests. The webRequest API is incompatible with non-persistent background pages. 所以绝大多数时候,我们把它设为 false 就可以了。另外 background 也是可以跨域的,所以我们可以总结出,除了页面内的 js chrome 对其他的 js 都没有跨域的限制。 好,我们看看我们扩展里 background 干了啥 首先,初始时,肯定要监听浏览器的初始化事件,才可以绑定扩展关注的事件。 1chrome.runtime.onInstalled.addEventListener(function() {2 // init extention3}) 第二步,因为有的页面已经提供了返回顶部的功能,所以这个时候我们需要提供可以把我们的 icon 永久隐藏的功能,我们在 background 初始化的逻辑中,创建一个鼠标右键的菜单项,这个菜单项可以实现 切换我们的 icon 显示状态的功能 1chrome.contextMenus.create({2 title: 'toggle',3 id: 'toggle'4 });56chrome.contextMenus.onClicked.addListener(function() {7 chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {8 sendMsg(tabs.length ? tabs[0].id : null);9 });10});1112function sendMsg(tabId) {13 chrome.tabs.sendMessage(tabId, 'toggle', function(res) {14 console.log(res);15 });16} 发送消息这里我们用了 chrome.tabs API 因为我们每个 tab 都会有一个 content-script 所以需要筛选出当前所在的标签页,然后发送消息。 最后,我们的 content-script 需要监听发送消息的事件,并切换 icon 的状态 1chrome.runtime.onMessage.addListener(function(req, sender, respond) {2 if(req === 'toggle') {3 if (target.scrollTop > visibleHeight) {4 el.style.opacity = el.style.opacity === '1' ? 0 : 1;5 }6 el.show = !el.show;7 respond('toggle success');8 }9}); 至此,整个扩展的功能就基本介绍完了,过程中用到的 API 这里并不作详细的介绍,详细使用还是需要大家去 官网 查看 调试最后说一下如何调试,调试个人认为还是比较麻烦的, 代码变更并没有热更新,所以需要我们手动去扩展管理页重新加载。而且几种不同的 JS 调试的位置也不同,设计通讯时经常需要在几个不同的地方来回切换 popup 调试:在弹出的窗口里,右键审查元素就可以弹出调试窗口,调试方式和普通的页面调试没有区别。 background 调试: 在管理页点击背景页,就可以弹出调试窗口了 content-script 调试: 因为是在页面运行,所以调试的地方和页面 js 是在一个窗口里 代码位置:在 sources tab 下选中 Content scripts 就可以看到页面加载的全部的扩展 contnt-script 了。 console 输出: 在 Console tab 下拉框里选中要调试的扩展,就可以看到对应扩展的 console 输出。 打包发布在扩展管理页,可以打包扩展程序,打包后就可以生成 .crx 文件 打包之后,就可以发布了,不过发布需要先花 5$ 注册开发者,我就没有继续下去了。 总结在开发过程中,可以发现写一个扩展其实并不难,用到的技术都是前端每天都在用的东西。只要我们多加留心,就会发现使用 chrome 过程中有许多可以提效,优化体验的地方,这时候我们就可以试着用扩展的方式解决。总体来说,chrome 扩展是一种技术成本很低,就可以干些有趣的事情的技术 参考资料: 官方文档 Chrom插件开发全攻略","link":"/2019/08/20/fontend/chrome/"},{"title":"Grid 布局初探","text":"Grid布局是微软在2010年提出来的一种新的布局方式,到2016年的时候提交了该布局的草案,经过这三四年的发展,grid布局慢慢变的成熟,兼容性也越来越好,可以适当学起来用起来了 本次学习的几个点: CSS布局发展过程 Grid布局的优点以及相关术语介绍 Grid布局的使用 注意事项、备注 参考资料 在我们开始学习前,先了解下它能用在什么情况下 例如这个页面就是用grid布局的: Grid布局 网格在了解和学习网格布局之前,我们先了解下什么是网格,网格是一组相交的水平和垂直线,它定义了网格的行和列 CSS布局发展过程 table 表格布局:table 比其他 html标记占更多的字节、布局比较死板不灵活、会阻挡浏览器渲染引擎的渲染顺序从而使得加载速度慢 float + position方式布局:使用 float 浮动和 position 定位去布局,float会使得元素脱离文档流,浮动高度塌陷,还需要额外的清除浮动解决这种高度塌陷、不易于垂直居中等问题 flexbox 弹性布局:一维布局,最适合应用于程序的组件和比较小规模的布局,比较好用而且支持性较好 grid 网格布局:二维布局,适用于大规模的布局 例如:下面这两种类型的布局就很适合用grid布局: 本人认为Grid布局的出现并不是要取代上面的几种布局方式,而且跟上面的几种方式一起结合使用,用更简洁的代码实现页面布局 Grid布局的优点优势 固定或弹性的轨道尺寸:可以给每个轨道设置固定的尺寸,也可以设置auto | 1fr | 10% 等弹性的尺寸,实际展示的轨道大小会随着父级的宽高变化而变化 定位项目:可以给每个子项设置具体所占据的位置 创建隐式的轨道:当子项设置的定位位置超出了父级设定的轨道大小,会创建隐式的轨道 对齐控制:和flexbox一样,有多种对齐方式的控制 控制重叠内容,直接在子项上设置z-index的值即可 兼容性 pc端的浏览器的兼容性还是不错的,IE10和11需要添加-ms-来实现兼容 移动端需要注意的是:ios10.3版本以下不支持,使用需斟酌或者做好兼容处理 Grid布局中的相关术语Grid Container: 网格容器,一个元素应用了 display: grid; 后就是一个网格容器了,它是所有网格项的父元素,例如下面的代码里<div class="grid"></div>就是网格容器 1// html2<div class="grid">3 <div class="grid-item1">grid-item1</div>4 <div class="grid-item2">grid-item2</div>5 <div class="grid-item3">grid-item3</div>6 <div class="grid-item4">grid-item4</div>7 <div class="grid-item5">grid-item5</div>8 <div class="grid-item6">grid-item6</div>9</div>1011//css12.grid {13 display: grid;14} Grid item: 网格项,上面的 grid-item 就是网格子项 Grid Line: 网格线,组成网格项的分界线,虚拟的,下图的3×4的网格里有4条水平网格线和5条垂直网格线 Grid track: 轨道,网格轨道,两个相邻的网格线之间为网格轨道 如下图:共有7个网格轨道,水平方向3个网格轨道,垂直方向4个网格轨道 Grid Cell: 网格单元,两个相邻的列网格线和两个相邻的行网格线组成的网格单元,网格项是HTML里的dom元素,而网格单元是定义网格容器的时候分割出来的网格单元 Grid area: 网格区域: 四个网格线包围的总区域,与网格单元不同的是,网格单元必须是相邻的网格线 fr 单位: 剩余空间分配数,用于在一系列长度值中分配剩余空间,按数字比例分配 例如: 网格总宽度如果是600px,那么下面这种设置中,1fr = (600 - 50 - 150) * (1 / (1+3)) = 100px 1.grid {2 grid-template-columns: 50px 1fr 3fr 150px;3} 容器中的属性查看练习demo display 它的值为: display: grid;:设置为容器元素 display: inline-grid 设置为容器元素,且为行内网格 display: subgrid :如果网格容器本身是网格项,此属性用来继承父网格容器的列和行大小 它的兼容性很差,基本可以先不了解 grid-template : 定义行与列的轨道大小,它是一个复合写法,具体属性包含了: grid-template-rows: 水平方向划分行,值为每一行的高度,空格分隔 grid-template-columns: 垂直方向划分列,值为每一列的宽度,空格分隔 grid-template-areas: 网格划分区域,值为命名 语法: 1.container {2 grid-template-rows: <track-size> | <line-name> <track-size>;3 grid-template-columns: <track-size> | <line-name> <track-size>;4 grid-template-areas: 5 <grid-area-name> | . | none6 <grid-area-name> | . | none7}89// 复合写法:10.container {11 grid-template: <grid-template-rows> / <grid-template-columns>12} <track-size>: 可以使用css的长度单位、百分比、auto或者一个新的单位fr 其中 auto 是用来表示剩下的长度 单位 fr :除去其他的设定的固定的宽度以外,剩下的按比例分,类似于flex中的flex: n; <line-name>: 可以给每条网格线设置名称 [任何名称] <grid-area-name> : 区域名称 “任何名称” 1.container {2 grid-template-rows: [第一条行线] 25% 100px auto;3 grid-template-columns: [第一条列线] 100px 20px auto 40px;4} 表现为如下: grid-template-areas: 设置区域名称的 grid-gap :行和列之间的间隔宽度 , 它是两个属性的复合写法 grid-gap-rows: 行与行之间的间隔 grid-gap-columns: 列与列之间的间隔 1.container {2 grid-gap-rows: 20px;3 grid-gap-columns: 10px; 4}56// 复合写法:7.container {8 grid-gap: 20px 10px;9} place-items: 每个单元格内部的水平垂直对齐方式的复合写法 justify-items: 水平方向对齐方式 align-items: 垂直方向对齐方式 两个属性的值都有以下几种 stretch : 默认值,水平|垂直 内容拉伸填充 start: 水平|垂直 (宽度|高度)收缩为内容大小,(左侧|上侧)对齐 end:水平|垂直 (宽度|高度)收缩为内容大小,(右侧|下侧)对齐 center:水平|垂直 (宽度|高度)收缩为内容大小,居中对齐 1.container {2 place-items: <align-items> / <justify-items>;3} place-content: 以下两个属性的复合写法,是表示网络单元的水平布局方式 justify-content: 仅在网格总宽度小于grid容器宽度时候有效果 值分为以下几种: stretch:拉伸,宽度填满grid容器,需要定的网格尺寸为auto的时候有效,如果定死宽度则无法拉伸 start:左对齐 end:右对齐 center:居中对齐 space-between:两端对齐 space-around: 每个grid子项两侧都环绕互不干扰的等宽的空白间距,最终视觉上边缘两侧的空白只有中间空白宽度一半 space-evenly:每个grid子项两侧空白间距完全相等 align-content: 网络元素的垂直方向布局方式, 如果grid子项只有一行则不生效,它的值同上 grid-auto: 以下三个属性的复合写法 grid-auto的相关demo grid-auto-rows:网格项目多余设置的单元格,会创建隐式轨道 grid-auto-columns:网格项目多余设置的单元格,会创建隐式轨道 1.container {2 grid-auto-rows: 100px;3 grid-auto-columns: 70px;4} grid-auto-flow: 控制没有明确指定位置的grid子项的放置方式 值分为以下几种: row: 默认值,没有指定位置的网格按顺序水平方向排列 column: 没有指定位置的网格垂直顺序排列 row dense:自动排列启动密集排序,水平方向 column dense:自动排列启动密集排序,垂直方向 看示例 demo grid: 以下几个属性的复合写法: grid-template-rows grid-template-columns grid-template-areas grid-auto-rows grid-auto-columns grid-auto-flow 具体设置值如下: 1.grid:none:所有子属性都是初始化的值 2.grid: <grid-template> 3.grid: <grid-template-rows> / [ auto-flow && dense? ] <grid-auto-columns>? 4.grid: auto-flow & dense ? <grid-auto-rows> ? / <grid-template-columns>auto-flow: 表示的值为 row | column,但是统一使用 auto-flow来表示,具体需要看它放置的位置在哪里,如果放置在 / 的左侧,就表示 grid-auto-flow: row, 如果放在右侧,就表示 grid-auto-flow: column 1grid: 100px 60px / 1fr 2fr2相当于:3 grid-template-rows: 100px 60px;4 grid-template-columns: 1fr 2fr;563.grid: 100px 60px / auto-flow 1fr 2fr78相当于:9grid-template-rows: 100px 60px;10grid-auto-columns: 1fr 2fr;11grid-auto-flow: column12134.grid: auto-flow dense 100px 60px / 1fr 2fr;1415相当于:16grid-auto-rows: 100px 60px17grid-template-columns: 1fr 2fr;18grid-auto-flow: row dense 使用grid复合写法的例子:grid复合写法demo 以上属性都是外层容器属性的值 作用在容器子项上的属性操作demo grid-column: 以下两个属性的复合写法 grid-column-start grid-column-end 1.item {2 grid-column-start: <name> | <number> | span <name> | span <number>3 grid-column: <start-line> <end-line>4} 值的含义: <name>自定义网格线的名称 <number> 从第几条网格线开始 span <name> 当前网格会自动扩充,直到命中指定的网格线名称 span <number> 当前网格会自动跨越指定的网格数量 auto 全自动,包括定位和跨度 例如:下图中的item-a定义了它从第一条水平方向的网格线到第三条水平方向的网格线,从第2条垂直网格线到第3条垂直网格线,也就是占据了第1、2行第2列 grid-row: 以下两个属性的复合写法 grid-row-start grid-row-end 1.item {2 grid-row-start: <name> | <number> | span <name> | span <number>3 grid-row: <start-line> <end-line>4} grid-area: 当前网格所占区域,使用grid-template-areas自定义网络区域,使用grid-area让grid子项指定这些使用区域,就自动进行了区域分布例如: 1grid-area:2<name> 区域名称,由容器属性grid-template-area创建3<row-start> / <column-start> / <row-end> / <column-end> 占据网格区域的纵横起始位置 justify-self: 单个网格元素的水平对齐方式 值分为以下几种: stretch(默认):拉伸,水平填充 start 水平尺寸收缩为内容大小,沿着网格线左侧对齐 end 水平尺寸收缩为内容大小, 沿着网格线右侧对齐 center 水平尺寸收缩为内容大小,当前区域内部水平居中对齐显示 align-self: 单个网格元素的垂直对齐方式例如: stretch(默认):拉伸,垂直填充 start 垂直尺寸收缩为内容大小,沿着网格线上侧对齐 end 垂直尺寸收缩为内容大小, 沿着网格线下侧对齐 center 垂直尺寸收缩为内容大小,当前区域内部垂直居中对齐显示 以上两个属性可以使用 place-self 去写place-items:<align-self> / <justify-self> grid布局中的css函数查看css函数的相关demo repeat(): 跟踪列表的重复片段,允许大量重复显示模式的行或列以以更紧凑的方式编写 可用范围:grid-template-columns 和 grid-template-rows 语法:repeat(<repeat>, <value>) <repeat>: 取值有以下几种: 整数,确定确切的重复次数 <auto-fill>: 以网格项为准自动填充,需要结合minmax()函数来使用 <auto-fit> : 以网格容器为准自动填充,需要结合minmax()函数来使用 <value>: 取值有以下几种: 固定长度 百分比 fr单位 max-content: 表示网格的轨道长度自适应内容最大的那个单元格 min-content:表示网格的轨道长度自适应内容最小的那个单元格 auto:不推荐使用 可以多次使用 grid-template-columns: 20px auto repeat(3, 1fr) 40px fit-content():参数是长度值或百分比 公式:min(maximum size, max(minimum size, argument)) 它在内容的最小值和参数中取一个最大值,然后再在内容的最大值中取一个最小值 当内容少时,它取内容的长度,如果内容多,内容长度大于参数长度时,它取参数长度,可以理解为它可以控制最大值是多少 minmax(min, max):定义了长度范围区间 取值: 固定长度 百分比 fr单位 max-content: 表示网格的轨道长度自适应内容最大的那个单元格 min-content:表示网格的轨道长度自适应内容最小的那个单元格 auto:不推荐使用 注意事项 1、当元素设置了网格布局,column、float、clear、vertical-align属性无效2、grid布局是二维布局,适合布局整体 一个grid的demo 参考资料张鑫旭空间:grid布局 MDN:grid 阮一峰:CSS Grid 网格布局教程","link":"/2019/11/08/fontend/grid/"},{"title":"Node 使用火焰图优化 CPU 爆涨","text":"一、背景话不多说,先上图,这是得到 App 静态资源更新服务的 CPU 使用率监控,可以看到 7 月 2 号到 7 月 3 号后,cpu 使用率发生了爆涨,在八点的早高峰和下午六点的晚高峰,几乎可以把 cpu 打满。发现问题当机立断,升级配置将 2 核 4g 升级至 4 核 8g,先保证服务稳定,我们再继续查问题。 下图是升级配置后的截图,所以看到的图已经温柔很多了,本人当时看到监控的时候,所有波峰都是打在红线以上的,虽然还没有引起报警,但是默默掏出小本本记下找时间查问题。 因为有很明显的发生变化的时间点,直接能找到这一次的改动,经过一点点的代码级 review,并没有发现变动的代码上有什么问题。作为一个小前端没遇到过这种问题呀,毫无头绪的我,把救世主锁定在了火焰图身上,想看一看到底什么地方耗时长到底 cpu 占用在了什么东西上。 二、火焰图于是怎么生成火焰图成了我最大的难题,开始 Google 搜索,“如何生成火焰图” ,“node 火焰图”,“node cpu profiler”, “node flamegraph”。看来看去所有文章千篇一律,95%以上的文章都是如下解决方案。 方案一:Linux perf参考文章:nodejs 调试指南 perf + FlameGraph Linux 自带的系统性能分析工具,一堆功能我就不多说了,有兴趣的自己去看nodejs 调试指南打开书的第一页。因为使用的局限性不是 Linux 的我,第一步 apt install linux-tools-common 都安不上,如果还要跑在虚拟机什么的上面是不是太麻烦了,方案一卒。 方案二:Node.js 自带的分析工具参考文章:易于分析的 Node.js 应用程序 | Node.js Node.js4.4.0 开始,node 本身就可以记录进程中 V8 引擎的性能信息(profiler),只需要在启动的时候加上参数–prof。Node 自带的分析工具: 启动应用的时候,node 需要带上—-prof 参数 然后就会将性能相关信息收集到 node 运行目录下生成 isolate-xxxxxxxxxxxxx-v8.log 文件 npm 有一个包可以方便的直接将 isolate 文件转换成,html 形式的火焰图GitHub - mapbox/flamebearer: Blazing fast flame graph tool for V8 and Node 完成以上步骤火焰图果不其然的跑了出来 可是仔细一看好像不是那么一回事,因为项目用的是 egg 框架,火焰图里的全部信息都是 egg 启动的东西啊,我长达五分钟的接口压测,一点都没有体现在火焰图上,一拍脑袋,想起来我用 node –prof 的形式收集到的性能数据都是 egg 主进程上的东西,而我们所有的接口全都打到了 egg worker 上去了,一点都没有收集到。顺便提一句 egg 提供了单进程模式RFC 增加单进程启动模式 · Issue #3180 · eggjs/egg · GitHub但还只是实验阶段。方案二又卒,好在我起码看到了一张图。 方案三:使用 Dtrace 收集性能数据直接查到应用的 pid 直接对 pid 进行收集,然后也可以将收集到的数据制成火焰图,具体操作就不做赘述了,最后跑出来的图如下, 全部是一些 v8 底层的东西,好像也没有我想要看的内容呀,方案三卒。 好了以上就是我 Google 出来的各种方案在我一一踩坑后全部以失败告终,其实也还有一些更简单的方案,例如直接接入 alinode 用阿里云的平台就好,一方面该项目没有接入阿里云,刚好用的 node 镜像又不是 ali 的,另一方面,如果可以在开发环境查出问题,不希望再通过上线去查问题。 方案四:v8-profilerNode.js 是基于 V8 引擎的,V8 暴露了一些 profiler API,我们可以通过 v8-profiler 收集一些运行时的 CPU 和内存数据。在安装 v8-profiler 的时候遇到了一些问题总是安装失败,并且得不到解决。不过好在有大神基于 v8-profiler 发布了 v8-profiler-node8,下面是 v8-profiler-node8 的一段描述。 Based on v8-profiler-node8@5.7.0, Solved the v8-profiler segment fault error in node-v8.x.v8-profiler-node8 provides node bindings for the v8 profiler and integration with node-inspector收集数据:简单的 npm install v8-profiler-node8 后,开始收集 CPU profile,收集时长五分钟。 1const profiler = require(\"v8-profiler-node8\");2const fs = require(\"fs\");3const Bluebird = require(\"bluebird\");45class PackageController extends Controller {6 async cpuProf() {7 console.log(\"开始收集\");8 // Start Profiling9 profiler.startProfiling(\"CPU profile\");10 await Bluebird.delay(60000 * 5);11 const profile = profiler.stopProfiling();12 profile13 .export()14 .pipe(fs.createWriteStream(`cpuprofile-${Date.now()}.cpuprofile`))15 .on(\"finish\", () => profile.delete());16 this.ctx.status = 204;17 }18} 然后立即用 ab 压测,给服务压力, 1ab -t 300 -c 10 -p post.txt -T \"application/json\" http://localhost:7001/xxx/xxxxx/xxxxxx/xxxxx 收集完成后,得到一个 cpuProfile 文件,Chrome 自带了分析 CPU profile 日志的工具。打开 Chrome -> 调出开发者工具(DevTools) -> 单击右上角三个点的按钮 -> More tools -> JavaScript Profiler -> Load,加载刚才生成的 cpuprofile 文件。可以直接用 chrome 的性能分析直接读这个文件打开分析。这里我要推荐一下 speedscope 一个根据 cpuProfile 生成火焰图的工具,他生成的火焰图,更清晰,还有 leftHeavy 模式,直接将 CPU 占用率最高的排在最左边,一目了然,快速的可以定位到问题。 三、根据火焰图解决问题下面是该火焰图的 leftHeavy 模式 看火焰图的时候越图形越尖说明越正常,横条越长说明占用时间越长,从图中可以看到压测的五分钟里,CPU 占用时间长达两分钟,其中绝大多数被红框中占据,来张大图 这个火焰图中是由上至下的调用顺序,一眼看过去没有我业务代码中出现的内容,再仔细一看,fetchDocs、Cursor.next、completeMany、Document.init 这好像是 mongo 的东西呀,开心的像个傻子,赶快去搜源码。从 completeMany 这里破案了,这是 mongoose 中的一个方法,作用是将查询到的结果进行包装,使结果中的每一个文档成为 mongoose 文档,使之可以继续使用 mongoose 提供的方法。如下相关源码。 1/*!2 * hydrates many documents3 *4 * @param {Model} model5 * @param {Array} docs6 * @param {Object} fields7 * @param {Query} self8 * @param {Array} [pop] array of paths used in population9 * @param {Function} callback10 */11function completeMany(model, docs, fields, userProvidedFields, pop, callback) {12 var arr = [];13 var count = docs.length;14 var len = count;15 var opts = pop ? { populated: pop } : undefined;16 var error = null;17 function init(_error) {18 if (error != null) {19 return;20 }21 if (_error != null) {22 error = _error;23 return callback(error);24 }25 --count || callback(null, arr);26 }27 for (var i = 0; i < len; ++i) {28 arr[i] = helpers.createModel(model, docs[i], fields, userProvidedFields);29 arr[i].init(docs[i], opts, init);30 }31} completeMany 方法会将传入的每一个 docs 通过 helpers.createModel 变成一个 mongoose Document,我们再来看一下是哪里调用的 completeMany 方法,发现在 find 方法中会判断 options.lean 是否等于 true 如果不等于 true 才会去调用 completeMany 方法去包装查询结果。 1/**2 * Thunk around find()3 *4 * @param {Function} [callback]5 * @return {Query} this6 * @api private7 */8Query.prototype._find = function(callback) {9 this._castConditions();10 if (this.error() != null) {11 callback(this.error());12 return this;13 }14 this._applyPaths();15 this._fields = this._castFields(this._fields);16 var fields = this._fieldsForExec();17 var options = this._mongooseOptions;18 var _this = this;19 var userProvidedFields = _this._userProvidedFields || {};20 var cb = function(err, docs) {21 if (err) {22 return callback(err);23 }24 if (docs.length === 0) {25 return callback(null, docs);26 }27 if (!options.populate) {28 // 看这里 重点重点!29 return !!options.lean === true30 ? callback(null, docs)31 : completeMany(32 _this.model,33 docs,34 fields,35 userProvidedFields,36 null,37 callback38 );39 }40 var pop = helpers.preparePopulationOptionsMQ(_this, options);41 pop.__noPromise = true;42 _this.model.populate(docs, pop, function(err, docs) {43 if (err) return callback(err);44 return !!options.lean === true45 ? callback(null, docs)46 : completeMany(47 _this.model,48 docs,49 fields,50 userProvidedFields,51 pop,52 callback53 );54 });55 };56 return Query.base.find.call(this, {}, cb);57}; 去文档上搜一下 lean mongoose query lean 文档上说了如果使用了 lean 那么查询返回的将是一个 javascript objects, not Mongoose Documents 。原话如下。 Documents returned from queries with theleanoption enabled are plain javascript objects, not Mongoose Documents . They have nosavemethod, getters/setters, virtuals, or other Mongoose features.在文档中还提到了,lean 精简模式,对于高性能只读的情况是非常有用的。 四、后续优化回到问题上来,看到 mongoose Document 的问题,7 月 2 号到 7 月 3 号后,为什么会突然导致 CPU 暴涨恍然大悟,自己之前 review代码,看着代码没问题,但是忽略了这一个版本因为业务调整导致查询压力大大增加,可能是过去的好几倍这个问题。随即将查询改成精简模式。只需要如下很简单的几处修改即可。 1await model.Package.find(query).lean(); 那说到频繁的处理 mongoose Document 导致的性能问题,那其实还有一个优化点可以做,其实在查询的时候多多使用 find 的第二个参数 projection 去投影所需要返回的键,需要用什么就投影什么,不要一股脑把所有的键值一起返回了。处理完这一系列,重写在本地进行了一次同样的压测五分钟,出了一份火焰图,下面图 1 就是这五分钟期间的火焰图,图二就是经过 speedscope 解析过后的 leftHeavy 图,直接观察重灾区。 从图一的火焰图中,并不能看出明显的区别,但是一看到图二就知道我们的优化是有效果的,从最直观的,原本左侧红框中 completeMany 的部分直接没有了,然后 cpu 占用的总时长也由原本的接近两分钟直接降到了 36s,优化效果还是十分明显的。上线观察几天看看效果 如图可以看到,cpu 使用率在优化后得到了大大提升,并且稳定在了百分之十五以内。问题解决了,一切皆大欢喜,服务器降配一切回到正常。但这次故障也让我对诸如 mongoos 这样的 ODM 在使用时需要更加小心谨慎,他给我们带来了无限的便利的同时,可能也会因为一些额外的操作,让我们的服务承受额外的负担,正常情况下这一点性能差距不易察觉,然而到了高峰期,或者大型活动的时侯,可能就会因为这一点小坑,对服务造成更大的影响。 谨记。","link":"/2019/07/26/fontend/node-flamegraph-optimize/"},{"title":"Java并发编程通识","text":"引言并发编程是一个经典的话题,由于摩尔定律已经改变,芯片性能虽然仍在不断提高,但相比加快CPU的速度,计算机正在向多核化方向发展。虚拟化的赋能,让多核服务器的弹性创建和扩容都更加便捷。为了尽可能的提高程序的性能,硬件,操作系统,程序编译器进行一系列的设计和优化,但是同时,带来了影响并发安全的3类问题: cpu增加了缓存,以均衡与内存的速度差异,但是带来了可见性问题 操作系统增加了线程,行程,分时复用cpu以增加cpu利用率,但是带来的线程切换的原子性问题 编译程序优化指令次序,但是带来了有序性问题 Java作为互联网后端最主流的高级语言,以及大数据工程的事实标准语言,从诞生初始并发编程就是其重要特性之一。Java提供了许多基本的并发功能来辅助多线程应用程序的开发。从1.5之前基于管程模型的同步锁,到1.5内存模型重构后广泛使用的CAS+AQS的乐观模型,随着版本的演进,并发编程的操作难度越来越低,但是另一方面,相对底层的并发功能与上层的应用程序的并发语义之间并不存在一种简单而直观的映射关系。因此,即使面对众多并发工具,开发人员可能也陷入着无法选取合理武器的困局,为了能正确且高效的使用这些功能,对Java提供的并发工具有一个系统的大局观并了解其原理是Java开发人员必须关注的重点。本文将通过几个最具有代表性的问题的剖析,展现Java并发设计的核心关键点,为最佳实践打好理论基础。 synchronized 和 Lock 可以互相替代吗?synchronized是Java1.0即加入的并发解决方案,其原理为只支持一个条件变量的简化后的MESA模型。而Lock是Java1.5加入的基于完整MESA模型的Api原语。解答是否可以互相替代的问题,可以先从区别比较入手: | synchronized | Lock— | — | —形态 | Jvm层面的关键字 | Java语言层面的Api管程模型 | 只支持一个条件变量的简化后的MESA模型 | 支持多个条件变量的完整MESA模型锁的获取 | 进入同步代码块即开始竞争锁,未获得锁的线程会一直等待 | 可以通过API实现多种多样的的竞争锁的释放 | 1.持有锁的线程发生异常,Jvm强制线程释放锁 2.拥有锁的线程执行完同步代码块,自动释放 | 基于Api的手动释放锁类型 | 可重入,不可中断,不可公平 | 可重入,可中断,可公平锁状态 |无法判断 | 通过Api判断取舍 | 1.6优化后性能是Lock的两倍 |基于管程语义的Api功能更强大 通过表格中的对比非常明显的得出,Lock可以在大部分情况下替换synchronize,但是反过来不然。对于两者的使用,有以下最佳实践方案: 优先使用synchronized,当不满足并发需求时使用Lock,如多个条件变量,希望竞争公平等 使用Lock时注意两个范式:try-finally 和 乐观自旋 下面是两种工具实现的阻塞队列,其间区别非常明显: synchronized1//很标准的模式,没有扩展点2public class BlockQueue {3 private final int maxSize;4 private LinkedList<Integer> values;5 6 BlockQueue(int size) {7 maxSize = size;8 values = new LinkedList<>();9 }10 11 public void put(int value) throws InterruptedException{12 //可以锁values,也可以锁BlockQueue.class13 synchronized (values) {14 while (values.size() == maxSize) {15 try {16 values.wait();17 } catch (InterruptedException ex) {18 ex.printStackTrace();19 }20 }21 values.add(value);22 values.notifyAll();23 }24 }25 26 public int take() throws InterruptedException{27 synchronized (values) {28 while (values.size() == 0) {29 try {30 values.wait();31 } catch (InterruptedException e) {32 e.printStackTrace();33 }34 }35 values.notifyAll();36 return values.removeFirst();37 }38 }39} Lock1 2public class BlockQueue {3 private final int maxSize;4 private ReentrantLock lock;5 private Condition notFull;6 private Condition notEmpty;7 private LinkedList<Integer> values;8 9 BlockQueue(int size) {10 //公平锁,讲究先来后到11 lock = new ReentrantLock(true);12 //两个条件变量13 notFull = lock.newCondition();14 notEmpty = lock.newCondition();15 maxSize = size;16 values = new LinkedList<>();17 }18 19 public void put(int value) {20 //尝试1分钟21 lock.tryLock(1, TimeUnit.MINUTES);22 //try-finally范式23 try {24 //while范式,可以判断锁的状态25 while (values.size() == maxSize && lock.isLocked()) {26 //阻塞线程至相应条件变量的等待队列27 notFull.await();28 }29 values.add(value);30 //唤醒相应条件变量的等待队列中的线程31 notEmpty.signalAll();32 } catch (Exception e) {33 } finally {34 lock.unlock();35 }36 }37 38 public int take() {39 int value;40 //获取可以被中断的锁,当被中断时,需要处理InterruptedException41 lock.lockInterruptibly();42 try {43 while (values.size() == 0 && lock.isLocked()) {44 notEmpty.await();45 }46 notFull.signalAll();47 } catch (InterruptedException e) {48 if (Thread.currentThread().isInterrupted() {49 System.out.println(Thread.currentThread().getName() + " interrupted.");50 }51 } finally {52 value = values.poll();53 lock.unlock();54 }55 return value;56 }57 } 当然,在Java中是不需要自己手写阻塞队列的,Java1.8 并发包中提供了7种实现,满足各类场景的需求 如何按需定制一个线程池在并发处理的场景下,程序可能要频繁的创建线程工作,完毕后销毁。虽然Java中创建线程就像new 一个对象一样简单,销毁也是Jvm的GC自动搞定的。但实际上创建线程是非常复杂的。创建一个普通对象,仅仅是在Jvm的堆内存中划分一块内存而已。而创建一个线程,却需要调用操作系统的Api分配一系列资源,这个成本和对象无法相提并论的。程序中应该避免频繁创建和销毁如此重量级的线程对象,标准的解决方案就是池技术,在Java中,ThreadPoolExecutor就是线程池工具。 不同于标准的池模型,ThreadPoolExecutor没有acquire方法获得资源,没有release方法释放资源,其通过7个构造参数构建了生产者-消费者的模式。线程池的内部核心原理是内部通过阻塞队列来缓存任务,调用execute方法的线程为生产者,内部的一组工作线程为消费者,获得Runnable任务并执行。 正确使用线程池就是正确配置其构造参数,有以下最佳实践或注意事项: 不要使用工厂类Executors创建线程池,由于其内部默认使用无界的LinkedBlockingQueue,高负载情况下无界队列很可能导致OOM,同时这也表明,使用阻塞队列必须设定容量。 基于1,由于队列有界,如果任务重要程度较高的情况下必须关注队列已满的情况的下线程池拒绝策略的设置,默认的抛出拒绝异常策略可能导致问题,此时可以自定义策略做补偿或者降级操作。 必须给线程池中的线程指定有辨识度的名称用于问题出现时是JVM栈信息排查。可以通过参数threadFactory包装设定策略 队列容量的设定,有一个标准公式:CPU核数 * [1 + (IO耗时/CPU耗时) ] ,但是考虑落地成本过高,可采用大部分场景适用的公式:2 * CPU核数+1,程序运行后通过监控慢慢优化至最优 设定不同的阻塞队列将得到支撑不同场景的线程池,在日常大部分场景下,使用指定容量的LinkedBlockingQueue和ArrayBlockingQueue可以满足需求,一个就基本的选择原则是:采用读写锁分离的LinkedBlockingQueue,但是由于内部数据结构的原因,LinkedBlockingQueue会产生更多的内存消耗,如果对GC敏感即采用单锁的ArrayBlockingQueue ThreadPoolExecutor 1 2public class ThreadPool {3 4 // Atomic* 是Java并发工具包中的原子类,核心原理是CAS5 final AtomicInteger poolNumber = new AtomicInteger(1);6 7 //4c情况下的设定8 ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(9, 12,9 10L, TimeUnit.SECONDS,10 new LinkedBlockingQueue<>(20),11 new ThreadFactory() {12 13 @Override14 public Thread newThread(Runnable r) {15 return new Thread(r, "THREAD-POOL-EXAMPLE-" + poolNumber.getAndIncrement());16 }17 },18 new RejectedExecutionHandler() {19 @Override20 public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {21 //todo 启动补偿或者降级操作,比如将任务放到消息队列或者数据库,启动另外一个补偿线程进行任务22 }23 }24 );25} 优先使用并发容器,但是并发容器那么多,何时该使用何种?在1.5之前,Java的同步容器是保证线程安全的数据结构集合,由于采用了synchronized来保证互斥性能很差。在1.5版本,Doug Lea大神提供了性能高度优化的并发容器包。并发容器的类型非常丰富,所以何时该使用何种?如同《Effective Java》中提到:”标准类库有那么多优点,为何程序员没有使用呢,答案可能是程序员并不知道这些类库的存在。”所以,用好并发容器就需要开发者对并发包的成员有一个全面的了解,从分类来看: List:CopyOnWriteArrayList 是空间换时间的思路,写时将共享变量新复制出来一份,读可以完全无锁。适用场景:写操作少,数据量不大,可容忍短暂的读写不一致。 Map:ConcurrentHashMap,ConcurrentSkipListMap。ConcurrentHashMap的底层还是HashMap,而ConcurrentSkipListMap是1.6后加入的,其底层是跳表,跳表本身是一种比较复杂的数据类型,对于插入,删除,查询操作的平均时间复杂度都是O(log n),相比HashMap底层的红黑树是一种非常稳定的数据结构,如果对程序稳定性有要求可以选择ConcurrentSkipListMap Set:CopyOnWriteArraySet,ConcurrentSkipLisSet。原理同上 BlockingQueue & BlockingDeque:单端队列和双端队列,并发包中最复杂的组件集合,需要关注的: 单端队列Queue是指只能队尾入队,队首出队,双端队列Deque是指队首队尾都可入队出队。 需要特别关注,生产环境中一定要使用有界队列,无界队列可能会产生OOM。 BlockingQueue的应用场景最为广泛,更与线程池紧密联系在一起,对于阻塞队列不同的配置将得到完全不同的线程池,比如代码示例,Spring使用ScheduledThreadPoolExecutor来实现任务调度器@Scheduled,ScheduledThreadPoolExecutor内部使用的就是支持延时的阻塞队列BlockingQueue ScheduledThreadPoolExecutor关键代码1 2public static ScheduledExecutorService newSingleThreadScheduledExecutor() {3 return new DelegatedScheduledExecutorService4 (new ScheduledThreadPoolExecutor(1));5}678public ScheduledThreadPoolExecutor(int corePoolSize) {9 super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,10 new DelayedWorkQueue());11}1213//其内部原理是在入队和出队时对队列中的元素按照堆二叉树排序,保证延时时间少的任务首先出队1415static class DelayedWorkQueue extends AbstractQueue<Runnable> implements BlockingQueue<Runnable> {1617******1819//在offer方法被调用时将入堆的元素排序至正确的位置20private void siftUp(int k, RunnableScheduledFuture<?> key) 21 // 当k==0时跳出循环,表名已经遍历到堆二叉树的根节点了22 while (k > 0) {23 int parent = (k - 1) >>> 1;24 RunnableScheduledFuture<?> e = queue[parent];25 if (key.compareTo(e) >= 0)26 break;27 queue[k] = e;28 setIndex(e, k);29 k = parent;30 }31 queue[k] = key;32 setIndex(key, k);33}3435//在移除元素时排序36private void siftDown(int k, RunnableScheduledFuture<?> key) {37 int half = size >>> 1;38 while (k < half) {39 int child = (k << 1) + 1;40 RunnableScheduledFuture<?> c = queue[child];41 int right = child + 1;42 if (right < size && c.compareTo(queue[right]) > 0)43 c = queue[child = right];44 if (key.compareTo(c) <= 0)45 break;46 queue[k] = c;47 setIndex(c, k);48 k = child;49 }50 queue[k] = key;51 setIndex(key, k);52}5354****** 实战:实现一个限流器在互联网应用中,限流是一个非常高频的词。由于有限资源下的Api接口可支撑的QPS也是有限的,为了保护服务不被超越能力的尖峰流量,或DDOS攻击打挂,应用限流算法来对Api进行主动防护。 主流限流算法 Token bucket 令牌桶 Leaky bucket 漏桶 Fixed window counter 固定窗口计数 Sliding window log 滑动窗口日志 Sliding window counter 滑动窗口计数 其中令牌桶算法由于能够在限流的基础上,处理一定量的突发请求,成为最主流的限流算法。令牌桶算法,核心可以简单总结为:有一个桶存放令牌,每一个请求会消耗一个令牌,另一边以固定速率向桶中放令牌,当令牌消耗速率大于放入的速率时,Api不提供服务,此时可以执行相应的措施,比如等待,拒绝,降级等。 可以看到,存放令牌的桶会出现并发操作,由一个生产者,和一组消费者产生竞争。貌似我们通过类似线程池的生产者-消费者模型就像能解决问题了:一个生产者线程定时向阻塞队列添加令牌,试图通过限流器的线程从阻塞队列中获取到令牌就可以执行任务。但是在实际场景中,需要限流的场景一般都是高并发的,如果系统的压力已经接近极限了,生产者定时器的精度误差会非常大,会很大程度的影响限流器的稳定性。而在单机JVM中最广泛使用的限流器,是Google的Guava包中的限流器RateLimiter,其巧妙的增强了令牌桶算法,提供包括两种限流策略:平滑突发限流(SmoothBursty)和平滑预热限流(SmoothWarmingUp),一个标准使用方法如下: RateLimiter 示例1public class RateLimiterSimple {2 3 public static void main(String[] args) {45 //限定QPS = 56 RateLimiter rateLimiter = RateLimiter.create(5.0);78 ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(1, 1,9 0L, TimeUnit.MILLISECONDS,10 new LinkedBlockingQueue<>(1),11 r -> new Thread(r, "RATE-LIMITER-EXAMPLE-THREAD"),12 (r, executor) -> {13 //todo 启动补偿或者降级操作,比如将任务放到消息队列或者数据库,启动另外一个补偿线程进行任务14 }15 );1617 long start = System.currentTimeMillis();18 for (int i = 0; i < 10; i++) {19 long s = System.currentTimeMillis();20 //获得令牌前阻塞21 rateLimiter.acquire();22 threadPoolExecutor.execute(() -> {23 long now = System.currentTimeMillis();24 System.out.println((now - s));25 });26 }27 long end = System.currentTimeMillis();28 System.out.println("cost time " + (end - start));2930 }31}3233//运行结果,每秒通过5个请求34335150362043720138203391964020041203422014320044cost time1757 RateLimiter的关键巧妙设计是:记录并动态计算下一个令牌发放的时间,每一次acquire操作都会触发Ratelimiter的时间槽变化,而令牌桶在这里仅仅是一个虚拟的概念了。 RateLimiter 核心代码 1 2//核心方法:若当前时间晚于nextFreeTicketMicros,则计算该段时间内可以生成多少令牌,将生成的令牌加入令牌桶中并更新数据。3void resync(long nowMicros) {4 if (nowMicros > nextFreeTicketMicros) {5 double newPermits = (nowMicros - nextFreeTicketMicros) / coolDownIntervalMicros();6 storedPermits = min(maxPermits, storedPermits + newPermits);7 nextFreeTicketMicros = nowMicros;8 }9} RateLimiter的设计非常巧妙,也很抽象,是否有其他简单的解决思路呢?那就是Java并发包中提供的线程协作工具包。其一系列的协作工具可以解决并发下线程之间的协作依赖关系,最有代表性的包括: CountDownLatch 闭锁,基于计数器的主线程通知器 CyclicBarrier 栅栏锁,基于屏障的多线程协调 Semaphore 信号量,基于管程实现的信号量,应用于有限资源的抢占 Phaser 阶段器,多个线程分阶段共同完成任务 ForkJoinPool,JVM单机内的 Map/Reduce 模型 其中Semaphore模型,常用于管理有限资源资源的分配,在限流器需求场景下,令牌桶中的令牌就可以认为是这些有限资源,而信号量就是令牌桶。 基于Semaphore模型实现的简单限流器 12public class SemaphoreLimter {3 4 private Semaphore semaphore;56 //size即为固定的QPS限额7 SemaphoreLimter(int size) {8 semaphore = new Semaphore(size);9 }1011 void acquire(Runnable r) {12 //依然是try/finally 范式13 try {14 semaphore.acquire();15 r.run();16 } catch (InterruptedException e) {17 e.printStackTrace();18 } finally {19 semaphore.release();20 }21 }22} 参考资料 《Java Concurrency in Practice》 《Effective Java》 Java并发编程实战 - 极客时间","link":"/2019/10/16/big-data/java-concurrent/"},{"title":"Yarn 资源隔离与访问控制指北","text":"你还在为可爱的小朋友向基于Yarn进行资源调度的大数据集群提交巨牛无比的笛卡尔积任务而苦恼吗? 你还在为不知道如何让可爱的小朋友乖乖地在正确的资源调度队列里排队而惆怅吗? 本文将为你简要分享我罗是如何从粗放式资源调度的蛮荒阶段,过渡到现如今在Hive+Sentry+Hue的使用环境下,通过Cloudera Manager提供的公平调度器、动态资源池和Linux cgroups实现队列资源隔离、队列资源可伸缩、队列访问控制的精确治理阶段。 背景目的 协调计算资源,实现队列资源隔离、队列资源可伸缩:保障azkaban调度用户绝对有资源可用的同时,满足其他用户即席查询的需求 结合Sentry和Linux cgroups,实现队列的访问控制 基本需求用户组及权值分配说明 分组 说明 权值 成员 supergroup azkaban调度用户;通过Hive Gateway提交任务 80 hdfs, dwadmin, datadev hive-admin Hive+Sentry 认证用户, 分组与Sentry一致;一般通过Hue等工具提交任务 12 我罗大数据中心成员 hive-read 一般通过Hue等工具提交任务 8 我罗大数据中心之外的成员 Sentry未授权用户 被Sentry拦截 — UNDEFINED 队列(资源池)访问控制 队列(资源池) 可提交组 管理组 supergroup supergroup supergroup hive-admin hive-admin hive-admin,supergroup hive-read * hive-admin,supergroup 队列配置由需求可以梳理出Yarn资源调度队列配置: 公平调度器Cloudera Manager 推荐使用该调度器,且对该调度器的支持力度最大,可以配合动态资源池和Sentry灵活地按照队列放置规则,将特定用户提交的任务放置到特定的队列中,并通过ACL严格限制队列的访问。 配置详解Overview 为了便于管理用户提交的任务,因此将用户分组,提交任务时按分组提交到对应的队列中。具体规则如下: yarn-site.xml 在 Cloudera Manager -> 集群 -> Yarn -> 配置 中直接搜索配置项 1# 禁止未声明的资源池2yarn.scheduler.fair.allow-undeclared-pools = false3# 禁止公平调度器抢占4yarn.scheduler.fair.preemption = false5# 禁止以用户名作为默认队列6yarn.scheduler.fair.user-as-default-queue = false7# 禁用,以使队列内部分配资源的时候,采用轮询方式公平地为每个应用分配资源。若设为true,则可以设置权重,权重越大,分配的资源越多8yarn.scheduler.fair.sizebasedweight = false9# 开启Yarn的ACL10yarn.acl.enable=true 资源池 路径:集群 -> 动态资源池 -> Yarn -> 资源池 Overview 配置说明 配置 描述 默认值 设置 权 队列之间按权值分配资源 — 如上图 最大/小资源数 该配置会覆盖权,即该配置优先级高于按权分配的最大值限制;这里只加入最大资源数限制,保证某个队列不会占用过多的资源。 — 如上图 计划策略 CM推荐使用DRF,按照CPU和内存分配资源 DRF ALL: DRF Application Master最大份额 限制可用于运行 ApplicationMaster 的资源池公平份额的比例。例如,如果设为 1.0,叶池中的 ApplicationMaster 最多可使用 100% 的内存和 CPU 公平份额;如果值为 -1.0,将禁用 ApplicationMaster 份额的监控。 0.5 ALL:-1.0 ACL root.supergroup: 提交访问控制 组:supergroup,用户:datadev,dwadmin,hdfs 管理访问控制 组:supergroup,用户:datadev,dwadmin,hdfs root.hive-admin: 提交访问控制 组:hive-admin 管理访问控制 组:hive-admin root.hive-read: 提交访问控制 用户:* (允许任何用户向该池提交) 管理访问控制 组:hive-admin 放置规则路径:集群 -> 动态资源池 -> Yarn -> 放置规则 规则匹配顺序 放置规则 1 仅当池 已在运行时指定 存在时使用该池。 2 使用池 root.[secondary group]。 3 仅当池 root.[primary group] 存在时使用该池。 4 使用池 root.hive-read。 此规则始终满足。不会使用后续规则。 在配置 规则1 和 规则3 时,不要勾选: 在池不存在时创建池。 规则说明 规则1:保证了在Hive+Sentry的环境下,CM能够根据用户及其分组,将用户提交的任务匹配到指定的队列中。 规则2:azkaban调度用户通过在Hive Gateway上通过hive -e "<HQL>"等方式提交任务。因此,需要在Hive Gateway所在的主机中,添加用户 hdfs, dwadmin, datadev,并为这三个用户都加上secondary group[^1]:supergroup(usermod -G supergroup <user>) 规则3:Hive + Sentry 认证用户(Hue用户等),与Sentry Server上对用户的主分组一致。hive-admin分组被分配到root.hive-admin队列,hive-read分组被分配到root.hive-read队列 规则4:兜底,如果上面的规则都未满足,任务将被放置到权值低的hive-read队列 禁止Hive用户自行制定队列 Hive启用Sentry后禁用了用户模拟功能,导致所有作业均以hive用户提交,为了防止用户提交作业到其它资源池,需要禁用hive的mapreduce.job.queue.name(mapred.job.queue.name) 在CM Hive配置中搜索 hive-site.xml 追加或修改:1 <property>2 <name>hive.conf.restricted.list</name>3 <value>mapred.job.queue.name,mapreduce.job.queue.name</value>4 </property>5 ``` 6 7### 运维手册89**窗口期**1011下午8点之后有调度,因此尽量在下午8点之前;暂定下午7-8点进行离线集群Yarn的维护。其中不涉及重启的部分不影响正常使用,因此可以先行修改如下配置,之后发公告留出一个小时的窗口期用于重启和验证。1213**[18:30] 发布公告**1415声明将于19点-20点进行Yarn的维护,届时Hive、Hue、Impala将不可用,Presto可正常使用1617**添加用户组**1819azkaban调度用户2021```shell22# login root@master1.cal.bgd.mjq.luojilab.com, 分发命令到Hive Gateway角色23sh ~/send_command.sh \"groupadd supergroup\"2425sh ~/send_command.sh \"useradd hdfs\"26sh ~/send_command.sh \"useradd dwadmin\"27sh ~/send_command.sh \"useradd datadev\"28# 添加azkaban调度用户secondary group29sh ~/send_command.sh \"usermod -G supergroup hdfs\"30sh ~/send_command.sh \"usermod -G supergroup dwadmin\"31sh ~/send_commasupergrouph \"usermod -G supergroup datadev\" Hive + Sentry 认证用户 在对用户改动之前,先在Hue中完成对用户及其组的 新增/修改/删除 登录到Hive Server2: 1# login root@mg03.cal.bgd.mjq.luojilab.com, 在Hive Server2角色中调整Hive+Sentry用户和组2cd /root/sentry/3# sh adduser.sh <user> <group> :添加/修改用户到组, 组可用值: hive-admin, hive-read4# sh deleteuser <user>: 删除用户 修改 [19:00] 重启 影响范围: Yarn Hue Impala Hive Oozie YARN [19:00-20:00] 验证 用例1: 在hive-read组提交了几个大任务的条件下,azkaban调度用户通过Hive Gateway提交任务。观察azkaban调度用户能否获得足够的资源。 登录Hive Gateway,做好使用azk用户提交任务的准备 通过脚本,使用hive-admin和hive-read组的用户名向hive发送SQL,创建几个大任务 在Hive Gateway上使用azk用户提交任务 用例2: 使用azkaban调度用户,在hive -e "<HQL>" 的 HQL 中设定 mepreduce.queue.name和mepred.queue.name 容量调度器(Deprecated)由于我罗使用了Sentry认证,而Sentry底层全部使用hive用户并且关闭了模拟(impersonate),故而无法使用容量调度器满足我罗需求。 配置详解公共配置Overview 为了便于管理用户提交的任务,因此将用户分组,提交任务时按分组提交到对应的队列中。具体规则如下: 配置 1<property>2 <name>yarn.scheduler.capacity.maximum-applications</name>3 <value>10000</value>4 <description>队列容量,包含状态为Pending和Running的application</description>5</property>6<property>7 <name>yarn.scheduler.capacity.maximum-am-resource-percent</name>8 <value>100</value>9 <description>集群中用于运行application master的资源占比。可用于控制application的并发数。</description>10</property>11<property>12 <name>yarn.scheduler.capacity.resource-calculator</name>13 <value>org.apache.hadoop.yarn.util.resource.DominantResourceCalculator</value>14 <description>计算资源数量的具体实现。DominantResourceCalculator: 内存,CPU;DefaultResourceCalculator: 内存。</description>15</property>16<property>17 <name>yarn.scheduler.capacity.queue-mappings</name>18 <value>g:supergroup:supergroup,g:hive-admin:hive-admin,g:hive-read:hive-read,g:default:hive-read</value>19 <description>用户/组 与 队列的对应关系。格式: [u or g]:[name]:[queue_name][,next_mapping]*</description>20</property>21<property>22 <name>yarn.scheduler.capacity.queue-mappings-override.enable</name>23 <value>true</value>24 <description>确保用户提交的任务被放入指定的队列中。</description>25</property>26<property>27 <name>yarn.scheduler.capacity.root.state</name>28 <value>RUNNING</value>29 <description>yarn.scheduler.capacity.{queue_path}.state。可用于启停指定queue_path的队列30 </description>31</property> 将root队列分为default, admin队列 1<property>2 <name>yarn.scheduler.capacity.root.queues</name>3 <value>supergroup,default</value>4 <description>容量调度器中,root队列下必须有一个名为default的队列</description>5</property> supergroup队列配置 1<property>2 <name>yarn.scheduler.capacity.root.supergroup.capacity</name>3 <value>80</value>4 <description>默认队列容量</description>5</property>6<property>7 <name>yarn.scheduler.capacity.root.supergroup.maximum-capacity</name>8 <value>95</value>9 <description>最大队列容量</description>10</property>11<property>12 <name>yarn.scheduler.capacity.root.supergroup.minimum-user-limit-percent</name>13 <value>30</value>14 <description>每个用户的最低资源保证</description>15</property>16<property>17 <name>yarn.scheduler.capacity.root.supergroup.user-limit-factor</name>18 <value>100</value>19 <description>每个用户可用的最大资源占比</description>20</property>21<!-- 已通过用户组映射限定用户提交任务到指定队列,无需配置22<property>23 <name>yarn.scheduler.capacity.root.supergroup.acl_submit_applications</name>24 <value>hdfs,dwadmin,datadev</value>25 <description>只允许hdfs,dwadmin,datadev三个用户向supergroup队列提交任务</description>26</property> --> default队列配置 1<property>2 <name>yarn.scheduler.capacity.root.default.queues</name>3 <value>hive-admin,hive-read</value>4 <description>root.default队列分为hive-admin,hive-read两个子队列</description>5</property>6<property>7 <name>yarn.scheduler.capacity.root.default.capacity</name>8 <value>20</value>9 <description>默认队列容量</description>10</property>11<property>12 <name>yarn.scheduler.capacity.root.default.maximum-capacity</name>13 <value>60</value>14 <description>最大队列容量</description>15</property>1617<property>18 <name>yarn.scheduler.capacity.root.default.hive-admin.capacity</name>19 <value>60</value>20 <description>默认队列容量</description>21</property>22<property>23 <name>yarn.scheduler.capacity.root.default.hive-admin.maximum-capacity</name>24 <value>80</value>25 <description>最大队列容量</description>26</property>27<property>28 <name>yarn.scheduler.capacity.root.default.hive-admin.minimum-user-limit-percent</name>29 <value>30</value>30 <description>每个用户的最低资源保证</description>31</property>32<property>33 <name>yarn.scheduler.capacity.root.default.hive-admin.user-limit-factor</name>34 <value>80</value>35 <description>每个用户可用的最大资源占比</description>36</property>37<property>38 <name>yarn.scheduler.capacity.root.default.hive-read.capacity</name>39 <value>40</value>40 <description>默认队列容量</description>41</property>42<property>43 <name>yarn.scheduler.capacity.root.default.hive-read.maximum-capacity</name>44 <value>60</value>45 <description>最大队列容量</description>46</property>47<property>48 <name>yarn.scheduler.capacity.root.default.hive-read.minimum-user-limit-percent</name>49 <value>30</value>50 <description>每个用户的最低资源保证</description>51</property>52<property>53 <name>yarn.scheduler.capacity.root.default.hive-read.user-limit-factor</name>54 <value>60</value>55 <description>每个用户可以使用的最大资源占比</description>56</property>57<!-- 已通过用户组映射限定用户提交任务到指定队列,无需配置58<property>59 <name>yarn.scheduler.capacity.root.default.hive-admin.acl_submit_applications</name>60 <value>usera,userb,userc</value>61 <description>可以向root.default.hive-admin提交任务的用户</description>62</property>63<property>64 <name>yarn.scheduler.capacity.root.default.hive-read.acl_submit_applications</name>65 <value>*</value>66 <description>所有人都可以向root.default.hive-read</description>67</property> --> 运维手册窗口期 下午8点之后有调度,因此尽量在下午8点之前;暂定下午7-8点进行离线集群Yarn的维护。其中不涉及重启的部分不影响正常使用,因此可以先行修改如下配置,之后发公告留出一个小时的窗口期用于重启和验证。 [18:30] 发布公告 声明将于19点-20点进行Yarn的维护,届时Hive、Hue、Impala将不可用,Presto可正常使用 修改调度器配置 修改 “容量调度程序配置高级配置代码段”,添加2.1. 配置详解中提到的配置 修改用户分组 假设当前用户组映射的具体实现为 org.apache.hadoop.security.ShellBasedUnixGroupsMapping,因此用户组映射关系基于Name Node所在的操作系统用户组设置[^2]。 首先在Hue管理用户界面配置用户组映射,然后登录如下主机操作: master1.cal.bgd.mjq.luojilab.com (NN) 1# 创建用户组映射2hdfs: supergroup3dwadmin: supergroup4datadev: supergroup56usera: hive-admin7userb: hive-admin8userc: hive-admin910xxxx1: hive-read11xxxx2: hive-read 切换调度器 修改Scheduler 类: org.apache.hadoop.yarn.server.resourcemanager.scheduler.fair.FairScheduler org.apache.hadoop.yarn.server.resourcemanager.scheduler.fifo.FifoScheduler org.apache.hadoop.yarn.server.resourcemanager.scheduler.capacity.CapacityScheduler [19:00] 重启 影响范围: Yarn Hue Impala Hive Oozie YARN [19:00-20:00] 验证 如 1.2.5. 验证 是否重启? 需要重启:修改了yarn-site.xml等其他组件依赖的配置。 例如:切换yarn的资源调度器 无需重启,需要刷新集群:修改其他组件不依赖的配置。 不切换yarn的资源调度器,而是仅仅修改队列配置、用户组映射等调度器具体配置时 总结本文根据对 CM 动态资源池以及Yarn的公平/容量调度器的调研和实验结果,给出了两种调度资源管理策略,并最终确定下我罗通过CM 动态资源池、Yarn的公平容量调度器、Hive+Sentry用户认证的方式,实现队列资源隔离、队列资源可伸缩、队列访问控制。 参考资料 Hive启用Sentry后如何限制用户提交Yarn资源池 如何使用Cloudera Manager设置使用YARN队列的ACL 如何在Cloudera Manager中配置Yarn放置规则 [^1]: 因为 hdfs 等用户所在的主组另有他用,不能覆盖。[^2]: 此外,如果启用了HDFS的HA,除了Name Node之外,用户组映射配置也需要同步到Name Node Standby节点。","link":"/2019/08/27/big-data/tutorial-cm-yarn-hive-sentry-hue/"},{"title":"【八里庄技术沙龙-14 期】Kubernetes在得到App的落地实践","text":"引言罗辑思维是一家创业公司,主要产品有:得到App。主要有两类业务,线上: 订阅课程、商城、听书、讲座、电子书,线下:跨年演讲,得到大学,线下大课等。目前有高质量用户3300万,后端服务以容器方式运行,正在基于Kubernetes进行混合云建设,目前线上主要的主机资源是使用的阿里云。 由于技术选型比较“激进”,并且践行微服务架构设计,目前的语言栈有:按照占比排名,Golang、Node.js、Python、Java、PHP、C++,之前使用云主机(ECS)带来的运行环境管理复杂、发布过程不统一等问题。所以,将应用容器化以及微服务治理,一直是较为迫切的需求。 从2013年底Docker开源,到现在已经发展了超过5年的时间,大家已经听说容器技术的优势和收益并逐渐接受准备拥抱之。但想要落地容器以及容器管理系统Kubernetes这种新一代基础设施,远没有想象中容易,在新技术落地的过程中,阻碍往往不是来自于技术本身,而在于观念的更新、生态的丰富以及易用性。 首先介绍一下容器技术。容器是Linux Kernel的功能模块封装,主要基于有十年年以上历史的namespace(since1992)和cgroups(since2007),容器镜像是一类CopyOnWrite的Overlay文件系统,跟虚拟机的主要区别是,容器间共享宿主机系统内核,所以启动速度较虚拟机更快,可以达到秒级。但简单来说,每个容器是一个进程以及它所拥有的资源和边界。Docker是目前容器技术的事实标准,也可能是未来应用的交付标准。 然后介绍一下Kubernetes(k8s)。Kubernetes是跨主机、跨集群、跨IDC的容器管理系统,基于Google 生产负载上的 15 年管理经验(Borg),最初由 Google 的工程师设计和开发并开源,且融合了来自社区的经验与实践,已经成为企业级容器管理的事实标准。简单来说,Kubernetes是管理容器(进程)的云操作系统。 架构演进回顾容器技术在得到App落地的过程, 主要有四个阶段:公有云虚拟机阶段,容器化Docker阶段,Kubernetes阶段,混合云阶段。 虚拟机阶段此阶段的进步,是由发布系统代替了散落在不同代码仓库中的发布脚本,从shell脚本时代进入工具时代,使得发布不再需要运维人员参与,并且引入了发布审核机制,大大提高了发布效率。但是,新项目上线时,须经历购买服务器、系统初始化和安装运行环境、项目发版配置,调试部署过程脚本几个步骤,较为繁琐。并且需要增加实例时需要手工操作,仅适合管理少量使用ECS的服务发布。最让人头疼的是由于环境不一致引发的各种线上事故。 于是,我们决定用通过使用容器技术来解决这些问题。首先,制定出一系列规范:统一运行时版本、域名规范、端口规范、目录规范、日志规范等;然后基于规范开发了两层基础镜像:操作系统层、各语言运行时层;并且整理出Dockerfile模板、entrypoint.sh模板。当应用代码需要容器化时,只需要将Dockerfile和entrypoint.sh两个模板文件添加到项目代码仓库中即可,无需要任何修改,降低了改造的工作量。 容器阶段通过应用的容器化部署,简化了环境管理,屏蔽了部署细节,统一了交付方式,基础设施只关注资源和容器状态,极大减少了运维工作量。同时,因为有容器的快速启动能力加持,扩容速度达到了秒级,使得应用实例的管理更加敏捷。 其底层是阿里云容器服务全家桶,上层根据发布流程和规范开发了相关功能。使用的组件有Gitlab(代码管理),Jenkins(调用其API进行docker build,并回调Dozer),Swarm API(发布、调整容器数量等核心功能),云监控(容器监控)、云日志服务(日志收集 存储 分析)、云SLB(多容器实例的汇聚和负载均衡)、云OpenAPI(添加集群Worker节点)。 该方案的优点是:简单,快速,并且有良好的商业支持,可以将精力投在内部的落地上。缺点也是明显的,即限制较多,强依赖公有云,有时候业务的需求由于公有云暂未开放相关功能,不得不进行取舍。同时,由于历史原因我们使用了Overlay网络,当时这种网络方案在高负载集群中非常不稳定,并且Swarm集群缺乏广泛的生产环境考验并不稳定,以及社区不活跃,最终我们决定更新VPC网络和容器基础设施。 Kubernetes阶段我们首先花费巨大的时间和精力将公有云的经典网络(Public)中的所有资源迁移到了VPC网络(Private),在此过程中,由于容器化的应用有“一次构建,随处部署”的优势,这使得我们节省了很多时间。然后基于Kubernetes进行了新的容器基础设施的构建,同时建设了自己的监控和日志收集体系。 通过Kubernetes来进行资源管理和交付,通过管理API来进行应用上线和发布。Kubernetes同时提供了弹性伸缩和故障自动迁移,可以应对简单的流量突增或服务器节点故障等问题。还可跟私有云或公有云的基础设施进行联动,对存储、计算资源或负载均衡设备进行自动化管理。 通过一年的努力,所有的业务流量迁移到了围绕容器和Kubernetes构建的基础设施之上。新的容器网络方案,从性能和稳定性上较之前有了本质提升。同时,Kubernetes的良好生态和优秀设计,底层服务器节点进行了标准化管理,极大简化了运维成本。它的容器配额更加高效,把所有的应用容器进行了资源保障和限制,最终提升了资源利用率。 基础设施的更新,为业务架构迭代提供了支撑,应用的开始大范围的微服务化更新,带来了成倍的管理工作,同时微服务间调用链路变长,问题排查难度增加。此时,微服务的管理成为新的挑战,于是我们开始了工具平台研发和服务治理工作。 微服务治理对于服务治理,我们是在2018年初启动,这方面我们的思路是:用规范和约定来将编程框架和基础设施打通、服务以编程框架的形式连接基础设施。 开发框架:将与各个组件对接的代码和公共代码抽离到框架中,集成了服务注册和配置中心以及tracing功能。同时框架中提供Liveness和Readness探针、Graceful Shutdown等,统一日志格式等。通过使用这套框架,可以提升开发效率,统一微服务面向管理的接口。 配置中心:统一管理配置,将配置和发布包解藕,减少业务开发者维护配置的工作。 服务注册与发现:得到服务注册与服务发现的中间件,以AP为设计目标,支持多种健康检测和负载均衡方式,服务在启动时自动将自己注册到服务发现服务上。并且会实时(定期)从服务发现服务同步各个应用服务的地址列表到本地进行缓存,以起到加速的效果,同时监听远端中心事件,进行数据同步。 追踪系统:分布式链路追踪系统,由开发框架中统一封装,每个服务内嵌标准化接口,将分布式请求还原成调用链路,可以集中展示各个服务节点的请求状态,以及花费时间,进行链路追踪、问题分析。 API网关:API网关是系统与外界联通的入口,支持反向代理、重定向、限流等功能,基于服务发现服务发现中心的数据,进行后端实例的注册,API网关一般作为系统与外界联通的入口,在微服务架构中,所有的客户端和消费端都通过统一的网关接入微服务,在网关层处理所有的非业务功能,反向代理、重定向、限流等功能。 服务启动时请求配置中心,获取运行环境配置,将自身加入注册中心、获取所依赖服务信息,启动完成后,上报健康状态,并提供API服务。微服务间通过服务发现互相感知,服务通过框架接口来维护自身上线、离线。API网关连接到服务注册中心,动态感知服务变化,并自动更新API路由,外部流量通过API网关将流量转发到对应的业务模块。 Kubernetes在底层基础设施跟上层微服务治理组建中间,起到了承接作用。 方案细节所有Kubernetes方案中,网络方案是最重要的部分之一,由于我们的基础设施分别在公有云和私有云,虽然网络组件不同,但使用整体相似的网络模型。 网络(公有云)公有云使用VPC网络,Kubernetes的网络组件,使用Flannel + alivpc Backend,每个Worker节点中的容器作为一个子网,掩码为24,并且使用NAT(地址转换),通过Flannel的alivpc插件调用vRouterAPI,将该此条路由信息写入VPC网络的虚拟路由器(vRouter)的路由表中,以此实现Pod IP与ECS IP互通,该方案设计简洁,比较稳定。 网络(私有云)私有云中的网络方案,虽然看起来跟公有云结构很相似,但是基于Calico,主要是基于BGP路由协议进行路由分发,以达到互联互通的效果。节点内使用BIRD软路由,将每个节点上的Pod所在子网,掩码为24,发送给物理设备RouteReflector进行路由学习和发布。相较公有云的网络,BGP协议更加高效和可靠,同时网络设备可对网络流量进行路径优化,间接提升了网络性能。 构建构建服务的核心是Jenkins,管理系统通过调用Jenkins的HTTP API进行任务管理,Jenkins接收到请求后将构建任务加入队列,排队构建。构建时,从Git仓库拉取代码,执行Docker build,产出Docker image,成功后push到registry存储。 CI系统的流程是:开发人员在本地进行功能开发,本地测试,当通过单元测试后,进行代码提交。Git服务接收到开发人员的提交后,通知CI系统,触发CI流程。CI系统使用Jenkins进行构建,将代码编译成制品,并产出Docker镜像,然后将镜像push到镜像仓库存储,然后回调Kubernetes系统API,更新测试和预发布环境。 日志方案服务数量和应用规模变大时,需要将分布在各处的日志进行收集,集中存储,以提升日志分析效率。我们日志收集分为两类:应用日志和APM日志。 由于自建的日志系统需要较多服务器资源,后续要花费很多精力去优化,考虑到投入产出比,使用阿里云日志服务(SLS)比较有优势,目前我们容器内产生的日志都是使用ilogtail收集发往SLS,使用阿里云监控的日志关键字监控功能监控错误日志中的特定关键字,进行告警。 另外,filebeat目前只负责收集APM产生的trace日志,发往kafka,由日志处理程序来进行消费,处理后序列化到ElasticSearch,由APM系统进行使用。 日志收集filebeat和ilogtail以DaemonSet方式部署,每个Worker节点上部署一个Agent。Pod使用EmptyDir易失性存储方式,通过HostPath挂载形式收集。应用将日志写入规范目录后,日志收集Agent会监听到特定事件,将新增日志取出,发往存储服务。 监控我们的Prometheus是单独的服务器,以运行在集群外部的方式,通过APIServer获取资源信息,然后对自动发现的各个endpoint进行metrics pull。导出metrics,使用了cAdvisor、node-exporter、kube-state-metrics等组件来导出不同维度的度数。prometheus拉取到数据后,根据预设阈值进行评估,触发阈值后发往AlertManager,在AlertManager中根据不同的级别对告警进行路由、沉默和收敛。通知通道有:邮件、企业微信、短信。 当数据量大、查询较慢时,可使用Prometheus alert中的record语句,进行数据预处理,即将查询产生的结果存入新的metrics,使用新metric绘制图表和报警的rules检查,速度会有较大提升。 监控(Pod)监控数据展示使用Grafana,数据来自Prometheus,Pod级别。主要展示CPU、内存使用率,TCP连接数。 监控(服务)服务维度的监控项有:主要展示CPU、内存使用率,TCP连接数,文件描述符,nf_conntrack,IO等。 监控(大盘)监控大盘,管理人员使用监控大盘,关注各集群控制平面的各系统组件监控状况,资源的分配情况,依据资源水位进行节点的增减。 踩到的坑 初期由于我们的服务器还运行在阿里云经典网络(IaaS的早期多租户网络),在Swarm集群中我们使用了overlay网络,每个容器创建或删除时,由于需要集群内部广播该容器IP等信息,随着容器数量的增加,会有同步失败情况,造成服务容器间不通问题。 得到App的微服务,大部分是golang语言开发,由于我们的服务多为HTTP短连接形式,并且如果请求的是域名的话,golang会直接发起dns查询,当查询量过大时会遇到“lookup failed”相关报错,需要在容器内部运行nscd服务。 内核中的tcp参数,尤其是netfilter相关,对于容器网络稳定性影响较大,图中为目前我们在生产环境的Worker节点使用的内核参数。 总结目前得到App后端服务中,80%以上项目、90%以上业务流量运行在Kubernetes管理的容器基础设施之上,日均发布近百次。通过容器的落地,简化了环境管理,统一了发布流程,屏蔽发布细节,基础设施只关注服务运行状态,开放了运维能力,打通了开发和运维间屏障。得益于Kubernetes的优秀设计,现在运维人员不需要关注节点用途,运行环境配置等功能,每个节点都只是资源池的一部分,只需关注集群资源水位,管理工作只剩下增减节点。通过资源配额,对计算资源进行再分配,从而保证和限制了应用的资源需求,进而提升了资源的利用率。 未来我们将会Kubernetes和容器技术的特性进行混合云建设和落地,实现跨云基础环境下的流量调度、资源分配、伸缩等。同时精细化发布过程、设计多种可预期场景的弹性伸缩控制器降、强化资源交付效率,为研发人员赋能。 愿大家都能够落地感受容器化交付方式的便利,拥抱Kubernetes“云操作系统”,希望我们的经验对大家有所帮助。","link":"/2019/10/21/dd-technical/k8s/"},{"title":"牛顿冷却定律在得到APP的实践","text":"背景介绍「得到锦囊」产品刚上线时,该版块首页的最热排序暴露了两个问题:分页时数据重复和最热榜单被霸屏,本文将围绕解决这两个问题来展开,介绍下如何参考牛顿冷却定律来优化最热内容的排序。 “牛顿冷却定律”本质上它描述了高于周围温度的物体会向外散热,并逐渐降温的过程,同时单位时间内散热与周围温差会成正比关系。通过建立”温度”与”时间”之间的函数关系,构建一个”指数式衰减”(Exponential decay)的过程。 如果我们把”热文排名”想象成一个”自然冷却”的过程,那么如下的场景是成立的: 任一时刻,网站中所有的文章,都有一个”当前温度”,温度最高的文章就排在第一位。 随着时间流逝,所有文章的温度都逐渐”冷却”。 一、最热榜单暴露的问题2020年1月初,得到App的新产品「得到锦囊」正式上线。产品刚上线时,版块首页的最热排序模块,暴露出了两个问题:分页时数据重复和最热榜单被霸屏,本文将围绕解决这两个问题来展开。 排序规则与朴素的实现方案产品需求定义的最热排序规则是:按照问题的总查看量来倒序排列,且有分页和查询条件。服务端对于这种场景,最简单高效的实现方式,就是利用sql的query语句了,于是我们就直接 [order by {问题的查看量} desc] 来实现了。 总查看数 = 获得查看权益的用户数 = 购买数 + 赠一得一领取数 这个简单朴素的实现方式,在加上缓存策略,使得我们用较小的成本就满足了产品需求,也应对了较高的流量。 得到锦囊(原问答)的产品定位,和知乎、头条的不太一样,不仅对于提问的内容进行严格审核,对于老师们的解答内容也是会按照标准来品控,所以我们上新的速度并不会很快。等未来发布的问题数量大了以后,肯定是会启用基于大数据的个性推荐算法的,但是摆在眼前的两个问题,必须要尽快解决。 说明:在2020-03月底的版本中,【得到问答】正式改名为【得到方案】。在2020-04月初的版本中【得到方案】改名为【得到锦囊】,为用户提供更为全面的知识服务。时间和精力的关系,本文中的一些文字和图片不进行全量更新了。 问题一 : 分页时会出现重复数据分页时数据重复,在性质上是个bug,得优先解决。 利用sql的order by 和 limit 对行数据进行排序和分页时,最正确的姿势的是基于至少一个固定的字段值。排序值变化频繁,db加载下一页数据的时候如果上一页某条行记录的排序值发生了变化,就很可能会导致上一页中出现过的记录在下一页再次出现。我们最热排序的依据【总查看数】就是个变化频繁的值,加上我们对每页的结果数据都使用了缓存策略,使得数据在不同的页面出现的几率加大。 通过一些算法策略的调整,比如缓存的key值和查询条件增加了时间窗口,我们降低了数据被重复展现的概率,低到用户几乎无察觉,低到让自己也以为彻底解决了问题。 上新的内容接近百条的时候,新的问题暴露了。春节前的某一天,老板、产品经理、运营都分别吐槽了“最热榜单被霸屏”的问题,希望能有所优化,每天能呈现不同的热点内容 。于是,我们调整了排序语句,在其中加入了最近被查看时间的参数,踩着“知识春晚封版”最后上线的时间点完成了上线。很有效,最热榜单活了,每个小时都会变化,很多问题都有了更多露脸的机会,包括在多个页面反复露脸…… 数据重复的几率增高了,高到产品经理和测试都发现了,居然还录屏为证 (ಥ﹏ಥ) 如下是其中的一个实例截屏,我们能看到问题《28岁码农,不想做管理岗,又担心以后失去竞争力,怎么办?》在两个页面出现。 问题二 : 最热榜单被霸屏我们的新内容是以每天5~10个的频率来发布上线的,用户对于内容的喜好和曝光率会直接影响被查看的数量和排序。 产品上线没多久,排名靠前的榜单就被上线较早且又被关注较多的几条问题占据了,后面新上线的问题,曝光的机会很低。如果按照刚上线时的排序算法,下面几条问题几乎会持续霸屏: 我们的运营发起了一些专题的促销活动,才足以打破了上面的局面。但是,我们是不能对每个问题方案都进行运营活动来拉动销量,更多的还是顺其自然,交给用户去选择,让内容靠实力去竞争。 “长尾效应”的理论告诉我们,非热点内容的累计销售数量,一定是高于几条热点内容的累计销售数量的,所以我们要解决被霸屏的现象,让更多的内容能够有机会登上热榜去“抛头露面”。 二、问题解决方案分析1、 分页时会出现重复数据问题出现的原因是排序所依据的键值变换频繁,导致翻页语句在执行时数据已经发生了变化。我们需要找到一个可以用于排序的值,一个即能根据热点趋势及时变化,又相对固定不变的值。 开始时,觉得挺矛盾的,解决了一个问题却加剧了另一个问题,直到发觉这个排序值可以和解决最热榜单霸屏问题使用一个值,思路就开始打开了。 2、 最热榜单被霸屏尝试了把问题的被查看时间参与到排序规则中,使得排序可以每个小时刷新1次。上线后,榜单是真的变了起来,最新被查看的问题能够浮上来,但是当热门问题也被查看后依然会霸屏。 这个改良版的排序方法,其实就是文末参考资料中描述的Delicious算法的思路,这是最直接、最简单的算法,按照单位时间内用户的投票数进行排名,得票最多的项目,自然就排在第一位。 这个算法的优点是简单、容易部署、内容更新快;缺点是,一方面,排名变化不够平滑,前一个小时还排名靠前的内容,往往第二个小时就一落千丈,另一方面,缺乏自动淘汰旧内容的机制,某些热门内容可能会长期占据排行榜前列。 最热榜单开始变化的几天中,运营从数据上查看,问题的整体访问情况要好于调整之前,虽然由于使用姿势不正确带来了数据重复的bug,但证明最热榜单的排序动起来是很有意义的。在咨询公司大数据的专业人士“溥神”后,给我们推荐了业内解决这种问题的通用算法之一“牛顿冷却定律”,开始更科学的解决这个问题。 定律的解读伟大的物理学家牛顿,早在17世纪就提出了温度冷却的数学公式,被后人称作”牛顿冷却定律“(Newton’s Law of Cooling)。 牛顿冷却定律有广泛的用途。例如,人死亡以后温度调节功能随即消失,因此借助于由正常体温(约为37摄氏度)与环境温度的比较,利用这个定律,就可以判定死亡的时间。 举个实际计算的例子。某冬晨,警察局接到报案,在街头发现一具尸体。在6点30分的时候,测量其体温为18摄氏度,到 7点30分已下降到16摄氏度。假设室外气温维持在约10摄氏度不变,而且设人体正常体温是37摄氏度,问这个人是什么时候死的? 【来自得到电子书《不可思议的自然对数》】 如果我们把”热文排名”想象成一个”自然冷却”的过程,那么如下的场景是成立的: 当前温度。任一时刻,网站中所有的文章,都有一个”当前温度”,温度最高的文章就排在第一位。 冷却。随着时间流逝,所有文章的温度都逐渐”冷却”。 加温。如果一个用户对某篇文章投了赞成票,该文章的温度就上升一度。 上面的这种场景,概括起来就是新的内容总是会替代老的内容,而它又恰好类似于物理学当中的“牛顿冷却定律”。本质上它描述了高于周围温度的物体会向外散热,并逐渐降温的过程,同时单位时间内散热与周围温差会成正比关系。这样假设的意义,在于我们可以照搬物理学的冷却定律,使用现成的公式,建立”温度”与”时间”之间的函数关系,从而构建一个”指数式衰减”(Exponential decay)的过程。 结论物体的冷却速度,与其当前温度与室温之间的温差成正比,我们可以参考牛顿冷却定律构建问答最热排名的算法。新加一个用于排序的值,俗气点就叫热度值(hot value),这个值周期性的更新规则。 三、影响因素与实践过程由于问答已经有了”最新上架“的榜单,我们的”最热“榜单不需要重点关注新上架内容的曝光,所以也就不需要完全采用定义一个初始温度来逐步降温的模式。 问答的最热榜单,希望能根据用户最近的实际查看量(例如前一天的查看量),同时结合上架时间的因素,来决定今天的热度排序。于是我们重点关注决定降温趋势的时间因子,用户行为的加温值,由此来计算热度值,公式入下: 热度值 = 加温值 * 时间因子 1、时间因子无论对于用户还是平台,显然是不希望一直是同样的内容霸占了排行榜或者列表的最前面位置的,而是希望不断被更新的内容所替代。站在用户的角度上,一方面当然是希望对于一些比较热门的问题能够及时看到,但另外一方面还是希望能不断看到新的东西。 对于任一内容,在某一定时间段之后,排列顺序能够发生周期性的变化,内容的热度需要随时时间的推移而慢慢衰减,这个周期就是降温周期,在周期内最好能呈现出下面这样的一种趋势: 降温周期,可以理解为刷新文章热度的时间间隔。具体的间隔多少则需要根据平台的内容量来确定,如果内容较多,希望尽快呈现新的内容给用户,便可以把时间间隔设置的比较短;如果内容较少,则可以把间隔时间设置的比较长。截至到这篇文档初次编写的时候(2020-02-12 18:43),我们问答的数量是298,所以应该把间隔时间设置的长些,例如30天。 实践:经过观察和数据调试,把问答热点问题的降温趋势定义在了30天,即我们新上线的问题从100°降到0°的时间为30天左右,在这期间所有上架的问题都有机会被加温升上来。当天上架的问题,只要被浏览,那么就可以优先级最高的机会被排到前面。 2、用户行为的加温值如果只是有上面提到的热度因素,那么可能会导致的问题是,有很多相近或者同一时间段上架的问题,热度值随着时间的推移降低的速度几乎是一致的,这就还会导致内容的排名也基本上是一致的。为了解决这样的问题,还可以在除了初始热度以外,引入问题被发布后用户对其产生的一些行为,常见的行为可以包括浏览、评价、收藏、分享、点赞等。 目前我们在问题列表里只展示了查看量,为了更容易被理解,我们先只把查看量当做加温值。 实践:在问题信息的扩展表中增加一列来记录昨日被查看量(yesterday_count ),每天更新这个值,这样根据准实时总查看量和昨日总查看量的差值,就能计算出当日的查看量,作为计算热度值的重要参数。 3、热度值的计算关键点:热度因子和昨日被查看量,直接决定了一个问题的热度值,影响“最热”排序。 热度值的更新:目前定在了每天凌晨的2点钟,直接用sql来计算更新。 版本1 、参考牛顿冷却定律热度因子取一个和时间反比例缩小的值。这个版本,采用了如下的计算方式,问题会在35天左右衰减。 热度因子factor = (1+ROUND(1 / (DATEDIFF(当前时间,首次被浏览的时间) + 1),2) * 100) 加温值 = 昨日浏览量 = 总浏览量 - 昨日浏览量 热度值 HotValue = 加温值 * factor 实际效果: 如下是初次写稿时(2020-02-12 19:33)的一个线上问题top 10,从结果可以看到,前面的那几个霸屏问题已经被排到了下面,新上架的问题按规则排到了前面。 这个版本解决的问题1、解决了分页排序数据重复的问题。由于HotValue只在固定的时间段更新,我们按照HotValue排序相当于按照了一个固定的值,所以出现重复数据的几率大大降低,在2020-02-10日下午上线后,再没有发现。 2、问题的热度排序更加科学,增加了老问题的曝光几率,看到了热度降温的效果,对于学习牛顿冷却定律更有兴趣了。 这个版本存在的问题1、前期降温过快 问题上架的第一天是100°,第二天就直接降到了50°,如果问题在第一天的加温情况并不好(查看量较低),那么就会被排到后面。 2、冷却期较长 35天左右才会降到0°,而产品经理希望以后能调整到15天左右试试,而这个版本的算法比较费劲,调整多次都达不到期望的效果。 版本1的实验过程: 2月8日 确定方案,添加基础支撑的字段 2月9日 记录昨日查看量,上线计算热度值算法 2月10日 更新了线上的排序策略 版本2、使用牛顿冷却定律的公式改进1、改进热度因子经过版本1的数据和经验积累,最终求出了几个适合问答现状的值,即降到冰点天数和冷却系数的定义。 热度因子fac = ROUND(exp(-冷却系数* DATEDIFF(当前时间,首次被浏览的时间) * 24 ),2) * 100 30天的降温趋势图如下,降温趋势将更加平滑了。 提前计算了几个适用的冷却参数,当问题达到一定数量的时候,可以加快降温的速度,这点是版本1所不具备的。 冷却天数 冷却系数 30天 0.0072 15天 0.014 10天 0.022 2、改进加温因子可以把留言数、收藏数等值加进来,实现类似如下的加温值: 加温值 = 浏览量50% + 留言量20%+收藏量*30% …… 版本2的实施: 2月21日,修改热度因子的计算规则为版本2 等正式发布的问题数量接近1000时,可以考虑把冷却天数缩短为15天 最后,我们看一张图,2020-03-08女神节问题最热榜单的排序情况。在第20条以后,我们看到上线超过30天热度因子为0的几个问题,由于昨日访问量增加到一定程度,也有机会上浮到前面了。 思考与结语1、定律使用要结合实际的场景牛顿冷却定律是伟大的,但使用时要结合实际的场景,不能生搬硬套。 在算法刚上线时,我还是按照Delicious算法的惯性思路,觉得榜单要动起来就该让榜单在一天中刷新多次,结果由于新上线内容的热度因子值较大,导致最新和最热榜单的排序在第1页几乎完全一样,这样的话最热榜单意义就被打了折扣。 资料中的对牛顿冷却定律的使用,都是定义一个初始热度值再去逐渐降温,而我们的产品中由于有了最新的榜单,也采用这种方式的话,也会导致榜单数据重合。 2、最热排序未来的优化方向在内容的总量达到一定程度之前,可以由服务端对所有用户统一规则排序展示,当到达一定规模后,毫无疑问,个性化的推荐算法就一定是更好的了。 在内容的数量接近1000个时,会把降温的速度加快,15天后就降低到冰点。 3、牛顿冷却定律适用的场景通过阅读资料和实践,单就排序而言,个人觉得牛顿冷却定律适用于投票策略以正方向为主的场景,例如浏览、留言、收藏这些都是加温行为,即使各个行为的权重不同,对于排序的影响都是正向的,唯一的负方向值就是时间。 如果打分排序的场景还需要同时考虑正方向和负方向的投票行为,牛顿冷却定律就不太适用了。比如同一个内容,有的读者觉得有帮助会顶一顶增加正值,有的读者觉得没意义会踩一踩增加负值,平台希望内容列表的排序规则,即考虑时间因素又结合用户投票还要客观公正,算法可就没有那么简单了! 最后列举列举了一些相关的文章和参考资料,感兴趣的读者们可以继续延伸阅读。 https://baike.baidu.com/item/长尾效应/6352848?fr=aladdin 长尾效应 http://www.ruanyifeng.com/blog/2012/03/ranking_algorithm_newton_s_law_of_cooling.html 基于用户投票的排名算法(四):牛顿冷却定律 http://www.woshipm.com/pd/2970177.html 信息流优化:牛顿冷却定律的应用 https://www.ruanyifeng.com/blog/2012/02/ranking_algorithm_hacker_news.html 基于用户投票的排名算法(一):Delicious和Hacker News https://www.ruanyifeng.com/blog/2012/03/ranking_algorithm_reddit.html 基于用户投票的排名算法(二):Reddit https://www.ruanyifeng.com/blog/2012/03/ranking_algorithm_stack_overflow.html 基于用户投票的排名算法(三):Stack Overflow https://www.ruanyifeng.com/blog/2012/03/ranking_algorithm_newton_s_law_of_cooling.html 基于用户投票的排名算法(四):牛顿冷却定律 https://www.ruanyifeng.com/blog/2012/03/ranking_algorithm_wilson_score_interval.html 基于用户投票的排名算法(五):威尔逊区间 https://www.ruanyifeng.com/blog/2012/03/ranking_algorithm_bayesian_average.html 基于用户投票的排名算法(六):贝叶斯平均 得到电子书《不可思议的自然对数》","link":"/2020/03/08/backend/sort-with-law-of-cooling/"},{"title":"【八里庄技术沙龙 - 18期】得到 hybrid 架构的演进之路","text":"得到 APP 是一个三年多的产品,最初采用纯 Native 的方式开发,在 18 年初,我们开始了 Hybyid 开发技术方案的探索和实践, 目前得到 APP 共包含了 ReactNative 和 Webview 两套 Hybrid 方案。本文从时间维度上,重点回顾一下 Webview Hybrid 方案在得到 APP 从 0 到 1 的过程,也希望我们的经历可以给一些想落地 Hybrid 方案的团队一点启发。 1. 背景和动机得到是一个重运营场景的产品,APP 内大部分的功能都会有分享功能。18 年初时,开发一个功能,基本需要三端三个人。部分业务使用了内嵌 Webview 、类浏览器式的方案,虽然满足了跨端,但体验较差。所以最初的目的是希望有一套跨平台方案,一套代码可以三端执行,并且有较好的体验,这是当时 Hybrid 的架构图: 除了 Webview,当时较为流行的跨平台方案主要是 ReactNative、Weex,对比了两个方案,Weex 较为接近我们团队的技术栈,而 RN 当时社区较为成熟,最终我们认为社区更重要一些,所以选择了 RN。 在 RN 调研阶段,我们发现 RN 虽然支持三端和动态更新,但是需要配套的基础设施才可以实现其动态更新的能力,因此我们需要一个离线资源的管理系统,能够动态更新客户端内部的 RN 文件,而我们在思考和设计这个离线资源管理系统时,发现同样的思路可以应用于 Webview,我们可以把前端代码打成离线包,通过离线资源管理系统进行更新,而 Weview 在启动过程中,仅需要访问数据 API 而不需要下载 HTML/JS/CSS 等,也算是变相的增加了离线能力。 因此,我们制定了最初的 Roadmap: 先开发离线资源管理系统; 完成之后接入 Web 离线包,因为 Web 离线包开发成本较低,可以快速的改善现有项目的体验,快速收益; 最后在进行 RN 的开发和接入; 2. 离线资源包管理系统-Seeder做一个技术驱动的项目就像是做一个产品,需要先梳理清楚需求、使用场景等,再想思考技术架构和实现细节。我们首先为项目起了个名字,叫 Seeder。(为什么起这个名字,其实没什么意义,主要是内部没有其他系统叫 Seeder。。。) 2.1. 目标通过梳理,我们认为 Seeder 需要达成以下目标: 可以动态更新资源; 可以支持非最新版客户端进行更新; 支持增量更新; 支持多频道发布; 2.2. 技术选型和架构明确目标后,我们要做技术选型和架构,在技术选型上,我们使用团队熟悉的 Nodejs+Mongodb 组合,架构图如下: 服务端包含 Seeder 和 CDN 两部分,CDN 部分主要是用来承接资源包的下载。Seeder 则拆分为 Updater 服务和 Manager 服务: Updater 服务:主要是承接处理客户端的更新请求; Manager 服务:主要进行资源包及相关配置的管理,包括生成 diff 包等等; 通过合理的拆分,Updater 服务在我们后续的压力测试中,2 台 8C16G 机器可以稳定承载 6000QPS; 2.3. 关键实现点 - Package 定义既然是对资源包进行管理,我们需要定义资源包的格式和约束。 格式方面,我们选择了 tgz 格式,即使用 tar 进行归档,用 gzip 进行压缩的格式,以减少传输体积。 文件结构方面,在原有资源目录结构下的根目录,增加了一个 info.json 格式的文件,用来描述包的信息。 ![Package 结构(https://piccdn.luojilab.com/fe-oss/default/image-20200114171319102.png) 我们来看下一 package.json 的结构: appId:标示包的应用 ID; version:标示这个包的版本; depend.containerVersion: 标示这个包依赖的容器版本,目前的容器只有客户端; files: 一个数组,记录所有的文件和路径及其 MD5; meta: 扩展信息字段,这里使用了两个扩展字段,后面详细讲这两个字段 type:包的类型 routes:包需要注册的路由列表 2.4. 关键实现点 - 增量更新增量更新指的是我们只需要下载一个 Patch 包,安装 Patch 包之后即可以完成应用的更新,像我们常用的 VSCode 之类的软件、大部分手机游戏,都支持增量更新。实现增量更新关键点是增量算法,通过调研,最终选择了支持二进制 diff 算法 bsdiff 。 确认算法之后就要开始思考增量包的实现方式,因为 bsdiff 是对单个二进制进行 diff,而我们是一个包。因此有两种方式: 基于归档压缩完的 tgz 包进行 diff 和 patch,这种方案的优势是实现成本低,带来的问题是客户端必须保留一份底包,并且由 于 Package 在客户端是下载完先解压才能执行,这种方案无法连续 patch 升级(不能增量从 v1.0->v1.1->v1.2,只能 v1.0->v1.1,v1.0->v1.2); 基于单文件 diff,即增量包其实包含多个 patch 文件,包含了描述 Package 变更信息的文件,这种方案虽然实现会复杂些,但是并没有方案 1 的各种问题,因此我们采用了是单文件 diff 的方案; 下面我们看下单文件 diff 的方案,先看一个增量包的结构: 相较于普通包,多了一个 update.json 文件,这个文件描述了整个包是如果变化的,基于这个文件和包内的其他文件,便可以 Patch 到最先的版本,看一下 update.json 的结构: files 是描述变化的文件。关键字段 type, 标示了变更类型,add、delete、move、modify 等,分别表示新增的、需要删除的、发生目录和文件名变化的、内容变化的文件。add、delete、move 只涉及到了文件的新增、删除、变更路径等操作,而 modify 则是用到了 bsdiff,表示这个文件发生变化,需要增量更新。 通过这种精细化的操作,可以提高 patch 的效率,同时客户端无需保留底包,基于解压完的代码文件就可以完成增量更新。 梳理完了增量包结构,还有面临一个问题,就是增量包的生成时机。同样有两种方案: 请求来了生成增量包,好处就是一定会有增量包,问题是增量包的生成是一个 CPU 密集型操作,无法支持高并发; 提前生成增量包,但是只能提前生成指定版本数量的增量包,但可能存在较老版本没有增量包可用; 我们最终采用了提前生成增量包的方案,因为包内容差异越大,增量带来的收益越小,没有必要生成所有版本的增量包。我们在上传包时,会同时生成历史 10 个版本的增量包。基于我们目前的更新频率,10 个历史版本目前基本可以满足需求(后续不满足可以调整,就是一个配置项)。当然,用户长时间不打开 APP,可能再次打开,我们已经更新了十几个版本,这个时候只能通过全量包来进行更新。 2.5. 架构变化看一下我们调整后的架构变化: 3. 应用框架 - Adam完成了基础设施的建设之后,客户端的离线资源也具备的动态更新的能力,但普通的 Web 离线包还有以下的限制: 每个 Webview 只能有一个页面,无法实现复杂的功能(为了跟客户端保持一致的页面交互体验,每个 Webview 只有一个页面,这样前进后退、导航条的表现是一致的); 无法控制导航条,一些需要定制导航条的功能依赖客户端; 没有体系化的框架,无法统一处理异常、缓存等; 为了解决以上问题,我们决定开发一个应用层的框架。 3.1. 目标和分解我们整个团队最熟悉的技术栈是 Vue,因此 Adam 肯定是基于 Vue 做封装,在设计 Adam 之前,需要我们先确认目标: 功能上:一个 Package 可以作为一个完整的 Application,能够完整地实现一个功能模块,包括多页面的功能等; 技术上:实现标准化的解决方案,由框架处理缓存、异常页面等通用逻辑; 对目标进一步做分解: 需要客户端将界面全部交给 Webview 处理; 需要 Router,并且像客户端一样,支持栈式管理页面的路由; 页面要实现客户端相同的前进和后退动效,要支持滑动返回上一个页面; 需要抽象缓存和异常页面等到框架层; 3.2. 架构图我们先看下一下 Adam 的整体架构,以便于我们后续内容的表述: 每一个 Web Package 就是一个应用,每个 Application 实例对应一个 Global Store 和 vue-stack-router 的实例,对应多个 Page 实例。 每一个页面都由 Page Componets 和 Page Store 组成。其中 Page Store 的生命周期跟页面保持一致。 3.3. 关键实现点 - Router最初的 Router 方案我们是选了我们常用的 vue-router,但在实现过程中,遇到了以下问题: 实现类似栈式的路由较为困难。客户端内的页面大部分都具有栈式的特点,页面实例的存活取决于是否在栈中。而 vue-router 中,组件实例的存活则是取决与是否使用了 kee-alive 组件; 实现两个页面间的、类似 Native 的滑动返回较为困难; 无法实现多例页面。Native 中,A 页面跳转 A 页面,会产生一个新的 A 页面的实例。vue-router 中,A 页面跳转 A 页面会重新渲染现有的 A 页面,也就是 A 页面始终是单例的; 为了解决这些问题,我们开发了 vue-stack-router (已开源,具体实现细节,感兴趣的可以直接看 github 代码,内容较多,这里不展开),相较于 vue-router,有以下新功能: 栈式的路由管理; 路由间数据传递; 支持更细粒度、可定制的路由过渡效果; 支持预渲染; 基于预渲染模式,我们实现了手势滑动返回的功能,即触发手势时,预渲染后一个页面,此时同时存在两个叠加在一起的页面,通过 JS 控制两个页面的动画,便可以实习类似 Native 的滑动返回的效果。 3.4. 关键实现点 - Store提到状态管理工具,共识都是简单的项目无需使用 Store,复杂项目才能体现出 Store 的价值,其实无非是引入 Store 带来了成本。我们分析一下移动端页面的特点: 展示为主 页面间耦合性低 数据流简单 因此,在移动端页面,我们追踪状态变化的收益可能不会很高,如果去掉状态追踪,Store 可以变的很精简, 看一下我们自己精简的 Store,原理如下: 1class MyStore {2 public name: string = '';3 public updateName(name: string): void {4 this.name = name;5 }6}7const store = Vue.observable(new MyStore()); 没有状态追踪,只是最精简的将状态抽离到一个类中进行管理。 聊完了 Store 实现,再看看关于 Store 的组织形态,我们常用 Vuex 和 Redux 都是单一组件树,连 MobX 也有 mobx-state-tree 这种单一组件树的社区方案。但是结合移动端业务的特点,单一组件树会有些问题,对多页面实例的支持,实现比较复杂。再一个,优秀的单一组件树的组织通常是跟页面分离的,经过单独设计的,因此会带来了额外的心智负担。 基于以上死牢,最后我们没有采用单一组件树,而是实现了多状态的 Store 方案:一个页面对应一个 Store,Store 和页面的生命周期保持一致的方案。逻辑跟展现分离,页面间又不耦合,最重要的是简单; 3.5. 缓存数据缓存是体验优化的一大利器,通过先渲染缓存数据,在更新正式数据的方式,我们可以立刻展现出一个页面而无需等待。Adam 实现了三级缓存: 依次从路由数据、内存、LocalStorage 中取。路由数据是什么呢,通常在客户端内,页面跳转很多都是摘要信息跳往详情信息的页面(如列表的 item 跳详情页),其实前一个页面已经包含一部分后续页面的信息,这个时候可以将前一个页面的数据带到后一个页面中,后一个页面便可以渲染出主要信息,提升用户体验。 那么缓存的数据是哪里来的呢,并不需要开发者手动写入。我们知道 View=fn(State),在 Store 方案中我们已经将页面的状态都放到 store 中了,只需要缓存 Store 就可以了。至于缓存和还原的时机,就是在页面销毁时,我们序列化 Store,等页面在打开,还原 Store 。 4. 标准化容器在开发 Adam 的同时,也不断有同学反馈,现在接入一个新的 Web Hybrid 业务比较麻烦,需要客户端配置 webview,而且新业务依赖发版,是不是可以我们完全不依赖客户端呢? 答案是可以的。 4.1. 路由协议我们在 Package 中增加了包的类型和包的全局路由信息,这样客户端在更新到包的信息时,可以动态注册路由,也就是所有的 Package 中的路由,都绑定到一个标准化的 webview,webview 启动后,根据跳转过来的路由加载对应 Package,已实现动态加载和注册功能。 4.2. 最终架构完成了 Adam 和 标准化容器后,我们看一下最终接架构: 至此我们可以将每个 Package 当做一个独立的 Application 来更新和迭代。 5. 总结和思考5.1. 成果功能方面,我们接入了讲座、电子书、评测、训练营、得到大学、活动系统、帮助中心等模块,接入了 90+的页面(其中 ReactNative 占 30+,Web 占 60+); 效率方面,我们在一年半内支撑了 49 个功能模考动态更新了 1900 次。测试环境中,动态更新了 1.3 万次; 性能方面,我们从性能监控系统中找到两个未使用和使用 Seeder 的功能进行对比(这个对比不太严谨,因为没有同一个功能先后采用两种方案的数据,我们找了两个功能相近,代码量相近的两个项目进行对比)。 普通 Webview 方案 Adam + Seeder 方案: 基本可以看到,稳定性和效率都有较好的改善。 5.2. 思考Hybrid 落地过程中,我们踩了很多坑,也有很多收货,简单谈两点。 第一个,如何评价一个技术方案的好坏?我们有太多的标准:站在业务角度,是不是能满足需求及低成本的满足潜在的后续需求;站在运维角度,是不是带来了新的部署运维成本。站在技术角度,我们甚至可以掏出一本设计模式大谈一番。但是我们很少有注意到技术方案的用户体验,这里的用户指的是使用你框架、库的开发同学。站在业务开发同学的角度会发现,提供的方案确实解决了问题,但是使用这个方案过程中,可能有 30% 工作是不属于方案部分,但是属于方案部分必须的,比如方案的入参是 A,开发者需要花大力气才能得到 A,才能使用这个方案。所以作为框架、库的开发者,要考虑清楚整个方案的使用场景,技术部分是不是可以覆盖整个场景,覆盖不了要怎么解决,是否需要提供自动化工具等等。 第二个,Hybrid 不是一个端的事情,而是三端一起的事情,而作为推动方,要尽可能的了解三端,不了解可以多跟各端同学沟通交流,不要做成一方推动两方配合,要让大家感觉是在一起干一件事情,这样才能做好。 5.3. 后续的规划后续的规划主要是有两大方面: Adam 的多环境多端的支持,覆盖得到业务“端”的场景; Seeder 更加灵活的更新场景,比如支持 Lazy 加载等;","link":"/2020/01/16/dd-technical/the-evolution-of-iget-hybrid-architecture/"},{"title":"kubernetes 指南 -- 弹性伸缩","text":"0x0 pre弹性伸缩, 是云计算中的一种常用方法,通过该方法,服务器池中的计算资源量(通常根据有效的服务器数量来衡量)会根据服务器池中的负载进行动态伸缩。 本文旨在为想在 kubernetes 中使用弹性伸缩功能的读者解释相关概念,并制定一条较为清晰的路线图。 0x1 autoscaling在了解 k8s 弹性伸缩这个课题之前,我们先从更宏观的角度,了解一下弹性伸缩相关概念。 目的我们做工程都是结果导向的,就是说,我们做弹性伸缩,不是因为它看上去很酷,不是为了做弹性伸缩而做。而是从公司的实际问题出发,为了解决什么问题,以及投入产出比是否合理,才决定是否要做弹性伸缩,以及怎么做。一般来讲,弹性伸缩主要用来解决两个问题: 应对突发流量 节省资源 应对突发流量。这点很好理解,当系统由于外界因素(比如 XXX 突然结婚了, XXX 突然离婚了等突发的热门话题),导致系统负载激增,原有的配置已无法满足需求,需要增加系统配置。这种增加可能是自动的,可能是需要手工调整的,甚至是需要去购买机器,重启服务等对服务比较不友好的行为。具体进行何种操作,需要以及目前公司的弹性伸缩级别来制定相应的计划。关于弹性伸缩的级别,我们将在稍后说明。 节省资源。绝大多数情况下,我们不可能让我们的工作负载跑满服务器(即便能跑满,为了服务稳定性,我们也不大敢这么做)。在不损失服务稳定性的大前提下,尽可能地提高资源利用率,一直是各个弹性伸缩方案不懈的目标。减少单个服务资源占用,通过合理调度资源减少服务器数量,及时增减服务数量等手段,都能直接或间接的达到节省资源的目的。 针对以上两种目的,我们可能设计出完全不同的弹性伸缩方案。例如,如果我们的需求是 在流量激增时能够在数分钟内为指定服务扩容,保证服务稳定可用,那么我们可能仅仅需要把服务迁移到容器环境中,并配置一个延迟较低的监控报警系统,在出问题时能够及时通知到运维人员,然后手动为服务扩容即可;如果服务器成本在公司所有成本中已经占了很大的比重,要在不影响业务的情况下尽可能地减少服务器开支,秒级伸缩,则我们需要从公司后端服务各个方面好好设计一番了。 场景节点和服务节点是承载工作负载的单元,是集群中提供容器运行环境的一台机器(物理机或虚拟机)。服务是具体的工作负载,具体在 kubernetes 中,就是 pod 以及 pod 所包含的容器。kubernetes 上的弹性伸缩会在节点和服务两个粒度进行。 两个粒度之间会相互影响。例如,应对突发流量时,如果触发了服务粒度的扩容操作,就会占用部分节点资源,如果碰巧扩容到一半时,集群所有资源都被用完了,那么此时只有节点也能进行自动扩容才能完成服务的扩容,否则会导致服务由于没有找到足够的资源而扩容失败。当然,现实情况中,我们一般不会等到集群资源完全用完时才去触发节点扩容。一般会在资源超过某个特定阈值(90-95%)时,就会触发扩容。 同样,在使用缩容来节省资源时,只有将负载低的服务缩容后,才能空闲出一批利用率较低的节点,此时节点弹性伸缩器检测到某些节点利用率低,关闭这些节点,或是从云厂商取消续订这些节点,才能达到节省资源的最终目的。 垂直伸缩与水平伸缩垂直(Vertical)伸缩:调整节点或服务的资源配额。 水平(Horizontal)伸缩: 调整节点或服务的数量。 弹性伸缩的级别。借鉴业界对自动驾驶的分级规则,现定义服务的弹性伸缩级别如下: 级别 说明 扩展 收缩 业务是否感知 操作时间 0 系统不支持伸缩 - - - - 1 运维手动添加机器 人工 人工 是 x 小时 2 管理系统中修改配置 人工 人工 否 x 分钟 3 自动扩容 自动 人工 否 扩展 x 秒,收缩 x 分钟 4 自动伸缩 自动 自动 否 x 秒 5 按计划伸缩 自动 自动 否 x 秒 6 基于预测的伸缩 自动 自动 否 x 秒 level 0: 系统还处在比较原始的阶段,服务不支持任何形式的扩容缩容。 level 1:系统直接跑在自己维护的虚拟机或物理机上,能够通过人工增加减少机器数量来实现扩缩容。此种方式的通常方案是在服务上层搭建一个负载均衡,然后在增加、减少机器时使负载均衡感知。这种方式扩缩容的时间通常较长,几分钟到几小时不等。 level 2: 服务已经容器化 有一套标准的容器调度管理平台(mesos/kubernetes) 有一套完整的监控数据(prometheus/zabbix/open-falcon/ELK) 由于容器的启动速度比虚拟机和物理机要快很多,所以容器化后,可以将扩容速度提高很多倍,能够达到秒级至分钟级。 level 3:由于 level 2 中我们已经有个各个维度的监控数据,在此基础之上,可以做一些自动化策略来减少人工操作的时间,能够将扩容速度稳定在秒级。 level 4:在弹性伸缩的级别中,自动缩容的等级(4)要比自动扩容(3)高一级,主要从两个方面考虑: 从公司发展角度来看,肯定是先跑起来 –> 抗住大流量 –> 精细化控制成本。如果温饱问题都还没有解决, 公司生死存亡的问题都还没解决, 就去考虑节约成本,显然是没有抓对发展的大方向。 从实现的技术难度来讲,缩容要考虑的细节要多一些。比如扩容时由于都是添加资源和服务,较容易做到业务无感知。但是在缩容时,如果想做到业务无感知,可能需要结合网关、负载均衡、部署策略、服务优雅关闭、服务发现、容器生性周期管理等多方面的知识才能实现。 此外,在制定弹性伸缩策略时,业界通常会采用 激进的扩容,谨慎的缩容 的大方针来最大限度地保障服务的稳定性。 level 5:level 3 和 level 4 虽说已经实现了秒级的弹性伸缩,但是他们都是被动式的,换句话说,只能在事情发生后,再进行扩缩容。这样做可能会导致出现少量的请求超时或请求出错。一种更优雅的方案是在出现问题前,预先准备好充足的资源,避免问题的发生,前置性地进行伸缩。 level 5 周期性的弹性伸缩是指基于对历史数据和公司、服务业务的资源用量波峰波谷的周期性规律的总结,定时对服务和集群规模进行扩缩容。 例如如果公司主要业务是一款公交出行类软件,那么很显然每天的早晚上班期间是公司业务的高峰,且流量是闲时流量的数倍。该公司可以指定一个定时伸缩策略,每天早晨 5 点将集群规模扩大 x 倍,xx 业务规模扩大 y 倍,11 点再缩容至原规模,下午 4 点再进行扩容,以此类推。level 5 的弹性伸缩的实现,依赖于 level 3 和 level 4 对基础设施和中间件的优化与改造,只有在能保证扩缩容的过程中不会对业务产生影响,才可以频繁地进行扩缩容。 level 6:level 5 的周期性伸缩用一种简单地预测性的方式很好的解决了公司常规业务的资源使用率的问题。但由于仅仅采集了时间维度对系统资源使用情况影响的数据,其调整方式较为单一,无法应对突发情况。通常情况下,在系统出现高流量,高负载前,都会有一些前置性信号。这些信号包括但不限于以下方面: 基于以往压测数据和以往运营活动数据对即将开展的营销活动的流量的预测 利用社会工程学手段对社会上即将发生的热点事件和正在发生的热点事件的走向的预测 利用机器学习对系统响应时间、流量走势、系统负载走势等多维度海量监控数据的分析对即将到来的高流量和高负载的预测(例如 netflx 的预测分析引擎Scryer) 技术手段能够解决许多问题,但是并不能解决所有问题。在解决系统性问题时,尤其是较复杂的系统问题时,我们不能把思维完全限制在技术领域,同时结合多种手段,可以使许多看似无法用技术手段解决的问题迎刃而解。 当然,随着机器学习技术的兴起,一些以往无法用传统方式解决的技术问题,也变得可解。只不过需要专业人士投入较多的人力物力,在使用这种方式时,要在充分考虑投入产出比后,再做决定。 Netflix发现,对于部分基础架构和特定的工作负载,其预测分析引擎 Scryer 比传统的被动自动缩放方法提供了更好的结果。它特别适合以下场景: 在不久的将来识别需求的巨大峰值,并提前一点准备好产能 处理大规模中断,例如整个可用区和区域的故障 处理可变的流量模式,根据一天中不同时间的典型需求水平和变化率,在横向扩展或纵向扩展速率上提供更大的灵活性 任何系统和工程都不是一蹴而就的,只有当基础设施达到一定完善程度后,再逐级实现弹性伸缩,才是一个比较可行的路线。本文假定目前系统已经达到了2 级的弹性伸缩。以下主要讲述在此基础上,实现更高级的弹性伸缩的相关知识点。 0x2 autoscaling in kubernetes弹性伸缩这种功能,不是很多系统都已经实现了,我们直接用就行了吗,为什么还需要个指南呢。因为。。。。我们先来看看都有哪些相关知识点吧。。。 弹性伸缩涉及到各种软硬件,各色调度平台,策略和系统,其本身就是一个较复杂的课题。此外,kubernetes 不单单是一个容器调度平台,而是一个活跃,庞大的生态系统。kubernetes 弹性伸缩这个课题涉及了诸多知识点,主要如下: - 水平(Horizontal)伸缩 - 垂直(Vertical)伸缩 - 定时(Scheduled)伸缩 - 预测(Predictive)性伸缩 - 服务画像 - node - service - CA - cloudprovider - VPA - HPA - HPA controller - metric - metric server - heapster - metric state - prometheus - CRD - custom metric api - prometheus adapter - cronhpa controller - cloud controller manager在刚开始调研这个课题的时候,一上来看到这么多名词和术语,肯定会一脸懵逼的。终于,在知识的海洋中遨游了好一阵后,终于了摸索出一条路,爬上岸来。接下来我们开始逐步详细讲解并绘制路线图。 service autoscaling首先,按照伸缩粒度,分为服务伸缩和节点伸缩。我们先来看服务伸缩。k8s 默认提供了多个服务粒度的弹性伸缩组件。主要有 VPA, addon resizer 和 HPA。此外,各大云厂商也积极贡献提供了多种伸缩组件,例如阿里云提供的 cronHPA。以下我们分别详细说明。 在服务粒度的伸缩中,依据执行触发时机不同,可分为立即执行,定时执行和预测性执行。先来看立即执行。 立即执行又细分为垂直伸缩和水平伸缩。 垂直伸缩k8s 中的垂直伸缩一般是指调整 Pod 的内存和 CPU 配额(resource limit 和 request)。k8s 官方 autoscaler 包中有两个类型的垂直伸缩组件:VPA(vertical pod autoscaler) 和 addon resizer。 addon resizer可以根据集群节点数量来动态地调整某个其他 deployment 中的 pod 的配额。addon resizer 周期性地查看集群节点数量,然后计算出监控的 pod 需要分配的内存和 CPU,如果 pod 的实际 pod 配额和所需配额超过某个阈值,则会修改 deployment 并触发生成新的 pod。addon resizer 的这种特性决定了它用来伸缩与集群规模密切相关的服务。一般, addon resizer 用来伸缩部署在集群内的 heapster, metrics-server, kube-state-metrics等监控组件。 VPA的应用范围要广一些。设置 VPA 后,它能够自动为 pod 设定 request 和 limit 配额值,然后动态地将 pod 调度到合适的节点上去。 VPA 在 k8s 中定义类型为 VerticalPodAutoscaler 的 CRD,为每个需要开启垂直弹性伸缩功能的 deployment 创建一个 custom resource,然后 VPA 会定期查看对应 pod 的资源使用情况,结合历史数据,自动调整 pod 的配额。 VPA controller 由三部分组成。 Recommender:它监视当前和过去的资源消耗,并基于此提供容器的cpu和内存请求的推荐值。 Updater:它检查哪个托管的pod设置了正确的资源,如果没有,则杀死它们,以便它们的控制器可以用更新后的请求重新创建它们。 Admission Plugin:它在新pod上设置正确的资源请求(由于Updater的活动,它们的控制器只是创建或重新创建了这些请求)。 使用 VPA 监控 deployment 时,它并不会去改变 deployment 的配置,而是使用 admission plugin 以类似 pre hook 的方式在创建 pod 时动态为其配置配额值。 使用 VPA 之前需要注意以下问题: VPA 默认会从 metrics server 中采集历史数据,因此使用 VPA 之前,需要配置好 metrics server。VPA 也支持从 prometheus 中采集历史数据,不过需要额外的配置。关于 metrics server、state metrics、prometheus 等监控服务的异同,我们将在 数据监控一节中详细介绍 VPA 是在伸缩调整过程中,是通过重启 pod 来使调整生效的,因此已经公司基础架构不同,可能会引起服务闪断,需要具体分析服务是否可接受 使用 VPA 扩容有上限,具体受 pod 所在宿主机影响。为 pod 分配的资源无法超过宿主机的大小。如果 recommender 计算出的 pod 所需资源超过节点可用资源,将导致 pod 一直 pending。这点可与通过与 cluster autoscaler 共同使用来部分解决。 VPA 目前不应与基于内存和 CPU 监控的水平Pod自动调度器(HPA)一起使用,否则可能产生预期外的行为。 水平伸缩Horizontal Pod Autoscaler 是 k8s 内置的水平伸缩控制器。 它根据观察到的CPU使用率(或使用自定义指标支持,基于某些其他应用程序提供的指标)自动缩放 replication 控制器,deployment,副本集或状态集中的 pod 数量。 需要注意的是,水平窗格自动缩放不适用于无法缩放的对象,例如DaemonSets。 HPA 实现为Kubernetes API资源和控制器。资源决定控制器的行为。控制器定期(默认为 15 秒)调整复制控制器或部署中的副本数量,以使所观察到的平均CPU利用率与用户指定的目标相匹配。 与 VPA 仅支持从 metrics server 中采集 CPU 和内存数据不同的是,HPA 支持多种数据维度和数据采集方式: 从 heapster 中采集 CPU 和内存数据(自 kubernetes 1.11 起废除) 遵循特定格式记录在 annotation 中的应用自定义 metrics(自 kubernetes 1.6 起废除) 使用 metrics API 采集 metric server 中的数据 通过 custom metrics adapter 将 prometheus 等第三方中的数据采集提供给 custom metrics API 使用。 和 VPA 一样,使用 HPA 一般需要先搭建 metrics server,具体方法可参考 kubernetes 官方指南。 在新版本(kubernetes 1.6 以后)的 metrics API 中,引入聚合层(aggregation layer),为应用查询 metrics server内部的数据和第三方数据提供了一致的抽象。第三方监控数据只需自己实现相应的 adapter,并在 metrcis API 中为其监控的 metric 注册相应的 custom metrics API 即可。 下图展示了使用 HPA 根据 metrics server 和 prometheus 中的数据进行弹性伸缩的过程。 定时伸缩kubernetes 官方并没有提供定时伸缩相关的组件,但是其原理并不难,只需按照设定的时间调用 kubernetes 的 API 即可。kubernetes-cronhpa-controller 是阿里云工程师基于 go-cron 开源的定时伸缩组件。 预测性伸缩目前暂无成熟的技术方案。 node autoscaling垂直伸缩与 kubernetes 本身关系不大,其功能主要取决于云厂商。例如,厂商是否支持主机的升降配,以及升降配过程中是否需要重启主机等。如果需要重启主机,那么在进行伸缩之前,我们需要先把节点上的 pod 驱逐到其他主机。但是如果我们是由于机器资源不够用而扩容的话,这样会加剧资源不够用的情况,造成更大的 pending 状态的 pod。因此,如果如果云厂商不支持不重启就能扩容的话,我们不建议采用这种方式进行节点扩容。 水平伸缩CA(Cluster Autoscaler) 是 kubernetes 官方的节点水平伸缩组件。它可以在下列条件之一为真时自动调整Kubernetes集群的大小: 集群中有 pod 由于资源不足而一直 pending 集群中有些节点在很长一段时间内没有得到充分利用,且其上的 pod 可以被调度到其他节点上。 cluster autoscaler 虽然是 kubernetes 官方标准,但是由于其去云厂商依赖较深,因此具体使用方法,功能以及限制以云厂商具体实现为准。目前支持以下云厂商: Google Cloud Platform, AWS, AliCloud, Azure, BaiduCloud。 以 AliCloud 为例,默认单个用户按量付费实例的配额是30台,单个VPC的路由表限额是50条;且每个可用区的同一类型的实例库存容量波动很大,如果短时间内大量购买同一区同一配置的实例,很容易出现库存不足导致扩容失败。建议在伸缩组中配置多种同规格的实例类型,提高节点伸缩成功率。 实际使用中,一般为 node 建立多个 node group,专门配置几个 group 来启用弹性伸缩应对突发流量进行扩缩容。主要的 group 并不进行扩缩容,来避免扩缩容导致对大范围的服务的影响。 此外,节点水平伸缩能否成功实施,与调度策略密切相关。kubernetes 在为 pod 选择可分配节点时,是采用 LeastRequestedPriority 策略,简单来说就是就是尽可能把资源打散,把 pod 分配到资源利用率低的节点。这样会倒是有一批利用率较低,但未到缩容阈值的节点,因此会导致无法成功缩容,资源利用率低。因此实际使用时,需要调整 kubernetes 调度策略,来达到最优的结果。 定时伸缩目前暂无成熟的技术方案。 数据监控heapsterheapster 是一个数据收集器,从每个 node 上采集数据并进行汇总,然后转存到第三方工具中,它本身并不生成监控数据,也不负责存储。 目前 heapster 已经被逐渐废弃(从 kubernetes 1.11 开始),不再维护和推荐使用。 heapster 曾负责以下功能: 监控节点、pod 的 cpu 内存基础数据。这部分数据采自每个节点的 cadvisor(新版本的 kubernetes 已集成到 kubelet)。 通用的数据项目监控。 kubernetes 事件信号转发。 heapster 调用 api-server 并将集群的 event 转发处理。 目前这三个功能可依次由 metrics server、pormetheus operator、eventrouter 等独立的组件替换。 metrics servermetrics server 是 heapster 的继承者,kubernetes 1.8 开始,集群内监控数据可以通过 metrics API从监控数据聚合器 metrics server 中获取。Metrics API相比于之前的监控采集方式(hepaster)是一种新的思路,官方希望核心指标的监控应该是稳定的,版本可控的,可以直接被用户访问(kubectl top),也可以由组件通过 API 调用。由于监控数据量巨大,不适宜像其他资源一样存到 etcd 中,因此 Metrics server 将数据存在内存中,只提供实时数据的查询,不提供历史数据查询功能。除了可以通过 metrics API 查询内部监控数据外,metrics server 提供了灵感的扩展方式。 系统可以为自定义监控数据实现对应的 adapter, 用户就可以以 custom metrics API 的方式查询数据,保持与查询原生数据一致的体验。 通常, metrics server 的数据可作为各个维度弹性伸缩的数据指标。 state metricskube-state-metrics 侦听 Kubernetes API服务器并生成关于对象状态的指标。它并不关注Kubernetes组件的健康状况,而是关注内部各种对象(如 deployment、node 和 pod)的健康状况。我们可以直接访问 Kube-state-metrics 的 /metrics HTTP 接口来查看监控数据。 这些数据用来提供给 prometheus 获取其它一些第三方程序抓取使用。 关于 state metrics 和 metrics server 的差异: metrics server 仅采集 kubernetes 中的 CPU、内存等核心指标,它周期性地调用所有节点的 kubelet 提供的 summary API。这些数据是按 pod、node 维度聚合的,存储在内存中,并且以 metrics API 的格式提供。它仅存储最新数据,并且不负责将这些数据导出提供给第三方使用。 kube state metrics 主要关注于从 deployment, replica set, stateful set 等 kubernetes 对象生成新的监控数据,将原始数据重新聚合、呈现。它在内存中存储整个 kubernetes 集群的状态快照,并在此之上持续生成新的监控数据。同样, 它也负责将这些数据导出提供给第三方使用。prometheus用于存储、聚合、查询监控数据的时序型数据库。 0x3 方案对比下面对上述方案从以下几方面做统一比对: 稳定性:在伸缩时尽可能保证服务稳定性是大前提。 接入成本:实现弹性伸缩的一个重要目的就是节省成本,如果接入成本太高就得不偿失了。 灵活性:在落地到具体公司时,每个公司的开发语言、框架、服务类型多种多样,不同服务对弹性伸缩的要求不尽相同。 数据源丰富性:每个成熟的 IT 公司都有一套自己的监控报警体系,收集了诸多维度的数据,如果能更好的利用这些数据,将能够实现更精准的弹性伸缩。 混合云支持:随着公司规模扩大,云服务费用日益增高,越来越多的公司开始组建多机房,多云服务供应商组建的混合云。 方案名 功能简述 优点 缺点 混合云支持 Horizontal Pod Autoscaler 根据服务负载调整服务实例数 稳定性:扩容是通过增加实例数来实现,对原有容器无影响,因此稳定性较好。 接入成本:k8s 原生支持,只需配置 k8s 资源即可实现基本功能。 数据源丰富性:有一定的灵活性,可以直接采集 kubelet 数据,也可基于 prometheus 采集的自定义数据。 灵活性:判定机制较简单,一次命中即触发伸缩,无法配置次数阈值,无法解决毛刺问题;无法自定义触发动作(如通知等) 否 Vertical Pod Autoscaler 根据服务负载调整 pod 的资源分配数 接入成本:k8s 原生支持,只需配置 k8s 资源即可实现基本功能。 数据源丰富性:有一定的灵活性,可以直接采集 kubelet 数据,也可基于 prometheus 采集的自定义数据。 稳定性:伸缩时会重新发版,触发过程可能会加剧请求超时现象,可能引发宕机时间。 灵活性:判定机制较简单,一次命中即触发伸缩,无法配置次数阈值,无法解决毛刺问题;无法自定义触发动作(如通知等)。 否 Cluster Autoscaler 根据 pod 和节点状态增减集群节点数 接入成本:成熟开源方案,且官方适配了阿里云,配置即可。 稳定性:方案较成熟。 数据源:不支持自定义。 灵活性:较不灵活。 否 kubernetes-cronhpa-controller 阿里云基于 go-cron 开源的定时伸缩服务组件。 稳定性:目前该功能已上线阿里云 k8s 服务,较稳定。 接入成本:较低,配置即可。 灵活性:无法支持伸缩前校验,伸缩后通知等自定义操作。 否 0x4 其他最后分享一张我们分析弹性伸缩时用到的思维导图: 好了,弹性伸缩相关路径我们都已经基本熟悉了,我们又可以踏上征程,去探索维斯特洛大陆(kubernetes)其他的版块了。","link":"/2019/12/30/zeroteam/k8s-autoscale-guide/"},{"title":"不得不了解系列之限流","text":"限流简介现在说到高可用系统,都会说到高可用的保护手段:缓存、降级和限流,本博文就主要说说限流。限流是流量限速(Rate Limit)的简称,是指只允许指定的事件进入系统,超过的部分将被拒绝服务、排队或等待、降级等处理。对于server服务而言,限流为了保证一部分的请求流量可以得到正常的响应,总好过全部的请求都不能得到响应,甚至导致系统雪崩。限流与熔断经常被人弄混,博主认为它们最大的区别在于限流主要在server实现,而熔断主要在client实现,当然了,一个服务既可以充当server也可以充当client,这也是让限流与熔断同时存在一个服务中,这两个概念才容易被混淆。 那为什么需要限流呢?很多人第一反应就是服务扛不住了所以需要限流。这是不全面的说法,博主认为限流是因为资源的稀缺或出于安全防范的目的,采取的自我保护的措施。限流可以保证使用有限的资源提供最大化的服务能力,按照预期流量提供服务,超过的部分将会拒绝服务、排队或等待、降级等处理。 现在的系统对限流的支持各有不同,但是存在一些标准。在HTTP RFC 6585标准中规定了『429 Too Many Requests 』,429状态码表示用户在给定时间内发送了太多的请求,需要进行限流(“速率限制”),同时包含一个 Retry-After 响应头用于告诉客户端多长时间后可以再次请求服务。 1HTTP/1.1 429 Too Many Requests2Content-Type: text/html3Retry-After: 360045<html>6 <head>7 <title>Too Many Requests</title>8 </head>9 <body>10 <h1>Too Many Requests</h1>11 <p>I only allow 50 requests per hour to this Web site per12 logged in user. Try again soon.</p>13 </body>14</html> 很多应用框架同样集成了,限流功能并且在返回的Header中给出明确的限流标识。 X-Rate-Limit-Limit:同一个时间段所允许的请求的最大数目; X-Rate-Limit-Remaining:在当前时间段内剩余的请求的数量; X-Rate-Limit-Reset:为了得到最大请求数所等待的秒数。 这是通过响应头告诉调用方服务端的限流频次是怎样的,保证后端的接口访问上限,客户端也可以根据响应的Header调整请求。 限流分类限流,拆分来看,就两个字限和流,限就是动词限制,很好理解。但是流在不同的场景之下就是不同资源或指标,多样性就在流中体现。在网络流量中可以是字节流,在数据库中可以是TPS,在API中可以是QPS亦可以是并发请求数,在商品中还可以是库存数… …但是不管是哪一种『流』,这个流必须可以被量化,可以被度量,可以被观察到、可以统计出来。我们把限流的分类基于不同的方式分为不同的类别,如下图。 因为篇幅有限,本文只会挑选几个常见的类型分类进行说明。 限流粒度分类根据限流的粒度分类: 单机限流 分布式限流 现状的系统基本上都是分布式架构,单机的模式已经很少了,这里说的单机限流更加准确一点的说法是单服务节点限流。单机限流是指请求进入到某一个服务节点后超过了限流阈值,服务节点采取了一种限流保护措施。 分布式限流狭义的说法是在接入层实现多节点合并限流,比如NGINX+redis,分布式网关等,广义的分布式限流是多个节点(可以为不同服务节点)有机整合,形成整体的限流服务。 单机限流防止流量压垮服务节点,缺乏对整体流量的感知。分布式限流适合做细粒度不同的限流控制,可以根据场景不同匹配不同的限流规则。与单机限流最大的区别,分布式限流需要中心化存储,常见的使用redis实现。引入了中心化存储,就需要解决以下问题: 数据一致性 在限流模式中理想的模式为时间点一致性。时间点一致性的定义中要求所有数据组件的数据在任意时刻都是完全一致的,但是一般来说信息传播的速度最大是光速,其实并不能达到任意时刻一致,总有一定的时间不一致,对于我们CAP中的一致性来说只要达到读取到最新数据即可,达到这种情况并不需要严格的任意时间一致。这只能是理论当中的一致性模型,可以在限流中达到线性一致性即可。 时间一致性 这里的时间一致性与上述的时间点一致性不一样,这里就是指各个服务节点的时间一致性。一个集群有3台机器,但是在某一个A/B机器的时间为Tue Dec 3 16:29:28 CST 2019,C为Tue Dec 3 16:29:28 CST 2019,那么它们的时间就不一致。那么使用ntpdate进行同步也会存在一定的误差,对于时间窗口敏感的算法就是误差点。 超时 在分布式系统中就需要网络进行通信,会存在网络抖动问题,或者分布式限流中间件压力过大导致响应变慢,甚至是超时时间阈值设置不合理,导致应用服务节点超时了,此时是放行流量还是拒绝流量? 性能与可靠性 分布式限流中间件的资源总是有限的,甚至可能是单点的(写入单点),性能存在上限。如果分布式限流中间件不可用时候如何退化为单机限流模式也是一个很好的降级方案。 限流对象类型分类按照对象类型分类: 基于请求限流 基于资源限流 基于请求限流,一般的实现方式有限制总量和限制QPS。限制总量就是限制某个指标的上限,比如抢购某一个商品,放量是10w,那么最多只能卖出10w件。微信的抢红包,群里发一个红包拆分为10个,那么最多只能有10人可以抢到,第十一个人打开就会显示『手慢了,红包派完了』。 限制QPS,也是我们常说的限流方式,只要在接口层级进行,某一个接口只允许1秒只能访问100次,那么它的峰值QPS只能为100。限制QPS的方式最难的点就是如何预估阈值,如何定位阈值,下文中会说到。 基于资源限流是基于服务资源的使用情况进行限制,需要定位到服务的关键资源有哪些,并对其进行限制,如限制TCP连接数、线程数、内存使用量等。限制资源更能有效地反映出服务当前地清理,但与限制QPS类似,面临着如何确认资源的阈值为多少。这个阈值需要不断地调优,不停地实践才可以得到一个较为满意地值。 限流算法分类不论是按照什么维度,基于什么方式的分类,其限流的底层均是需要算法来实现。下面介绍实现常见的限流算法: 计数器 令牌桶算法 漏桶算法 计数器固定窗口计数器计数限流是最为简单的限流算法,日常开发中,我们说的限流很多都是说固定窗口计数限流算法,比如某一个接口或服务1s最多只能接收1000个请求,那么我们就会设置其限流为1000QPS。该算法的实现思路非常简单,维护一个固定单位时间内的计数器,如果检测到单位时间已经过去就重置计数器为零。 其操作步骤: 时间线划分为多个独立且固定大小窗口; 落在每一个时间窗口内的请求就将计数器加1; 如果计数器超过了限流阈值,则后续落在该窗口的请求都会被拒绝。但时间达到下一个时间窗口时,计数器会被重置为0。 下面实现一个简单的代码。 1package limit23import (4 \"sync/atomic\"5 \"time\"6)78type Counter struct {9 Count uint64 // 初始计数器10 Limit uint64 // 单位时间窗口最大请求频次11 Interval int64 // 单位ms12 RefreshTime int64 // 时间窗口13}1415func NewCounter(count, limit uint64, interval, rt int64) *Counter {16 return &Counter{17 Count: count,18 Limit: limit,19 Interval: interval,20 RefreshTime: rt,21 }22}2324func (c *Counter) RateLimit() bool {25 now := time.Now().UnixNano() / 1e626 if now < (c.RefreshTime + c.Interval) {27 atomic.AddUint64(&c.Count, 1)28 return c.Count <= c.Limit29 } else {30 c.RefreshTime = now31 atomic.AddUint64(&c.Count, -c.Count)32 return true33 }34} 测试代码: 1package limit23import (4 \"fmt\"5 \"testing\"6 \"time\"7)89func Test_Counter(t *testing.T) {10 counter := NewCounter(0, 5, 100, time.Now().Unix())11 for i := 0; i < 10; i++ {12 go func(i int) {13 for k := 0; k <= 10; k++ {14 fmt.Println(counter.RateLimit())15 if k%3 == 0 {16 time.Sleep(102 * time.Millisecond)17 }18 }19 }(i)20 }21 time.Sleep(10 * time.Second)22} 看了上面的逻辑,有没有觉得固定窗口计数器很简单,对,就是这么简单,这就是它的一个优点实现简单。同时也存在两个比较严重缺陷。试想一下,固定时间窗口1s限流阈值为100,但是前100ms,已经请求来了99个,那么后续的900ms只能通过一个了,就是一个缺陷,基本上没有应对突发流量的能力。第二个缺陷,在00:00:00这个时间窗口的后500ms,请求通过了100个,在00:00:01这个时间窗口的前500ms还有100个请求通过,对于服务来说相当于1秒内请求量达到了限流阈值的2倍。 滑动窗口计数器滑动时间窗口算法是对固定时间窗口算法的一种改进,这词被大众所知实在TCP的流量控制中。固定窗口计数器可以说是滑动窗口计数器的一种特例,滑动窗口的操作步骤: 将单位时间划分为多个区间,一般都是均分为多个小的时间段; 每一个区间内都有一个计数器,有一个请求落在该区间内,则该区间内的计数器就会加一; 每过一个时间段,时间窗口就会往右滑动一格,抛弃最老的一个区间,并纳入新的一个区间; 计算整个时间窗口内的请求总数时会累加所有的时间片段内的计数器,计数总和超过了限制数量,则本窗口内所有的请求都被丢弃。 时间窗口划分的越细,并且按照时间”滑动”,这种算法避免了固定窗口计数器出现的上述两个问题。缺点是时间区间的精度越高,算法所需的空间容量就越大。 常见的实现方式主要有基于redis zset的方式和循环队列实现。基于redis zset可将Key为限流标识ID,Value保持唯一,可以用UUID生成,Score 也记为同一时间戳,最好是纳秒级的。使用redis提供的 ZADD、EXPIRE、ZCOUNT 和 zremrangebyscore 来实现,并同时注意开启 Pipeline 来尽可能提升性能。实现很简单,但是缺点就是zset的数据结构会越来越大。 漏桶算法漏桶算法是水先进入到漏桶里,漏桶再以一定的速率出水,当流入水的数量大于流出水时,多余的水直接溢出。把水换成请求来看,漏桶相当于服务器队列,但请求量大于限流阈值时,多出来的请求就会被拒绝服务。漏桶算法使用队列实现,可以以固定的速率控制流量的访问速度,可以做到流量的“平整化”处理。 大家可以通过网上最流行的一张图来理解。 漏桶算法实现步骤: 将每个请求放入固定大小的队列进行存储; 队列以固定速率向外流出请求,如果队列为空则停止流出; 如队列满了则多余的请求会被直接拒绝· 漏桶算法有一个明显的缺陷:当短时间内有大量的突发请求时,即使服务器负载不高,每个请求也都得在队列中等待一段时间才能被响应。 令牌桶算法令牌桶算法的原理是系统会以一个恒定的速率往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。从原理上看,令牌桶算法和漏桶算法是相反的,前者为“进”,后者为“出”。漏桶算法与令牌桶算法除了“方向”上的不同还有一个更加主要的区别:令牌桶算法限制的是平均流入速率(允许突发请求,只要有足够的令牌,支持一次拿多个令牌),并允许一定程度突发流量; 令牌桶算法的实现步骤: 令牌以固定速率生成并放入到令牌桶中; 如果令牌桶满了则多余的令牌会直接丢弃,当请求到达时,会尝试从令牌桶中取令牌,取到了令牌的请求可以执行; 如果桶空了,则拒绝该请求。 四种策略该如何选择? 固定窗口:实现简单,但是过于粗暴,除非情况紧急,为了能快速止损眼前的问题可以作为临时应急的方案。 滑动窗口:限流算法简单易实现,可以应对有少量突增流量场景。 漏桶:对于流量绝对均匀有很强的要求,资源的利用率上不是极致,但其宽进严出模式,保护系统的同时还留有部分余量,是一个通用性方案。 令牌桶:系统经常有突增流量,并尽可能的压榨服务的性能。 怎么做限流?不论使用上述的哪一种分类或者实现方式,系统都会面临一个共同的问题:如何确认限流阈值。有人团队根据经验先设定一个小的阈值,后续慢慢进行调整;有的团队是通过进行压力测试后总结出来。这种方式的问题在于压测模型与线上环境不一定一致,接口的单压不能反馈整个系统的状态,全链路压测又难以真实反应实际流量场景流量比例。再换一个思路是通过压测+各应用监控数据。根据系统峰值的QPS与系统资源使用情况,进行等水位放大预估限流阈值,问题在于系统性能拐点未知,单纯的预测不一定准确甚至极大偏离真实场景。正如《Overload Control for Scaling WeChat Microservices》所说,在具有复杂依赖关系的系统中,对特定服务的进行过载控制可能对整个系统有害或者服务的实现有缺陷。希望后续可以出现一个更加AI的运行反馈自动设置限流阈值的系统,可以根据当前QPS、资源状态、RT情况等多种关联数据动态地进行过载保护。 不论是哪一种方式给出的限流阈值,系统都应该关注以下几点: 运行指标状态,比如当前服务的QPS、机器资源使用情况、数据库的连接数、线程的并发数等; 资源间的调用关系,外部链路请求、内部服务之间的关联、服务之间的强弱依赖等; 控制方式,达到限流后对后续的请求直接拒绝、快速失败、排队等待等处理方式 go限流类库使用限流的类库有很多,不同语言的有不同的类库,如大Java的有concurrency-limits、Sentinel、Guava 等,这些类库都有很多的分析和使用方式了,本文主要介绍Golang的限流类库就是Golang的扩展库:https://github.com/golang/time/rate 。可以进去语言类库的代码都值得去研读一番,学习过Java的同学是否对AQS的设计之精妙而感叹呢!time/rate 也有其精妙的部分,下面开始进入类库学习阶段。 github.com/golang/time/rate进行源码分析前的,最应该做的是了解类库的使用方式、使用场景和API。对业务有了初步的了解,阅读代码就可以事半功倍。因为篇幅有限后续的博文在对多个限流类库源码做分析。类库的API文档:https://godoc.org/golang.org/x/time/rate。time/rate类库是基于令牌桶算法实现的限流功能。前面说令牌桶算法的原理是系统会以一个恒定的速率往桶里放入令牌,那么桶就有一个固定的大小,往桶中放入令牌的速率也是恒定的,并且允许突发流量。查看文档发现一个函数: 1func NewLimiter(r Limit, b int) *Limiter newLimiter返回一个新的限制器,它允许事件的速率达到r,并允许最多突发b个令牌。也就是说Limter限制时间的发生频率,但这个桶一开始容量就为b,并且装满b个令牌(令牌池中最多有b个令牌,所以一次最多只能允许b个事件发生,一个事件花费掉一个令牌),然后每一个单位时间间隔(默认1s)往桶里放入r个令牌。 1limter := rate.NewLimiter(10, 5) 上面的例子表示,令牌桶的容量为5,并且每一秒中就往桶里放入10个令牌。细心的读者都会发现函数NewLimiter第一个参数是Limit类型,可以看源码就会发现Limit实际上就是float64的别名。 1// Limit defines the maximum frequency of some events.2// Limit is represented as number of events per second.3// A zero Limit allows no events.4type Limit float64 限流器还可以指定往桶里放入令牌的时间间隔,实现方式如下: 1limter := rate.NewLimiter(rate.Every(100*time.Millisecond), 5) 这两个例子的效果是一样的,使用第一种方式不会出现在每一秒间隔一下子放入10个令牌,也是均匀分散在100ms的间隔放入令牌。rate.Limiter提供了三类方法用来限速: Allow/AllowN Wait/WaitN Reserve/ReserveN 下面对比这三类限流方式的使用方式和适用场景。先看第一类方法: 1func (lim *Limiter) Allow() bool2func (lim *Limiter) AllowN(now time.Time, n int) bool Allow 是AllowN(time.Now(), 1)的简化方法。那么重点就在方法 AllowN上了,API的解释有点抽象,说得云里雾里的,可以看看下面的API文档解释: 1AllowN reports whether n events may happen at time now. 2Use this method if you intend to drop / skip events that exceed the rate limit. 3Otherwise use Reserve or Wait. 实际上就是为了说,方法 AllowN在指定的时间时是否可以从令牌桶中取出N个令牌。也就意味着可以限定N个事件是否可以在指定的时间同时发生。这个两个方法是无阻塞,也就是说一旦不满足,就会跳过,不会等待令牌数量足够才执行。也就是文档中的第二行解释,如果打算丢失或跳过超出速率限制的时间,那么久请使用该方法。比如使用之前实例化好的限流器,在某一个时刻,服务器同时收到超过了8个请求,如果令牌桶内令牌小于8个,那么这8个请求就会被丢弃。一个小示例: 1func AllowDemo() {2 limter := rate.NewLimiter(rate.Every(200*time.Millisecond), 5)3 i := 04 for {5 i++6 if limter.Allow() {7 fmt.Println(i, \"====Allow======\", time.Now())8 } else {9 fmt.Println(i, \"====Disallow======\", time.Now())10 }11 time.Sleep(80 * time.Millisecond)12 if i == 15 {13 return14 }15 }16} 执行结果: 11 ====Allow====== 2019-12-14 15:54:09.9852178 +0800 CST m=+0.00599800122 ====Allow====== 2019-12-14 15:54:10.1012231 +0800 CST m=+0.12200330133 ====Allow====== 2019-12-14 15:54:10.1823056 +0800 CST m=+0.20308580144 ====Allow====== 2019-12-14 15:54:10.263238 +0800 CST m=+0.28401820155 ====Allow====== 2019-12-14 15:54:10.344224 +0800 CST m=+0.36500420166 ====Allow====== 2019-12-14 15:54:10.4242458 +0800 CST m=+0.44502600177 ====Allow====== 2019-12-14 15:54:10.5043101 +0800 CST m=+0.52509030188 ====Allow====== 2019-12-14 15:54:10.5852232 +0800 CST m=+0.60600340199 ====Disallow====== 2019-12-14 15:54:10.6662181 +0800 CST m=+0.6869983011010 ====Disallow====== 2019-12-14 15:54:10.7462189 +0800 CST m=+0.7669991011111 ====Allow====== 2019-12-14 15:54:10.8272182 +0800 CST m=+0.8479984011212 ====Disallow====== 2019-12-14 15:54:10.9072192 +0800 CST m=+0.9279994011313 ====Allow====== 2019-12-14 15:54:10.9872224 +0800 CST m=+1.0080026011414 ====Disallow====== 2019-12-14 15:54:11.0672253 +0800 CST m=+1.0880055011515 ====Disallow====== 2019-12-14 15:54:11.1472946 +0800 CST m=+1.168074801 第二类方法:因为ReserveN比较复杂,第二类先说WaitN。 1func (lim *Limiter) Wait(ctx context.Context) (err error)2func (lim *Limiter) WaitN(ctx context.Context, n int) (err error) 类似Wait 是WaitN(ctx, 1)的简化方法。与AllowN不同的是WaitN会阻塞,如果令牌桶内的令牌数不足N个,WaitN会阻塞一段时间,阻塞时间的时长可以用第一个参数ctx进行设置,把 context 实例为context.WithDeadline或context.WithTimeout进行制定阻塞的时长。 1func WaitNDemo() {2 limter := rate.NewLimiter(10, 5)3 i := 04 for {5 i++6 ctx, canle := context.WithTimeout(context.Background(), 400*time.Millisecond)7 if i == 6 {8 // 取消执行9 canle()10 }11 err := limter.WaitN(ctx, 4)1213 if err != nil {14 fmt.Println(err)15 continue16 }17 fmt.Println(i, \",执行:\", time.Now())18 if i == 10 {19 return20 }21 }22} 执行结果: 11 ,执行: 2019-12-14 15:45:15.538539 +0800 CST m=+0.01102340122 ,执行: 2019-12-14 15:45:15.8395195 +0800 CST m=+0.31200390133 ,执行: 2019-12-14 15:45:16.2396051 +0800 CST m=+0.71208950144 ,执行: 2019-12-14 15:45:16.6395169 +0800 CST m=+1.11200130155 ,执行: 2019-12-14 15:45:17.0385893 +0800 CST m=+1.5110737016context canceled77 ,执行: 2019-12-14 15:45:17.440514 +0800 CST m=+1.91299840188 ,执行: 2019-12-14 15:45:17.8405152 +0800 CST m=+2.31299960199 ,执行: 2019-12-14 15:45:18.2405402 +0800 CST m=+2.7130246011010 ,执行: 2019-12-14 15:45:18.6405179 +0800 CST m=+3.113002301 适用于允许阻塞等待的场景,比如消费消息队列的消息,可以限定最大的消费速率,过大了就会被限流避免消费者负载过高。 第三类方法: 1func (lim *Limiter) Reserve() *Reservation2func (lim *Limiter) ReserveN(now time.Time, n int) *Reservation 与之前的两类方法不同的是Reserve/ReserveN返回了Reservation实例。Reservation在API文档中有5个方法: 1func (r *Reservation) Cancel() // 相当于CancelAt(time.Now())2func (r *Reservation) CancelAt(now time.Time)3func (r *Reservation) Delay() time.Duration // 相当于DelayFrom(time.Now())4func (r *Reservation) DelayFrom(now time.Time) time.Duration5func (r *Reservation) OK() bool 通过这5个方法可以让开发者根据业务场景进行操作,相比前两类的自动化,这样的操作显得复杂多了。通过一个小示例来学习Reserve/ReserveN: 1func ReserveNDemo() {2 limter := rate.NewLimiter(10, 5)3 i := 04 for {5 i++6 reserve := limter.ReserveN(time.Now(), 4)7 // 如果为flase说明拿不到指定数量的令牌,比如需要的令牌数大于令牌桶容量的场景8 if !reserve.OK() {9 return10 }11 ts := reserve.Delay()12 time.Sleep(ts)13 fmt.Println(\"执行:\", time.Now(),ts)14 if i == 10 {15 return16 }17 }18} 执行结果: 1执行: 2019-12-14 16:22:26.6446468 +0800 CST m=+0.008000201 0s2执行: 2019-12-14 16:22:26.9466454 +0800 CST m=+0.309998801 247.999299ms3执行: 2019-12-14 16:22:27.3446473 +0800 CST m=+0.708000701 398.001399ms4执行: 2019-12-14 16:22:27.7456488 +0800 CST m=+1.109002201 399.999499ms5执行: 2019-12-14 16:22:28.1456465 +0800 CST m=+1.508999901 398.997999ms6执行: 2019-12-14 16:22:28.5456457 +0800 CST m=+1.908999101 399.0003ms7执行: 2019-12-14 16:22:28.9446482 +0800 CST m=+2.308001601 399.001099ms8执行: 2019-12-14 16:22:29.3446524 +0800 CST m=+2.708005801 399.998599ms9执行: 2019-12-14 16:22:29.7446514 +0800 CST m=+3.108004801 399.9944ms10执行: 2019-12-14 16:22:30.1446475 +0800 CST m=+3.508000901 399.9954ms 如果在执行Delay()之前操作Cancel()那么返回的时间间隔就会为0,意味着可以立即执行操作,不进行限流。 1func ReserveNDemo2() {2 limter := rate.NewLimiter(5, 5)3 i := 04 for {5 i++6 reserve := limter.ReserveN(time.Now(), 4)7 // 如果为flase说明拿不到指定数量的令牌,比如需要的令牌数大于令牌桶容量的场景8 if !reserve.OK() {9 return10 }1112 if i == 6 || i == 5 {13 reserve.Cancel()14 }15 ts := reserve.Delay()16 time.Sleep(ts)17 fmt.Println(i, \"执行:\", time.Now(), ts)18 if i == 10 {19 return20 }21 }22} 执行结果: 11 执行: 2019-12-14 16:25:45.7974857 +0800 CST m=+0.007005901 0s22 执行: 2019-12-14 16:25:46.3985135 +0800 CST m=+0.608033701 552.0048ms33 执行: 2019-12-14 16:25:47.1984796 +0800 CST m=+1.407999801 798.9722ms44 执行: 2019-12-14 16:25:47.9975269 +0800 CST m=+2.207047101 799.0061ms55 执行: 2019-12-14 16:25:48.7994803 +0800 CST m=+3.009000501 799.9588ms66 执行: 2019-12-14 16:25:48.7994803 +0800 CST m=+3.009000501 0s77 执行: 2019-12-14 16:25:48.7994803 +0800 CST m=+3.009000501 0s88 执行: 2019-12-14 16:25:49.5984782 +0800 CST m=+3.807998401 798.0054ms99 执行: 2019-12-14 16:25:50.3984779 +0800 CST m=+4.607998101 799.0075ms1010 执行: 2019-12-14 16:25:51.1995131 +0800 CST m=+5.409033301 799.0078ms 看到这里time/rate的限流方式已经完成,除了上述的三类限流方式,time/rate还提供了动态调整限流器参数的功能。相关API如下: 1func (lim *Limiter) SetBurst(newBurst int) // 相当于SetBurstAt(time.Now(), newBurst).2func (lim *Limiter) SetBurstAt(now time.Time, newBurst int)// 重设令牌桶的容量3func (lim *Limiter) SetLimit(newLimit Limit) // 相当于SetLimitAt(time.Now(), newLimit)4func (lim *Limiter) SetLimitAt(now time.Time, newLimit Limit)// 重设放入令牌的速率 这四个方法可以让程序根据自身的状态动态的调整令牌桶速率和令牌桶容量。 结尾通过上述一系列讲解,相信大家对各个限流的应用场景和优缺点也有了大致的掌握,希望在日常开发中有所帮助。限流仅仅是整个服务治理中的一个小环节,需要与多种技术结合使用,才可以更好的提升服务的稳定性的同时提高用户体验。 附录https://github.com/uber-go/ratelimithttps://en.wikipedia.org/wiki/Token_buckethttps://www.cs.columbia.edu/~ruigu/papers/socc18-final100.pdfhttps://github.com/alibaba/Sentinelhttps://tools.ietf.org/html/rfc6585https://www.yiichina.com/doc/guide/2.0/rest-rate-limitinghttps://github.com/RussellLuo/slidingwindowhttp://zim.logdown.com/posts/300977-distributed-rate-limiterhttps://www.yuque.com/clip/dev-wiki/axo1wb?language=en-us","link":"/2019/12/16/zeroteam/You_have_to_know_the_rate_limit_of_the_series/"},{"title":"聊聊组件设计","text":"前言掘金传送门 组件化思想并不是前端独有的,但却是前端技术的延伸 随着三大框架崛起,前端组件化逐渐成为前端开发的迫切需求,一种主流,一种共识,它不仅提高开发效率,同时也降低了维组件内聚原则护成本开发者们不需要再面对一堆晦涩难懂的代码,转而只需要关注以组件⽅式存在的代码⽚段 这是一场新的挑战! 1、文章开始之前,明确本文的边界: 从前端工程谈到组件化开发 组件的设计原则 组件的职能划分及利弊 组件设计的边界 落实到具体业务中如何做 一些感悟 总结 2、一个面试题引发的思考: 1面试官通常会问 写过前端通用组件吗? 你可能会自信的表示: sure! emm..是的吗? 从前端工程谈到组件化开发前端工程经历的三个阶段 库/框架选型确定技术选型,为项目节省许多工程量后来三大框架的横空出世,解放了不少生产力 简单构建优化 解决完开发效率,还需要兼顾运行性能,故而选择某种构建工具,对代码进行压缩,校验,之后再以页面为单位进行简单的资源合并 JS/CSS模块化开发 解决了基本开发效率和运行效率之后,开始考虑维护效率了 分而治之(以分解降低复杂度)是软件工程中的重要思想,是复杂系统开发和维护的基石,模块化就是前端的分治手段 因此,模块化强调的是拆分,最大的价值就是分治,意味着不管你将来是否要复用这块儿代码,都有将他们拆成一个模块的理由 将一个大问题,不断的拆解为各个小问题进行分析研究,然后再组合到一起(分而治之原则) 模块化的方案 JS模块化 1无模块化->函数写法->对象写法->自执行函数->CommonJS/AMD/CMD->ES6 Module CSS模块化 1css模块化是在less,sass等预处理器的支持下实现的 做到这些就够了吗? 当然是不够的 模块化强调的是拆分,无论是从业务角度还是从架构、技术角度,模块化首先意味着将代码、数据等内容按照其职责不同分离 单纯的横向拆分业务功能模块有一些问题 面向过程的代码 随着业务的发展不利于维护 随着业务发展,”过程线“也会越来越长,其他项目成员根据各自需要,在”过程线“ 加插各自逻辑,最终这个页面的逻辑变得难以维护 我们需要摆脱【一泻而下】式的代码编写 仅仅有JS/CSS模块化是不够的,UI(页面)的分治也比较迫切 除了JS和CSS,界面也需要拆分,如何让模块化思想融入HTML语言 组件化开发(本文重点)4.1 组件化开发的演变 在大肆宣扬组件化开发概念之前,也经历了寻求组件化最佳实践的阶段 ** 4.2 页面结构模块化** 简单来说就是把页面想象成乐高机器人,需要不同零件组装,然后将各个部分拼到一起 落实到实际开发中像这样 我们可以发现 页面pageModel包含了 tabContainer,listContainer 和 imgsContainer 三个模块 我们根据不同的业务逻辑封装了不同类型的model 每个model有自己的数据,模板,逻辑,已经算是一个完整的功能单元 咦?嗅到一丝组件化的味道 ** 4.3 N年前微软的组件化的解决方案 HTML Component**历史总有遗猪 早在N年前微软提出过一套解决方案,名为HTML Component 事实上已经是一个比较完整的组件化方案了,但最后却没能进入标准,从今天的角度看,它可以说是生不逢时 ** 4.4 WebComponents 标准** 当时”所谓的组件“ 此时的组件基本上只能达到某个功能单元上的集合,资源都是资源都是松散地分散在三种资源文件中 而且组件作用域暴露在全局作用域下,缺乏内聚性很容易就会跟其他组件产生冲突(如最简单的 css 命名冲突) 于是 W3C 按耐不住了,制定一个 WebComponents 标准,为组件化的未来指引了明路 大致四部分功能 <template> 定义组件的 HTML模板能力 Shadow Dom 封装组件的内部结构,并且保持其独立性 Custom Element 对外提供组件的标签,实现自定义标签 import 解决组件结合和依赖加载 我们思考一下,可行的实践化方案需要具备哪些能力 资源高内聚(组件资源内部高内聚,组件资源由自身加载控制) 作用域独立(内部结构密封,不与全局或其他组件产生影响) 自定义标签(定义组件的使用方式) 可相互组合(组件间组装整合) 接口规范化(组件接口有统一规范,或者是生命周期的管理) 4.5 三大框架出现 今天的前端生态里面 React,Angular和Vue三分天下,即使它们定位不同,但核心的共同点就是提供了组件化的能力,算是目前是比较好的组件化实践 Vue.js采用了JSON的方法描述一个组件 1import PageContainer from './layout/PageContainer'2import PageFilter from './layout/PageFilter'34export default {5 install(Vue) {6 Vue.component('PageContainer', PageContainer)7 Vue.component('PageFilter', PageFilter)8 }9} 还提供了SFC(Single File Component,单文件组件)‘.vue’文件格式 1<template>2//...3</template>45<script>6 export default {7 data(){}8 }9</script>1011<style lang="scss">12//...13</style> React.js发明了JSX,把CSS和HTML都塞进JS文件里 1class Tabs extends React.Component {2 render() {3 if (!this.props.items) {4 console.error('Tabs中需要传入数据');5 return null;6 }7 const propId = this.props.id;8 return (9 <ul className={this.props.className}>10 <li>测试</li>11 </ul>12 );13 }14} Angular.js选择在原本的HTML上扩展 1<input type="text" ng-model="firstname">23var app = angular.module('myApp', []);4app.controller('formCtrl', function($scope) {5 $scope.firstname = "John";6}); ** 4.5 标准下的资源整合 ** 具有以下特点 每个组件对应一个目录,组件所需的各种资源都在这个目录下就近维护;(最具软件工程价值) 页面上的每个独立的可视/可交互区域视为一个组件; 由于组件具有独立性,可以自由组合; 页面是组件的容器,负责组合组件形成功能完整的界面; 当不需要某个组件,或者想要替换组件时,可以整个目录删除/替换 ** 4.6 应用结构图** 分子是由原子组成的,分子分成原子,原子也可以重新组合成新的分子 一个界面是由独立的分子组件搭建而成,分子组件由原子元件构成,这些原子可通过不同的组合方式,组成新分子组件,继而重组构成新的界面 模块化与组件化对比如果你去网上搜【模块和组件的异同】可能会得到截然不同的答案,大部分描述的都是片面的 它们之间的关系可以从以下三个方面分析: ** 5.1 从整体概念来讲 ** 模块化是一种分治的思想,诉求是解耦,一般指的是js模块,比如用来格式化时间的模块 组件化是模块化思想的实现手段,诉求是复用,包含了template,style,script,script又可以由各种模块组成 ** 5.2 从复用的角度来讲 ** 模块一般是项目范围内按照项目业务内容来划分的,比如一个项目划分为子系统、模块、子模块,代码分开就是模块 组件是按照一些小功能的通用性和可复用性抽象出来的,可以跨项目,是可复用的模块 ** 5.3 从历史发展角度来讲 ** 随着前端开发越来越复杂、对效率要求越来高,由项目级模块化开发,进一步提升到通用功能组件化开发,模块化是组件化的前提,组件化是模块化的演进 组件的设计原则组件化方案下,我们需要具有组件化设计思维,它是一种【整理术】帮助我们高效开发整合 标准性 1任何一个组件都应该遵守一套标准,可以使得不同区域的开发人员据此标准开发出一套标准统一的组件23API尽量和已知概念保持一致 独立性 1遵循单一职责原则,保持组件的纯粹性2属性配置等API对外开放,组件内部状态对外封闭,尽可能的少与业务耦合34避免暴露组件内部实现56入口处检查参数的有效性,出口处检查返回的正确性 复用与易用,适用SPOT法则 1UI差异,消化在组件内部(注意并不是写一堆if/else)2输入输出友好,易用34Single Point Of Truth,就是尽量不要重复代码,出自《The Art of Unix Programming》 避免直接操作DOM,避免使用ref 1使用父组件的 state 控制子组件的状态而不是直接通过 ref 操作子组件 无环依赖原则(ADP) 设计不当导致环形依赖示意图 影响 组件间耦合度高,集成测试难 一处修改,处处影响,交付周期长 因为组件之间存在循环依赖,变成了“先有鸡还是先有蛋”的问题 那倘若我们真的遇到了这种问题,就要考虑如何处理? 消除环形依赖 我们的追求是沿着逆向的依赖关系即可寻找到所有受影响的组件 创建一个共同依赖的新组件 稳定抽象原则(SAP) 1- 组件的抽象程度与其稳定程度成正比,2- 一个稳定的组件应该是抽象的(逻辑无关的)3- 一个不稳定的组件应该是具体的(逻辑相关的)4- 为降低组件之间的耦合度,我们要针对抽象组件编程,而不是针对业务实现编程 避免冗余状态 1如果一个数据可以由另一个 state 变换得到,那么这个数据就不是一个 state,只需要写一个变换的处理函数,在 Vue 中可以使用计算属性23如果一个数据是固定的,不会变化的常量,那么这个数据就如同 HTML 固定的站点标题一样,写死或作为全局配置属性等,不属于 state45如果兄弟组件拥有相同的 state,那么这个state 应该放到更高的层级,使用 props 传递到两个组件中 合理的依赖关系 1父组件不依赖子组件,删除某个子组件不会造成功能异常 扁平化参数 1除了数据,避免复杂的对象,尽量只接收原始类型的值 良好的接口设计 1把组件内部可以完成的工作做到极致,虽然提倡拥抱变化,但接口不是越多越好23如果常量变为 props 能应对更多的场景,那么就可以作为 props,原有的常量可作为默认值。45如果需要为了某一调用者编写大量特定需求的代码,那么可以考虑通过扩展等方式构建一个新的组件。67保证组件的属性和事件足够的给大多数的组件使用。 组件的职能划分那有了组件设计的“API”,就一定能开发出高质量的组件吗? 组件最大的不稳定性来自于展现层,一个组件只做一件事,基于功能做好职责划分 根据以往经验,我将组件分为以下几类 基础组件(通常在组件库里就解决了) 容器型组件(Container) 展示型组件(stateless) 业务组件 通用组件 UI组件 逻辑组件 高阶组件(HOC) 除容器组件外,尽量保证组件都是stateless的,这并不冲突! 基础组件 为了让开发者更关注业务逻辑,涌现出了很多优秀的UI组件库比如antd,element-ui,我们只需要调用API便能满足大部分的业务场景,前端角色后置了,开发变得更简单了 容器型组件一个容器性质的组件,一般当作一个业务子模块的入口,比如一个路由指向的组件 *2.2.1特点 * 容器组件内的子组件通常具有业务或数据依赖关系 集中/统一的状态管理,向其他展示型/容器型组件提供数据(充当数据源)和行为逻辑处理(接收回调) 如果使用了全局状态管理,那么容器内部的业务组件可以自行调用全局状态处理业务 业务模块内子组件的通信等统筹处理,充当子级组件通信的状态中转站 模版基本都是子级组件的集合,很少包含DOM标签 辅助代码分离 2.2.2 表现形式(vue) 1<template>2<div class="purchase-box">3 <!-- 面包屑导航 -->4 <bread-crumbs />5 <div class="scroll-content">6 <!-- 搜索区域 -->7 <Search v-show="toggleFilter" :form="form"/>8 <!--展开收起区域-->9 <Toggle :toggleFilter="toggleFilter"/>10 <!-- 列表区域-->11 <List :data="listData"/>12 </div>13</template> 展示型(stateless)组件 主要表现为组件是怎样渲染的,就像一个简单的模版渲染过程 ** 2.3.1 特点 ** 只通过props接受数据和回调函数,不充当数据源 可能包含展示和容器组件 并且一般会有Dom标签和css样式 通常用props.children(react) 或者slot(vue)来包含其他组件 对第三方没有依赖(对于一个应用级的组件来说可以有) 可以有状态,在其生命周期内可以操纵并改变其内部状态,职责单一,将不属于自己的行为通过回调传递出去,让父级去处理(搜索组件的搜索事件/表单的添加事件) 2.3.2 表现形式(vue 1 <template>2 <div class="purchase-box">3 <el-table4 :data="data"5 :class="{'is-empty': !data || data.length ==0 }"6 >7 <el-table-column8 v-for = "(item, index) in listItemConfig"9 :key="item + index" 10 :prop="item.prop" 11 :label="item.label" 12 :width="item.width ? item.width : ''"13 :min-width="item.minWidth ? item.minWidth : ''"14 :max-width="item.maxWidth ? item.maxWidth : ''">15 </el-table-column>16 <!-- 操作 -->17 <el-table-column label="操作" align="right" width="60">18 <template slot-scope="scope">19 <slot :data="scope.row" name="listOption"></slot>20 </template>21 </el-table-column>22 <!-- 列表为空 -->23 <template slot="empty">24 <common-empty />25 </template>26 </el-table>27 28 </div>29 </template>30<script>31 export default {32 props: {33 listItemConfig:{ //列表项配置34 type:Array,35 default: () => {36 return [{37 prop:'sku_name',38 label:'商品名称',39 minWidth:20040 },{41 prop:'sku_code',42 label:'SKU',43 minWidth:12044 },{45 prop:'product_barcode',46 label:'条形码',47 minWidth:12048 }]49 }50 }}51 }52</script> 业务组件 通常是根据最小业务状态抽象而出,有些业务组件也具有一定的复用性,但大多数是一次性组件 通用组件可以在一个或多个APP内通用的组件UI组件 界面扩展类组件,比如弹窗 特点:复用性强,只通过 props、events 和 slots 等组件接口与外部通信 表现形式(vue) 1<template>2 <div class="empty">3 <img src="/images/empty.png" alt>4 <p>暂无数据</p>5 </div>6</template> 逻辑组件 不包含UI层的某个功能的逻辑集合 高阶组件(HOC)高阶组件可以看做是函数式编程中的组合可以把高阶组件看做是一个函数,他接收一个组件作为参数,并返回一个功能增强的组件 高阶组件可以抽象组件公共功能的方法而不污染你本身的组件比如 debounce 与 throttle 用一张图来表示 React中高阶组件是比较常用的组件封装形式,Vue官方内置了一个高阶组件keep-alive,但并未推荐使用HOC :( 猜想原因 React:写组件就是在写函数,函数拥有的功能组件都有 Vue:更像是高度封装的函数,能够让你轻松的完成一些事情的同时损失一定的灵活性,你需要按照一定规则才能使系统更好的运行 ** 表现形式(react)** 品牌车系滑动的动画 各类组件协同组成业务模块 容器/展示组件对比图 ** 引入容器组件的概念只是一种更好的组织方式 ** 各司其职,不易出错,即使出错,也能快速定位问题 容器组件,一个载体的存在 展示型组件不与store耦合,通过props接口来定义所需的数据和方法,复用性与正确性更能保证 1展示型组件直接和store通信的话,那么它就会收到限制,因为你在store里面的字段已经限制他的使用次数和使用的位置 既然如此,那我什么时候引入容器组件,什么时候引入展示组件 ** 引入容器组件的时机 ** 优先考虑展示组件,当你意识到有一些中间组件不使用它继承的props而是转而传递给他们的子级,每次子级组件需要更多数据时,都需要“路过”这些中间组件时就要考虑引入容器组件! 两者的区别并没有被严格定义,事实上不在技术上而是目的性上 这里有几个供参考的点 容器组件倾向于有状态,展示组件倾向于无状态,这不是硬性规定,它们都是可以有状态的 不要把分离容器组件和展示组件当做教条,如果你不确定该组件是容器组件还是展示组件,就暂时不要分离,写成展示组件,也许是为时尚早,别着急! 这是一个持续的重构过程,不用试图一次就把它做好,习惯这种模式就会培养起一种直觉,知道何时引入容器 就像你知道何时封装一个函数那样! 进行组件职能划分的利弊** 优点 ** 更好的关注分离 1用这种方式写组件,你可以更好的理解你的app和你的ui,甚至会逐渐形成你自己的开发套路 复用性高 1一个组件只做一件事,解除了组件的耦合带来更高复用性 它是app的调色版,设计师可以随意调整它的ui而不用改变app的逻辑 这会强制你提取“布局组件”,达到更高的易用性 提高健壮性 1由于展示组件和容器组件是通过prop接口来连接,可以利用props的校验机制来增强代码的可靠性,混合的组件就没有这种好处23举个栗子(Vue)4 props: {5 editData: Object,6 statusConfig: {7 type: Object,8 default() {9 return {10 isShowOption: true, //是否有操作栏11 isShowSaveBtn: false12 };13 }14 }15 } 可测试性 1组件做的事情更少了,测试也会变得容易2容器组件不用关心UI的展示,只关心数据和更新3展示组件只是呈现传入的props,写单元测试的时候也非常容易mock数据层 ** 所谓的缺点 ** 设计组件初期会增加一些学习成本 由于需要封装一个容器,包装一些数据和接口给展示组件,会增加一些工作量 在展示组件内对props的声明会带来少量的工作 长远来看,利大于弊,特别是项目初期,一定要有一个好的设计习惯 组件设计的边界物极必反,跃跃欲试前,常常思考以下几个问题以引导完善组件的设计 ** 页面层级不宜嵌套超过三层,切勿过度设计 ** 1原则上组件嵌套超过三层,数据传递的过程就会变得相对复杂 ** 这个组件可否(有必要)再分? ** 1划分粒度的根据实际情况权衡,太小会提升维护成本,太大又不够灵活和高复用性23是否打破了一个逻辑上有意义的实体,倘若抽离的话,这个代码被复用的概率有多大?45如果它只是几行代码,那么最终可能会创建更多的代码来分离它,有必要吗?我这么做的好处是否超过了成本?67如果你当前的逻辑不太可能出现在其他地方,那么将它嵌入其中更好,如果需要,你可以随时抽离,毕竟组件化没有终点89每一个组件都应该有其独特的划分目的的,有的是为了复用实现,有的是为了封装复杂度清晰业务实现10组件划分的依据通常是业务逻辑、功能,要考虑各组件之间的关系是否明确,及可复用度 ** 性能会受到影响吗? ** 1如果状态频繁更改,并且当前在一个较大且关系比较紧密的组件里,为了避免性能受到影响最好抽离出来 与diff策略相关 ** 这个组件的依赖是否可再缩减?** 缩减组件依赖可以提高组件的可复用度 ** 这个组件是否对其它组件造成侵入?** 封装性不足或自身越界操作,就可能对自身之外造成了侵入 一个组件不应对其它兄弟组件造成直接影响 1常见的一种情况是:组件运行时对window对象添加resize监听事件以实现组件响应视窗尺寸变化事件23最优的方案:组件提供刷新方法,由父组件实现调用4次优的方案:组件destroy前清理恢复 ** 接口设计是否兼容大部分场景?** 1需要考虑需要适用的不同场景,在组件接口设计时进行必要的兼容 ** 当别人使用这个组件时,会怎么想?** 1接口设计符合规范和大众习惯,尽量让别人用起来简单易上手,易上手是指更符合直觉 ** 假如业务需要不需要这个功能,是否方便清除?** 1各组件之前以组合的关系互相配合,也是对功能需求的模块化抽象,当需求变化时可以将实现以模块粒度进行调整 上文提到的各种准则仅仅描述了一种开发理念,也可以认为是一种开发规范,倘若你认可这规范,对它的分治策略产生了共鸣,那我们就可以继续聊聊它的具体实现了 问自己一个问题 你心中的相对完美的组件是什么样子的? 落实到具体业务中如何做划分依据明确你的组件划分依据,目前是两种 根据业务划分 根据技术划分 我更多的是根据业务去设计我应用中的组件树,可能会画个草图或xmind,它可以帮我统观全局 明确各个组件的边界,内部state的设计,props的设计以及与其他组件的关系(需要回调出去的事件) 明确各个组件的定位与职能划分,设计好父子组件、兄弟组件的通信机制 搭架子 架子有了,开始填空 切割模版(页面结构模块化)这是最容易想到的方法,当一个组件渲染了很多元素,就需要尝试分离这些组件的渲染逻辑我们以掘金页面为例 大体上看,可以分为Part1,Part2,Part3 ** 初步开发 ** 1<template>2 <div id="app">3 <div class="panel">4 <div class="part1 left">5 <!--内容-->6 </div>7 <div class="part1 right">8 <!--内容-->9 </div>10 <div class="part1 right">11 <!--内容-->12 </div>13 </div>14</template> 问题: 代码量大,难以维护,难以测试 有些许重复量 ** 化繁为简 ** 1<template>2 <div id="app">3 <part1 />4 <part2 />5 <part3 /> 6 </div>7</template> 好处: 同之前的方式相比,这个微妙的改进是革命性的 解决了测试困难,维护困难的问题 问题: 没有解决代码重复的问题,这种按模块划分,复用性低 但我看过很多项目的代码,就是这么干的,认为自己做了组件化,抽象的还不错(@_@) ** 组件抽象 ** 它们有相似的外层,part2和part3更有相似的titlebar,除了业务内容,完全就是一模一样 栗子(vue) 1<template>2 <div class="part">3 <header>4 <span>{{ title }}</span>5 </header>6 <slot name="content" />7 </div>8</template> 我们将part内可以抽象的数据都做成了props,利用slot去做模版那么我们在开发相应Part1,Part2时 栗子(vue) 1<template>2 <div id="app">3 <part title="亦舒">4 <div slot="content">----</div>5 </part>6 <part title="兴隆臻园户型">7 <div slot="content">-----</div>8 </part>9 </div>10</template> 更具代表性的示例图 UI差异在哪里定义? 在业务逻辑层处理 1首先要明确一点,这些差异并不是组件本身造成的,是你自己的业务逻辑造成的,所以容器组件(父组件)应该为此买单 数据差异在哪里定义? 结合组件本身和业务上下文将差异合理的消除在内部 1比如part3中,其他的part只有一个类似更多>>的link,但是它却有多个(一居,二居...)2这里我推荐将这种差异体现在组件内部,设计方法也很多:3比如可以将link数组化为links;4比如可以将更多>>看作是一个default的link,而多余的部分则是用户自定义的特殊link,这两者合并组成了links。用户自定义的默认是没有的,需要引用组件时进行传入。 组件命名规则? 组件设计初期,就应该拥有不耦合业务的名字 1一个通用的或者说未来可能通用的,要有相对合理的命名,比如 Search,List,尽量不要出现与业务耦合过深的业务名词,通用组件与业务无关,只与自身抽象的组件有关2我们在设计组件初期,就应该有这种思想,等到真正可以抽出公用组件了,再去苦逼的名改名字?3库通常都想让广大开发者用,我们在设计组件时,可以降低标准到先做到你的整个APP中通用 组件划分细粒度的考量(抽之有度)组件设计规则明明白白写着我们要遵循单一职责原则,这也带来了上文聊过的过度抽象(组件化)的问题,我们结合具体的业务聊一下 要实现徽章组件,它有两部分组成 按钮 右上角提示(小红点/icon) 两者都是符合单一职责的,可以将其抽离成一个独立组件,但是通常不要这么做 1因为同一个app的风格必将是统一的,除此之外没别的应用场景了,就像上文所说的,抽离组件之前,多问自己为什么以及投入/产出比,没有绝对的规则 ** tips ** 单一职责组件要建立在可复用的基础上,对于不可复用的单⼀职责组件我们仅仅作为独立组件的内部组件即可 ** 某二手车网站体现其细粒度的例子 ** 思考,如果让你实现你会如何设计…我当初是这么设计的 index.js(react) 1<div className="select-brand-box" onTouchStart={touchStartHandler} onTouchMove={touchMoveHandler} onTouchEnd={touchEndHandler.bind(this, touchEndCallback)}>2 <NavBar></NavBar>3 <Brand key="brands-list" {...brandsProps} />4 <Series key="series-list" {...seriesProps} >5 </div>6 7 export default BrandHoc(index); Brand.js(react) 1<div className="brand-box">2 <div className="brand-wrap" ref="brandWrap">3 <p className="brands-title hot-brands-title">热门品牌</p>4 <FlexLayout onClick={hotBrandClick}>5 <HotBrands HotBrands={hotBrands} />6 </FlexLayout>7 {!isHideStar && <UnlimitType {...unlimitProps} />}8 <AllBrands {...brandsProps} />9 </div>10 <AsideLetter {...asideProps} />11 {showPop ? <PopTips key="pop-tips" tip={currentLetter} /> : null}12 {showBrandLoading ? <Loading /> : null}13</div> FlexLayout.js(react) 这个示例几乎涵盖了所有的规则 首先组件的设计是根据业务划分的,所以右侧字母导航(AsideLetter)才没有在最外层的容器组件,否则通信问题会占用一部分篇幅,事实上这是有解的 入口组件是容器组件,事实上把它当做一个规则就行了,业务逻辑的载体 除了容器组件外,其他的组件都被抽成公用的了,二手车平台类似的场景非常多 卖车平台类似的图文混排多且形态各不相同,应用场景广泛,抽!UI差异消化在组件内部,参考FlexLayout.js,给定default props 可提取的组件过多(业务驱动)导致通讯困难如何解决? 那说明你需要新增可管理状态的容器组件,上例中Brand,Series也是容器组件,负责管理子组件的大小事宜 细粒度的考量,考虑付出产出比 1<p className="brands-title hot-brands-title">热门品牌</p> 只有一行,直接写就完了 组件抽离的过程就是无限向无状态(展示型)组件无限靠近的过程 通用性考量组件的形态(UI)永远是千变万化的,但是其行为(逻辑)是固定的,因此通用组件的秘诀之⼀就是将DOM 结构的控制权交给开发者,组件只负责⾏为和最基本的DOM结构 这是一个显眼的栗子 某一天,你接到这样儿的需求 开心,简单,三下五除二写完了 突然有一天又有这样儿的需求 emm..可定制?之前的select没法用了,怎么做?要修改上一个或者再写一个吗?一旦出现了这种情况,证明之前的组件需要重新设计了 实现通用性设计的关键一点是放弃对Dom的掌控 ** 那么问题又来了,那么多需要自定义的地方,那组件会不会很难用?** 通用性设计在将Dom结构决定权交给开发者的同时指定默认值 这里是一个新鲜出炉(vue) List组件 父组件(vue)及slot 1模版(伪代码)2<template>3<List :data="tableData[item.type]" :loading="loading" @loadMore="loadMore" :noMore="noMore">4 <a v-if="item.type == 0" slot="listOption" slot-scope="childScope" class="edit-btn" @click="edit(childScope.data)" v-bind:key="childScope.data.id">{{Status[childScope.data.status]['text']}}</a>5</List>6</template>78config(伪代码)9export const Status = {10 //....11 1: {12 label: '草稿',13 type: '',14 text: '编辑',15 class: 'note'16 }}17 //... 又有一个栗子(vue) Dialog只负责基础的逻辑,交出控制权给到业务,至于你的业务需要什么,在容器组件(业务逻辑层)去处理 忍不住放上磐石业务的反面例子 难用无非是两方面的问题 不肯移交控制权 没有API文档 所有的业务逻辑与场景都包含在组件内部,外界只通过变量来控制,初衷是好的,但是随着业务发展,组件越来越庞大,开发者也越来越力不从心了 刚好现阶段UI改版,我们的工作量就由只改样式直接转化为推倒重来了,又没有详细的文档,工作量瞬间翻了N倍,宝宝心里苦宝宝不说 善用设计模式其实一开始,我并没有专门去套用设计模式,完全是业务驱使你一定见到过这样儿的 一旦这样儿的逻辑多了,那是不是就跟业务耦合了,跟业务耦合多了,那组件自然没有什么通用性了,即使我们不考虑到通用性,那写的累吧? 考虑下这样写会不会好一点 1config(伪代码)2export const Status = {3 4: {4 label: '部分入库',5 type: '',6 text: '查看'7 }8}9模版(vue)10<a v-if="item.type == 0" slot="listOption" slot-scope="childScope" class="edit-btn" @click="edit(childScope.data)" v-bind:key="childScope.data.id">{{Status[childScope.data.status]['text']}}</a> 世界上本没有设计模式,写的人多了,就自成一套脱颖而出进而被历史铭记了!不仅如此,一部分看似复杂的业务如果合理设计配置项,可以会为你省去一大篇js 一些感悟像磐石这种底层的业务支持系统,离不开大量的列表,查询,编辑,详情等,我一般会花30秒搭好架子,像但不限于下面这种 index:模块入口(承担容器职责) api:整块业务的API components 业务组件集合 11. Form:表单 一般会被add.vue(编辑) 和edit.vue(详情)引用22. List:列表33. Search: 搜索组件44. 其他业务中有但却没看到的基本上都已经抽离到common了 比如面包屑导航,收起展开功能等 libs 页面的各种配置 ** 1. 具体体现(磐石刚刚重构的模块)** 采购模块结构图 Form Edit 无论有多少种状态,只在edit这层容器维护 ** 2. 要这么做的原因 ** components中的组件只是暂存,都有可能被升级成通用组件,所以命名要注意,一类的保持了统一,防止业务耦合 bug有迹可循,数据的问题我一定从外向里排查,样式问题从里向外排查,定位问题快 与重复代码做斗争,时刻保持一种强迫症的心态去整理各个模块,形成自己的编码风格,进而团队风格才有可能统一 总结 对于组件设计,充分的准备固然,但在现实世界中,切实的结果才是最重要的,组件设计也不要过度设计更不要停滞不前,该做的时候就去做,发现不好就去改 有空闲时间就去思考早期不够理想的代码,它可以作为我们向前发展的基础 技术在变迁,但组件化的核心并没有改变,目标仍然是在API设计尽可能接近原生的情况下完成复用、解耦、封装、抽象的目标,最终服务于开发,提高效率降低错误率 组件化是对实现的分层,是更有效地代码组合方式 组件化是对资源的重组和优化,从而使项目资源管理更合理,方便拔插、方便集成、方便删除、方便删除后重新加入 这种化繁为简的思想在后端开发中的体现是微服务,而在前端开发中的体现就是组件化 组件化有利于单元测试与自测效率对重构较友好 新人加入可以直接分配组件进行开发、测试,而非需要熟悉整个项目,可以从一个组件的开发使新进人员比较快速熟悉项目、了解到开发规范 你的直接责任可能是编写代码,但你的终极目标是在创建产品 最后说一句 组件化没有终点,day day up 参考链接 https://engineering.carsguide.com.au/front-end-component-design-principles-55c5963998c9?gi=b5b86599de92 https://segmentfault.com/a/1190000009952681 https://juejin.im/post/5a73d6435188257a6a789d0d https://medium.com/merrickchristensen/function-as-child-components-5f3920a9ace9 http://www.alloyteam.com/2015/11/we-will-be-componentized-web-long-text/","link":"/2019/08/13/fontend/components-design/"},{"title":"如何打造一套Vue组件库","text":"开篇组件库能帮我们节省开发精力,无需所有东西都从头开始去做,通过一个个小组件拼接起来,就得到了我们想要的最终页面。在日常开发中如果没有特定的一些业务需求,使用组件库进行开发无疑是更便捷高效,而且质量也相对更高的方案。 目前的开源组件库有很多,不管是react还是vue的体系里都有很多非常优秀的组件库,比如我经常使用的就有elementui和iview。当然也还有其他的一些组件库,他们的本质其实都是为了节省重复造基础组件这一轮子的过程。也有的公司可能会对自己公司的产品有特别的需求,不太愿意使用开源的组件库的样式,或者自己有一些公司内部的业务项目需要用到,但开源项目无法满足的组件需要沉淀下来的时候,自建一套组件库就成为了一个作为业务驱动所需要的项目。 本文会从 ”准备“ 和 ”实践“ 两个阶段来阐述,一步步完成一个组件库的打造。大致内容如下: 准备:主要讲了搭建组件库之前我们需要先提及一下一些基础知识,为实践阶段做铺垫。 实践:有了一些基本概念,咱们就直接通过一个实践案例来动手搭建一套基础的组件库。从做的过程中去感受组件库的设计。 希望通过本文的分享以及包含的一个简单的 实际操作案例,能让你从组件库使用者的角色向组件库创造者的角色迈进那么一小步,在日常使用组件库的时候心里有个底,那我的目的也就达到了。 我们的案例地址是:https://arronkler.github.io/lime-ui/ 对应的 repo也就是:https://github.com/arronKler/lime-ui 准备 :打造组件库之前你应该知道些什么?这一个章节主要是想先解析清楚一些在组件库的建立中会用到的一些平时在业务概念中很少去关注的概念。我会分为工程和组件两个方面来阐述,把我所知道的一些其中的技巧和坑点都交付出来,以帮助我们在实际去做的过程中可以有所准备。 项目:做一个组件库项目有哪些额外需要考虑的事?做组件库项目和常规业务项目肯定还是有一些事情是我们业务项目不怎么需要,但是类库项目一般都会考虑的事,这一小节就是介绍说明一下,那些我们在做组件库的过程中需要额外考虑的事。 组件测试很多开发者平时业务项目都比较赶,然后就是一般业务项目中都不怎么写测试脚本。但在做一个组件库项目的过程中,最好还是有对应的组件测试的脚本。至少有两点好处: 自动化测试你写的组件的功能特性 改动代码不用担心会影响之前的使用者。(测试脚本会告诉你有没有出现未预料到的影响) 对于类库型项目,我觉得第二点好处还是很重要的,这才能保证你在不断推进项目升级迭代的过程中,确保不会出现影响已经在用你所创造的类库的那些人,毕竟你要是升级一次让他的项目出现大问题,那可真保不准别人饭碗都能丢。(就像之前的antd的圣诞节雪花事件一样) 由于我们是要写vue的组件库,这里推荐的测试工具集是 vue-test-utils 这套工具,https://vue-test-utils.vuejs.org/zh/ 。其中提供的各种测试函数和方法都能很好的满足我们的测试需要。具体的安装使用可以参见它的文档。 我们这里主要想提的是 组件测试到底要测什么? 我们这里给到一张很直观的图,看到这张图其实你应该也清楚了这个问题的答案 这张图来自视频 https://www.youtube.com/watch?v=OIpfWTThrK8 ,也是vue-test-util推荐的一个非常棒的演讲,想要具体了解可以进去看一下。 所以回过头来,组件测试,实际需要我们不仅仅作为创造者的角度对组件的功能特性进行测试。更要从使用者的角度来看,把组件当做一个“黑盒子”,我们能给到它的是用户的交互行为、props数据等,这个“黑盒子”也会对应的反馈出一定的事件和渲染的视图可以被使用者所捕获和观察。通过对这些位置的检查,我们就能获知一个组件的行为是否如我们所愿的去进行着,确保它的行为一定是一致不出幺蛾子的。 另外还想提的一点偏的话题就是 契约精神。作为组件的使用者,我使用你的组件,等于咱们签订一个契约,这个组件的所有行为应该是和你描述的是一致的,不会出现第三种意料之外的可能。毕竟对于企业项目来说,我们不喜欢surprise。antd的彩蛋事件也是给各位都提个醒,咱们搞技术可以这么玩也挺有创意,但是这种公用类库,特别是企业使用的也比较多的,还是把创意收一收,讲究契约,不讲surprise。就算是自家企业内部使用的组件库,除非是业务上的人都是认可的,否则也不要做这种危险试探。 好的组件测试也是能够帮助我们识别出那些我们有意或无意创造的surprise,有意的咱就不说了,就怕是那种无意中出现的surprise那就比较要命了,所以写好组件测试还是挺有必要的。 文档生成一般来说,我们做一个类库项目都会有对应的说明文档的,有的项目一个README.md 的文档就够了,有的可能需要在来几个 Markdown的文档。对于组件库这一类的项目来说,我们可以用文档工具来辅助直接生成文档。这里推荐 vuepress ,可以快速帮我们完成组件库文档的建设。(https://vuepress.vuejs.org/zh/guide/) vuepress是一个文档生成工具,默认的样式和vue官方文档几乎是一致的,因为创造它的初衷就是想为vue和相关的子项目提供文档支持。它内置了 Markdown的扩展,写文档的时候就是用 markdown来写,最让人省心的是你可以直接在 Markdown 文件中使用Vue组件,意味着我们的组件库中写的一个个组件,可以直接放到文档里去用,展示组件的实际运行效果。 我们的案例网站也就是通过vuepress来写的,生成静态网站后,用 gh-pages 直接部署到github上。 vuepress更好的一点在于你可以自定义其webpack配置和主题,意味着你可以让你自己的文档站点在开发阶段有更多的功能特性的支持,同时可以把站点风格改成自己的一套主题风格。这就无需我们重头开始去做一套了,对于咱们想要快速完成组件库文档建设这一需求来说,还是挺有效的。 不过这只是咱们要做的事情的一个辅助性的东西,所以具体的使用咱们在实践阶段再说明,这里就不赘述了。 自定义主题自定义主题的功能对于一个开源类库来说肯定还是挺有好处的,这样使用者就可以自己使用组件库的功能而在界面设计上使用自己的设计风格。其实大部分组件库的功能设计都是挺好挺完善的,所以一般来说中小型公司即使想要实现自己的一套组件风格的东西,直接使用开源类库如 element、iview或者基于react的Antd 所提供的功能和交互逻辑,然后在其上进行主题定制基本就满足需求了(除非你家设计师很有想法。。。)。 自定义主题的功能一般的使用方式是这样的 通过主题生成工具。(制作者需要单独做一个工具) 引入关键主题文件,覆盖主题变量。(这种方式一般都需要适配制作者所使用的css预处理器) 对于第一种方式往往都是组件库的制作者通过把生成组件样式的那一套东西做成一个工具,然后提供给使用者去根据自己的需要来调整,最后生成一套特定的样式文件,引入使用。 第二种方式,作为使用者来说,你主要做的其实是覆盖了组件库中的一些主题变量,因为具体的组件的样式文件不是写死的固定样式值,而是使用了定义好的变量,所以你的自定义主题就生效了。但是这也会引入一个小问题就是你必须适配组件库的创造者所使用的样式预处理器,比如你用iview,那你的项目就要能解析Less文件,你用ElementUI,你的项目就必须可以解析SCSS。 其实对于第一种方式也主要是以调整主题变量为主。所以当咱们自己要做一套组件库的时候,不难看出,一个核心点就是需要把主题变量文件和样式文件拆开来,后面的就简单了。 webpack打包类库项目的构建这里提两点: 暴露入口 外部化依赖 先谈第一点 “暴露接口”。业务项目中,我们的整个项目通过webpack或其他打包工具打包成一个或多个bundle文件,这些文件被浏览器载入后就会直接运行。但是一个类库项目往往都不是单独运行的,而是通过暴露一个 “入口”,然我在业务项目中去调用它。 在webpack配置文件里,可以通过定义 output 中的 library 和 libraryTarget 来控制我们要暴露的一个 “入口变量” ,以及我们要构建的目标代码。 这一点可以详细参考webpack官方文档: https://webpack.js.org/configuration/output/#outputlibrarytarget 1module.exports = {2 // other config3 output: {4 library: \"MyLibName\",5 libraryTarget: \"umd\",6 umdNamedDefine: true7 }8} 再说一下 “外部化依赖”,我们做一个vue组件库项目的时候,我们的组件都是依赖于vue的,当我们组件库项目中的某个地方引入了vue,那么打包的时候vue的运行时也是会被一块儿打包进入最终的组件库bundle文件的。这样的问题在于,我们的vue组件库是被vue项目使用的,那么项目中已经有运行时了,我们就没必要在组件库中加入运行时,这样会多增加组件库bundle的体积。使用webpack的 externals可以将vue依赖 “外部化”。 1module.exports = {2 // other config3 externals: {4 vue: {5 root: 'Vue',6 commonjs: 'vue',7 commonjs2: 'vue',8 amd: 'vue'9 }10 }11} 按需加载组件库的按需加载功能还是很实用的, 这样可以避免我们在使用组件库的过程中把所有的用到和没用到的内容都打包到业务代码中去,导致最后的bundle文件过大影响用户体验。 在业务项目中我们的按需加载都是把需要按需加载的地方单独生成为一个chunk,然后浏览器运行我们的打包代码的时候发现我们需要这一块儿资源了,再发起请求获取到对应的所需代码。 在组件库里边,我们就需要改变一下引入的方式,比如一开始我们引入一个组件库的时候是直接将组件库和样式全部引入的。如下面这样 1import LimeUI from 'lime-ui' // 引入组件库2import 'lime-ui/styles/index.css' // 引入整个组件库的样式文件34Vue.use(LimeUI) 那么,换成手动的按需加载的方式就是 1import { Button } from 'lime-ui' // 引入button组件2import 'lime-ui/styles/button.css' // 引入button的样式34Vue.component('l-button', Button) // 注册组件 这种方式的确是按需引入的,但也一个不舒服的地方就是每次我们引入的时候都需要手动的引入组件和样式。一般来说一个项目里面用到的组件少说也有十多个,这就比较麻烦了。组件库是怎么解决这个问题的呢? 通过babel插件的方式,将引入组件库和组件样式的模式自动化,比如antd、antd-mobile、material-ui都在使用的babel-plugin-import、还有ElementUI使用的 babel-plugin-component。在业务项目中配置好babel插件之后,它内部就可以给你做一个这样的转换(这里以 babel-plugin-component) 1// 原始代码2import { Button } from 'components'3 45// 转换代码6var button = require('components/lib/button')7require('components/lib/button/style.css') OK,那既然代码可以做这样的转换的话,其实我们所要做的一点就是在我们打造组件库的时候,把我们的组件库的打包代码放到对应的文件目录结构之下就可以了。使用者可以选择手动载入组件,也可以使用babel插件的方式优化这一步骤。 babel-plugin-component 文档: https://www.npmjs.com/package/babel-plugin-component babel-pluigin-import 文档: https://www.npmjs.com/package/babel-plugin-import 组件:比起日常的组件设计,做组件库你还需要知道些什么?做组件库中的组件的技巧和在项目中用到的还是有一些区别的,这一小节就是告诉大家,组件库中的组件设计,我们还应该知道哪些必要的知识内容。 组件通信:除了上下级之间进行数据通信,还有什么?我们常规用到的组件通信的方法就是通过 props 和 $emit 来进行父组件和子组件之间的数据传递,如下面的示意图中展示的那样:父组件通过 props 将数据给子组件、子组件通过 $emit 将数据传递给父组件,顶多通过eventBus或Vuex来达到任意组件之间数据的相互通信。这些方法在常规的业务开发过程中是比较有效的,但是在组件库的开发过程中就显得有点力不从心了,主要的问题在于: 如何处理跨级组件之间的数据通信呢? 如果在日常项目中,我们当然可以使用像 vuex 这样的将组件数据直接 ”外包“ 出去的方式来实现数据的跨级访问,但是vuex 始终是一个外部依赖项,组件库的设计肯定是不能让这种强依赖存在的。下面我们就来说说两个在组件库项目中我们会用到的数据通信方式。 内置的provide/injectprovide/inject 是vue自带的可以跨级从子组件中获取父级组件数据的一套方案。 这一对东西类似于react里面的 Context ,都是为了处理跨级组件数据传递的问题。 使用的时候,在子组件中的 inject 处声明需要注入的数据,然后在父级组件中的某个含有对应数据的地方,提供子级组件所需要的数据。不管他们之间跨越了多少个组件,子级组件都能获取到对应的数据。(参考下面的伪代码例子) 1// 引用关系 CompA --> CompB --> CompC --> ... --> ChildComp23// CompA.vue4export default {5 provide: {6 theme: 'dark'7 }8}910// CompB.vue11// CompC.vue12// ... 1314// ChildComp.vue15export default {16 inject: ['theme'],17 mounted() {18 console.log(this.theme) // 打印结果: dark19 }20} 不过provide/inject的方式主要是子组件从父级组件中跨级获取到它的状态,却不能完美的解决以下问题: 子级组件跨级传递数据到父级组件 父级组件跨级传递数据到子级组件 派发和广播: 自制dispatch和broadcast功能dispatch和broadcast可以用来做父子级组件之间跨级通信。在vue1.x里面是有dispatch和broadcast功能的,不过在vue2.x中被取消掉了。这里可以参考一下下面链接给出的v1.x中的内容。 dispatch文档(v1.x):https://v1.vuejs.org/api/#vm-dispatch broadcast文档(v1.x):https://v1.vuejs.org/api/#vm-broadcast 根据文档,我们得知 dispatch会派发一个事件,这个事件首先在自己这个组件实例上去触发,然后会沿着父级链一级一级的往上冒泡,直到触发了某个父级中声明的对这个事件的监听器后就停止,除非是这个监听器返回了true。当然监听器也是可以通过回调函数获取到事件派发的时候传递的所有参数的。这一点很像我们在DOM中的事件冒泡机制,应该不难理解。 而broadcast就是会将事件广播到自己的所有子组件实例上,一层一层的往下走,因为组件树的原因,往下走的过程会遇到 “分叉”,也就可以看成是一条条的多个路径。事件沿着每一个子路径向下冒泡,每个路径上触发了监听器就停止,如果监听器返回的是true那就继续向下再传播。 简单总结一下。dispatch派发事件往上冒泡,broadcast广播事件往下散播,遇到处理对应事件的监听器就处理,监听器没有返回true就停止 需要注意的是,这里的派发和广播事件都是 跨层级的 , 而且可以携带参数,那也就意味着可以跨层级进行数据通信。 由于dispatch和broadcast在vue2.x中取消了,所以我们这里可以自己写一个,然后通过mixin的方式混入到需要使用到跨级组件通信的组件中。 方法内容其实很简单,这里就直接列代码 1// 参考自iview的实现2function broadcast(componentName, eventName, params) {3 this.$children.forEach(child => {4 const name = child.$options.name;56 if (name === componentName) {7 child.$emit.apply(child, [eventName].concat(params));8 } else {9 broadcast.apply(child, [componentName, eventName].concat([params]));10 }11 });12}13export default {14 methods: {15 dispatch(componentName, eventName, params) {16 let parent = this.$parent || this.$root;17 let name = parent.$options.name;1819 while (parent && (!name || name !== componentName)) {20 parent = parent.$parent;2122 if (parent) {23 name = parent.$options.name;24 }25 }26 if (parent) {27 parent.$emit.apply(parent, [eventName].concat(params));28 }29 },30 broadcast(componentName, eventName, params) {31 broadcast.call(this, componentName, eventName, params);32 }33 }34}; 其实这里的实现和vue1.x中的实现还是有一定的区别的: dispatch没有事件冒泡。找到哪个就直接执行 设定了一个name参数,只针对特定name的组件触发事件 其实看懂了这里的代码,你就应该可以举一反三想出 找寻任何一个组件的方法了,不管是向上还是向下找,无非就是循环遍历和迭代处理,直到目标组件出现,然后调用它。 派发和广播无非就是找到之后利用vue自带的事件机制来发布事件,然后在具体组件中监听该事件并处理。 渲染函数:它可以释放javascript的能力首先我们回顾一下一个组件是如何从写代码到被转换成界面的。我们写vue单文件组件的时候一般会有template、script和style三部分,在打包的时候,vue-loader会将其中的template模板部分先编译成Vue实例中render选项所需要的构建视图的代码。在具体运行的时候,vue运行时会使用$mount 进行渲染,渲染好之后将其挂载到你提供的DOM节点下。 整个过程里面我们只日常关注最多的当然就是template的部分,但是template其实只是vue提供的一个语法糖,只是让我们写代码写起来跟写html一样轻松,降低刚入手vue的小伙伴的学习成本。React就没有提供template的语法糖,而是使用的JSX来降低写组件的复杂度。(vue能在react和angular两大框架的压力下异军突起,简洁易懂的模板语法是有一定促进作用的,毕竟看起来更简单) 通过上面我们回顾的内容,其实我们也发现了,我们写的template,最终都是javascript。这里template被编译之后,给到了 render这个渲染函数,在执行渲染的时候vue就会执行render中的操作来渲染我们的组件。 所以template是好,但 如果你想要使用全部的javascript的能力,那就可以使用渲染函数。 渲染函数&JSX (官方文档):https://cn.vuejs.org/v2/guide/render-function.html 日常写业务组件,我们用template就挺OK的,不过当遇到一些复杂情况,用 写组件 --> 引入使用 --> 注册组件 --> 使用组件 的方式就不好处理了,比如下面两种情况: 通过代码动态渲染组件 将组件渲染到其他位置 第一种情况是通过代码动态渲染组件,比如运营常常使用的活动h5页面,每个活动都不一样,每次要么都重新做一份,要么在原有的基础上修改。但是这种修改的页面结构调整是很大的,每次都会是破坏性的,和重做其实没区别。这样的话,每次活动无论内容如何,前端都要上手去写代码。但其实只需要在管理后台做一个活动编辑器,编辑器的内容直接转化为render函数的代码,然后通过配置下发到某个页面上,承载页拿到数据给到render函数执行渲染。这样就可以动态的根据管理后台配置的方式来渲染组件内容,每次的活动页,运营也可以通过编辑器自行生成。 第二种情况是要将组件渲染到不同位置。我们日常写业务组件基本就是写一个组件,在需要的拿来使用。如果你只是在template中把组件写进去,那你的组件的内容就都会作为当前组件的子组件进行渲染,所生成的DOM结构也是在当前的DOM结构之下的。知道render之后,其实我们可以新建vue实例,动态渲染之后,手动挂载到任意的DOM位置上去。 1import CompA from './CompA.vue'23let Instance = new Vue({4 render(h) {5 return h(CompA)6 }7})89let component = Instance.$mount() // 执行渲染10document.body.appendChild(component.$el) // 挂载到body元素下 我们使用的element里面的 this.$message 就用到了动态渲染,然后手动挂载到指定位置。 实践:做一遍你就会了这里先贴上我们的github地址,各位可以在做的过程中对照着看。https://github.com/arronKler/lime-ui 建立一个工程化的项目第一步,建立工程化结构这里就不废话了,直接贴目录结构和解释 1|- assets/ # 存放一些额外的资源文件,图片之类的2|- build/ # webpack打包配置3|- docs/ # 存放文档4 |- .vuepress # vuepress配置目录5 |- component # 组件相关的文档放这里6 |- README.md # 静态首页7|- lib/ # 打包生成的文件放这里8 |- styles/ # 打包后的样式文件9|- src/ # 在这里写代码10 |- mixins/ # mixin文件11 |- packages/ # 各个组件,每个组件是一个子目录12 |- styles/ # 样式文件13 |- common/ # 公用的样式内容14 |- mixins/ # 复用的mixin15 |- utils # 工具目录16 |- index.js # 打包入口,组件的导出17|- test/ # 测试文件夹18 |- specs/ # 存放所有的测试用例19|- .npmignore20|- .gitignore21|- .babelrc22|- README.md23|- package.json 这里比较重要的目录就是我们的src目录,下面存放了我们的各个单一的组件和一套样式库,另外还有一些辅助的东西。我们写文档就是在 docs目录下去写。项目目录最外层都是些常规的配置内容,比如 .npmignore 和 .gitignore 这样的文件我们都是很常见的,所以我就不具体细说这一部分了,要是有一定疑惑可以直接参见github上的源码对照着看。 这里我们把需要使用到的类库文件也先建立好 在 src/mixins 下创建一个 emitter.js,写入如下内容,也就是我们的dispatch和broadcast的方法,之后的组件设计中会用到 1function broadcast(componentName, eventName, params) {2 this.$children.forEach(child => {3 const name = child.$options.name;45 if (name === componentName) {6 child.$emit.apply(child, [eventName].concat(params));7 } else {8 broadcast.apply(child, [componentName, eventName].concat([params]));9 }10 });11}12export default {13 methods: {14 dispatch(componentName, eventName, params) {15 let parent = this.$parent || this.$root;16 let name = parent.$options.name;1718 while (parent && (!name || name !== componentName)) {19 parent = parent.$parent;2021 if (parent) {22 name = parent.$options.name;23 }24 }25 if (parent) {26 parent.$emit.apply(parent, [eventName].concat(params));27 }28 },29 broadcast(componentName, eventName, params) {30 broadcast.call(this, componentName, eventName, params);31 }32 }33}; 然后在 src/utils 下新建一个 assist.js 文件,写下辅助性的函数 1export function oneOf(value, validList) {2 for (let i = 0; i < validList.length; i++) {3 if (value === validList[i]) {4 return true;5 }6 }7 return false;8} 这两个地方都是之后会使用到的,如果你需要其他的辅助内容,也可以在这两个文件所在的目录下去建立。 第二步, 完善打包流程目录建好了,那就该填充血肉了,要打包一个组件库项目,肯定是要先配置好我们的webpack,不然写了源码也没法跑起来。所以我们先定位到 build目录下,在build目录下先建立三个文件 webpack.base.js 。存放基本的一些rules配置 webpack.prod.js 。整个组件库的打包配置 gen-style.js 。单独对样式进行打包 以下是具体的配置内容 1/* webpack.base.js */2const path = require('path');3const webpack = require('webpack');4const pkg = require('../package.json');5const VueLoaderPlugin = require('vue-loader/lib/plugin')67function resolve(dir) {8 return path.join(__dirname, '..', dir);9}1011module.exports = {12 module: {13 rules: [14 {15 test: /\\.vue$/,16 loader: 'vue-loader',17 options: {18 loaders: {19 css: [20 'vue-style-loader',21 {22 loader: 'css-loader',23 options: {24 sourceMap: true,25 },26 },27 ],28 less: [29 'vue-style-loader',30 {31 loader: 'css-loader',32 options: {33 sourceMap: true,34 },35 },36 {37 loader: 'less-loader',38 options: {39 sourceMap: true,40 },41 },42 ],43 },44 postLoaders: {45 html: 'babel-loader?sourceMap'46 },47 sourceMap: true,48 }49 },50 {51 test: /\\.js$/,52 loader: 'babel-loader',53 options: {54 sourceMap: true,55 },56 exclude: /node_modules/,57 },58 {59 test: /\\.css$/,60 loaders: [61 {62 loader: 'style-loader',63 options: {64 sourceMap: true,65 },66 },67 {68 loader: 'css-loader',69 options: {70 sourceMap: true,71 },72 }73 ]74 },75 {76 test: /\\.less$/,77 loaders: [78 {79 loader: 'style-loader',80 options: {81 sourceMap: true,82 },83 },84 {85 loader: 'css-loader',86 options: {87 sourceMap: true,88 },89 },90 {91 loader: 'less-loader',92 options: {93 sourceMap: true,94 },95 },96 ]97 },98 {99 test: /\\.scss$/,100 loaders: [101 {102 loader: 'style-loader',103 options: {104 sourceMap: true,105 },106 },107 {108 loader: 'css-loader',109 options: {110 sourceMap: true,111 },112 },113 {114 loader: 'sass-loader',115 options: {116 sourceMap: true,117 },118 },119 ]120 },121 {122 test: /\\.(gif|jpg|png|woff|svg|eot|ttf)\\??.*$/,123 loader: 'url-loader?limit=8192'124 }125 ]126 },127 resolve: {128 extensions: ['.js', '.vue'],129 alias: {130 'vue': 'vue/dist/vue.esm.js',131 '@': resolve('src')132 }133 },134 plugins: [135 new webpack.optimize.ModuleConcatenationPlugin(),136 new webpack.DefinePlugin({137 'process.env.VERSION': `'${pkg.version}'`138 }),139 new VueLoaderPlugin()140 ]141}; 1/* webpack.prod.js */2const path = require('path');3const webpack = require('webpack');4const merge = require('webpack-merge');5const webpackBaseConfig = require('./webpack.base.js');67process.env.NODE_ENV = 'production';89module.exports = merge(webpackBaseConfig, {10 devtool: 'source-map',11 mode: \"production\",12 entry: {13 main: path.resolve(__dirname, '../src/index.js') // 将src下的index.js 作为入口点14 },15 output: {16 path: path.resolve(__dirname, '../lib'),17 publicPath: '/lib/',18 filename: 'lime-ui.min.js', // 改成自己的类库名19 library: 'lime-ui', // 类库导出20 libraryTarget: 'umd',21 umdNamedDefine: true22 },23 externals: { // 外部化对vue的依赖24 vue: {25 root: 'Vue',26 commonjs: 'vue',27 commonjs2: 'vue',28 amd: 'vue'29 }30 },31 plugins: [32 new webpack.DefinePlugin({33 'process.env.NODE_ENV': '\"production\"'34 })35 ]36}); 1/* gen-style.js */2const gulp = require('gulp');3const cleanCSS = require('gulp-clean-css');4const sass = require('gulp-sass');5const rename = require('gulp-rename');6const autoprefixer = require('gulp-autoprefixer');7const components = require('./components.json')89function buildCss(cb) {10 gulp.src('../src/styles/index.scss')11 .pipe(sass())12 .pipe(autoprefixer())13 .pipe(cleanCSS())14 .pipe(rename('lime-ui.css'))15 .pipe(gulp.dest('../lib/styles'));16 cb()17}1819exports.default = gulp.series(buildCss) OK,这里我们的webpack配置基本设置好了,webpack.base.js 中的配置就主要是一些loader和插件的配置,具体的出入口都是在 webpack.prod.js 中配置的。这里webpack.prod.js 合并了 webpack.base.js 中的配置项。关于 output.libary 和 externals ,阅读了之前 “准备” 阶段的内容的应该不会陌生了。 另外还有 gen-style.js 这个文件是单独使用了 gulp 来对样式文件进行打包操作的,我们这里选用的是 scss的语法,如果你想用less或其他的预处理器,也可以自行修改这里的文件和相关依赖。 不过这个配置肯定还没有结束,首先我们需要安装好这里的配置里使用到的各种loader和plugin。为了不漏掉安装项和保持一致性,可以直接复制下面的配置内容放到 package.json 下,通过 npm install 来进行安装。需要注意的是,这里的安装完成之后,其实后面的一些内容的依赖也都一并安装好了。 1\"dependencies\": {2 \"async-validator\": \"^3.0.4\",3 \"core-js\": \"2.6.9\",4 \"webpack\": \"^4.39.2\",5 \"webpack-cli\": \"^3.3.7\"6},7\"devDependencies\": {8 \"@babel/core\": \"^7.5.5\",9 \"@babel/plugin-transform-runtime\": \"^7.5.5\",10 \"@babel/preset-env\": \"^7.5.5\",11 \"@vue/test-utils\": \"^1.0.0-beta.29\",12 \"babel-loader\": \"^8.0.6\",13 \"chai\": \"^4.2.0\",14 \"cross-env\": \"^5.2.0\",15 \"css-loader\": \"2.1.1\",16 \"file-loader\": \"^4.2.0\",17 \"gh-pages\": \"^2.1.1\",18 \"gulp\": \"^4.0.2\",19 \"gulp-autoprefixer\": \"^7.0.0\",20 \"gulp-clean-css\": \"^4.2.0\",21 \"gulp-rename\": \"^1.4.0\",22 \"gulp-sass\": \"^4.0.2\",23 \"karma\": \"^4.2.0\",24 \"karma-chai\": \"^0.1.0\",25 \"karma-chrome-launcher\": \"^3.1.0\",26 \"karma-coverage\": \"^2.0.1\",27 \"karma-mocha\": \"^1.3.0\",28 \"karma-sinon-chai\": \"^2.0.2\",29 \"karma-sourcemap-loader\": \"^0.3.7\",30 \"karma-spec-reporter\": \"^0.0.32\",31 \"karma-webpack\": \"^4.0.2\",32 \"less\": \"^3.10.2\",33 \"less-loader\": \"^5.0.0\",34 \"mocha\": \"^6.2.0\",35 \"node-sass\": \"^4.12.0\",36 \"rimraf\": \"^3.0.0\",37 \"sass-loader\": \"^7.3.1\",38 \"sinon\": \"^7.4.1\",39 \"sinon-chai\": \"^3.3.0\",40 \"style-loader\": \"^1.0.0\",41 \"url-loader\": \"^2.1.0\",42 \"vue-loader\": \"^15.7.1\",43 \"vue-style-loader\": \"^4.1.2\",44 \"vuepress\": \"^1.0.3\"45}, 另外,由于我们使用了babel,所以需要在项目的根目录下设置一下 .babelrc 文件,内容如下: 1{2 \"presets\": [3 [4 \"@babel/preset-env\",5 {6 \"loose\": false,7 \"modules\": \"commonjs\",8 \"spec\": true,9 \"useBuiltIns\": \"usage\",10 \"corejs\": \"2.6.9\"11 }12 ]13 ],14 \"plugins\": [15 \"@babel/plugin-transform-runtime\",16 ]17} 当然也不要忘记在package.json文件中写上scripts简化手动输入命令的过程 1{2 \"scripts\": {3 \"build:style\": \"gulp --gulpfile build/gen-style.js\",4 \"build:prod\": \"webpack --config build/webpack.prod.js\",5 }6} 第三步,建立文档化工具如果在上一步中未安装了 vuepress ,可以通过 npm install vuepress --save-dev 来安装, 然后在 package.json 中加入脚本,快速启动 1{2 \"scripts\": {3 // ...4 \"docs:dev\": \"vuepress dev docs\",5 \"docs:build\": \"vuepress build docs\"6 }7} 这个时候你可以在你的 docs/README.md 文件里写点内容,然后运行 npm run docs:dev 就可以看到本地的文档内容了。需要打包的时候使用 npm run docs:build 就可以了。 如果我们的项目是要放到github上的,那么其实也可以一并将我们的文档生成之后也放到github上去,利用github的pages功能让这个本地的文档在线运行。(github pages托管我们的静态页面和资源) 可以运行 npm install gh-pages --save-dev 安装 gh-pages 这个可以帮我们一键部署github pages文档的工具。它的工作原理就是将对应的某个文件夹下的资源迁移到我们的当前项目的gh-pages分支上,然后这个分支在push给了github之后,github就会将该分支内的内容服务起来。为了更好的使用它,我们可以在package.json中添加scripts 1{2 \"scripts\": {3 // ...4 \"deploy\": \"gh-pages -d docs/.vuepress/dist\",5 \"deploy:build\": \"npm run docs:build && npm run deploy\",6 }7} 这样你就可以使用 npm run deploy 直接部署你的vuepress生成的静态站点,不过务必在部署之前运行一下文档的构建程序。因此我们也添加了一条 npm run deploy:build 命令,使用这条命令就可以直接把文档的构建和部署直接一起解决。是不是很简单呢? 不过为了我们能够直接使用自己写的组件,还需要对vuepress做一点点配置。在 docs/.vuepress目录下新建一个 enhanceApp.js 文件,写入如下内容,将我们的组件库的入口和样式注入进去 1import LimeUI from '../../src/index.js'2import \"../../src/styles/index.scss\"34export default ({5 Vue,6 options,7 router8}) => {9 Vue.use(LimeUI)10} 这个时候我们之后写的组件就可以直接在文档中使用了。 第四步,样式构建先需要说明的是这里我们所使用的样式预处理器的语法是scss。那么在“完善打包流程”这一小节中已经将用gulp进行打包的代码给出了,不过有必要说明一下,我们又是如何去整合样式内容的。 首先,为了之后便于做按需加载,对于每个组件的样式都是一个单独的scss文件,写样式的时候,为了避免太多的层级嵌套,使用了BEM风格的方式去书写。 我们需要先在 src/styles目录执行如下命令生成一个基本的样式文件 1cd src/styles2mkdir common3mkdir mixins4touch common/var.scss # 样式变量文件5touch common/mixins.scss6touch index.scss # 引入所有样式 然后将对应的 var.scss 和 mixins.scss 文件填充上一些基础内容 1/* common/var.scss */23$--color-primary: #ff6b00 !default;4$--color-white: #FFFFFF !default;5$--color-info: #409EFF !default;6$--color-success: #67C23A !default;7$--color-warning: #E6A23C !default;8$--color-danger: #F56C6C !default; 1/* mixins/mixins.scss */2$namespace: 'lime'; /* 组件库的样式前缀 */34/* BEM5 -------------------------- */6@mixin b($block) {7 $B: $namespace+'-'+$block !global;89 .#{$B} {10 @content;11 }12} 在mixins文件中我们声明了一个mixin,用于帮助我们更好的去构建样式文件。 组件打造案例上面的内容设置好了, 咱们就可以开始具体去做一个组件试试了 简单的button组件这是做好之后的大致效果 OK,那我们建立基本的button组件相关的文件 1cd src/packages2mkdir button && cd button3touch index.js4touch button.vue 写入button.vue的内容 1<template>2 <button class=\"lime-button\" :class=\"{[`lime-button-${type}`]: true}\" type=\"button\">3 <slot></slot>4 </button>5</template>67<script>8import { oneOf } from '../../utils/assist';910export default {11 name: 'Button',12 props: {13 type: {14 validator (value) {15 return oneOf(value, ['default', 'primary', 'info', 'success', 'warning', 'error']);16 },17 type: String,18 default: 'default'19 }20 }21}22</script> 这里我们需要在 index.js 中导出这个组件 1import Button from './button.vue'2export default Button 这样单个的一个组件就完成了,之后你可以再多做几个组件试试,不过有一点就是这些组件需要一个统一的打包入口,我们再webpack中已经配置过了,那就是 src/index.js 这个文件,我们需要在这个文件里面将我们刚才写的button组件以及你自己写的其他组件都引入进来,然后统一导出给webpack打包使用,具体代码见下 1import Button from './packages/button'23const components = {4 lButton: Button,5}67const install = function (Vue, options = {}) {89 Object.keys(components).forEach(key => {10 Vue.component(key, components[key]);11 });12}1314export default install 可以看到的是index.js中我们最终导出的是一个叫install的函数,这个函数其实就是Vue插件的一种写法,便于我们在实际项目中引入的时候可以使用 Vue.use 的方式来自动安装我们的整个组件库。install接受两个参数,一个是Vue,我们把它用来注册一个个的组件。还有一个是options,便于我们可以在注册组件的时候传入一些初始化参数,比如默认的按钮大小、主题等信息,都可以通过参数的方式来设定。 然后我们可以在 src/styles目录下新建一个button.scss 文件,写入我们button对应的样式 1/* button.scss */2@charset \"UTF-8\";3@import \"common/var\";4@import \"mixins/mixins\";56@include b(button) {7 min-width: 60px;8 height: 36px;9 font-size: 14px;10 color: #333;11 background-color: #fff;12 border-width: 1px;13 border-radius: 4px;14 outline: none;15 border: 1px solid transparent;16 padding: 0 10px;1718 &:active,19 &:focus {20 outline: none;21 }2223 &-default {24 color: #333;25 border-color: #555;2627 &:active,28 &:focus,29 &:hover {30 background-color: rgba($--color-primary, 0.3);31 }32 }33 &-primary {34 color: #fff;35 background-color: $--color-primary;3637 &:active,38 &:focus,39 &:hover {40 background-color: mix($--color-primary, #ccc);41 }42 }4344 &-info {45 color: #fff;46 background-color: $--color-info;4748 &:active,49 &:focus,50 &:hover {51 background-color: mix($--color-info, #ccc);52 }53 }54 &-success {55 color: #fff;56 background-color: $--color-success;5758 &:active,59 &:focus,60 &:hover {61 background-color: mix($--color-success, #ccc);62 }63 }64} 最后我们还需要在 src/styles/index.scss 文件中将button的样式引入进去 1@import \"button\"; 为了简单的实验,你可以直接在 docs/README.md 文件下写两个button组件试试看 1<template>2 <l-button type=\"primary\">Click me</l-button>3</template> 如果你想要得到和我在 https://arronkler.github.io/lime-ui/ 上一样的效果,可以参考 https://github.com/arronKler/lime-ui 项目中的 docs 目录下的配置。如果想要更个性化的配置,可以查阅vuepress的官方文档。 Notice提示组件这个组件就要用到我们的动态渲染的相关的东西了。具体最后的使用方式是这样的 1this.$notice({2 title: '提示',3 content: this.content || '内容',4 duration: 35}) 效果类似于这样 OK,我们先来写一下这个组件的一个基本源码 在 src/packages 目录下新建notice文件夹,然后新建一个 notice.vue 文件 1<template>2 <div class=\"lime-notice\">3 <div class=\"lime-notice__main\" v-for=\"item in notices\" :key=\"item.id\">4 <div class=\"lime-notice__title\">{{item.title}}</div>5 <div class=\"lime-notice__content\">{{item.content}}</div>6 </div>7 </div>8</template>910<script>11export default {12 data() {13 return {14 notices: []15 }16 },17 methods: {18 add(notice) {19 let id = +new Date()20 notice.id = id21 this.notices.push(notice)2223 const duration = notice.duration24 setTimeout(() => {25 this.remove(id)26 }, duration * 1000)27 },28 remove(id) {29 for(let i = 0; i < this.notices.length; i++) {30 if (this.notices[i].id === id) {31 this.notices.splice(i, 1)32 break;33 }34 }35 }36 }37}38</script> 代码很简单,其实就是声明了一个容器,然后在其中通过控制 notices 的数据来展示和隐藏,接着我们在同一个目录下新建一个notice.js 文件来做动态渲染 1import Vue from 'vue'2import Notice from './notice.vue'34Notice.newInstance = (properties) => {5 let props = properties || {}6 const Instance = new Vue({7 render(h) {8 return h(Notice, {9 props10 })11 }12 })1314 const component = Instance.$mount()15 document.body.appendChild(component.$el)1617 const notice = component.$children[0]1819 return {20 add(_notice) {21 notice.add(_notice)22 }, 23 remove(id) {2425 }26 }27}2829let noticeInstance303132export default (_notice) => {33 noticeInstance = noticeInstance || Notice.newInstance()34 noticeInstance.add(_notice)35} 这里我们我们通过动态渲染的方式让我们的组件可以直接挂在到body下面,而非归属于根挂载点之下。 然后在 src/styles 目录下新建 notice.scss 文件,写上我们的样式文件 1/* notice.scss */2@charset \"UTF-8\";3@import \"common/var\";4@import \"mixins/mixins\";56@include b(notice) {7 position: fixed;8 right: 20px;9 top: 60px;10 z-index: 1000;1112 &__main {13 min-width: 100px;14 padding: 10px 20px;15 box-shadow: 0 0 4px #aaa;16 margin-bottom: 10px;17 border-radius: 4px;18 }1920 &__title {21 font-size: 16px;22 }23 &__content {24 font-size: 14px;25 color: #777;26 }27} 最后同样的,也需要在 src/index.js 这个入口文件中对 notice做处理。完整代码是这样的。 1import Button from './packages/button'2import Notice from './packages/notice/notice.js'34const components = {5 lButton: Button6}78const install = function (Vue, options = {}) {910 Object.keys(components).forEach(key => {11 Vue.component(key, components[key]);12 });1314 Vue.prototype.$notice = Notice;15}1617export default install 我们可以看到我们再Vue的原型上挂上了我们的 $notice 方法,这个方法调用的时候就会触发我们在 notice.js 文件中动态渲染组件的一套流程。这个时候我们就可以在 docs/README.md 文档中测试着用了。 1<script>2export default() {3 mounted() {4 this.$notice({5 title: '提示',6 content: this.content,7 duration: 38 })9 }10}11<script> 单独打包样式和组件为了能支持按需加载的功能,我们除了将整个组件库打包之外,还需要对样式和组件单独打包成单个的文件。这里我们需要做两件事儿 打包单独的css文件 打包单独的组件内容 对于第一点,我们需要对 build/gen-style.js 文件做一下改造,加上buildSeperateCss任务,完整代码如下 1// 其他之前的代码...23function buildSeperateCss(cb) {4 Object.keys(components).forEach(compName => {5 gulp.src(`../src/styles/${compName}.scss`)6 .pipe(sass())7 .pipe(autoprefixer())8 .pipe(cleanCSS())9 .pipe(rename(`${compName}.css`))10 .pipe(gulp.dest('../lib/styles'));11 })1213 cb()14}1516exports.default = gulp.series(buildCss, buildSeperateCss) // 加上 buildSeperateCss 对于第二点,我们可以用一个新的webpack配置来处理,新建一个 build/webpack.component.js 文件,写入 1const path = require('path');2const webpack = require('webpack');3const merge = require('webpack-merge');4const webpackBaseConfig = require('./webpack.base.js');5const components = require('./components.json')6process.env.NODE_ENV = 'production';78const basePath = path.resolve(__dirname, '../')9let entries = {}10Object.keys(components).forEach(key => {11 entries[key] = path.join(basePath, 'src', components[key])12})1314module.exports = merge(webpackBaseConfig, {15 devtool: 'source-map',16 mode: \"production\",17 entry: entries,18 output: {19 path: path.resolve(__dirname, '../lib'),20 publicPath: '/lib/',21 filename: '[name].js',22 chunkFilename: '[id].js',23 // library: 'lime-ui',24 libraryTarget: 'umd',25 umdNamedDefine: true26 },27 externals: {28 vue: {29 root: 'Vue',30 commonjs: 'vue',31 commonjs2: 'vue',32 amd: 'vue'33 }34 },35 plugins: [36 new webpack.DefinePlugin({37 'process.env.NODE_ENV': '\"production\"'38 })39 ]40}); 这里我们引用了build文件夹下的一个叫做 component.json 的文件,该文件是我自定义用来标识我们的组件和组件路径的,实际上你也可以通过脚本直接遍历 src/packages目录自动获得这样一些信息。这里只是简单演示, build/component.json 的代码如下 1{2 \"button\": \"packages/button/index.js\",3 \"notice\": \"packages/notice/notice.js\"4} 所有的单独打包流程配置好以后,我们就可以在 package.json 文件中再加上 scripts 命令 1{2 \"scripts\": {3 // ...4 \"build:components\": \"webpack --config build/webpack.component.js\",5 \"dist\": \"npm run build:style && npm run build:prod && npm run build:components\",6 }7} OK,现在只需要运行 npm run dist 命令,它就会自动去构建完整的样式内容和各个组件单独的样式内容,然后会打包一个完整的组件包和各个组件的单独的包。 这里需要注意的一点就是你的package.json 文件中的这几个字段需要做一下调整 1{2 \"name\": \"lime-ui\",3 \"version\": \"1.0.0\",4 \"main\": \"lib/lime-ui.min.js\",5 //...6} 其中name表示别人使用了你的包的时候的包名,main字段很重要,表示别人直接引入你包的时候,入口文件是哪一个。这里因为我们webpack打包后的文件是 lib/lime-ui.min.js 所以我们这样去设置。 一切就绪后,你就可以运行 npm run dist 打包你的组件库,然后 npm publish 去发布你的组件库了(发布前需要 npm login 登陆) 使用自己的组件库直接使用我们可以用vue-cli 或其他工具另外生成一个demo项目,用这个项目去引入我们的组件库。如果你的包还没有发布出去,可以在你的组件库项目目录下 用 npm link 或者 yarn link的命令创建一个link(推荐使用yarn) 然后在你的demo目录下使用 npm link package_name 或者 yarn link package_name 这里的package_name就是你的组件库的包名,然后在你的demo项目的入口文件里 1import Vue from vue2import LimeUI from 'lime-ui'3import 'lime-ui/lib/styles/lime-ui.css'4// 其他代码 ...56Vue.use(LimeUI) 这样设置好之后,我们创建的组件就可以在这个项目里使用了 按需加载上面我们谈的是全局载入的一种使用方法,那如何按需加载呢?其实我们之前也说过那么一点 先通过npm安装好 babel-plugin-component 包,然后在你的demo项目的 .babelrc 文件中写上这部分内容 1{2 \"plugins\": [3 [\"component\", {4 \"libraryName\": \"lime-ui\",5 \"libDir\": \"lib\",6 \"styleLibrary\": {7 \"name\": \"styles\",8 \"base\": false, // no base.css file9 \"path\": \"[module].css\"10 }11 }]12 ]13} 这里的配置是要符合我们的lime-ui 的一个目录结构的,有了这个配置我们就可以进行按需加载了,你可以像这样做加载一个Button 1import Vue from 'vue'2import { Button } from 'lime-ui'34Vue.component('a-button', Button) 可以看到的是,我们并没有在这个位置加载任何样式,因为 babel-plugin-component 已经帮我们做了,不过因为我们只在组件库的入口点里面设置了 install 方法用来注册组件,所以这里我们按需引入的时候,就需要自己手动注册了。 主题定制前面的内容做好之后,主题定制就比较简单了,我们先在DEMO项目的入口文件同级目录下创建一个 global.scss 文件,然后在其中写入类似下面这样的代码。 1$--color-primary: red;2@import \"~lime-ui/src/styles/index.scss\"; 然后在入口文件中把引入组件库的方式改变一下 1import Vue from vue2import LimeUI from 'lime-ui'3import './global.scss'4// 其他代码 ...56Vue.use(LimeUI) 我们在入口文件中把对组件库的样式引入,改成引入我们自定义的global.scss文件。 其实这里就是覆盖了我们在组件库项目里 var.scss 里的变量的值,然后其余的组件基础样式还是使用了各自的样式内容,这样就可以达到主题定制了。 结语本文通过对组件库的一些特性的介绍和一个实际的操作案例,阐述了打造一套组件库的一些基础的东西。希望能通过这样的一次分享,让我们不只是去使用组件库,而是能知道组件库的诞生过程和了解组件库的一些内部特性,帮助我们在日常使用的过程中能“心中有数”,当出现问题或组件库需求可能不满足的时候有一个新的思考入手点,那就足够了。 引用参考 Vue$dispatch和$broadcast详解: https://juejin.im/post/5c7fd345f265da2da771f4cd Component Tests with Vue.js - Matt O’Connell : https://www.youtube.com/watch?v=OIpfWTThrK8 掘金小册:Vue.js 组件精讲 ElementUI :https://github.com/ElemeFE/element iView :https://github.com/iview/iview","link":"/2019/08/26/fontend/build-a-vue-component/"}],"tags":[{"name":"前端","slug":"前端","link":"/tags/%E5%89%8D%E7%AB%AF/"},{"name":"开源项目","slug":"开源项目","link":"/tags/%E5%BC%80%E6%BA%90%E9%A1%B9%E7%9B%AE/"},{"name":"Elasticsearch","slug":"Elasticsearch","link":"/tags/Elasticsearch/"},{"name":"大数据","slug":"大数据","link":"/tags/%E5%A4%A7%E6%95%B0%E6%8D%AE/"},{"name":"八里庄技术沙龙","slug":"八里庄技术沙龙","link":"/tags/%E5%85%AB%E9%87%8C%E5%BA%84%E6%8A%80%E6%9C%AF%E6%B2%99%E9%BE%99/"},{"name":"Android","slug":"Android","link":"/tags/Android/"},{"name":"性能优化","slug":"性能优化","link":"/tags/%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96/"},{"name":"NodeJs","slug":"NodeJs","link":"/tags/NodeJs/"},{"name":"Java","slug":"Java","link":"/tags/Java/"},{"name":"并发编程","slug":"并发编程","link":"/tags/%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B/"},{"name":"Yarn","slug":"Yarn","link":"/tags/Yarn/"},{"name":"资源调度","slug":"资源调度","link":"/tags/%E8%B5%84%E6%BA%90%E8%B0%83%E5%BA%A6/"},{"name":"计算机资源管理","slug":"计算机资源管理","link":"/tags/%E8%AE%A1%E7%AE%97%E6%9C%BA%E8%B5%84%E6%BA%90%E7%AE%A1%E7%90%86/"},{"name":"Kubernetes","slug":"Kubernetes","link":"/tags/Kubernetes/"},{"name":"Docker","slug":"Docker","link":"/tags/Docker/"},{"name":"自然冷却算法","slug":"自然冷却算法","link":"/tags/%E8%87%AA%E7%84%B6%E5%86%B7%E5%8D%B4%E7%AE%97%E6%B3%95/"},{"name":"最热内容排序","slug":"最热内容排序","link":"/tags/%E6%9C%80%E7%83%AD%E5%86%85%E5%AE%B9%E6%8E%92%E5%BA%8F/"},{"name":"牛顿冷却定律的运用","slug":"牛顿冷却定律的运用","link":"/tags/%E7%89%9B%E9%A1%BF%E5%86%B7%E5%8D%B4%E5%AE%9A%E5%BE%8B%E7%9A%84%E8%BF%90%E7%94%A8/"},{"name":"后端数据排序","slug":"后端数据排序","link":"/tags/%E5%90%8E%E7%AB%AF%E6%95%B0%E6%8D%AE%E6%8E%92%E5%BA%8F/"},{"name":"Hybrid","slug":"Hybrid","link":"/tags/Hybrid/"},{"name":"Nodejs","slug":"Nodejs","link":"/tags/Nodejs/"},{"name":"服务治理","slug":"服务治理","link":"/tags/%E6%9C%8D%E5%8A%A1%E6%B2%BB%E7%90%86/"},{"name":"云原生","slug":"云原生","link":"/tags/%E4%BA%91%E5%8E%9F%E7%94%9F/"},{"name":"微服务","slug":"微服务","link":"/tags/%E5%BE%AE%E6%9C%8D%E5%8A%A1/"},{"name":"组件化","slug":"组件化","link":"/tags/%E7%BB%84%E4%BB%B6%E5%8C%96/"}],"categories":[{"name":"前端","slug":"前端","link":"/categories/%E5%89%8D%E7%AB%AF/"},{"name":"大数据","slug":"大数据","link":"/categories/%E5%A4%A7%E6%95%B0%E6%8D%AE/"},{"name":"八里庄技术沙龙","slug":"八里庄技术沙龙","link":"/categories/%E5%85%AB%E9%87%8C%E5%BA%84%E6%8A%80%E6%9C%AF%E6%B2%99%E9%BE%99/"},{"name":"NodeJs","slug":"前端/NodeJs","link":"/categories/%E5%89%8D%E7%AB%AF/NodeJs/"},{"name":"服务端","slug":"服务端","link":"/categories/%E6%9C%8D%E5%8A%A1%E7%AB%AF/"},{"name":"前端","slug":"八里庄技术沙龙/前端","link":"/categories/%E5%85%AB%E9%87%8C%E5%BA%84%E6%8A%80%E6%9C%AF%E6%B2%99%E9%BE%99/%E5%89%8D%E7%AB%AF/"},{"name":"基础架构","slug":"基础架构","link":"/categories/%E5%9F%BA%E7%A1%80%E6%9E%B6%E6%9E%84/"}]}