开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

万字解读 Service Mesh服务网格新生代--Isti

发表于 2017-11-27

今天的主角名叫 Istio,估计很多同学在此之前可能完全没有听过这个名字。请不必介意,没听过很正常,因为Istio的确是一个非常新的东西,出世也才四个月而已。

今天的内容将会分成三个部分:

  • 介绍: 让大家了解Istio是什么,以及有什么好处,以及Istio背后的开发团队
  • 架构: 介绍Istio的整体架构和四个主要功能模块的具体功能,这块内容会比较偏技术
  • 展望: 介绍Istio的后续开发计划,探讨未来的发展预期

介绍
Istio是什么:

Istio是Google/IBM/Lyft联合开发的开源项目,2017年5月发布第一个release 0.1.0, 官方定义为:

Istio:一个连接,管理和保护微服务的开放平台。

按照isito文档中给出的定义:

Istio提供一种简单的方式来建立已部署的服务的网络,具备负载均衡,服务到服务认证,监控等等功能,而 不需要改动任何服务代码。

简单的说,有了Istio,你的服务就不再需要任何微服务开发框架(典型如Spring Cloud,Dubbo),也不再需要自己动手实现各种复杂的服务治理的功能(很多是Spring Cloud和Dubbo也不能提供的,需要自己动手)。只要服务的客户端和服务器可以进行简单的直接网络访问,就可以通过将网络层委托给Istio,从而获得一系列的完备功能。

可以近似的理解为:

Istio = 微服务框架 + 服务治理

名字和图标:

Istio来自希腊语,英文意思是”Sail”, 翻译为中文是“启航”。它的图标如下:

可以类比Google的另外一个相关产品:Kubernetes,名字也是同样起源于古希腊,是船长或者驾驶员的意思。下图是Kubernetes的图标:

后面会看到,Istio和Kubernetes的关系,就像它们的名字和图标一样, 可谓”一脉相传”。

主要特性:

Istio的关键功能:

  • HTTP/1.1,HTTP/2,gRPC和TCP流量的自动区域感知负载平衡和故障切换。
  • 通过丰富的路由规则,容错和故障注入,对流行为的细粒度控制。
  • 支持访问控制,速率限制和配额的可插拔策略层和配置API。
  • 集群内所有流量的自动量度,日志和跟踪,包括集群入口和出口。
  • 安全的服务到服务身份验证,在集群中的服务之间具有强大的身份标识。

这些特性在稍后的架构章节时会有介绍。

为什么要使用Istio
在深入Istio细节之前,先来看看,为什么要使用Istio?它可以帮我们解决什么问题?

微服务的两面性

最近两三年来微服务方兴未艾, 可以看到越来越多的公司和开发人员陆陆续续投身到微服务架构, 让一个一个的微服务项目落地。

但是,在这一片叫好的喧闹中, 我们还是发觉一些普遍存在的问题:虽然微服务对开发进行了简化,通过将复杂系统切分为若干个微服务来分解和降低复杂度,使得这些微服务易于被小型的开发团队所理解和维护。但是,复杂度并非从此消失。微服务拆分之后,单个微服务的复杂度大幅降低,但是由于系统被从一个单体拆分为几十甚至更多的微服务, 就带来了另外一个复杂度:微服务的连接、管理和监控。

试想, 对于一个大型系统, 需要对多达上百个甚至上千个微服务的管理、部署、版本控制、安全、故障转移、策略执行、遥测和监控等,谈何容易。更不要说更复杂的运维需求,例如A/B测试,金丝雀发布,限流,访问控制和端到端认证。

开发人员和运维人员在单体应用程序向分布式微服务架构的转型中, 不得不面临上述挑战。

服务网格

Service Mesh,服务网格,也有人翻译为”服务啮合层”.

这貌似是今年才出来的新名词?在2017年之前没有听过,虽然类似的产品已经存在挺长时间。

什么是Service Mesh(服务网格)?

  • Service Mesh是专用的基础设施层。
  • 轻量级高性能网络代理。
  • 提供安全的、快速的、可靠地服务间通讯。
  • 与实际应用部署一起,但对应用透明。

为了帮助理解, 下图展示了服务网格的典型边车部署方式:

图中应用作为服务的发起方,只需要用最简单的方式将请求发送给本地的服务网格代理,然后网格代理会进行后续的操作,如服务发现,负载均衡,最后将请求转发给目标服务。

当有大量服务相互调用时,它们之间的服务调用关系就会形成网格,如下图所示:

在上图中绿色方块为服务,蓝色方块为边车部署的服务网格,蓝色线条为服务间通讯。可以看到蓝色的方块和线条组成了整个网格,我们将这个图片旋转90°,就更加明显了:服务网格呈现出一个完整的支撑态势,将所有的服务”架”在网格之上:

服务网格的细节我们今天不详细展开, 详细内容大家可以参考网上资料。或者稍后我将会推出一个服务网格的专题,单独深入介绍服务网格。

Istio也可以视为是一种服务网格, 在Istio网站上详细解释了这一概念:

如果我们可以在架构中的服务和网络间透明地注入一层,那么该层将赋予运维人员对所需功能的控制,同时将开发人员从编码实现分布式系统问题中解放出来。通常将这个统一的架构层与服务部署在一起,统称为“服务啮合层”。由于微服务有助于分离各个功能团队,因此服务啮合层有助于将运维人员从应用特性开发和发布过程中分离出来。通过系统地注入代理到微服务间的网络路径中,Istio将迥异的微服务转变成一个集成的服务啮合层。

Istio能做什么?

Istio力图解决前面列出的微服务实施后需要面对的问题。

Istio 首先是一个服务网络,但是Istio又不仅仅是服务网格: 在 Linkerd, Envoy 这样的典型服务网格之上,Istio提供了一个完整的解决方案,为整个服务网格提供行为洞察和操作控制,以满足微服务应用程序的多样化需求。

Istio在服务网络中统一提供了许多关键功能(以下内容来自官方文档):

  • 流量管理:控制服务之间的流量和API调用的流向,使得调用更可靠,并使网络在恶劣情况下更加健壮。
  • 可观察性:了解服务之间的依赖关系,以及它们之间流量的本质和流向,从而提供快速识别问题的能力。
  • 策略执行:将组织策略应用于服务之间的互动,确保访问策略得以执行,资源在消费者之间良好分配。策略的更改是通过配置网格而不是修改应用程序代码。
  • 服务身份和安全:为网格中的服务提供可验证身份,并提供保护服务流量的能力,使其可以在不同可信度的网络上流转。

除此之外,Istio针对可扩展性进行了设计,以满足不同的部署需要:

  • 平台支持:Istio旨在在各种环境中运行,包括跨云, 预置,Kubernetes,Mesos等。最初专注于Kubernetes,但很快将支持其他环境。
  • 集成和定制:策略执行组件可以扩展和定制,以便与现有的ACL,日志,监控,配额,审核等解决方案集成。

这些功能极大的减少了应用程序代码,底层平台和策略之间的耦合,使微服务更容易实现。

Istio的真正价值

上面摘抄了Istio官方的大段文档说明,洋洋洒洒的列出了Istio的大把大把高大上的功能。但是这些都不是重点!理论上说,任何微服务框架,只要愿意往上面堆功能,早晚都可以实现这些。

那,关键在哪里?

不妨设想一下,在平时理解的微服务开发过程中,在没有Istio这样的服务网格的情况下,要如何开发我们的应用程序,才可以做到前面列出的这些丰富多彩的功能? 这数以几十记的各种特性,如何才可以加入到应用程序?

无外乎,找个Spring Cloud或者Dubbo的成熟框架,直接搞定服务注册,服务发现,负载均衡,熔断等基础功能。然后自己开发服务路由等高级功能, 接入Zipkin等Apm做全链路监控,自己做加密、认证、授权。 想办法搞定灰度方案,用Redis等实现限速、配额。 诸如此类,一大堆的事情, 都需要自己做,无论是找开源项目还是自己操刀,最后整出一个带有一大堆功能的应用程序,上线部署。然后给个配置说明到运维,告诉他说如何需要灰度,要如何如何, 如果要限速,配置哪里哪里。

这些工作,相信做微服务落地的公司,基本都跑不掉,需求是现实存在的,无非能否实现,以及实现多少的问题,但是毫无疑问的是,要做到这些,绝对不是一件容易的事情。

问题是,即使费力做到这些事情到这里还没有完:运维跑来提了点要求,在他看来很合理的要求,比如说:简单点的加个黑名单, 复杂点的要做个特殊的灰度:将来自iPhone的用户流量导1%到Stagging环境的2.0新版本……

这里就有一个很严肃的问题, 给每个业务程序的开发人员: 你到底想往你的业务程序里面塞多少管理和运维的功能? 就算你hold的住技术和时间,你有能力一个一个的满足各种运维和管理的需求吗? 当你发现你开始疲于响应各种非功能性的需求时,就该开始反省了: 我们开发的是业务程序,它的核心价值在业务逻辑的处理和实现,将如此之多的时间精力花费在这些非业务功能上, 这真的合理吗? 而且即使是在实现层面,微服务实施时,最重要的是如何划分微服务,如何制定接口协议,你该如何分配你有限的时间和资源?

Istio 超越 spring cloud和dubbo等传统开发框架之处, 就在于不仅仅带来了远超这些框架所能提供的功能, 而且也不需要应用程序为此做大量的改动, 开发人员也不必为上面的功能实现进行大量的知识储备。

总结:

Istio 大幅降低微服务架构下应用程序的开发难度,势必极大的推动微服务的普及。

个人乐观估计,随着isito的成熟,微服务开发领域将迎来一次颠覆性的变革。

后面我们在介绍Istio的架构和功能模块时, 大家可以了解到Istio是如何做到这些的。

开发团队

在开始介绍Istio的架构之前, 我们再详细介绍一下Istio的开发团队, 看看背后的大佬。

首先,Istio的开发团队主要来自 Google, IBM和Lyft,摘抄一段官方八股:

基于我们为内部和企业客户构建和运营大规模微服务的常见经验,Google,IBM和Lyft联手创建Istio,希望为微服务开发和维护提供可靠的基础。

Google和IBM相信不需要介绍了, 在Istio项目中这两个公司是绝对主力,举个例子,下图是 Istio Working Group的成员列表:

数一下, 总共18人,10个google,8个IBM。注意这里没有Lyft出现,因为Lyft的贡献主要集中在Envoy。

Google

Istio来自鼎鼎大名的GCP/Google Cloud Platform, 这里诞生了同样大名鼎鼎的 App Engine, Cloud Engine等重量级产品。

Google为Istio带来了Kubernetes和gRPC, 还有和Envoy相关的特性如安全,性能和扩展性。

八卦: 负责Istio的GCP产品经理Varun Talwar, 同时也负责gRPC项目, 所以关注gRPC的同学(比如我自己)可以不用担心:Istio对gRPC的支持必然是没有问题的。

IBM

IBM的团队同来来自IBM云平台, IBM的贡献是:

除了开发Istio控制面板之外, 还有和Envoy相关的其他特性如跨服务版本的流量切分, 分布式请求追踪(Zipkin)和失败注入.

Lyft

Lyft的贡献主要集中在Envoy代理,这是Lyft开源的服务网格,基于C++。据说Envoy在Lyft可以管理超过100个服务,跨越10000个虚拟机,每秒处理2百万请求。本周最新消息,Envoy刚刚加入CNCF,成为该基金会的第十一个项目。

最后, 在Isito的介绍完成之后, 我们开始下一节内容,Istio的架构。

架构
整体架构

Istio服务网格逻辑上分为数据面板和控制面板。

  • 数据面板由一组智能代理(Envoy)组成,代理部署为边车,调解和控制微服务之间所有的网络通信。
  • 控制面板负责管理和配置代理来路由流量,以及在运行时执行策略。

下图为Istio的架构详细分解图:

这是宏观视图,可以更形象的展示Istio两个面板的功能和合作:

以下分别介绍 Istio 中的主要模块 Envoy/Mixer/Pilot/Auth。

Envory

以下介绍内容来自Istio官方文档:

Istio 使用Envoy代理的扩展版本,Envoy是以C++开发的高性能代理,用于调解服务网格中所有服务的所有入站和出站流量。

Istio利用了Envoy的许多内置功能,例如动态服务发现,负载均衡,TLS termination,HTTP/2&gRPC代理,熔断器,健康检查,基于百分比流量拆分的分段推出,故障注入和丰富的metrics。

Envoy实现了过滤和路由、服务发现、健康检查,提供了具有弹性的负载均衡。它在安全上支持TLS,在通信方面支持gRPC.

概括说,Envoy提供的是服务间网络通讯的能力,包括(以下均可支持TLS):

  • HTTP/1.1
  • HTTP/2
  • gRPC
  • TCP

以及网络通讯直接相关的功能:

  • 服务发现:从Pilot得到服务发现信息
  • 过滤
  • 负载均衡
  • 健康检查
  • 执行路由规则(Rule): 规则来自Polit,包括路由和目的地策略
  • 加密和认证: TLS certs来自 Istio-Auth

此外, Envoy 也吐出各种数据给Mixer:

  • Metrics
  • Logging
  • Distribution Trace: 目前支持 Zipkin

总结: Envoy是Istio中负责”干活”的模块,如果将整个Istio体系比喻为一个施工队,那么 Envoy 就是最底层负责搬砖的民工,所有体力活都由Envoy完成。所有需要控制,决策,管理的功能都是其他模块来负责,然后配置给Envoy。

Istio架构回顾

在继续介绍Istio其他的模块之前,我们来回顾一下Istio的架构,前面我们提到, Istio服务网格分为两大块: 数据面板和控制面板。

刚刚介绍的Envoy,在Istio中扮演的就是数据面板,而其他我们下面将要陆续介绍的Mixer、Pilot和Auth属于控制面板。上面我给出了一个类比:Istio中Envoy (或者说数据面板)扮演的角色是底层干活的民工,而该让这些民工如何工作,由包工头控制面板来负责完成。

在Istio的架构中,这两个模块的分工非常的清晰,体现在架构上也是经纬分明: Mixer,Pilot和Auth这三个模块都是Go语言开发,代码托管在Github上,三个仓库分别是 Istio/mixer, Istio/pilot/auth。而Envoy来自Lyft,编程语言是c++ 11,代码托管在Github但不是Istio下。从团队分工看,Google和IBM关注于控制面板中的Mixer,Pilot和Auth,而Lyft继续专注于Envoy。

Istio的这个架构设计,将底层Service Mesh的具体实现,和Istio核心的控制面板拆分开。从而使得Istio可以借助成熟的Envoy快速推出产品,未来如果有更好的Service Mesh方案也方便集成。

Envoy的竞争者

谈到这里,聊一下目前市面上Envoy之外的另外一个Service Mesh成熟产品:基于Scala的Linkerd。 Linkerd的功能和定位和Envoy非常相似,而且就在今年上半年成功进入CNCF。而在 Istio 推出之后,Linkerd做了一个很有意思的动作:Istio推出了和Istio的集成,实际为替换Envoy作为Istio的数据面板,和Istio的控制面板对接。

回到Istio的架构图,将这幅图中的Envoy字样替换为Linkerd即可。另外还有不在图中表示的Linkerd Ingress / Linkerd Egress用于替代Envoy实现 k8s的Ingress/Egress。

本周最新消息: Nginx推出了自己的服务网格产品Nginmesh,功能类似,比较有意思的地方是Ngxinmesh一出来就直接宣布要和Istio集成,替换Envoy。

继续八卦:一出小三上位原配出局的狗血剧情貌似正在酝酿中。 结局如何我等不妨拭目以待,还是那句话: 没有挖不倒的墙角,只有不努力的小三! Linkerd,Nginmesh,加油!

下面开始介绍 Istio 中最核心的控制面板。

Pilot

  • 流量管理

Istio最核心的功能是流量管理,前面我们看到的数据面板,由Envoy组成的服务网格,将整个服务间通讯和入口/出口请求都承载于其上。

使用Istio的流量管理模型,本质上将 流量和基础设施扩展解耦,让运维人员通过Pilot指定它们希望流量遵循什么规则,而不是哪些特定的pod/VM应该接收流量。

对这段话的理解, 可以看下图:假定我们原有服务B,部署在Pod1/2/3上,现在我们部署一个新版本在Pod4在,希望实现切5%的流量到新版本。

如果以基础设施为基础实现上述5%的流量切分,则需要通过某些手段将流量切5%到Pod4这个特定的部署单位,实施时就必须和ServiceB的具体部署还有ServiceA访问ServiceB的特定方式紧密联系在一起. 比如如果两个服务之间是用Nginx做反向代理,则需要增加Pod4的IP作为Upstream,并调整Pod1/2/3/4的权重以实现流量切分。

如果使用Istio的流量管理功能, 由于Envoy组成的服务网络完全在Istio的控制之下,因此要实现上述的流量拆分非常简单. 假定原版本为1.0,新版本为2.0,只要通过Polit 给Envoy发送一个规则:2.0版本5%流量,剩下的给1.0。

这种情况下,我们无需关注2.0版本的部署,也无需改动任何技术设置, 更不需要在业务代码中为此提供任何配置支持和代码修改。一切由 Pilot 和智能Envoy代理搞定。

我们还可以玩的更炫一点, 比如根据请求的内容来源将流量发送到特定版本:

后面我们会介绍如何从请求中提取出User-Agent这样的属性来配合规则进行流量控制。

  • Pilot的功能概述

我们在前面有强调说,Envoy在其中扮演的负责搬砖的民工角色,而指挥Envoy工作的民工头就是Pilot模块。

官方文档中对Pilot的功能描述:

Pilot负责收集和验证配置并将其传播到各种Istio组件。它从Mixer和Envoy中抽取环境特定的实现细节,为他们提供独立于底层平台的用户服务的抽象表示。此外,流量管理规则(即通用4层规则和7层HTTP/gRPC路由规则)可以在运行时通过Pilot进行编程。

每个Envoy实例根据其从Pilot获得的信息以及其负载均衡池中的其他实例的定期健康检查来维护 负载均衡信息,从而允许其在目标实例之间智能分配流量,同时遵循其指定的路由规则。

Pilot负责在Istio服务网格中部署的Envoy实例的生命周期。

  • Pilot的架构

下图是Pilot的架构图:

  1. Envoy API负责和Envoy的通讯, 主要是发送服务发现信息和流量控制规则给Envoy
  2. Envoy提供服务发现,负载均衡池和路由表的动态更新的API。这些API将Istio和Envoy的实现解耦。(另外,也使得Linkerd之类的其他服务网络实现得以平滑接管Envoy)
  3. Polit定了一个抽象模型,以从特定平台细节中解耦,为跨平台提供基础
  4. Platform Adapter则是这个抽象模型的现实实现版本, 用于对接外部的不同平台
  5. 最后是 Rules API,提供接口给外部调用以管理Pilot,包括命令行工具Istioctl以及未来可能出现的第三方管理界面
  • 服务规范和实现

Pilot架构中, 最重要的是Abstract Model和Platform Adapter,我们详细介绍。

  • Abstract Model:是对服务网格中”服务”的规范表示, 即定义在istio中什么是服务,这个规范独立于底层平台。
  • Platform Adapter:这里有各种平台的实现,目前主要是Kubernetes,另外最新的0.2版本的代码中出现了Consul和Eureka。

来看一下Pilot 0.2的代码,pilot/platform 目录下:

瞄一眼platform.go:

服务规范的定义在 modle/service.go 中:

由于篇幅有限,代码部分这里不深入, 只是通过上面的两段代码来展示Pilot中对服务的规范定义和目前的几个实现。

暂时而言(当前版本是0.1.6, 0.2版本尚未正式发布),目前 Istio 只支持K8s一种服务发现机制。

备注: Consul的实现据说主要是为了支持后面将要支持的Cloud Foundry,Eureka没有找到资料。Etcd3 的支持还在Issue列表中,看Issue记录争执中。

  • Pilot功能

基于上述的架构设计,Pilot提供以下重要功能:

  • 请求路由
  • 服务发现和负载均衡
  • 故障处理
  • 故障注入
  • 规则配置

由于篇幅限制,今天不逐个展开详细介绍每个功能的详情。大家通过名字就大概可以知道是什么,如果希望了解详情可以关注之后的分享。或者查阅官方文档的介绍。

Mixer

Mixer翻译成中文是混音器, 下面是它的图标:

功能概括:Mixer负责在服务网格上执行访问控制和使用策略,并收集Envoy代理和其他服务的遥测数据。

  • Mixer的设计背景

我们的系统通常会基于大量的基础设施而构建,这些基础设施的后端服务为业务服务提供各种支持功能。包括访问控制系统,遥测捕获系统,配额执行系统,计费系统等。在传统设计中, 服务直接与这些后端系统集成,容易产生硬耦合。

在Istio中,为了避免应用程序的微服务和基础设施的后端服务之间的耦合,提供了 Mixer 作为两者的通用中介层:

Mixer 设计将策略决策从应用层移出并用配置替代,并在运维人员控制下。应用程序代码不再将应用程序代码与特定后端集成在一起,而是与Mixer进行相当简单的集成,然后 Mixer 负责与后端系统连接。

特别提醒: Mixer 不是为了在基础设施后端之上创建一个抽象层或者可移植性层。也不是试图定义一个通用的Logging API,通用的Metric API,通用的计费API等等。

Mixer的设计目标是减少业务系统的复杂性, 将策略逻辑从业务的微服务的代码转移到Mixer中, 并且改为让运维人员控制。

  • Mixer的功能

Mixer 提供三个核心功能:

  • 前提条件检查。允许服务在响应来自服务消费者的传入请求之前验证一些前提条件。前提条件包括认证,黑白名单,ACL检查等等。
  • 配额管理。使服务能够在多个维度上分配和释放配额。典型例子如限速。
  • 遥测报告。使服务能够上报日志和监控。

在Istio内, Envoy重度依赖Mixer。

  • Mixer的适配器

Mixer是高度模块化和可扩展的组件。其中一个关键功能是抽象出不同策略和遥测后端系统的细节,允许Envoy和基于Istio的服务与这些后端无关,从而保持他们的可移植。

Mixer在处理不同基础设施后端的灵活性是通过使用通用插件模型实现的。单个的插件被称为适配器,它们允许Mixer与不同的基础设施后端连接,这些后台可提供核心功能,例如日志,监控,配额,ACL检查等。适配器使Mixer能够暴露一致的API,与使用的后端无关。在运行时通过配置确定确切的适配器套件,并且可以轻松指向新的或定制的基础设施后端。

这个图是官网给的,列出的功能不多,我从Github的代码中抓个图给大家展示一下目前已有的Mixer Adapter:

  • Mixer的工作方式

Istio使用 属性 来控制在服务网格中运行的服务的运行时行为。属性是描述入口和出口流量的有名称和类型的元数据片段,以及此流量发生的环境。Istio属性携带特定信息片段,例如:

请求处理过程中,属性由Envoy收集并发送给Mixer,Mixer中根据运维人员设置的配置来处理属性。基于这些属性,Mixer会产生对各种基础设施后端的调用。

Mixer设计有一套强大(也很复杂, 堪称Istio中最复杂的一个部分)的配置模型来配置适配器的工作方式,设计有适配器、切面、属性表达式,选择器、描述符,manifests 等一堆概念.

由于篇幅所限,今天不展开这块内容,这里给出两个简单的例子让大家对Mixer的配置有个感性的认识:

  1. 这是一个IP地址检查的Adapter,实现类似黑名单或者白名单的功能:


2. metrics的适配器,将数据报告给Prometheus系统


3. 定义切面, 使用前面定义的 myListChecker 这个adapter 对属性 source.ip 进行黑名单检查。

![](https://gitee.com/songjianzaina/juejin_p5/raw/master/img/75d6f093ecd6c993f20cf83da40b994c2ff0851e52459423c72986daf2ed07ef)

Istio-Auth

Istio-Auth提供强大的服务到服务和终端用户认证,使用交互TLS,内置身份和凭据管理。它可用于升级服务网格中的未加密流量,并为运维人员提供基于服务身份而不是网络控制实施策略的能力。

Istio的未来版本将增加细粒度的访问控制和审计,以使用各种访问控制机制(包括基于属性和角色的访问控制以及授权钩子)来控制和监视访问您的服务,API或资源的人员。

  • Auth的架构

下图展示Istio Auth架构,其中包括三个组件:身份,密钥管理和通信安全。

在这个例子中, 服务A以服务帐户“foo”运行, 服务B以服务帐户“bar”运行, 他们之间的通讯原来是没有加密的. 但是Istio在不修改代码的情况, 依托Envoy形成的服务网格, 直接在客户端Envoy和服务器端Envoy之间进行通讯加密。

目前在Kubernetes上运行的 Istio,使用Kubernetes service account/服务帐户来识别运行该服务的人员。

  • 未来将推出的功能

Auth在目前的Istio版本(0.1.6和即将发布的0.2)中,功能还不是很全,未来则规划有非常多的特性:

  • 细粒度授权和审核
  • 安全Istio组件(Mixer, Pilot等)
  • 集群间服务到服务认证
  • 使用JWT/OAuth2/OpenID_Connect终端到服务的认证
  • 支持GCP服务帐户和AWS服务帐户
  • 非http流量(MySql,Redis等)支持
  • Unix域套接字,用于服务和Envoy之间的本地通信
  • 中间代理支持
  • 可插拔密钥管理组件

需要提醒的是:这些功能都是不改动业务应用代码的前提下实现的。

回到我们前面的曾经讨论的问题,如果自己来做,完成这些功能大家觉得需要多少工作量?要把所有的业务模块都迁移到具备这些功能的框架和体系中,需要改动多少?而Istio,未来就会直接将这些东西摆上我们的餐桌。

未来

前面我们介绍了Istio的基本情况,还有Istio的架构和主要组件。相信大家对Istio应该有了一个初步的认识。

需要提醒的是,Istio是一个今年5月才发布 0.1.0 版本的新鲜出炉的开源项目,目前该项目也才发布到0.1.6正式版本和 0.2.2 pre release版本. 很多地方还不完善,希望大家可以理解,有点类似于最早期阶段的Kubernetes。

在接下来的时间,我们将简单介绍一下Istio后面的一些开发计划和发展预期。

运行环境支持
Istio目前只支持Kubernetes, 这是令人比较遗憾的一点. 不过 istio 给出的解释是istio未来会支持在各种环境中运行,只是目前在 0.1/0.2 这样的初始阶段暂时专注于Kubernetes,但很快会支持其他环境。

注意: Kubernetes平台,除了原生Kubernetes, 还有诸如 IBM Bluemix Container Service和RedHat OpenShift这样的商业平台。 以及google自家的 Google Container Engine。这是自家的东西, 而且现在k8s/istio/gRPC都已经被划归到 Google cloud platform部门, 自然会优先支持.

另外isito所说的其他环境指的是:

  • Mesos: 这个估计是大多人非K8s的Docker使用者最关心的了, 暂时从Github上的代码中未见到有开工迹象, 但是Istio的文档和官方声明都明显说会支持, 估计还是希望很大的.
  • Cloud foundry: 这个东东我们国内除了私有云外玩的不多, Istio对它的支持似乎已经启动. 比如我看到代码中已经有了Consul这个服务注册的支持, 从Issue讨论上看到是说为上Cloud foundry做准备, 因为Cloud foundry没有k8s那样的原生服务注册机制。
  • VM: 这块没有看到介绍, 但是有看到Istio的讨论中提到会支持容器和非容器的混合(Hybrid)支持

值得特别指出的是,目前我还没有看到Istio有对Docker家的Swarm有支持的计划或者讨论, 目前我找到的任何Istio的资料中都不存在Swarm这个东东。我只能不负责任的解读为:有人的地方就有江湖,有江湖就自然会有江湖恩怨。

路线图
按照Istio的说法,他们计划每3个月发布一次新版本,我们看一下目前得到的一些信息:

  • 0.1 版本2017年5月发布,只支持Kubernetes
  • 0.2 即将发布,当前是0.2.1 pre-release, 也只支持Kubernetes
  • 0.3 roadmap上说要支持k8s之外的平台, “Support for Istio meshes without Kubernetes.”, 但是具体哪些特性会放在0.3中,还在讨论中
  • 1.0 版本预计今年年底发布

注: 1.0版本的发布时间官方没有明确给出,我只是看到官网资料里面有信息透露如下:

“we invite the community to join us in shaping the project as we work toward a 1.0 release later this year.”

按照上面给的信息,简单推算:应该是9月发0.2, 然后12月发0.3, 但是这就已经是年底了, 所以不排除1.0推迟发布的可能,或者0.3直接当成 1.0 发布。

社区支持
虽然Istio初出江湖,乳臭未干,但是凭借google和IBM的金字招牌,还有Istio前卫而实际的设计理念,目前已经有很多公司在开始提供对Istio的支持或者集成,这是Istio官方页面有记载的:

  • Red Hat:Openshift and OpenShift Application Runtimes
  • Pivotal:Cloud Foundry
  • Weaveworks:Weave Cloud and Weave Net 2.0
  • Tigera:Project Calico Network Policy Engine
  • Datawire:Ambassador project

然后一些其他外围支持, 从代码中看到的:

  • eureka
  • consul
  • etcd v3: 这个还在争执中,作为etcd的坚定拥护者, 我对此保持密切关注

存在问题
Istio毕竟目前才是0.2.2 pre release版本,毕竟才出来四个月,因此还是存在大量的问题,集中表现为:

  1. 只支持k8s,而且要求k8s 1.7.4+,因为使用到k8s的 CustomResourceDefinitions
  2. 性能较低,从目前的测试情况看,0.1版本很糟糕,0.2版本有改善
  3. 很多功能尚未完成

给大家的建议:可以密切关注Istio的动向,提前做好技术储备。但是,最起码在年底的1.0版本出来之前,别急着上生产环境。

推荐阅读

  • Service
    Mesh:下一代微服务?

  • 阿里Dubbo疯狂更新,关Spring
    Cloud什么
    事?

  • 从架构演进的角度聊聊Spring
    Cloud都做了些什么
    ?

  • 微服务(Microservice)那
    点事

  • [微服务架构的理论基础

    康威定律](http://mp.weixin.qq.com/s?__biz=MzI4NDY5Mjc1Mg==&mid=2247484170&idx=1&sn=5f93458d66937fe27fcdea575097a5ef&chksm=ebf6db75dc81526387e5b325cfb821821f1d8d247d00778616b0ee2dcac316909c5a9e16f2c2&scene=21#wechat_redirect)

  • [微服务-
    Martin

    Fowler](http://mp.weixin.qq.com/s?__biz=MzI4NDY5Mjc1Mg==&mid=2247484107&idx=1&sn=d5bbd6c3d3dad950ca12e06b13b5dc78&chksm=ebf6dab4dc8153a2eb9910f52beb581b489b7c5a1054bc3a9c2aea3d0d88dc729113bc2afe3b&scene=21#wechat_redirect)


活动推荐
活动推荐:12月16日,数人云将在北京举办《服务网格:ServiceMesh is coming》专场Meetup,小剑老师此次将会带来《山雨欲来风满楼:ServiceMesh时代的选边与站队》的新演讲,进一步解读ServiceMesh发展趋势 点击阅读原文,即可报名参与本次活动现场更有TalkingData、当当、微博技术大咖分享关于Kubernetes、云原生、ServiceMesh落地实践的精彩演讲

👇戳阅读原文 ,即可免费报名。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

线程条件队列ConditionObject源码解读

发表于 2017-11-26

小记
好久没更博,窗外光芒万丈,冬日的晚晨,多么美好,就不浪费了,循着键盘上的点点星辰,开工!

啥子是条件队列?

我们都知道,在万类之祖Object里面定义了几个监视器方法:wait(),notify
(),notifyAll(),配合synchronized语义来控制线程的一些状态,在JDK1.5之后,由Lock替代了synchronized,而这几个监视器由条件队列Condition来实现,以便在某个状态条件现在可能为 true 的另一个线程通知它之前,一直挂起该线程(即让其“等待”),以原子的方式释放锁,并挂起当前线程,所以,也可以叫它为线程的条件队列(自创的)。

来看一个应用示例

在以前刚学习Java的时候写过一个题,题干大概是这样的:开启三个线程依次轮流打印出75个数,且次序不能乱。下面是代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
复制代码import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class CountTo_75 {

public static void main(String[] args) {
final Box box = new Box();

new Thread(new Runnable() { // 线程1
@Override
public void run() {
for (int i = 1; i <= 5; i++) {
box.main_1();
}
}
},"Thread-1").start();
new Thread(new Runnable() { // 线程2
@Override
public void run() {
for (int i = 1; i <= 5; i++) {
box.main_2();
}
}
},"Thread-2").start();
new Thread(new Runnable() { // 线程3
@Override
public void run() {
for (int i = 1; i <= 5; i++) {
box.main_3();
}
}
},"Thread-3").start();
}

static class Box {
Lock lock = new ReentrantLock();
Condition condition_1 = lock.newCondition();
Condition condition_2 = lock.newCondition();
Condition condition_3 = lock.newCondition();
private volatile int flag = 1;
public static int count = 0; //用于计数的变量

public void main_1() {
lock.lock();
try {
while(flag != 1){
try {
condition_1.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
for (int j = 0; j < 5; j++) {
count++;
System.out.println(Thread.currentThread().getName() +" "+ count);
}
flag = 2;
condition_2.signal();
} finally {
lock.unlock();
}
}

public void main_2() {
lock.lock();
try {
while(flag != 2){
try {
condition_2.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
for (int j = 0; j < 5; j++) {
count++;
System.out.println(Thread.currentThread().getName() +" " + count);
}
flag = 3;
condition_3.signal();
} finally {
lock.unlock();
}
}

public void main_3() {
lock.lock();
try {
while(flag != 3){
try {
condition_3.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
for (int j = 0; j < 5; j++) {
count++;
System.out.println(Thread.currentThread().getName() + " " + count);
}
flag = 1;
condition_1.signal();
} finally {
lock.unlock();
}
}
}
}

抛开当时混乱的逻辑和性能考虑不足不谈,这段丑陋的代码,终归是实现了功能,而且是运用了Lock和Condition来实现的,用在这里来说明Condition的语义再好不过了。代码中初始化了3个条件队列,分别来控制3个线程的挂起状态,flag变量则控制它们之间的关系。

与Lock之间的实现关系

Condition是一个接口,其实现类只有两个:AQS和AQLS,都以内部类的形式存在,内部类叫做ConditionObject,这里有点纳闷,既然这个类是为Lock专属定制的,为什么不在ReentrantLock里面来实现呢?放在AQS不会太臃肿吗?不知道Doug Lea上神当时是怎么考虑的。 由于在AQS中已经实现,因此在ReentrantLock里面对其操作也是很简单的,创建一个条件队列:

1
2
3
复制代码    public Condition newCondition() {
return sync.newCondition();
}

sync中的实现:

1
2
3
4
5
6
复制代码    abstract static class Sync extends AbstractQueuedSynchronizer {
private static final long serialVersionUID = -5179523762034025860L;
final ConditionObject newCondition() {
return new ConditionObject();
}
}

很简单吧?呵呵,下面来看ConditionObject

ConditionObject实现

首先定义了两个核心成员变量,条件队列的头节点和尾节点:

1
2
3
4
5
复制代码/** First node of condition queue. */
private transient Node firstWaiter;

/** Last node of condition queue. */
private transient Node lastWaiter;
1、核心方法:await() 不可中断的条件等待实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
复制代码public final void await() throws InterruptedException {
//当前线程被中断则抛异常
if (Thread.interrupted()) throw new InterruptedException();
//添加进条件队列
Node node = addConditionWaiter();
//释放当前线程持有的锁
int savedState = fullyRelease(node);
int interruptMode = 0;
//在这里一直查找创建的Node节点在不在Sync队列,不在就一直禁用当前线程
while (!isOnSyncQueue(node)) {
//park当前线程,直到唤醒
LockSupport.park(this);
//如被中断也跳出循环
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
}
//此时已被唤醒,获取之前被释放的锁,
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
//移除节点,释放内存
if (node.nextWaiter != null) // clean up if cancelled
unlinkCancelledWaiters();
//被中断后的操作
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
}

简而言之,await()方法其实就是为当前线程创建一个Node节点,加入到Condition队列并释放锁,之后就一直查看这个节点是否在Sync队列中了(signal()方法将它移到Sync队列),如果在的话就唤醒此线程,重新获取锁。此外,awaitNanos(long nanosTimeout) 方法和await(long time, TimeUnit unit) 方法的实现大同小异,只是在何时跳出while循环的时候加了一个超时罢了。 另外还有几个相关的方法也看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码/**
* 添加一个Node节点到Condition队列中
*/
private Node addConditionWaiter() {
Node t = lastWaiter;
//如果尾节点被取消,就清理掉
if (t != null && t.waitStatus != Node.CONDITION) {
unlinkCancelledWaiters();
t = lastWaiter;
}
//新建状态为的CONDITION节点,并添加在尾部
Node node = new Node(Thread.currentThread(), Node.CONDITION);
if (t == null)
firstWaiter = node;
else
t.nextWaiter = node;
lastWaiter = node;
return node;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码    /**
* 释放当前线程的state,实际还是调用tryRelease方法
*/
final int fullyRelease(Node node) {
boolean failed = true;
try {
int savedState = getState();
if (release(savedState)) {
failed = false;
return savedState;
} else {
throw new IllegalMonitorStateException();
}
} finally {
if (failed)
node.waitStatus = Node.CANCELLED;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码    /**
* 检查当前节点在不在Sync队列
*/
final boolean isOnSyncQueue(Node node) {
//如果当前节点状态为CONDITION,一定还在Condition队列
//如果Sync队列的前置节点为null,则表明当前节点一定还在Condition队列
if (node.waitStatus == Node.CONDITION || node.prev == null)
return false;
//有后继节点,也有前置节点,那么一定在Sync队列
if (node.next != null) // If has successor, it must be on queue
return true;

//倒查Node节点,前置节点不能为null,第一个if已经做了判断,其前置节点为non-null,但是当前节点也不在Sync也是可能的,因为CAS操作将其加入队列也可能失败,所以我们需要从尾部开始遍历确保其在队列
return findNodeFromTail(node);
}
2、核心方法:signal() 将等待时间最长的线程(如果存在)从Condition队列中移动到拥有锁的Sync队列
1
2
3
4
5
6
7
8
9
复制代码   public final void signal() {
//当前线程非独占线程,报非法监视器状态异常
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
//头节点是等待时间最长的节点
Node first = firstWaiter;
if (first != null)
doSignal(first);
}
1
2
3
4
5
6
7
8
9
10
11
复制代码   private void doSignal(Node first) {
do {
//如果头节点的下一节点为null,则将Condition的lastWaiter置为null
if ( (firstWaiter = first.nextWaiter) == null)
lastWaiter = null;
//将头结点的下一个节点设为null
first.nextWaiter = null;
//被唤醒并且头节点不为null则结束循环
} while (!transferForSignal(first) &&
(first = firstWaiter) != null);
}
1
2
3
4
5
6
7
8
9
10
11
复制代码   final boolean transferForSignal(Node node) {
//如果无法改变节点状态,说明节点已经被唤醒
if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
return false;
//将当前节点添加到Sync队列尾部,并设置前置节点的waitStatus为SIGNAL,表明后继有节点(可能)将被唤醒,如果取消或者设置waitStatus失败,会唤醒重新同步操作,这时候waitStatus是瞬时的,出现错误也是无妨的
Node p = enq(node);
int ws = p.waitStatus;
if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL))
LockSupport.unpark(node.thread);
return true;
}
3、核心方法:signalAll() 将所有线程从Condition队列移动到拥有锁的Sync队列中。
1
2
3
4
5
6
7
复制代码   public final void signalAll() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
Node first = firstWaiter;
if (first != null)
doSignalAll(first);
}
1
2
3
4
5
6
7
8
9
10
11
12
复制代码    /**
* 遍历所有节点,加入到拥有锁的Sync队列
*/
private void doSignalAll(Node first) {
lastWaiter = firstWaiter = null;
do {
Node next = first.nextWaiter;
first.nextWaiter = null;
transferForSignal(first);
first = next;
} while (first != null);
}

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

一个朋友圈泛型问题引发的“案子”

发表于 2017-11-26

昨天朋友圈问了一个问题:
对于下面的list,何如在list添加一个Integer型整数?

1
复制代码ArrayList<String> list = new ArrayList<String>();

有这样几种回答:

  • 1.不知道(非专业回答)
  • 2.硬塞(非专业回答)
  • 3.把String 改成Integer再添加(违背了问题初衷)
  • 4.把String改成Object,可以加任意类型(违背了问题初衷)
  • 5.String换成通配符
  • 6.反射

对于1、2就不说了,属于搞事情的!3、4、5三种方式违背了问题的初衷,如果可以改,那我们直接new三个ArrayList就可以了。6反射,这个是无限接近的,那么这个和反射有什么关系呢?下来看下下面几个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码public static void main(String[] args) {

ArrayList list=new ArrayList();

ArrayList<String> str_list=new ArrayList<String>();

ArrayList<Integer> int_list=new ArrayList<Integer>();

ArrayList<Object> obj_list=new ArrayList<Object>();
//对象比较
System.out.println(list == str_list);
System.out.println(list == int_list);
System.out.println(list == obj_list);

//对象的运行时class比较
System.out.println(list.getClass() == str_list.getClass());
System.out.println(list.getClass() == int_list.getClass());
System.out.println(list.getClass() == obj_list.getClass());
}

结果:

1
2
3
4
5
6
复制代码false
false
false
true
true
true

其实上面三个很容易理解,不同对象在内存中的地址肯定是不同的,因此均为false;下面三个均为true?是的,确实为true,这就引出了朋友圈的那个问题。为什么不同的三个对象,他们的getClass是一样的,不应该是有三个不同的hashCode吗?这个其实就是泛型编译时和运行时的问题。
对于泛型来说,泛型只在编译阶段有效,编译之后,集合的泛型是去泛型化的;原因:由于JVM泛型的擦除机制,在运行时JVM是不知道泛型信息的。
因此:java集合中的泛型,是来约束用户的错误输入的,只在编译时有效;
在回到问题最初,我们怎么才能将一个Integer对像放入上面定义的list中呢?既然集合中的泛型是编译时有效的,那我我们就可以通过绕过编译的方式进行插入。那么如何绕过编译时的校验呢?答案就是用反射;我们知道JAVA反射机制是指:
“在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意方法和属性;这种动态获取信息以及动态调用对象方法的功能称为java语言的反射机制。
OK,再来看程序:

1
2
3
4
5
6
7
8
9
复制代码        ArrayList<String> str_list=new ArrayList<String>();
//获取类信息
Class c=str_list.getClass();
//获取add方法
Method m=c.getMethod("add", Object.class);
//运行时调用add方法
m.invoke(str_list, 20);
//输出当前str_list
System.out.println(str_list);

结果:

1
复制代码[20]

从结果可以看出,我们完成了在list中添加Integer的任务。
【泛型、反射、编译时、运行时】
大家周末愉快!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Scala入门教程 (一)

发表于 2017-11-25

Scala入门教程

[TOC]

Scala是神马?

  • 说的正式点,Scala是一种多范式的编程语言,其将面向对象编程与函数式编程的特性结合了起来。并且Scala同Java一样都运行在Java虚拟机之上,经过编译之后都生成的是class字节码文件,所以scala同样具有跨平台的特性,可以做到一次编写,到处运行。
  • 说的通俗点,Scala是一种很有逼格的语言,具有优雅,简洁,速度等优点,对于猿圈的我们来说,还有什么比装逼更能吸引我们的呢?在深入了解后就会发现,很多在Java中的遗憾在scala中都有很好的实现。大名鼎鼎的Spark底层就是用scala实现的,如果你想好好深入了解这个大数据框架,那么一定要好好学习这门语言。

Scala的安装

​ 前面已经说到过,Scala是运行在JVM上,所以要想运行我们的程序,首先需要确保我们的电脑上已经安装好了JDK,这个直接在oracle的官网上就有,现在最新的已经是JDK9了。

​ 安装完运行环境,接下来就是安装Scala的编译器了,Scala的编译器将Scala源码编译为class字节码文件,然后就像运行Java的字节码那样在JVM中运行。

  • Windows上安装Scala编译器

直接登陆scala的官网进入下载,下载windows的安装包

image.png

image.png

  • 在Linux中安装scala

下载上面截图中scala-sources-2.12.4.tar.gz的那个,解压到Linux中你喜欢的目录

1
复制代码tar -zxvf scala-2.10.6.tgz -C /usr/share/local

配置环境变量,用vim编辑器编辑/etc/profile文件

1
2
复制代码export JAVA_HOME=/usr/share/local/jdk
export PATH=$PATH:$JAVA_HOME/bin:/usr/share/local/scala/bin

编辑保存后不要忘了source一下,使环境变量立即生效

1
复制代码source  /etc/profile

写个HelloWorld测试下吧

  • 按照国际惯例,学习任何一门语言首先都要写个HelloWorld,就像新店开张的剪彩仪式一样。我们先新建一个Hello.scala文件。内容如下:
1
2
3
4
5
复制代码object Hello{
def main(args: Array[String]) {
println("Hello World!")
}
}

类似于Java的main函数,不过语法看上去有点区别,细心的小伙伴也许会发现,这里每句代码结束居然没有;分号结束,这就是Scala的极简原则,能少写绝不多谢,当然写了也不会有问题的。

编译源文件:

1
复制代码scalac ./Hello.scala

这时候该目录下就会生成Hello.class文件,接下类运行:

1
复制代码scala Hello

image.png

image.png

出现如下结果就说明运行成功了。接下来就一起来打开Scala的异世界大门吧。

  • 相信对Java有一定了解的小伙伴都了解class文件是可以被反编译为Java代码的,那么Scala生成的class文件可不可以反编译为Java文件呢?当然是可以的呀:
1
2
3
4
5
6
7
8
复制代码import scala.reflect.ScalaSignature;
public final class Hello
{
public static void main(String[] paramArrayOfString)
{
Hello..MODULE$.main(paramArrayOfString);
}
}

上面就是通过Decompiler反编译后的结果,所以这是没问题的,事实上scala和java几乎是可以无缝对接的,在scala中可以直接引用Java中的对象,所以在scala是可以使用Java非常丰富的第三方框架的,这里之后也会介绍的。

Scala的开发工具

  • 工欲善其事,必先利其器。要想开发一个大型项目的话,我们总不能一直在文本编辑器中写代码吧(大佬的话可以忽略这个),一般我们习惯在IDEA中开发,在安装完IDEA之后,再安装一个scala的插件就可以了,简单方便快捷。

image.png

image.png

插件的名字就叫scala,直接搜索并安装就ok了。

接下来就像刚才那样写一个HelloWorld就Ok了:

image.png

image.png

​

总结

​ Ok,入门教程就差不多了。之后就详细介绍 Scala的更多基础以及高级用法。感兴趣的小伙伴可以关注我的微信公众号,会在第一时间更新。

img

img

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

使用 ClojureScript 开发浏览器插件的过程与收获

发表于 2017-11-25

本文首发于个人博客


随着 Firefox 57 的到来,之前维护的一个浏览器插件 gooreplacer 必须升级到 WebExtensions 才能继续使用,看了下之前写的 JS 代码,毫无修改的冲动,怕改了这个地方,那个地方突然就 broken 了。因此,这次选择了 cljs,整体下来流程很顺利,除了迁移之前的功能,又加了更多功能,希望能成为最简单易用的重定向插件 :-)

闲话少说,下面的内容依次会介绍 cljs 的工作机制、开发环境,如何让 cljs 适配浏览器插件规范,以及重写 gooreplacer 时的一些经验。
本文的读者需要对 Clojure 语言、浏览器插件开发一般流程有基本了解,并且完成 ClojureScript 的 Quick Start。对于 Clojure,我目前在 sf 上有一套视频课程,供参考。

为了方便大家使用 cljs 开发插件,我整理了一份模板,供大家参考。gooreplacer 完整代码在这里,技术栈为 ClojureScript + Reagent + Antd + React-Bootstrap。

ClojureScript 工作机制

ClojureScript 是使用 Clojure 编写,最终编译生成 JS 代码的一个编译器,在编译过程中使用 Google Closure Compiler 来优化 JS 代码、解决模块化引用的问题。整体工作流程如下:

cljs 编译流程

cljs 编译流程

Cljs 还提供 与原生 JS 的交互、集成第三方类库的支持,所以,只要能用 JS 的地方,都能用 cljs,

开发环境准备

开发 cljs 的环境首选 lein + figwheel,figwheel 相比 lein-cljsbuild 提供了热加载的功能,这一点对于开发 UI 很重要!

对于一般的 cljs 应用,基本都是用一个 script 标签去引用编译后的 js 文件,然后这个 js 文件再去加载其他依赖。比如:

1
2
3
4
5
复制代码<html>
<body>
<script type="text/javascript" src="js/main.js"></script>
</body>
</html>

js/main.js 是 project.clj 里面指定的输出文件,它会去加载其他所需文件,其内容大致如下:

1
2
3
4
5
6
7
8
9
10
复制代码var CLOSURE_UNCOMPILED_DEFINES = {};
var CLOSURE_NO_DEPS = true;
if(typeof goog == "undefined") document.write('<script src="js/out/goog/base.js"></script>');
document.write('<script src="js/out/goog/deps.js"></script>');
document.write('<script src="js/out/cljs_deps.js"></script>');
document.write('<script>if (typeof goog == "undefined") console.warn("ClojureScript could not load :main, did you forget to specify :asset-path?");</script>');
document.write('<script>goog.require("process.env");</script>');

document.write("<script>if (typeof goog != \"undefined\") { goog.require(\"figwheel.connect.build_dev\"); }</script>");
document.write('<script>goog.require("hello_world.core");</script>');

消除 inline script

对于一般的 Web 项目,只引用这一个 js 文件就够了,但是对于浏览器插件来说,有一些问题,浏览器插件出于安全因素考虑,是不让执行 incline script,会报如下错误

inline script error

inline script error

为了去掉这些错误,手动加载 js/main.js 里面动态引入的文件,require 所需命名空间即可,修改后的 html 如下:

1
2
3
4
5
6
7
复制代码<html>
<body>
<script src="js/out/goog/base.js"></script>
<script src="js/out/cljs_deps.js"></script>
<script src="js/init.js"></script>
</body>
</html>

其中 init.js 内容为:

1
2
3
4
复制代码// figwheel 用于热加载,这里的 build_dev 其实是 build_{build_id},默认是 dev
goog.require("figwheel.connect.build_dev");
// 加载为 main 的命名空间
goog.require("hello_world.core");

这样就可以正常在浏览器插件环境中运行了。可以在 DevTools 中观察到所有引用的 js 文件

动态加载的 JS 文件

动态加载的 JS 文件

在左下角可以看到,总共有 92 个文件。

对于 background page/option page/popup page 这三处都可采用这种措施,但是 content script 没法指定 js 脚本加载顺序,可以想到的一种方式是:

1
2
3
4
5
复制代码"content_scripts": [{
"matches": ["http://*/*", "https://*/*"],
"run_at": "document_end",
"js": ["content/js/out/goog/base.js", "content/js/out/cljs_deps.js", "content/init.js"]
}]

这里的 content 的目录与 manifest.json 在同一级目录。采用这种方式会报如下的错误

content script 报错

content script 报错

根据错误提示,可以看出是 base.js 再去动态引用其他 js 文件时,是以访问网站为相对路径开始的,因此也就找不到正确的 JS 文件了。

解决方法是设置 cljsbuild 的 optimizations 为 :whitespace,把所有文件打包到一个文件,然后引用这一个就可以了,这个方法不是很完美,采用 whitespace 一方面使编译时间更长,在我机器上需要12s;另一方面是无法使用 figwheel,会报 A Figwheel build must have :compiler > :optimizations default to nil or set to :none 的错误,因此也就无法使用代码热加载的功能。

gooreplacer 里面只使用了 background page 与 option page,所以这个问题也就避免了。

区分 dev 与 release 模式

这里的 dev 是指正常的开发流程,release 是指开发完成,准备打包上传到应用商店的过程。

在 dev 过程中,推荐设置 cljsbuild 的 optimizations 为 none,以便得到最快的编译速度;
在 release 过程中,可以将其设置为 advanced,来压缩、优化 js 文件,以便最终的体积最小。

为了在两种模式中复用使用的图片、css 等资源,可采用了软链的来实现,resources 目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
复制代码.
├── css
│ └── option.css
├── dev
│ ├── background
│ │ ├── index.html
│ │ └── init.js
│ ├── content
│ ├── manifest.json -> ../manifest.json
│ └── option
│ ├── css -> ../../css/
│ ├── images -> ../../images/
│ ├── index.html
│ └── init.js
├── images
│ ├── cljs.png
│ ├── cljs_16.png
│ ├── cljs_32.png
│ └── cljs_48.png
├── manifest.json
└── release
├── background
│ ├── index.html
│ └── js
│ └── main.js
├── content
│ └── js
│ └── main.js
├── manifest.json -> ../manifest.json
└── option
├── css -> ../../css/
├── images -> ../../images/
├── index.html
└── js
└── main.js

其次,为了方便开启多个 figwheel 实例来分别编译 background、option 里面的 js,定义了多个 lein 的 profiles,来指定不同环境下的配置,具体可参考 模板的 project.clj 文件。

externs

在 optimizations 为 advanced 时,cljs 会充分借用 Google Closure Compiler 来压缩、混淆代码,会把变量名重命名为 a b c 之类的简写,为了不使 chrome/firefox 插件 API 里面的函数混淆,需要加载它们对应的 externs 文件,一般只需要这两个 chrome_extensions.js、chrome.js。

测试环境

cljs 自带的 test 功能比较搓,比较好用的是 doo,为了使用它,需要先提前安装 phantom 来提供 headless 环境,写好测试就可以执行了:

1
复制代码lein doo phantom {build-id} {watch-mode}

非常棒的一点是它也能支持热加载,所以在开发过程中我一直开着它。

re-agent

re-agent 是对 React 的一个封装,使之符合 cljs 开发习惯。毫无夸张的说,对于非专业前端程序员来说,要想使用 React,cljs 比 jsx 是个更好的选择,Hiccup-like 的语法比 jsx 更紧凑,不用再去理睬 webpack,babel 等等层出不穷的 js 工具,更重要的一点是 immutable 在 cljs 中无处不在,re-agent 里面有自己维护状态的机制 atom,不在需要严格区分 React 里面的 props 与 state。

了解 re-agent 的最好方式就是从它官网给出的示例开始,然后阅读 re-frame wiki 里面的 Creating Reagent Components,了解三种不同的 form 的区别,98% gooreplacer 都在使用 form-2。如果对原理感兴趣,建议也把其他 wiki 看完。

re-agent 还有一点比较实用,提供了对 React 原生组件的转化函数:adapt-react-class,使用非常简单:

1
2
3
4
5
复制代码(def Button (reagent/adapt-react-class (aget js/ReactBootstrap "Button")))

[:div
[:h2 "A sample title"]
[Button "with a button"]]

这样就不用担心 React 的类库不能在 cljs 中使用的问题了。

说到 re-agent,就不能不提到 om.next,这两个在 cljs 社区里面应该是最有名的 React wrapper,om.next 理念与使用难度均远高于 re-agent,初学者一般不推荐直接用 om.next。感兴趣的可以看看这两者之间的比较:

  • Why Re-frame instead of Om Next,以及 Reddit 上的讨论
  • A rant on Om Next

坑

宏

cljs 里面加载宏的机制有别于 Clojure,一般需要单独把宏定义在一个文件里面,然后在 cljs 里面用(:require-macros [my.macros :as my]) 这样的方式去引用,而且宏定义的文件名后缀必须是 clj 或 cljc,不能是 cljs,这一点坑了我好久。。。

由于宏编译与 cljs 编程在不同的时期,所以如果宏写错了,就需要把 repl 杀掉重启来把新的宏 feed 给 cljs,这点也比较痛苦,因为 repl 的启动速度实在是有些慢。这一点在 Clojure 里面虽然也存在,但是 Clojure 里面一般 repl 开了就不关了,直到电脑重启。

我机器上启动的 repl 列表

我机器上启动的 repl 列表

IDE

Clojure 里面采用 Emacs + Cider 的开发环境非常完美,但是到了 cljs 里面,开发流程没有那么平滑,总是有些磕磕绊绊,也给 cider 提了个 issue,貌似一直没人理,支持确实不好,不过有了 figwheel,在一定程度上能弥补这个缺陷。在 Emacs 里面配置 repl 可参考:

  • cider.readthedocs.io/en/latest/u…

Cider 默认会使用 rhino 作为 repl 求值环境,这个在开发浏览器插件时功能很有限,但是对于查看函数定义还是可以的。可以根据需要换成 figwheel。

总结

ClojureScript 可以算是 Clojure 语言的一个杀手级应用,React 使得后端程序员也能快速作出美观实用的界面。ClojureScript + React,用起来不能再开心啦!

JS 社区里面层出不穷的框架每次都让跃跃欲试的我望而却步,有了 cljs,算是把 Lisp 延伸到了更宽广的“领土”。最近看到这么一句话,与大家分享:

也许 Lisp 不是解决所有问题最合适的语言,但是它鼓励你设计一种最合适的语言来解决这个难题。

出处忘记了,大体是这么个意思。

参考

  • Chrome extension in ClojureScript
  • github.com/binaryage/c…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

通过Ansi Escape Codes酷炫玩转命令行!

发表于 2017-11-25

引言

你是否:

  • 好奇过命令行里那些花里胡哨的进度条是如何实现的?
  • 好奇过Spring Boot为什么能够打印五颜六色的日志?
  • 好奇过Python或者PHP等脚本语言的交互式命令行是如何实现的?
  • 好奇过Vim或者Emacs等在Terminal中的编辑器是怎么实现的?

如果你曾经好奇过,或者被这段话勾起了你的好奇心,那么你绝对不能错过这篇文章!

背景

通过本文你可以学到:

  1. 何为Ansi Escape Codes以及它们能干什么?
  2. Ansi Escape Codes的一些高级应用。
  3. JDK9中Jshell的使用。

事先声明,本文主要参考:www.lihaoyi.com/post/Buildy…。原文思路清晰,案例生动形象,排版优秀,实为良心之作。但是由于原文是用英语书写且用Python作为演示,所以本后端小菜鸡不要脸地将其翻译一遍,并且用JDK9的Jshell做演示,方便广大的Javaer学习。

本文所有的代码已经推到Github中,地址为:github.com/Lovelcp/blo…。强烈建议大家将代码clone下来跑一下看看效果,加深自己的印象。

环境

  • Mac或Linux或者WIn10操作系统。除了Win10之外的Windows系统暂时不支持Ansi Escape Codes。
  • 因为本文采用Jshell作为演示工具,所以大家需要安装最近刚正式发布的JDK9。

OK!一切准备就绪,让我们开始吧!

富文本

Ansi Escape Codes最基础的用途就是让控制台显示的文字以富文本的形式输出,比如设置字体颜色、背景颜色以及各种样式。让我们先来学习如何设置字体颜色,而不用再忍受那枯燥的黑白二色!

字体颜色

通过Ansi指令(即Ansi Escape Codes)给控制台的文字上色是最为常见的操作。比如:

  • 红色:\u001b[31m
  • 重置:\u001b[0m

绝大部分Ansi Escape Codes都以\u001b开头。让我们通过Java代码来输出一段红色的Hello World:

1
复制代码System.out.print("\u001b[31mHello World");

从上图中,我们可以看到,不仅Hello World是变成了红色,而且接下来的jshell>提示符也变成了红色。其实不管你接下来输入什么字符,它们的字体颜色都是红色。直到你输入了其他颜色的Ansi指令,或者输入了重置指令,字体的颜色才会不再是红色。

让我们尝试输入重置指令来恢复字体的颜色:

1
复制代码System.out.print("\u001b[0m");

很好!jshell>提示符恢复为了白色。所以一个最佳实践就是,最好在所有改变字体颜色或者样式的Ansi Escape Codes的最后加上重置指令,以免造成意想不到的后果。举个例子:

1
复制代码System.out.print("\u001b[31mHello World\u001b[0m");

当然,重置指令可以被添加在任何位置,比如我们可以将其插在Hello World的中间,使得Hello是红色,但是World是白色:

1
复制代码System.out.print("\u001b[31mHello\u001b[0m World");

8色

刚才我们介绍了红色以及重置命令。基本上所有的控制台都支持以下8种颜色:

  • 黑色:\u001b[30m
  • 红色:\u001b[31m
  • 绿色:\u001b[32m
  • 黄色:\u001b[33m
  • 蓝色:\u001b[34m
  • 洋红色:\u001b[35m
  • 青色:\u001b[36m
  • 白色:\u001b[37m
  • 重置:\u001b[0m

不如将它们都输出看一下:

1
2
复制代码System.out.print("\u001b[30m A \u001b[31m B \u001b[32m C \u001b[33m D \u001b[0m");
System.out.print("\u001b[34m E \u001b[35m F \u001b[36m G \u001b[37m H \u001b[0m");

注意,A因为是黑色所以与控制台融为一体了。

16色

大多数的控制台,除了支持刚才提到的8色外,还可以输出在此之上更加明亮的8种颜色:

  • 亮黑色:\u001b[30;1m
  • 亮红色:\u001b[31;1m
  • 亮绿色:\u001b[32;1m
  • 亮黄色:\u001b[33;1m
  • 亮蓝色:\u001b[34;1m
  • 亮洋红色:\u001b[35;1m
  • 亮青色:\u001b[36;1m
  • 亮白色:\u001b[37;1m

亮色指令分别在原来对应颜色的指令中间加上;1。我们将所有的16色在控制台打印,方便大家进行比对:

1
2
3
4
复制代码System.out.print("\u001b[30m A \u001b[31m B \u001b[32m C \u001b[33m D \u001b[0m");
System.out.print("\u001b[34m E \u001b[35m F \u001b[36m G \u001b[37m H \u001b[0m");
System.out.print("\u001b[30;1m A \u001b[31;1m B \u001b[32;1m C \u001b[33;1m D \u001b[0m");
System.out.print("\u001b[34;1m E \u001b[35;1m F \u001b[36;1m G \u001b[37;1m H \u001b[0m");

从图中我们可以清晰地看到,下面的8色比上面的8色显得更加明亮。比如,原来黑色的A,在黑色的控制台背景下,几乎无法看到,但是一旦通过亮黑色输出后,对比度变得更高,变得更好辨识了。

256色

最后,除了16色外,某些控制台支持输出256色。指令的形式如下:

  • \u001b[38;5;${ID}m

让我们输出256色矩阵:

1
2
3
4
5
6
7
复制代码for (int i = 0; i < 16; i++) {
for (int j = 0; j < 16; j++) {
int code = i * 16 + j;
System.out.printf("\u001b[38;5;%dm%-4d", code, code);
}
System.out.println("\u001b[0m");
}

关于字体颜色我们就介绍到这,接下来我们来介绍背景色。

背景颜色

刚才所说的字体颜色可以统称为前景色(foreground color)。那么理所当然,我们可以设置文本的背景颜色:

  • 黑色背景:\u001b[40m
  • 红色背景:\u001b[41m
  • 绿色背景:\u001b[42m
  • 黄色背景:\u001b[43m
  • 蓝色背景:\u001b[44m
  • 洋红色背景:\u001b[45m
  • 青色背景:\u001b[46m
  • 白色背景:\u001b[47m

对应的亮色版本:

  • 亮黑色背景:\u001b[40;1m
  • 亮红色背景:\u001b[41;1m
  • 亮绿色背景:\u001b[42;1m
  • 亮黄色背景:\u001b[43;1m
  • 亮蓝色背景:\u001b[44;1m
  • 亮洋红色背景:\u001b[45;1m
  • 亮青色背景:\u001b[46;1m
  • 亮白色背景:\u001b[47;1m

首先让我们看看16色背景:

1
2
3
4
复制代码System.out.print("\u001b[40m A \u001b[41m B \u001b[42m C \u001b[43m D \u001b[0m");
System.out.print("\u001b[44m A \u001b[45m B \u001b[46m C \u001b[47m D \u001b[0m");
System.out.print("\u001b[40;1m A \u001b[41;1m B \u001b[42;1m C \u001b[43;1m D \u001b[0m");
System.out.print("\u001b[44;1m A \u001b[45;1m B \u001b[46;1m C \u001b[47;1m D \u001b[0m");

值得注意的是,亮色背景并不是背景颜色显得更加明亮,而是让对应的前景色显得更加明亮。虽然这点有点不太直观,但是实际表现就是如此。

让我们再来试试256背景色,首先指令如下:

  • \u001b[48;5;${ID}m

同样输出256色矩阵:

1
2
3
4
5
6
7
复制代码for (int i = 0; i < 16; i++) {
for (int j = 0; j < 16; j++) {
int code = i * 16 + j;
System.out.printf("\u001b[48;5;%dm%-4d", code, code);
}
System.out.println("\u001b[0m");
}

感觉要被亮瞎眼了呢!至此,颜色设置已经介绍完毕,让我们接着学习样式设置。

样式

除了给文本设置颜色之外,我们还可以给文本设置样式:

  • 粗体:\u001b[1m
  • 下划线:\u001b[4m
  • 反色:\u001b[7m

样式分别使用的效果:

1
复制代码System.out.print("\u001b[1m BOLD \u001b[0m\u001b[4m Underline \u001b[0m\u001b[7m Reversed \u001b[0m");

或者结合使用:

1
复制代码System.out.print("\u001b[1m\u001b[4m\u001b[7m BOLD Underline Reversed \u001b[0m");

甚至还可以和颜色结合使用:

1
2
复制代码System.out.print("\u001b[1m\u001b[31m Red Bold \u001b[0m");
System.out.print("\u001b[4m\u001b[44m Blue Background Underline \u001b[0m");

是不是很简单,是不是很酷!学会了这些,我们已经能够写出十分酷炫的命令行脚本了。但是如果要实现更复杂的功能(比如进度条),我们还需要掌握更加牛逼的光标控制指令!

光标控制

Ansi Escape Code里更加复杂的指令就是光标控制。通过这些指令,我们可以自由地移动我们的光标至屏幕的任何位置。比如在Vim的命令模式下,我们可以使用H/J/K/L这四个键实现光标的上下左右移动。

最基础的光标控制指令如下:

  • 上:\u001b[{n}A
  • 下:\u001b[{n}B
  • 右:\u001b[{n}C
  • 左:\u001b[{n}D

通过光标控制的特性,我们能够实现大量有趣且酷炫的功能。首先我们来看看怎么实现一个进度条。

进度数字显示

作为进度条,怎么可以没有进度数字显示呢?所以我们先来实现进度条进度数字的刷新:

1
2
3
4
5
6
7
复制代码void loading() throws InterruptedException {
System.out.println("Loading...");
for (int i = 1; i <= 100; i++) {
Thread.sleep(100);
System.out.print("\u001b[1000D" + i + "%");
}
}

从图中我们可以看到,进度在同一行从1%不停地刷新到100%。为了进度只在同一行显示,我们在代码中使用了System.out.print而不是System.out.println。在打印每个进度之前,我们使用了\u001b[1000D指令,目的是为了将光标移动到当前行的最左边也就是行首。然后重新打印新的进度,新的进度数字会覆盖刚才的进度数字,循环往复,这就实现了上图的效果。

PS:\u001b[1000D表示将光标往左移动1000个字符。这里的1000表示光标移动的距离,只要你能够确保光标能够移动到最左端,随便设置多少比如设置2000都可以。

为了方便大家更加轻松地理解光标的移动过程,让我们放慢进度条刷新的频率:

1
2
3
4
5
6
7
8
9
复制代码void loading() throws InterruptedException {
System.out.println("Loading...");
for (int i = 1; i <= 100; i++) {
System.out.print("\u001b[1000D");
Thread.sleep(1000);
System.out.print(i + "%");
Thread.sleep(1000);
}
}

现在我们可以清晰地看到:

  1. 从左到右打印进度,光标移至行尾。
  2. 光标移至行首,原进度数字还在。
  3. 从左到右打印新进度,新的数字会覆盖老的数字。光标移至行尾。
  4. 循环往复。

Ascii进度条

好了,我们现在已经知道如何通过Ansi Escape Code实现进度数字的显示和刷新,剩下的就是实现进度的读条。废话不多说,我们直接上代码和效果图:

1
2
3
4
5
6
7
8
9
10
复制代码void loading() throws InterruptedException {
System.out.println("Loading...");
for (int i = 1; i <= 100; i++) {
int width = i / 4;
String left = "[" + String.join("", Collections.nCopies(width, "#"));
String right = String.join("", Collections.nCopies(25 - width, " ")) + "]";
System.out.print("\u001b[1000D" + left + right);
Thread.sleep(100);
}
}

由上图我们可以看到,每次循环过后,读条就会增加。原理和数字的刷新一样,相信大家阅读代码就能理解,这里就不再赘述。

让我们来点更酷的吧!利用Ansi的光标向上以及向下的指令,我们还可以同时打印出多条进度条:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
复制代码void loading(int count) throws InterruptedException {
System.out.print(String.join("", Collections.nCopies(count, "\n"))); // 初始化进度条所占的空间
List<Integer> allProgress = new ArrayList<>(Collections.nCopies(count, 0));
while (true) {
Thread.sleep(10);

// 随机选择一个进度条,增加进度
List<Integer> unfinished = new LinkedList<>();
for (int i = 0; i < allProgress.size(); i++) {
if (allProgress.get(i) < 100) {
unfinished.add(i);
}
}
if (unfinished.isEmpty()) {
break;
}
int index = unfinished.get(new Random().nextInt(unfinished.size()));
allProgress.set(index, allProgress.get(index) + 1); // 进度+1

// 绘制进度条
System.out.print("\u001b[1000D"); // 移动到最左边
System.out.print("\u001b[" + count + "A"); // 往上移动
for (Integer progress : allProgress) {
int width = progress / 4;
String left = "[" + String.join("", Collections.nCopies(width, "#"));
String right = String.join("", Collections.nCopies(25 - width, " ")) + "]";
System.out.println(left + right);
}
}
}

在上述代码中:

  • 我们首先执行System.out.print(String.join("", Collections.nCopies(count, "\n")));打印出多个空行,这可以保证我们有足够的空间来打印进度条。
  • 接下来我们随机增加一个进度条的进度,并且打印出所有进度条。
  • 最后我们调用向上指令,将光标移回到最上方,继续下一个循环,直到所有进度条都到达100%。

实际效果如下:

效果真是太棒啦!剩下将读条和数字结合在一起的工作就交给读者啦。学会了这招,当你下次如果要做一个在命令行下载文件的小工具,这时候这些知识就派上用场啦!

制作命令行

最后,最为酷炫的事情莫过于利用Ansi Escape Codes实现一个个性化的命令行(Command-Line)。我们平常使用的Bash以及一些解释型语言比如Python、Ruby等都有自己的REPL命令行。接下来,让我们揭开他们神秘的面纱,了解他们背后实现的原理。

PS:由于在Jshell中,方向键、后退键等一些特殊键有自己的作用,所以接下来无法通过Jshell演示。需要自己手动进行编译运行代码才能看到实际效果。

一个最简单的命令行

首先,我们来实现一个最简单的命令行,简单到只实现下面两种功能:

  • 当用户输入一个可打印的字符时,比如abcd等,则在控制台显示。
  • 当用户输入回车时,另起一行,输出刚才用户输入的所有字符,然后再另起一行,继续接受用户的输入。

那么这个最简单的命令行的实现代码会长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
复制代码import java.io.IOException;

public class CommandLine {
public static void main(String[] args) throws IOException, InterruptedException {
// 设置命令行为raw模式,否则会自动解析方向键以及后退键,并且直到按下回车read方法才会返回
String[] cmd = { "/bin/sh", "-c", "stty raw </dev/tty" };
Runtime.getRuntime()
.exec(cmd)
.waitFor();
while (true) {
String input = "";
while (true) {
char ch = (char) System.in.read();
if (ch == 3) {
// CTRL-C
return;
}
else if (ch >= 32 && ch <= 126) {
// 普通字符
input += ch;
}
else if (ch == 10 || ch == 13) {
// 回车
System.out.println();
System.out.print("\u001b[1000D");
System.out.println("echo: " + input);
input = "";
}

System.out.print("\u001b[1000D"); // 首先将光标移动到最左侧
System.out.print(input); // 重新输出input
System.out.flush();
}
}
}
}

好的,让我们来说明一下代码中的关键点:

  1. 首先最关键的是我们需要将我们的命令行设置为raw模式,这可以避免JVM帮我们解析方向键,回退键以及对用户输入进行缓冲。大家可以试一下不设置raw模式然后看一下效果,就可以理解我说的话了。
  2. 通过System.in.read()方法获取用户输入,然后对其ascii值进行分析。
  3. 如果发现用户输入的是回车的话,我们这时需要打印刚才用户输入的所有字符。但是我们需要注意,由于设置了raw模式,不移动光标直接打印的话,光标的位置不会移到行首,如下图:

所以这里需要再次调用System.out.print("\u001b[1000D");将光标移到行首。

好了,让我们来看一下效果吧:

成功了!但是有个缺点,那就是命令行并没有解析方向键,反而以[D[A[C[B输出(见动图)。这样我们只能一直往后面写而无法做到将光标移动到前面实现插入的效果。所以接下来就让我们给命令行加上解析方向键的功能吧!

光标移动

简单起见,我们仅需实现按下方向键的左右两键时能控制光标左右移动。左右两键对应的ascii码分别为27 91 68和27 91 67。所以我们只要在代码中加上对这两串ascii码的解析即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
复制代码import java.io.IOException;

public class CommandLine {
public static void main(String[] args) throws IOException, InterruptedException {
// 设置命令行为raw模式,否则会自动解析方向键以及后退键,并且直到按下回车read方法才会返回
String[] cmd = { "/bin/sh", "-c", "stty raw </dev/tty" };
Runtime.getRuntime()
.exec(cmd)
.waitFor();
while (true) {
String input = "";
int index = 0;
while (true) {
char ch = (char) System.in.read();
if (ch == 3) {
// CTRL-C
return;
}
else if (ch >= 32 && ch <= 126) {
// 普通字符
input = input.substring(0, index) + ch + input.substring(index, input.length());
index++;
}
else if (ch == 10 || ch == 13) {
// 回车
System.out.println();
System.out.print("\u001b[1000D");
System.out.println("echo: " + input);
input = "";
index = 0;
}
else if (ch == 27) {
// 左右方向键
char next1 = (char) System.in.read();
char next2 = (char) System.in.read();
if (next1 == 91) {
if (next2 == 68) {
// 左方向键
index = Math.max(0, index - 1);
}
else if (next2 == 67) {
// 右方向键
index = Math.min(input.length(), index + 1);
}
}
}

System.out.print("\u001b[1000D"); // 将光标移动到最左侧
System.out.print(input);
System.out.print("\u001b[1000D"); // 再次将光标移动到最左侧
if (index > 0) {
System.out.print("\u001b[" + index + "C"); // 将光标移动到index处
}
System.out.flush();
}
}
}
}

效果如下:

It works!但是这个命令行还不支持删除,我们无法通过Backspace键删去敲错的字符。有了刚才的经验,实现删除功能也十分简单!

删除

照着刚才的思路,我们可能会在处理用户输入的地方,加上如下的代码:

1
2
3
4
5
6
7
复制代码else if (ch == 127) {
// 删除
if (index > 0) {
input = input.substring(0, index - 1) + input.substring(index, input.length());
index -= 1;
}
}

但是这段代码存在点问题,让我们看一下效果图:

从图中我们可以看到:

  • 第一次,当我输入了11234566,然后不停地按下删除键,想要删掉34566,但是只有光标在后退,字符并没有被删掉。然后我再按下回车键,通过echo的字符串我们发现删除实际上已经成功,只是控制台在显示的时候出了点问题。
  • 第二次,我先输入123456,然后按下删除键,删掉456,光标退到3。然后我再继续不断地输入0,我们发现随着0覆盖了原来的456显示的位置。

所以删除的确产生了效果,但是我们要解决被删除的字符还在显示的这个bug。为了实现删除的效果,我们先来学习一下Ansi里的删除指令:

  • 清除屏幕:\u001b[{n}J为指令。
    • n=0:清除光标到屏幕末尾的所有字符。
    • n=1:清除屏幕开头到光标的所有字符。
    • n=2:清除整个屏幕的字符。
  • 清除行:\u001b[{n}K为指令。
    • n=0:清除光标到当前行末所有的字符。
    • n=1:清除当前行到光标的所有字符。
    • n=2:清除当前行。

所以我们的思路就是不管用户输入了什么,我们先利用System.out.print("\u001b[0K");清除当前行,此时光标回到了行首,这时再输出正确的字符。完整代码如下:

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

public class CommandLine {
public static void main(String[] args) throws IOException, InterruptedException {
// 设置命令行为raw模式,否则会自动解析方向键以及后退键,并且直到按下回车read方法才会返回
String[] cmd = { "/bin/sh", "-c", "stty raw </dev/tty" };
Runtime.getRuntime()
.exec(cmd)
.waitFor();
while (true) {
String input = "";
int index = 0;
while (true) {
char ch = (char) System.in.read();
if (ch == 3) {
// CTRL-C
return;
}
else if (ch >= 32 && ch <= 126) {
// 普通字符
input = input.substring(0, index) + ch + input.substring(index, input.length());
index++;
}
else if (ch == 10 || ch == 13) {
// 回车
System.out.println();
System.out.print("\u001b[1000D");
System.out.println("echo: " + input);
input = "";
index = 0;
}
else if (ch == 27) {
// 左右方向键
char next1 = (char) System.in.read();
char next2 = (char) System.in.read();
if (next1 == 91) {
if (next2 == 68) {
// 左方向键
index = Math.max(0, index - 1);
}
else if (next2 == 67) {
// 右方向键
index = Math.min(input.length(), index + 1);
}
}
}
else if (ch == 127) {
// 删除
if (index > 0) {
input = input.substring(0, index - 1) + input.substring(index, input.length());
index -= 1;
}
}
System.out.print("\u001b[1000D"); // 将光标移动到最左侧
System.out.print("\u001b[0K"); // 清除光标所在行的全部内容
System.out.print(input);
System.out.print("\u001b[1000D"); // 再次将光标移动到最左侧
if (index > 0) {
System.out.print("\u001b[" + index + "C"); // 将光标移动到index处
}
System.out.flush();
}
}
}
}

让我们来看一下效果:

OK,成功了!那么至此为止,我们已经实现了一个最小化的命令行,它能够支持用户进行输入,并且能够左右移动光标以及删除他不想要的字符。但是它还缺失了很多命令行的特性,比如不支持解析像Alt-f、Ctrl-r等常见的快捷键,也不支持输入Unicode字符等等。但是,只要我们掌握了刚才的知识,这些特性都可以方便地实现。比如,我们可以给刚才的命令行加上简单的语法高亮——末尾如果有多余的空格则将这些空格标红,效果如下:

实现的代码也很简单,可以参考Github项目里的CustomisedCommandLine类。

最后,再介绍一下其他一些有用的Ansi Escape Codes:

  • 光标向上移动:\u001b[{n}A将光标向上移动n格。
  • 光标向下移动:\u001b[{n}B将光标向下移动n格。
  • 光标向右移动:\u001b[{n}C将光标向右移动n格。
  • 光标向左移动:\u001b[{n}D将光标向左移动n格。
  • 光标按行向下移动:\u001b[{n}E将光标向下移动n行并且将光标移至行首。
  • 光标按行向上移动:\u001b[{n}F将光标向上移动n行并且将光标移至行首。
  • 设置光标所在列:\u001b[{n}G将光标移至第n列(行数与当前所在行保持一致)。
  • 设置光标所在位置:\u001b[{n};{m}H将光标移至第n行m列,坐标原点从屏幕左上角开始。
  • 保存光标当前所在位置:\u001b[{s}。
  • 读取光标上一次保存的位置:\u001b[{u}。

光标按行移动的测试代码参考Github项目里的LineMovementTest类,设置光标位置的测试代码参考Github项目里的PositionTest类。如果想了解更多的Ansi Escape Codes请参考维基百科。

总结

通过本文的学习,我相信大家已经掌握了如何通过Ansi Escape Codes实现控制台的富文本输出以及控制台光标的自定义移动。那么文章一开始的那4个好奇,大家心中是否已经有了答案了呢?最后,还是强烈建议英文好的同学去阅读一下原文:www.lihaoyi.com/post/Buildy…。祝大家周末愉快!

本文首发于kissyu.org/2017/11/25/…
欢迎评论和转载!
订阅下方微信公众号,获取第一手资讯!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Python 中的作用域准则

发表于 2017-11-24

0x00 前言

因为最早用的是 Java 和 C#,写 Python 的时候自然也把 Python 作用域的想的和原有的一致。

Python 的作用域变量遵循在大部分情况下是一致的,但也有例外的情况。

本文着通过遇到的一个作用域的小问题来说说 Python 的作用域

0x01 作用域的几个实例

但也有部分例外的情况,比如:

1.1 第一个例子

作用域第一版代码如下

1
2
3
4
5
复制代码a = 1
print(a, id(a)) # 打印 1 4465620064
def func1():
print(a, id(a))
func1() # 打印 1 4465620064

作用域第一版对应字节码如下

1
2
3
4
5
6
7
8
9
复制代码  4           0 LOAD_GLOBAL              0 (print)
3 LOAD_GLOBAL 1 (a)
6 LOAD_GLOBAL 2 (id)
9 LOAD_GLOBAL 1 (a)
12 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
15 CALL_FUNCTION 2 (2 positional, 0 keyword pair)
18 POP_TOP
19 LOAD_CONST 0 (None)
22 RETURN_VALUE

PS: 行 4 表示 代码行数 0 / 3 / 9 … 不知道是啥,我就先管他叫做条吧 是 load global
PPS: 注意条 3/6 LOAD_GLOBAL 为从全局变量中加载

顺手附上本文需要着重理解的几个指令

1
2
3
复制代码LOAD_GLOBA          : Loads the global named co_names[namei] onto the stack.
LOAD_FAST(var_num) : Pushes a reference to the local co_varnames[var_num] onto the stack.
STORE_FAST(var_num) : Stores TOS into the local co_varnames[var_num].

这点似乎挺符合我们认知的,那么,再深一点呢?既然这个变量是可以 Load 进来的就可以修改咯?

1.2 第二个例子

然而并不是,我们看作用域第二版对应代码如下

1
2
3
4
5
6
复制代码a = 1
print(a, id(a)) # 打印 1 4465620064
def func2():
a = 2
print(a, id(a))
func2() # 打印 2 4465620096

一看,WTF, 两个 a 内存值不一样。证明这两个变量是完全两个变量。

作用域第二版对应字节码如下

1
2
3
4
5
6
7
8
9
10
11
12
复制代码  4           0 LOAD_CONST               1 (2)
3 STORE_FAST 0 (a)

5 6 LOAD_GLOBAL 0 (print)
9 LOAD_FAST 0 (a)
12 LOAD_GLOBAL 1 (id)
15 LOAD_FAST 0 (a)
18 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
21 CALL_FUNCTION 2 (2 positional, 0 keyword pair)
24 POP_TOP
25 LOAD_CONST 0 (None)
28 RETURN_VALUE

注意行 4 条 3 (STORE_FAST) 以及行 5 条 9/15 (LOAD_FAST)

这说明了这里的 a 并不是 LOAD_GLOBAL 而来,而是从该函数的作用域 LOAD_FAST 而来。

1.3 第三个例子

那我们在函数体重修改一下 a 值看看。

1
2
3
4
5
6
7
复制代码a = 1
def func3():
print(a, id(a)) # 注释掉此行不影响结论
a += 1
print(a, id(a))
func3() # 当调用到这里的时候 local variable 'a' referenced before assignment
# 即 a += 1 => a = a + 1 这里的第二个 a 报错鸟
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码  3           0 LOAD_GLOBAL              0 (print)
3 LOAD_FAST 0 (a)
6 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
9 POP_TOP

4 10 LOAD_FAST 0 (a)
13 LOAD_CONST 1 (1)
16 BINARY_ADD
17 STORE_FAST 0 (a)

5 20 LOAD_GLOBAL 0 (print)
23 LOAD_FAST 0 (a)
26 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
29 POP_TOP
30 LOAD_CONST 0 (None)
33 RETURN_VALUE

那么,func3 也就自然而言由于没有无法 LOAD_FAST 对应的 a 变量,则报了引用错误。

然后问题来了,a 为基本类型的时候是这样的。如果引用类型呢?我们直接仿照 func3 的实例把 a 改成 list 类型。如下

1.4 第四个例子

1
2
3
4
5
6
7
8
复制代码a = [1]
def func4():
print(a, id(a)) # 这条注不注释掉都一样
a += 1 # 这里我故意写错 按理来说应该是 a.append(1)
print(a, id(a))
func4()

# 当调用到这里的时候 local variable 'a' referenced before assignment

╮(╯▽╰)╭ 看来事情那么简单,结果变量 a 依旧是无法修改。

可按理来说跟应该报下面的错误呀

1
复制代码'int' object is not iterable

1.5 第五个例子

1
2
3
4
5
6
7
8
复制代码a = [1]
def func5():
print(a, id(a))
a.append(1)
print(a, id(a))
func5()
# [1] 4500243208
# [1, 1] 4500243208

这下可以修改了。看一下字节码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
复制代码  3           0 LOAD_GLOBAL              0 (print)
3 LOAD_GLOBAL 1 (a)
6 LOAD_GLOBAL 2 (id)
9 LOAD_GLOBAL 1 (a)
12 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
15 CALL_FUNCTION 2 (2 positional, 0 keyword pair)
18 POP_TOP

4 19 LOAD_GLOBAL 1 (a)
22 LOAD_ATTR 3 (append)
25 LOAD_CONST 1 (1)
28 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
31 POP_TOP

5 32 LOAD_GLOBAL 0 (print)
35 LOAD_GLOBAL 1 (a)
38 LOAD_GLOBAL 2 (id)
41 LOAD_GLOBAL 1 (a)
44 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
47 CALL_FUNCTION 2 (2 positional, 0 keyword pair)
50 POP_TOP
51 LOAD_CONST 0 (None)
54 RETURN_VALUE

从全局拿来 a 变量,执行 append 方法。

0x02 作用域准则以及本地赋值准则

2.1 作用域准则

看来这是解释器遵循了某种变量查找的法则,似乎就只能从原理上而不是在 CPython 的实现上解释这个问题了。

查找了一些资料,发现 Python 解释器在依据 基于 LEGB 准则 (顺手吐槽一下不是 LGBT)

LEGB 指的变量查找遵循

  • Local
  • Enclosing-function locals
  • Global
  • Built-In

StackOverFlow 上 martineau 提供了一个不错的例子用来说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
复制代码x = 100
print("1. Global x:", x)
class Test(object):
y = x
print("2. Enclosed y:", y)
x = x + 1
print("3. Enclosed x:", x)

def method(self):
print("4. Enclosed self.x", self.x)
print("5. Global x", x)
try:
print(y)
except NameError as e:
print("6.", e)

def method_local_ref(self):
try:
print(x)
except UnboundLocalError as e:
print("7.", e)
x = 200 # causing 7 because has same name
print("8. Local x", x)

inst = Test()
inst.method()
inst.method_local_ref()

我们试着用变量查找准则去解释 第一个例子 的时候,是解释的通的。

第二个例子,发现函数体内的 a 变量已经不是那个 a 变量了。要是按照这个查找原则的话,似乎有点说不通了。

但当解释第三个例子的时候,就完全说不通了。

1
2
3
4
5
6
7
复制代码a = 1
def func3():
print(a, id(a)) # 注释掉此行不影响结论
a += 1
print(a, id(a))
func3() # 当调用到这里的时候 local variable 'a' referenced before assignment
# 即 a += 1 => a = a + 1 这里的第二个 a 报错鸟

按照我的猜想,这里的代码执行可能有两种情况:

  • 当代码执行到第三行的时候可能是向从 local 找 a, 发现没有,再找 Enclosing-function 发现没有,最后应该在 Global 里面找到才是。注释掉第三行的时候也是同理。
  • 当代码执行到第三行的时候可能是向下从 local 找 a, 发现有,然后代码执行,结束。

但如果真的和我的想法接近的话,这两种情况都可以执行,除了变量作用域之外还是有一些其他的考量。我把这个叫做本地赋值准则 (拍脑袋起的名称)

一般我们管这种考量叫做 Python 作者就是觉得这种编码方式好你爱写不写 Python 作者对于变量作用域的权衡。

事实上,当解释器编译函数体为字节码的时候,如果是一个赋值操作 (list.append 之流不是赋值操作),则会被限定这个变量认为是一个 local 变量。如果在 local 中找不到,并不向上查找,就报引用错误。

1
2
3
复制代码这不是 BUG
这不是 BUG
这不是 BUG

这是一种设计权衡 Python 认为 虽然不强求强制声明类型,但假定被赋值的变量是一个 Local 变量。这样减少避免动态语言比如 JavaScript 动不动就修改掉了全局变量的坑。

这也就解释了第四个例子中赋值操作报错,以及第五个例子 append 为什么可以正常执行。

如果我偏要勉强呢? 可以通过 global 和 nonlocal 来 引入模块级变量 or 上一级变量。

PS: JS 也开始使用 let 进行声明,小箭头函数内部赋值查找变量也是向上查找。

0xEE 参考链接

  • Martineau 的例子

ChangeLog:

  • 2017-11-20 从原有笔记中抽取本文整理而成

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

使用dropwizard(6)-国际化-easy-i18n

发表于 2017-11-24

作者:@Ryan-Miao
本文为作者原创,转载请注明出处:www.cnblogs.com/woshimrf/p/…


目录

简单使用
#情形一 只有一个Resource Bundle
#情形二 我有多个Resource Bundle
#情形三 我有多个Resource Bundle但读取翻译的时候我想一起
#情形4
引入easy-i18n
添加Resource Bundle
新建MessageService
在IoC中提供ResourceBundleMessageSource
测试


前言

Dropwizard官方文档并没有提供国际化的模块,所以只能自己加。Spring的MessageResource用的很顺手,所以copy过来。

Easy i18n

在整合Dropwizard的时候,多语言貌似只能通过jdk自带的ResourceBundle拿数据。其实也就够了,但在开发过程中发现需要缓存,需要解析占位符等。代码越写越多,显然不是仅仅一个调用就完事的。写的差不多的时候突然觉得和spring context里的message source结构类似。于是,放弃维护已经开始变的复杂的逻辑,直接使用spring。

但选取dropwizard的时候就是摒弃了spring,再拿过来也不好玩了。干脆,抽取Spring context项目的MessageResource相关代码,重写封装了一个library: github.com/Ryan-Miao/e…, 欢迎star。

easy-i18n还是和在Spring项目中相同。

首先,引入依赖,由于github项目的library已经有仓库去维护了,就没费心思放到maven和jcenter了,直接从github上拉取。类库地址为:

1
2
3
4
5
6
复制代码`<repositories>`
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>

引入

1
2
3
4
5
复制代码`<dependency>`
<groupId>com.github.Ryan-Miao</groupId>
<artifactId>easy-i18n</artifactId>
<version>1.0</version>
</dependency>

简单使用

#情形一 只有一个Resource Bundle

在resources下新建i18n/messages.properties以及i18n/messages_zh_CN.properties. demo位置:l4dropwizard

然后,调用方法如下:

1
2
3
4
5
6
7
8
9
复制代码`@Test`
public void testI18n(){
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.addBasenames("i18n/messages");
messageSource.setDefaultEncoding("UTF-8");

String index = messageSource.getMessage("index", null, Locale.US);
System.out.println(index);
}

#情形二 我有多个Resource Bundle

实际项目中,由于产品分类,有时候需要创建多个Resource Bundle,这时候也简单,只要创建多个ResourceBundleMessageSource来读取翻译即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码
public void testI18n2(){
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.addBasenames("i18n/messages");
messageSource.setDefaultEncoding("UTF-8");

String index = messageSource.getMessage("index", null, Locale.US);
System.out.println(index);

ResourceBundleMessageSource messageSource2 = new ResourceBundleMessageSource();
messageSource.addBasenames("i18n/messages2");
messageSource.setDefaultEncoding("UTF-8");

String second = messageSource.getMessage("second", null, Locale.US);
System.out.println(second);
}

#情形三 我有多个Resource Bundle但读取翻译的时候我想一起

有时候,想要读取翻译,可能翻译文件在不同的Resource Bundle,但我指向用一个接口去调用。这时候,做法时候在这几个Resource Bundle的里面添加命名空间,即key要在这几个Resource Bundle里唯一,而不仅仅是本文件唯一。

然后,

1
2
3
4
5
6
7
8
9
复制代码
public void testI18n2(){
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.addBasenames("i18n/messages", "i18n/messages2");
messageSource.setDefaultEncoding("UTF-8");

String index = messageSource.getMessage("index", null, Locale.US);
System.out.println(index);
}

这种做法,会一次从两个Resource Bundle里寻找翻译,找到即返回。因此,如果有相同的key,将导致只有第一个生效。

#情形4

没有了,你翻译要那么复杂吗。

更多用法,参考测试类:ResourceBundleMessageSourceTest

Demo source

github.com/Ryan-Miao/l…

本文是基于dropwizard入门之上的演进。

确保依赖都是最新的,或者自行解决版本冲突,比如jackson不同版本之间的类有所不同。

引入easy-i18n

repository url

1
2
3
4
5
6
复制代码`<repositories>`
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>

引入

1
2
3
4
5
复制代码`<dependency>`
<groupId>com.github.Ryan-Miao</groupId>
<artifactId>easy-i18n</artifactId>
<version>1.0</version>
</dependency>

添加Resource Bundle

在resources下新增文件夹i18n, 依次添加几个Resource Bundle。具体做法是,在文件夹i18n右键 -> new -> Resource Bundle, 然后选择想要支持的语言。比如美国en_US,简体中文zh_CN。

新建MessageService

创建一个Util来处理翻译功能。 com.test.domain.service.IMessageService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
复制代码`package com.test.domain.service;`

import java.text.MessageFormat;
import java.util.List;
import java.util.Locale;

/**
* The Message translation service
* Created by Ryan Miao on 11/23/17.
*/
public interface IMessageService {

/**
* Get translation by message key.
*
* @param key The message key in the properties
* @return the translated message
*/
String getMessage(String key, Locale locale);

/**
* Get translation by message key and compose it with variables.
* Note that the variable would be injected by {@link MessageFormat}
*
* @param key The message key in the properties
* @param args The variables to inject into the message.
* @return the translated message.
*/
String getMessage(String key, List<String> args, Locale locale);
}

实现类com.test.domain.service.impl.MessageService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
复制代码`package com.test.domain.service.impl;`

import com.miao.easyi18n.support.ResourceBundleMessageSource;
import com.test.domain.service.IMessageService;

import javax.inject.Inject;
import javax.inject.Singleton;
import java.util.List;
import java.util.Locale;

/**
* Created by Ryan Miao on 11/23/17.
*/
@Singleton
public class MessageService implements IMessageService{

private final ResourceBundleMessageSource messageSource;

@Inject
public MessageService(ResourceBundleMessageSource messageSource) {
this.messageSource = messageSource;
}

@Override
public String getMessage(String key, Locale locale) {
return messageSource.getMessage(key, null, locale);
}

@Override
public String getMessage(String key, List<String> args, Locale locale) {
return messageSource.getMessage(key, args.toArray(), locale);
}
}

在IoC中提供ResourceBundleMessageSource

由于ResourceBundleMessageSource是公共组件,需要单独提取出来,并使用单例模式创建。关于IoC的配置,参阅dropwizard中添加DI

在ConfigurationModule中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
复制代码`package com.test.domain.ioc.module;`

import com.miao.easyi18n.support.ResourceBundleMessageSource;
import com.test.configuration.HelloWorldConfiguration;
import dagger.Module;
import dagger.Provides;

import javax.inject.Singleton;

/**
* Created by Ryan Miao on 11/20/17.
*/
@Module
public class ConfigurationModule {
private final HelloWorldConfiguration configuration;

public ConfigurationModule(HelloWorldConfiguration configuration) {
this.configuration = configuration;
}


@Provides
@Singleton
HelloWorldConfiguration helloWorldConfiguration(){
return configuration;
}

@Singleton
@Provides
ResourceBundleMessageSource resourceBundleMessageSource(){
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.addBasenames("i18n/messages", "i18n/messages2", "i18n/otherGroup");
messageSource.setDefaultEncoding("UTF-8");

return messageSource;
}
}

这里,关于Resource Bundle的位置没有单独提出来,后面可以放到HelloWorldConfiguration,提到配置文件中。

测试

在dagger中,接口和实现类的绑定只能通过手动声明。因此,绑定IMessageService

1
2
3
4
5
复制代码`@Singleton`
@Provides
IMessageService messageService(MessageService messageService){
return messageService;
}

创建测试Resource, com.test.domain.resource.LocalResource

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

import com.codahale.metrics.annotation.Timed;
import com.google.common.collect.ImmutableMap;
import com.test.domain.entiry.GithubUser;
import com.test.domain.service.IMessageService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;

import javax.inject.Inject;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import javax.ws.rs.GET;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import java.util.Locale;
import java.util.Map;

/**
* Test localization
* Created by Ryan Miao on 11/23/17.
*/
@Api("/local")
@Path("/local")
@Produces(MediaType.APPLICATION_JSON)
public class LocalResource {

private final IMessageService messageService;

@Inject
public LocalResource(IMessageService messageService) {
this.messageService = messageService;
}

@GET
@Timed
@Path("/{key}")
@ApiOperation(value = "Get github user profile.", notes = "There should be the note.")
@ApiResponses({
@ApiResponse(code = 401, message = "Valid credentials are required to access this resource."),
@ApiResponse(code = 400, message = "Params not valid."),
@ApiResponse(code = 500, message = "Something wrong from the server."),
@ApiResponse(code = 200, message = "Success.", response = GithubUser.class)
})
public Map<String, String> getIndex(
@PathParam("key") final String index,
@HeaderParam("Accept-Language") @Valid
@NotNull(message = "cannot be null.")
@Pattern(regexp = "([a-z]{2}-[A-Z]{2})", message = "pattern should like zh-CN, en-US.")
final String language
) {
final Locale locale = Locale.forLanguageTag(language);
final String message = messageService.getMessage(index, locale);
return ImmutableMap.of(index, message);
}
}

结果

So do it,and
change it,no regret!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【译】如何使用 Golang 中的 Go-Routines

发表于 2017-11-24
  • 原文地址:How to write high-performance code in Golang using Go-Routines
  • 原文作者:Vignesh Sk
  • 译文出自:掘金翻译计划
  • 本文永久链接:github.com/xitu/gold-m…
  • 译者:tmpbook
  • 校对者:altairlu

如何使用 Golang 中的 Go-Routines 写出高性能的代码

为了用 Golang 写出快速的代码,你需要看一下 Rob Pike 的视频 - Go-Routines。

他是 Golang 的作者之一。如果你还没有看过视频,请继续阅读,这篇文章是我对那个视频内容的一些个人见解。我感觉视频不是很完整。我猜 Rob 因为时间关系忽略掉了一些他认为不值得讲的观点。不过我花了很多的时间来写了一篇综合全面的关于 go-routines 的文章。我没有涵盖视频中涵盖的所有主题。我会介绍一些自己用来解决 Golang 常见问题的项目。

好的,为了写出很快的 Golang 程序,有三个概念你需要完全了解,那就是 Go-Routines,闭包,还有管道。

Go-Routines

让我们假设你的任务是将 100 个盒子从一个房间移到另一个房间。再假设,你一次只能搬一个盒子,而且移动一次会花费一分钟时间。所以,你会花费 100 分钟的时间搬完这 100 个箱子。

现在,为了让加快移动 100 个盒子这个过程,你可以找到一个方法更快的移动这个盒子(这类似于找一个更好的算法去解决问题)或者你可以额外雇佣一个人去帮你移动盒子(这类似于增加 CPU 核数用于执行算法)

这篇文章重点讲第二种方法。编写 go-routines 并利用一个或者多个 CPU 核心去加快应用的执行。

任何代码块在默认情况下只会使用一个 CPU 核心,除非这个代码块中声明了 go-routines。所以,如果你有一个 70 行的,没有包含 go-routines 的程序。它将会被单个核心执行。就像我们的例子,一个核心一次只能执行一个指令。因此,如果你想加快应用程序的速度,就必须把所有的 CPU 核心都利用起来。

所以,什么是 go-routine。如何在 Golang 中声明它?

让我们看一个简单的程序并介绍其中的 go-routine。

示例程序 1

假设移动一个盒子相当于打印一行标准输出。那么,我们的实例程序中有 10 个打印语句(因为没有使用 for 循环,我们只移动 10 个盒子)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码package main

import "fmt"

func main() {
fmt.Println("Box 1")
fmt.Println("Box 2")
fmt.Println("Box 3")
fmt.Println("Box 4")
fmt.Println("Box 5")
fmt.Println("Box 6")
fmt.Println("Box 7")
fmt.Println("Box 8")
fmt.Println("Box 9")
fmt.Println("Box 10")
}

因为 go-routines 没有被声明,上面的代码产生了如下输出。

输出

1
2
3
4
5
6
7
8
9
10
复制代码Box 1
Box 2
Box 3
Box 4
Box 5
Box 6
Box 7
Box 8
Box 9
Box 10

所以,如果我们想在在移动盒子这个过程中使用额外的 CPU 核心,我们需要声明一个 go-routine。

包含 Go-Routines 的示例程序 2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码package main

import "fmt"

func main() {
go func() {
fmt.Println("Box 1")
fmt.Println("Box 2")
fmt.Println("Box 3")
}()
fmt.Println("Box 4")
fmt.Println("Box 5")
fmt.Println("Box 6")
fmt.Println("Box 7")
fmt.Println("Box 8")
fmt.Println("Box 9")
fmt.Println("Box 10")
}

这儿,一个 go-routine 被声明且包含了前三个打印语句。意思是处理 main 函数的核心只执行 4-10 行的语句。另一个不同的核心被分配去执行 1-3 行的语句块。

输出

1
2
3
4
5
6
7
8
9
10
复制代码Box 4
Box 5
Box 6
Box 1
Box 7
Box 8
Box 2
Box 9
Box 3
Box 10

分析输出

在这段代码中,有两个 CPU 核心同时运行,试图执行他们的任务,并且这两个核心都依赖标准输出来完成它们相应的任务(因为这个示例中我们使用了 print 语句)
换句话来说,标准输出(运行在它自己的一个核心上)一次只能接受一个任务。所以,你在这儿看到的是一种随机的排序,这取决于标准输出决定接受 core1 core2 哪个的任务。

如何声明 go-routine?

为了声明我们自己的 go-routine,我们需要做三件事。

  1. 我们创建一个匿名函数
  2. 我们调用这个匿名函数
  3. 我们使用 「go」关键字来调用

所以,第一步是采用定义函数的语法,但忽略定义函数名(匿名)来完成的。

1
2
3
4
5
复制代码func() {
fmt.Println("Box 1")
fmt.Println("Box 2")
fmt.Println("Box 3")
}

第二步是通过将空括号添加到匿名方法后面来完成的。这是一种叫命名函数的方法。

1
2
3
4
5
复制代码func() {
fmt.Println("Box 1")
fmt.Println("Box 2")
fmt.Println("Box 3")
} ()

步骤三可以通过 go 关键字来完成。什么是 go 关键字呢,它可以将功能块声明为可以独立运行的代码块。这样的话,它可以让这个代码块被系统上其他空闲的核心所执行。

#细节 1:当 go-routines 的数量比核心数量多的时候会发生什么?

单个核心通过上下文切换并行执行多个go程序来实现多个核心的错觉。

#自己试试之1:试着移除示例程序2中的 go 关键字。输出是什么呢?

答案:示例程序2的结果和1一模一样。

#自己试试之 2:将匿名函数中的语句从 3 增加至 8 个。结果改变了吗?

答案:是的。main 函数是一个母亲 go-routine(其他所有的 go-routine 都在它里面被声明和创建)。所以,当母亲 go-routine 执行结束,即使其他 go-routines 执行到中途,它们也会被杀掉然后返回。

我们现在已经知道 go-routines 是什么了。接下来让我们来看看闭包。

如果之前没有在 Python 或者 JavaScript 中学过闭包,你可以现在在 Golang 中学习它。学到的人可以跳过这部分来节省时间,因为 Golang 中的闭包和 Python 或者 JavaScript 中是一样的。

在我们深入理解闭包之前。让我们先看看不支持闭包属性的语言比如 C,C++ 和 Java,在这些语言中,

  1. 函数只访问两种类型的变量,全局变量和局部变量(函数内部的变量)。
  2. 没有函数可以访问声明在其他函数里的变量。
  3. 一旦函数执行完毕,这个函数中声明的所有变量都会消失。

对 Golang,Python 或者 JavaScript 这些支持闭包属性的语言,以上都是不正确的,原因在于,这些语言拥有以下的灵活性。

  1. 函数可以声明在函数内。
  2. 函数可以返回函数。

推论 #1:因为函数可以被声明在函数内部,一个函数声明在另一个函数内的嵌套链是这种灵活性的常见副产品。

为了了解为什么这两个灵活性完全改变了运作方式,让我们看看什么是闭包。

所以什么是闭包?

除了访问局部变量和全局变量,函数还可以访问函数声明中声明的所有局部变量,只要它们是在之前声明的(包括在运行时传递给闭包函数的所有参数),在嵌套的情况下,函数可以访问所有函数的变量(无论闭包的级别如何)。

为了理解的更好,让我们考虑一个简单的情况,两个函数,一个包含另一个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码package main

import "fmt"

var zero int = 0

func main() {
var one int = 1
child := func() {
var two int = 3
fmt.Println(zero)
fmt.Println(one)
fmt.Println(two)
fmt.Println(three) // causes compilation Error
}
child()
var three int = 2
}

这儿有两个函数 - 主函数和子函数,其中子函数定义在主函数中。子函数访问

  1. zero 变量 - 它是全局变量
  2. one 变量 - 闭包属性 - one 属于主函数,它在主函数中且定义在子函数之前。
  3. two 变量 - 它是子函数的局部变量

注意:虽然它被定义在封闭函数「main」中,但它不能访问 three 变量,因为后者的声明在子函数的定义后面。

和嵌套一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
复制代码package main

import "fmt"

var global func()

func closure() {
var A int = 1
func() {
var B int = 2
func() {
var C int = 3
global = func() {
fmt.Println(A, B, C)
fmt.Println(D, E, F) // causes compilation error
}
var D int = 4
}()
var E int = 5
}()
var F int = 6
}
func main() {
closure()
global()
}

如果我们考虑一下将一个最内层的函数关联给一个全局变量「global」。

  1. 它可以访问到 A、B、C 变量,和闭包无关。
  2. 它无法访问 D、E、F 变量,因为它们之前没有定义。

注意:即使闭包执行完了,它的局部变量任然不会被销毁。它们仍然能够通过名字是 「global」的函数名去访问。

下面介绍一下 Channels。

Channels 是 go-routines 之间通信的一种资源,它们可以是任意类型。

1
复制代码ch := make(chan string)

我们定义了一个叫做 ch 的 string 类型的 channel。只有 string 类型的变量可以通过此 channel 通信。

1
复制代码ch <- "Hi"

就是这样发送消息到 channel 中。

1
复制代码msg := <- ch

这是如何从 channel 中接收消息。

所有 channel 中的操作(发送和接收)本质上是阻塞的。这意味着如果一个 go-routine 试图通过 channel 发送一个消息,那么只有在存在另一个 go-routine 正在试图从 channel 中取消息的时候才会成功。如果没有 go-routine 在 channel 那里等待接收,作为发送方的 go-routine 就会永远尝试发送消息给某个接收方。

最重要的点是这里,跟在 channel 操作后面的所有的语句在 channel 操作结束之前是不会执行的,go-routine 可以解锁自己然后执行跟在它后面的的语句。这有助于同步其他代码块的各种 go-routine。

免责声明:如果只有发送方的 go-routine,没有其他的 go-routine。那么会发生死锁,go 程序会检测出死锁并崩溃。

注意:所有以上讲的也都适用于接收方 go-routines。

缓冲 Channels

1
复制代码ch := make(chan string, 100)

缓冲 channels 本质上是半阻塞的。

比如,ch 是一个 100 大小的缓冲字符 channel。这意味着前 100 个发送给它的消息是非阻塞的。后面的就会阻塞掉。

这种类型的 channels 的用处在于从它中接收消息之后会再次释放缓冲区,这意味着,如果有 100 个新 go-routines 程序突然出现,每个都从 channel 中消费一个消息,那么来自发送者的下 100 个消息将会再次变为非阻塞。

所以,一个缓冲 channel 的行为是否和非缓冲 channel 一样,取决于缓冲区在运行时是否空闲。

Channels 的关闭

1
复制代码close(ch)

这就是如何关闭 channel。在 Golang 中它对避免死锁很有帮助。接收方的 go-routine 可以像下面这样探测 channel 是否关闭了。

1
2
3
4
复制代码msg, ok := <- ch
if !ok {
fmt.Println("Channel closed")
}

使用 Golang 写出很快的代码

现在我们讲的知识点已经涵盖了 go-routines,闭包,channel。考虑到移动盒子的算法已经很有效率,我们可以开始使用 Golang 开发一个通用的解决方案来解决问题,我们只关注为任务雇佣合适的人的数量。

让我们仔细看看我们的问题,重新定义它。

我们有 100 个盒子需要从一个房间移动到另一个房间。需要着重说明的一点是,移动盒子1和移动盒子2涉及的工作没有什么不同。因此我们可以定义一个移动盒子的方法,变量「i」代表被移动的盒子。方法叫做「任务」,盒子数量用「N」表示。任何「计算机编程基础 101」课程都会教你如何解决这个问题:写一个 for 循环调用「任务」N 次,这导致计算被单核心占用,而系统中的可用核心是个硬件问题,取决于系统的品牌,型号和设计。所以作为软件开发人员,我们将硬件从我们的问题中抽离出去,来讨论 go-routines 而不是核心。越多的核心就支持越多的 go-routines,我们假设「R」是我们「X」核心系统所支持的 go-routines 数量。

FYI:数量「X」的核心数量可以处理超过数量「X」的 go-routines。单个核心支持的 go-routines 数量(R/X)取决于 go-routines 涉及的处理方式和运行时所在的平台。比如,如果所有的 go-routine 仅涉及阻塞调用,例如网络 I/O 或者 磁盘 I/O,则单个内核足以处理它们。这是真的,因为每个 go-routine 相比运算来说更多的在等待。因此,单个核心可以处理所有 go-routine 之间的上下文切换。

因此我们的问题的一般性的定义为

将「N」个任务分配给「R」个 go-routines,其中所有的任务都相同。

如果 N≤R,我们可以用以下方式解决。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码package main

import "fmt"

var N int = 100

func Task(i int) {
fmt.Println("Box", i)
}
func main() {
ack := make(chan bool, N) // Acknowledgement channel
for i := 0; i < N; i++ {
go func(arg int) { // Point #1
Task(arg)
ack <- true // Point #2
}(i) // Point #3
}

for i := 0; i < N; i++ {
<-ack // Point #2
}
}

解释一下我们做了什么…

  1. 我们为每个任务创建一个 go-routine。我们的系统能同时支持「R」个 go-routines。只要 N≤R 我们这么做就是安全的。
  2. 我们确认 main 函数在等待所有 go-routine 完成的时候才返回。我们通过等待所有 go-routine(通过闭包属性)使用的确认 channel(「ack」)来传达其完成。
  3. 我们传递循环计数「i」作为参数「arg」给 go-routine,而不是通过闭包属性在 go-routine 中直接引用它。

另一方面,如果 N>R,则上述解决方法会有问题。它会创建系统不能处理的 go-routines。所有核心都尝试运行更多的,超过其容量的 go-routines,最终将会把更多的时间话费在上下文切换上而不是运行程序(俗称抖动)。当 N 和 R 之间的数量差异越来越大,上下文切换的开销会更加突出。因此要始终将 go-routine 的数量限制为 R。并将 N 个任务分配给 R 个 go-routines。

下面我们介绍 workers 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码var R int = 100
func Workers(task func(int)) chan int { // Point #4
input := make(chan int) // Point #1
for i := 0; i < R; i++ { // Point #1
go func() {
for {
v, ok := <-input // Point #2
if ok {
task(v) // Point #4
} else {
return // Point #2
}
}
}()
}
return input // Point #3
}
  1. 创建一个包含有「R」个 go-routines 的池。不多也不少,所有对「input」channel 的监听通过闭包属性来引用。
  2. 创建 go-routines,它通过在每次循环中检查 ok 参数来判断 channel 是否关闭,如果 channel 关闭则杀死自己。
  3. 返回 input channel 来允许调用者函数分配任务给池。
  4. 使用「task」参数来允许调用函数定义 go-routines 的主体。

使用

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码func main() {
ack := make(chan bool, N)
workers := Workers(func(a int) { // Point #2
Task(a)
ack <- true // Point #1
})
for i := 0; i < N; i++ {
workers <- i
}
for i := 0; i < N; i++ { // Point #3
<-ack
}
}

通过将语句(Point #1)添加到 worker 方法中(Point #2),闭包属性巧妙的在任务参数定义中添加了对确认 channel 的调用,我们使用这个循环(Point #3)来使 main 函数有一个机制去知道池中的所有 go-routine 是否都完成了任务。所有和 go-routines 相关的逻辑都应该包含在 worker 自己中,因为它们是在其中创建的。main 函数不应该知道内部 worker 函数们的工作细节。

因此,为了实现完全的抽象,我们要引入一个『climax』函数,只有在池中所有 go-routine 全部完成之后才运行。这是通过设置另一个单独检查池状态的 go-routine 来实现的,另外不同的问题需要不同类型的 channel 类型。相同的 int cannel 不能在所有情况下使用,所以,为了写一个更通用的 worker 函数,我们将使用空接口类型重新定义一个 worker 函数。

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

import "fmt"

var N int = 100
var R int = 100

func Task(i int) {
fmt.Println("Box", i)
}
func Workers(task func(interface{}), climax func()) chan interface{} {
input := make(chan interface{})
ack := make(chan bool)
for i := 0; i < R; i++ {
go func() {
for {
v, ok := <-input
if ok {
task(v)
ack <- true
} else {
return
}
}
}()
}
go func() {
for i := 0; i < R; i++ {
<-ack
}
climax()
}()
return input
}
func main() {

exit := make(chan bool)

workers := Workers(func(a interface{}) {
Task(a.(int))
}, func() {
exit <- true
})

for i := 0; i < N; i++ {
workers <- i
}
close(workers)

<-exit
}

你看,我已经试图展示了 Golang 的力量。我们还研究了如何在 Golang 中编写高性能代码。

请观看 Rob Pike 的 Go-Routines 视频,然后和 Golang 度过一个美好的时光。

直到下次…

感谢 Prateek Nischal。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、React、前端、后端、产品、设计 等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

PHP 调用 Go 服务的正确方式 - Unix Domai

发表于 2017-11-24

问题

可能是由于经验太少,工作中经常会遇到问题,探究和解决问题的过程总想记录一下,所以我写博客经常是问题驱动,首先介绍一下今天要解决的问题:

服务耦合

我们在开发过程中可能会遇到这样的情况:

  • 进程依赖于某服务,所以把服务耦合在进程代码中;
  • 服务初始化耗时长,拖慢了进程启动时间;
  • 服务运行要占用大量内存,多进程时内存损耗严重。

如我上篇文章 小时到分钟 - 一步步优化巨量关键词的匹配 中介绍的文本匹配服务,它是消息处理流程中的一环,被多个消息处理进程依赖,每次初始化进程要 6秒 左右时间构造 Trie 树,而且服务读取关键词大文件、使用树组构造 Trie 树,会占用大量(目前设置为 256M )内存。

我已经把进程写成了守护进程的形式,让它们长时间执行,虽然不用更多地考虑初始化时间了,但占用内存量巨大的问题没有办法。如果关键词量再大一些,一台机器上面跑十来个消息处理进程后就干不了其他了。

而且,如果有需求让我把文本匹配服务封装为接口给外部调用呢?我们知道,web 服务时,每一个请求处理进程的生存周期是从受理请求到响应结束,如果每次请求都用大量内存和时间来初始化服务,那接口响应时间和服务器压力可想而知。

服务抽取

这样,服务形式必须要改变,我们希望这个文本匹配这个服务能做到:

  • 随调随走,不依赖,不再与“消息处理服务”耦合在一起;
  • 一次初始化,进程运行期间持续提供服务;
  • 同步响应,高效而准确,最好能不用各种锁来保持资源占有;

解决办法也很简单,就是把这个文本匹配的服务抽取出来,单独作为一个守护进程来运行,像一个特殊的服务器,多个“消息处理服务”在有需要时能调用此服务进程。

现在,我们需要考虑文本匹配服务进程如何与外界通信,接受匹配请求,响应匹配结果。绕来绕去,问题还是回到了 进程间通信。


Unix Domain Sockets

进程间通信

进程间通信(IPC,Inter-Process Communication),指至少两个进程或线程间传送数据或信号的一些技术或方法。进程是计算机系统分配资源的最小单位(严格说来是线程)。每个进程都有自己的一部分独立的系统资源,彼此是隔离的。为了能使不同的进程互相访问资源并进行协调工作,才有了进程间通信。

进程间通信的方式有很多,网上对此介绍的也很多,下面根据文章的需求来分析一下这些方式:

  • 管道:管道是Unix最初的IPC形式,但它只能用于具有共同祖先进程的各个进程,无法用于在没有亲缘关系的进程。如果使用它,需要在“消息处理服务”中启动“文本匹配服务”,跟原来差别不大。
  • 命名管道:也被称为有名管道,它在Unix称为FIFO,它通过一个文件来进行进程间数据交互,但服务于多个进程时,需要添加锁来保证原子性,从而避免写入和读取不对应。
  • 信号和信号量:用于进程/线程事件级的通信,但它们能交流的信息太少。
  • 消息队列和共享内存:都是通过一个公共内存介质来进行通信,我之前也写过一篇关于PHP进程间使用消息队列和共享内存通信的文章: 从并发处理谈PHP进程间通信(二)System V IPC,但它们在通信上都是异步的,处理多个进程时无法分辨请求和对应的响应信息。
  • socket:通过Unix封装好的网络API来进行通信,像数据库、服务器都是通过这种方式实现,它们也能提供本地服务。不过网络socket固然能使用,但是要面临着数据包装和网络调用开销,也不是完美的选择。

简单介绍

当然还是有完美的方式的,这就是今天的主角 - Unix Domain Sockets ,它可以理解为一种特殊的 Socket,但它不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等,只是将应用层数据从一个进程拷贝到另一个进程,所以在系统内通信效率更高。而且免去了网络问题,它也更能保证消息的完整性,既不会丢失也不会顺序错乱。

作为特殊的 Socket,它的创建、调用方式和网络 Socket 一样,一次完整的交互,服务端都要经过create、bind、listen、accept、read、write,客户端要通过create、connect、write、read。与普通 Socket 不同的是它绑定一个系统内的文件,而不是 IP 和端口。

创建代码这里不再多介绍了,之前的一篇文章 用C写一个web服务器(一) 基础功能 的功能实现小节里详细介绍了 socket 通信的具体步骤,C 系的语言都是相似的,很容易理解。

适用场景

Unix Domain Sockets 真的是进程间通信的一个重型武器,用它可以快速实现进程间的数据、信息交互,而且不需要锁等繁杂操作,也不用考虑效率,可谓是简单高效。

当然,“重型武器” 的在各种场景下也有适合不适合。Unix Domain Sockets适用于以下场景:

  • 服务长时间存在。 Unix Domain Sockets 的服务端是个服务器一样的存在,在守护进程中,它阻塞并等待客户端连接的特性可以被充分利用。
  • 一服务器多客户端。它能通过 Socket 的文件描述符来区分不同的客户端,避免资源之间的锁操作。
  • 同一系统内。它只能在同一系统内进行进程数据复制,跨系统请使用传统 Sockets。

代码实现

接下来要 show code 了,不过学 PHP 的都知道,PHP 不太适合处理 CPU 密集形的任务,我刚好学了点 Go,一时手痒,就用 Go 实现了下 Trie 树,所以才牵扯到 PHP 和 Go 之间的通信,有了今天的文章。当然介绍的方法,并不只适合 PHP 与 Go 通信,其他语言也可以,至少 C系语言中是通用的。

完整代码见 IPC-GitHub-枕边书,里面还附带了一份随手写的 PHP 版本的 Unix Domain Sockets server 端。

Go 实现的 Trie 树

Trie树不再是今天的主题,这里介绍一下数据结构和需要注意的点。

1
2
3
4
5
复制代码`// trie树结点定义`
type Node struct {
depth int
children map[int32]Node // 用map实现key-value型的 字符-节点 对应
}

需要注意:

  • 使用 slice 的 append() 函数保存递增的匹配结果时,有可能由于 slice 容量不够而重新分配地址,所以要传入 slice 的地址来保存递增后的匹配结果结果,*result = append(*result, word),最后再将递增之后的 slice 地址传回。
  • 由于 Go 中的编码统一使用的 utf-8,不用像 PHP 一样判断字符的边界,所以在进行关键词拆散和消息拆散时,直接使用 int32() 方法将关键词和消息都转换为成员为 int32 类型的 slice,匹配过程中就使用 int32 类型的数字来代表这个中文字符,匹配完成后再使用fmt.Printf("%c", int32)将其转换为中文。

Go Server

Go 中创建一个 socket 并使用的步骤非常简单,只是 Go 没有异常,判断 error 会比较恶心一点,不知道有没有大神有更好的写法。下面为了精简,把 error 全置空了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码 `// 创建一个Unix domain soceket`
socket, _ := net.Listen("unix", "/tmp/keyword_match.sock")
// 关闭时删除绑定的文件
defer syscall.Unlink("/tmp/keyword_match.sock")
// 无限循环监听和受理客户端请求
for {
client, _ := socket.Accept()

buf := make([]byte, 1024)
data_len, _ := client.Read(buf)
data := buf[0:data_len]
msg := string(data)

matched := trie.Match(tree, msg)
response := []byte("[]") // 给响应一个默认值
if len(matched) > 0 {
json_str, _ := json.Marshal(matched)
response = []byte(string(json_str))
}
_, _ = client.Write(response)
}

PHP Client

下面是 PHP 实现的客户端:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码`$msg = "msg";`
// 创建 连接 发送消息 接收响应 关闭连接
$socket = socket_create(AF_UNIX, SOCK_STREAM, 0);
socket_connect($socket, '/tmp/keyword_match.sock');
socket_send($socket, $msg, strlen($msg), 0);
$response = socket_read($socket, 1024);
socket_close($socket);

// 有值则为匹配成功
if (strlen($response) > 3) {
var_dump($response);
}

小结

效率

这里总结一下这套设计的效率表现:

纯粹用 Go 进行文本关键词匹配,一千条数据运行一秒多,差不多是 PHP 效率的两倍。不过说好的 8倍效率呢?果然测评都是骗人的。当然,也可能是我写法有问题或者 Trie 树不在 Go 的发挥范围之内。然后是 PHP 使用 Unix Domain Socket 调用 Go 服务的耗时,可能是进程间复制数据耗时或 PHP 拖了后腿,3秒多一点,跟纯 PHP 脚本差不多。

杂谈

用 PHP 的都知道,PHP 因为解释型语言的特性和其高度的封装,导致其虽然在开发上速度很快,可是执行与其他语言相比略差。对此,业界的 FB 有 HHVM,PHP7 有 opcache 新特性,据说还要在 PHP8 添加 JIT,用以弥补其先天硬伤。

不过,对于开发者,特别是跟我一样对于效率有执著追求的人来说,在了解使用 PHP 的新特性之外,自己再掌握一门较高执行效率、开发效率略低的语言,用来写一些高计算量,逻辑单一的代码,与 PHP 互补或许会更好一点。

于是,在考虑良久,也见识了各种 Go 的支持者和反对者之间的撕逼后,我觉得还是要相信一下谷歌爸爸,毕竟也没什么其他我觉得可选的语言了。PS:请不要针对这一段发表意见,谢谢:)

另外C呢,虽然暂时开发中用不到,可是毕竟是当代N多语言的起源,偶尔写写数据结构、算法什么的以免生锈。而且学了些C,从 PHP 到 Go,切换起来还略有些得心应手的感觉~

关于本文有什么问题可以在下面留言交流,如果您觉得本文对您有帮助,可以点击下面的 推荐 支持一下我。博客一直在更新,欢迎 关注 。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…932933934…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%