(In Progress)手记-分布式系统的核心问题

整理一下之前的笔记

概述

我们常常讨论的分布式系统是什么?

  • 系统分布在多台服务器上
  • 系统维护数据-是stateful的

常见的分布式系统

Type of platform/framework	Example
Databases	Cassandra, HBase, Riak
Message Brokers	Kafka, Pulsar
Infrastructure	Kubernetes, Mesos, Zookeeper, etcd, Consul
In Memory Data/Compute Grids	Hazelcast, Pivotal Gemfire
Stateful Microservices	Akka Actors, Axon
File Systems	HDFS, Ceph

常见问题

  1. 程序中断
    • 数据不丢失:往往flush数据到storage是一个费时费力的操作,如何保证内存中的数据在程序意外退出时不丢失就是一个难点,一个常见的solution就是Write-Ahead Log(or Transaction Log, or Commit Log),将每条数据以append-only的形式快速持久化到一个日志之中。
    • 数据可用性:常用的手段是数据在多台服务器上冗余存储(副本)
  2. 网络延迟/网络分区
    • 服务器如何确认彼此还存活:HeartBeat – 按照周期发送心跳请求,来确认彼此的存活。
    • 解决脑裂问题:Quorum-保证‘大多数’make the decision.
    • 解决数据一致性问题:某些节点上数据已经更新,但是某些节点上因为网络和处理延迟,还没有存储着旧数据;解决方式之一是Leader and Followers模式;同时可以利用High-Water Mark来标记事务日志中哪些数据是可以满足一致性前提来提供给用户的。
  3. 程序暂停/挂起/卡住
    • 我们需要一个机制来检测 out-of-date leaders:Generation Clock,对于来自过时的主节点的请求,应当抛弃使用。
  4. 系统时钟/消息顺序
    • 分布式系统的时钟同步通常利用NTP服务,但是可能因为网络问题而中断,所以系统时钟无法用来保证消息的顺序性;
    • 如何标记消息的顺序:Lamport Clock-Generation Clock,通常是一个sequence number;
    • Hybrid-Clock使用系统时间以及一个SN来标记顺序;
    • 如何检测对于不同副本上的同一个值的并发更新呢?Version Vector就是用来检测冲突的。Versioned Value也是一个相关的模式

分布式一致性Distributed Consensus

Consensus refers to a set of servers which agree on stored data, the order in which the data is stored and when to make that data visible to the clients.

算法:PaxosRaftZab, Two-phased commit

Paxos describes a few simple rules to use two phase execution, Quorum and Generation Clock to achieve consensus across a set of cluster nodes even when there are process crashes, network delays and unsynchronized clocks.

相关技术还有:Replicated Log(也叫做State machine Replication),Lease,State Watch,Consistent Core

事务日志 Write-Ahead Log

The Write Ahead Log is divided into multiple segments using Segmented Log. This helps with log cleaning, which is handled by Low-Water Mark. Fault tolerance is provided by replicating the write-ahead log on multiple servers. The replication among the servers is managed using the Leader and Followers pattern and Quorum is used to update the High-Water Mark to decide which values are visible to clients.

数据分区

Consensus algorithms are useful when multiple cluster nodes all store the same data. Often, data size is too big to store and process on a single node. So data is partitioned across a set of nodes using various partitioning schemes such as Fixed Partitions or Key-Range Partitions. To achieve fault tolerance, each partition is also replicated across a few cluster nodes using Replicated Log.

常见的模式

分布式系统中的问题有非常多,上述加重的关键词都是解决特定问题的一些常见模式。下面是一个列表:

  • Consistent Core
  • Follower Reads
  • Generation Clock
  • Gossip Dissemination
  • HeartBeat
  • High-Water Mark
  • Hybrid Clock
  • Idempotent Receiver
  • Lamport Clock
  • Leader and Followers
  • Lease
  • Low-Water Mark
  • Paxos
  • Quorum
  • Replicated Log
  • Request Pipeline
  • Segmented Log
  • Single Socket Channel
  • Singular Update Queue
  • State Watch
  • Two Phase Commit
  • Version Vector
  • Versioned Value
  • Write-Ahead Log

分布式一致性

如何理解一致性:

狭义就是相等:两个副本,数据一致,就是数据是同一状态

广义是系统宏观处于一致性状态:这里我认为可以涵盖数据库的ACID中的一致概念,A向B转账,系统金钱的总和是不变的,这个是系统宏观的一致性。即所有分布式节点达到一个期望的整体状态。

系统的宏观一致性如何实现呢?就是通过分布式事务,保证多个操作在多个节点上都成功。所以我们在聊分布式事务和分布式一致性的时候,往往是混在一起的。

狭义的一致性是如何实现呢?其实总的就那么几种机制:

  • 借助一致性算法
  • 借助共享存储
  • 借助第三方服务,例如ZK

分类

  • 弱一致性
  • 强一致性
  • 最终一致性

MasterSlave(leader-followers)方案

Select one server amongst the cluster as leader. The leader is responsible for taking decisions on behalf of the entire cluster and propagating the decisions to all the other servers.

Every server at startup looks for an existing leader. If no leader is found, it triggers a leader election. The servers accept requests only after a leader is selected successfully. Only the leader handles the client requests. If a request is sent to a follower server, the follower can forward it to the leader server.

  1. 读写请求都由Master负责。
  2. 写请求写到Master上后,由Master同步到Slave上。(同步过程可以是master push,也可以是slave pull)

这种方案既可以做成强一直,也可以做成最终一致性。具体看是如何设计。

选主

对于小集群:

For smaller clusters of three to five nodes, like in the systems which implement consensus, leader election can be implemented within the data cluster itself without dependending on any external system. Leader election happens at server startup. Every server starts a leader election at startup and tries to elect a leader. The system does not accept any client requests unless a leader is elected.

  • Zab
  • Faft

大集群:

These large clusters typically have a server which is marked as a master or a controller node, which makes all the decisions on behalf of the entire cluster.

大集群可以直接使用外部存储ZK,etcd来帮助选主;

For electing the leader, each server uses the compareAndSwap instruction to try and create a key in the external store, and whichever server succeeds first, is elected as a leader. The elected leader repeatedly updates the key before the time to live value. Every server can set a watch on this key.

For zk:it can be implemented by trying to create a node, and expecting an exception if the node already exists.

Sample Code:
class ServerImpl…

  public void startup() {
      zookeeperClient.subscribeLeaderChangeListener(this);
      elect();
  }

  public void elect() {
      var leaderId = serverId;
      try {
          zookeeperClient.tryCreatingLeaderPath(leaderId);
          this.currentLeader = serverId;
          onBecomingLeader();
      } catch (ZkNodeExistsException e) {
          //back off
          this.currentLeader = zookeeperClient.getLeaderId();
      }
  }

Two/Three Phase Commit

引入一个协调者来管理所有的节点,负责各个本地资源的提交和回滚,并确保这些节点正确提交操作结果,若提交失败则放弃事务。

第一阶段:

协调者会问所有的参与者结点,是否可以执行提交操作。

各个参与者开始事务执行的准备工作:如:为资源上锁,预留资源,写undo/redo log……

参与者响应协调者,如果事务的准备工作成功,则回应“可以提交”,否则回应“拒绝提交”。

第二阶段:

如果所有的参与者都回应“可以提交”,那么,协调者向所有的参与者发送“正式提交”的命令。参与者完成正式提交,并释放所有资源,然后回应“完成”,协调者收集各结点的“完成”回应后结束这个Global Transaction。

如果有一个参与者回应“拒绝提交”,那么,协调者向所有的参与者发送“回滚操作”,并释放所有资源,然后回应“回滚完成”,协调者收集各结点的“回滚”回应后,取消这个Global Transaction。

问题:第二阶段执行过程中数据总会存在不一致性的时刻,那么还是强一致性吗?

是,强一致性指新的数据一旦写入,在任意副本任意时刻都能读到新值。写入过程不会有严格的百分百一致性,无法保证所有节点同一时刻成功修改某一个值。

我的理解:应该尽量把繁重,容易失败的工作放在第一阶段,第二阶段尽可能的保证不会失败且执行效率高;如果第二阶段执行出现问题,往往可能需要不断重试,抛弃整个副本,或者人工介入了。

Paxos

所有一致性算法都是基于Paxos的变形。

内容:任何一个点都可以提出要修改某个数据的提案,是否通过这个提案取决于这个集群中是否有超过半数的结点同意(所以Paxos算法需要集群中的结点是单数)

这个算法有两个阶段(假设这个有三个结点:A,B,C),

paxos中给节点赋予三个角色proposer,acceptor,learner;节点可以扮演某一个,几个,甚至全部角色;

第一阶段:Prepare阶段

A把申请修改的请求Prepare Request发给所有的结点A,B,C。注意,Paxos算法会有一个Sequence Number(你可以认为是一个提案号,这个数不断递增,而且是唯一的,也就是说A和B不可能有相同的提案号),这个提案号会和修改请求一同发出,任何结点在“Prepare阶段”时都会拒绝其值小于当前提案号的请求。所以,结点A在向所有结点申请 修改请求的时候,需要带一个提案号,越新的提案,这个提案号就越是是最大的。

如果接收结点收到的提案号n大于其它结点发过来的提案号,这个结点会一个promise(本结点上最新的被批准提案号),并保证不接收其它<n的提案。这样一来,结点上在Prepare阶段里总是会对最新的提案做承诺。

优化:在上述 prepare 过程中,如果任何一个结点发现存在一个更高编号的提案,则需要通知 提案人,提醒其中断这次提案。

注意:递增且不同的proposal id的生成是一个非常有意思的话题,有很多种方式可以实现,譬如,三个节点,他们的id分别按照这种方式递增:1,4,7.. / 2,5,8../3,6,9..,就能够满足简单的要求。

第二阶段:Accept阶段

如果提案者A收到了超过半数的结点返回的Yes,然后他就会向所有的结点发布Accept Request(同样,需要带上提案号n),如果没有超过半数的话,那就返回失败。

当结点们收到了Accept Request后,如果对于接收的结点来说,n是最大的了,那么,它就会修改这个值,如果发现自己有一个更大的提案号,那么,结点就会拒绝修改。

Zab

example:Zookeeper。ZAB协议的核⼼是 定义了对于那些会改变Zookeeper服务器数据状态的事务请求的处理⽅式


所有事务必须由一个 全局唯一的服务器来协调处理 ,这样的服务器被称为Leader服务器,余下的服务器则称为Follower服务器

  1. Leader服务器负责将一个客户端事务请求转化为一个事务Proposal(提案),并将该Proposal分发给集群中所有的Follower服务器
  2. Leader服务器等待所有Follower服务器的反馈,一旦超过半数的Follower服务器进行了正确的反馈后,Leader就会向所有的Follower服务器发送Commit消息,要求将前一个Proposal进行提交。

Raft

Bully

ES7.0之前实际上使用的是bully算法进行选主(zen discovery),之后使用基于Raft的选主策略。

PacificA

有的文章写ES使用了PacificA,但是实际上是借鉴了PacificA中的很多概念,并在此基础上ES做了适合自己的修改,譬如:

  • Master维护ClusterState,其中包括了副本,节点,索引等等分布式信息
  • SN,checkpoint类似与PacificA中的Serial Number和Commited Point;

选主算法

选主算法其实就是基于上述一致性算法;达成一致性的目标是确定-谁是master?

发表在 Uncategorized | 297条评论

到底什么是云原生

(转载请注明作者和出处‘https://fourthringroad.com/’,请勿用于任何商业用途)

去年开始做云服务之后,有一个经常词经常被提及:cloud native,云原生。

在CNCF的官网上对云原生的定义:

Cloud native technologies empower organizations to build and run scalable applications in modern, dynamic environments such as public, private, and hybrid clouds. Containers, service meshes, microservices, immutable infrastructure, and declarative APIs exemplify this approach.
 
These techniques enable loosely coupled systems that are resilient, manageable, and observable. Combined with robust automation, they allow engineers to make high-impact changes frequently and predictably with minimal toil.

翻译过来就是:在多种云环境中,构建高可用,弹性伸缩,方便管理和监控的应用。

也有很多文章将云原生解释成相关技术的集合:容器化,CD,DevOps,微服务等等;CNCF对成熟和孵化中的相关技术有一个详尽的列表:https://landscape.cncf.io/;甚至还编撰了一个路书来指导相关人员来实现云原生(trail map)。

对于当时的我来说,这两个解释都不好理解,前者比较空泛:在云服务上开发的应用就是云原生;后者则给人的感觉是一系列技术的堆砌,什么都是云原生docker是云原生,k8s是云原生,微服务是云原生,敏捷开发是云原生。

一年之后又重读了Cornelia Davis的《云原生模式》之后,回过头来看这个问题,有了一些自己的理解。

首先要明确

我们现在开发工作是基于‘云’展开的

所谓的‘云’本质是通过虚拟化技术将计算,存储,网络资源整合管理,再按需划分给用户。从最基础的IaaS开始,后续又发展出了PaaS,SaaS,FaaS,IDaaS等等。云也衍生出多种形态:私有云,混合云,公有云。

现在的开发工作很少会直接从裸机上开始,很多公司都是基于AWS,GCP,Azure,阿里云。。。等云平台。即使不是从上述的‘云’开始,也是基于一些公司内部的‘基础软件平台’基础设施平台’中间件平台’技术中台’数据中心’。。。等等。这些平台的底层搭建思路和云平台其实是类似的,可能某个中间件平台就是搭建在一个大k8s集群上,不同的租户可以通过控制层,按需申请MQ,ES,Redis等资源。

所以既定事实就是‘云’是我们现在进行开发工作的实际上下文

一个新的平台,它与之前开发环境的区别显然会影响到架设在其上的软件的架构。

需要‘面向变化设计’的思想

云环境中的软件无时无刻不处在变化之中,这个变化有三重含义:

  1. 基础设施的变化:例如:硬件故障,硬件替换等;
  2. 代码功能的变化:应用的代码无时不刻在快速迭代;
  3. 请求量/数据量的变化:例如大促的巨量波动;

应用在设计的各个阶段都必须考虑到这三个方面。这就是面向变化的设计思想;即牢记,一切都是不断变化的。我们要处理的问题是,如何在变化之中开发出稳定运行的应用

于是逐渐逐渐的有一些技术就发展出来了,例如微服务,零停机升级,DevOps,持续交付,限流熔断,自动扩缩容等等。

云原生

可以引出我对云原生的理解了:

所谓的云原生就是在这样的一个变化的云环境下如何去从全生命周期角度设计一个应用

强调全生命周期是不仅仅涵盖开发,还要涵盖测试,部署,运维等环节,而不应该将它们割裂开来;CNCF的路书里面这样介绍实现步骤和一些具体低层实现技术:

  1. 容器化:docker
  2. CI/CD(Continuous Integration / Delivery):Argo
  3. 编排:k8s+helm
  4. 监控&分析:Prometheus for monitoring, Fluentd for logging, Jaeger for tracing
  5. 服务代理(proxy),发现(discovery),治理(mesh):CoreDNS, Envoy, Linkerd
  6. 网络和安全:Calico, Flannel, OPA, Falco
  7. 分布式数据库和存储:Vitess(可分片的mysql),etcd, TiKV
  8. 流处理&消息处理
  9. 容器镜像库和运行环境:Harbor(私有镜像仓库)
  10. 软件发布

在罗列具体技术的时候必须要清楚,云原生更倾向是顶层设计需要跟底层实现技术区分开。

需要指出底层的技术抛开云原生,也是同样有自己的价值。譬如微服务架构。早在云原生概念诞生之前就已经得到了应用。

云原生也并不影响之前的软件质量衡量标准:弹性,可靠性,可维护性,可用性,可扩展性,性能,经济性,健壮性,敏捷性。。。等等。用新的架构去改善这些点,也是我们的最终目的。

云原生平台

我们希望可以将100%的精力放在为客户创造价值的事情上,所以上述某些方面,当然需要使用平台来支持。

宏观理解云原生平台可以是AWS这样的完善的平台,也可以是基于openstack, k8s等技术的其他小规模的平台,但是初衷是一样的。实现CD流水线,容器化,编排管理部署,提供高可用的支撑(例如跨az,autoscaling),提供功能支撑(配置管理,服务发现,链路追踪,监控报警。。。)等等。

技术团队当然也可以从应用程序团队剥离出来,成为独立的平台团队。

上云!=云原生

首先上云并不意味着性能的提升,从专用资源,乃至企业级的设备转到共享的虚拟化资源,单点性能往往是下降的。

但是云上资源往往会从云原生的角度设计(譬如高可用)。所以不采用云原生的设计思想改造应用,只是单纯上云,也是可能会有益处的。

总之,否需要采用云原生完全取决于是否它是否给自身带来价值。譬如如果一个银行的单体服务不存在上述‘变化’的背景,且能稳定的运行在IOE设备上,那么强行用云原生思想改造就未免多此一举。

发表在 云原生 | 713条评论

从SPI到ES插件开发,再到插件开发框架

(转载请注明作者和出处‘https://fourthringroad.com/’,请勿用于任何商业用途)

设计模式的一个基本原则是依赖倒置(Dependence Inversion Principle),说白了就是面向接口编程:模块与模块之间通过抽象层松耦合,这样可以增加系统的灵活性和可扩展性。

为了方便大型项目的管理和协作,我们往往自顶向下将项目划分成不同的模块(模块化),模块之间的连接就是利用上述的抽象层。

假如一个系统的功能不能够满足用户的需求,而用户希望自己进行定制化,最简单的方案是用户直接对源码进行修改,重新编译打包。这种方式很直接,但是违背了设计模式的开闭原则-即对拓展开放,对修改关闭。

另一个选择显然更好:用户自己去实现抽象接口,让系统通过抽象层直接对接自己的模块实现。

这就引出了第一个话题:SPI

SPI:Service Provider Interface

SPI的定义非常简单,一个提供给第三方实现的接口即是SPI。

StackOverflow上有一个API vs SPI区别的高分回答:

The API is the description of classes/interfaces/methods/… that you call and use to achieve a goal, and the SPI is the description of classes/interfaces/methods/… that you extend and implement to achieve a goal.

SPI的其实就是面向接口编程的延伸,但是实现层面则牵扯很多问题,其中核心的两个问题是

  • 用户的实现以什么方式提供接口实现
  • 应用如何定位用户提供的接口实现

JDK提供了一个SPI解决方案,基于ServiceLoader。这个解决方案对上面问题的解答是:

  • 用户的实现以classpath下的jar包提供
  • classpath的META-INF/services/路径下存储着接口与实现的映射

用一个简单的sample就可以理解

public interface Animal {
    String say();
}

public class Cat implements Animal{
    @Override
    public String say() {
        return "meow";
    }
}

public class Dog implements Animal{
    @Override
    public String say() {
        return "woof";
    }
}

public class App {
    public static void main(String[] args) throws ClassNotFoundException {
        System.out.println("start");
        final ServiceLoader<Animal> animals = ServiceLoader.load(Animal.class);
        for (Animal animal : animals) {
            System.out.println(animal.say());
        }
    }
}

//META-INF.services下的文件com.fourthringroad.spi.Animal
com.fourthringroad.spi.Cat

//则程序运行结果为打印Cat中定义的say方法实现“meow”

其中核心代码是ServiceLoader.load根据接口与实现的映射信息,去用户提供的jar包中寻找接口的实现类。

这个解决方案在现实中也有很多应用,譬如JDBC在加载底层driver实现时,就使用了这个办法:

//DriverManager在初始化时会调用
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);

//mysql:mysql-connector-java:version包的META-INF.services下有文件java.sql.Driver
com.mysql.cj.jdbc.Driver

通过这种方式只要引入mysql对driver实现的包,则程序自动就可以加载到相关driver实现。

除此之外springboot中也有类似机制,不同的是接口与类的映射关系存储在META-INF/spring.factories文件中。springboot应用启动过程中会遍历所有jar包的spring.factories文件,找到接口与实现的映射关系,应用在IOC注入时,就会为接口注入相应的实现。

利用springboot的这个特性也可以非常方便的引入一个接口的具体实现,常用的方式为:

  1. 引入一个新包,这个包定义了主app中接口的一个实现
  2. 在这个包的META-INF/spring.factories文件中配置com.xxx.interface=com.xxx.ClassName

或者

  1. 引入一个新包,这个包定义了主app中接口的一个实现
  2. 在这个包中添加一个@configuration配置文件com.xxx.XxxAutoConfiguration,里面指定接口实现
  3. 在这个包的META-INF/spring.factories文件中配置org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.xxx.XxxAutoConfiguration

原理是入口函数类上的注解@SpringBootApplication包括了@EnableAutoConfiguration注解,后者将XxxAutoConfiguration配置加载到IoC容器中。也可以在XxxAutoConfiguration上通过各种@Conditional注解来限制引入这个实现的条件。

后者的好处是配置以代码形式实现(放到configuration类中)。

插件模式

从SPI就可以引出一个常用的软件架构设计模式:插件模式。在设计系统的时候常常提到‘面向插件开发’,‘模块的可插拔’其实就是指这个模式。

我们日常使用的很多软件其实都使用了插件模式进行架构设计,例如eclipse,intellij idea,chrome,wordpress等。这种模式可以给系统开发引入很多灵活性:系统跟插件解耦,插件的开发可以独立进行;系统的功能可以通过插件不断增强,并且遵循开闭原则;系统可以方便实现定制化等等。尤其是开源软件,插件化的设计可以最大程度的发掘社区的力量。

当然插件模式的设计也会为系统引入额外的复杂性:在架构设计上为了支持插件模式,必须要做很多额外的工作。一个功能明确,变化小的应用是没有必要进行这种设计的。如何设计满足一个应用各个方面拓展性的插件框架而又避免没有意义的over-engineering是需要精心设计的。

我认为,一个好的插件模式,应该能够解决系统真实的拓展性需求;并且架构也是随着需求和应用场景发掘的深入,而不断迭代进化的;不存在一开始就完善的,一成不变的,能满足所有场景的插件框架设计。

拿ES的插件设计为例。ES的插件有两层的含义,首先狭义是官方定义为‘对ES核心功能的增强’,从这个角度讲就是位于plugins目录下的组件:

➜  elasticsearch-7.10.2 tree -L 1
.
├── LICENSE.txt
├── NOTICE.txt
├── README.asciidoc
├── bin
├── config
├── jdk.app
├── lib
├── logs
├── modules
└── plugins

通过命令可以把插件安装(解压)到plugins目录下,例如:

sudo bin/elasticsearch-plugin install analysis-icu

安装后目录下构成就是jar文件和属性文件。这些插件可能是官方开发的,也可以是用户开发的;可以根据需求进行安装和卸载。

进一步,如果看modules目录下,里面包括了一些核心功能模块以及付费用户使用的核心功能,也是用plugin模式进行设计的。只是它们不允许通过指令动态删除安装。

故广义上讲,ES功能模块都是以相同的插件模式进行设计并整合到系统当中的。

ES为什么要使用插件模式?

因为在进行功能抽象时,发现ES的某些功能实现是无法穷尽的,或者某方面有多个实现方案各有各的优势。

譬如在分词时,有非常多的语言,中文,英文,日文等等,同一个语言又有不同的分词方式,如下所示。这种情况下,最灵活的选择就是引入插件模式,通过不同的插件来区分不同的分词规则,用户可以按需‘插拔’使用。

从代码角度讲,ES插件抽象的接口为:

/**
 * An extension point allowing to plug in custom functionality. This class has a number of extension points that are available to all
 * plugins, in addition you can implement any of the following interfaces to further customize Elasticsearch:
 * <ul>
 * <li>{@link ActionPlugin}
 * <li>{@link AnalysisPlugin}
 * <li>{@link ClusterPlugin}
 * <li>{@link DiscoveryPlugin}
 * <li>{@link IngestPlugin}
 * <li>{@link MapperPlugin}
 * <li>{@link NetworkPlugin}
 * <li>{@link RepositoryPlugin}
 * <li>{@link ScriptPlugin}
 * <li>{@link SearchPlugin}
 * <li>{@link ReloadablePlugin}
 * </ul>
 */
public abstract class Plugin implements Closeable {...}

注解里面已经列出了它的多个拓展点,要实现不同的功能,那么还要实现具体的拓展点。例如想用别的底层通信框架取代es正在使用的netty4,那么就要自己实现NetworkPlugin对原插件功能进行取代。

ES在启动过程中,会通过反射加载这些插件,并且依次执行钩子函数,将插件的功能集成到系统之中.

关于es的插件开发我之前整理过一个模版:git link

可以看出插件开发是一个相对独立的过程;因为不用了解ES的所有源码,开发复杂度也降低了。同时也能够进行独立的单元,集成,端到端测试。

ES这是一个从头到尾独立实现插件框架的例子。那么是否有现成的插件框架供我们进行使用呢?

插件化开发的框架

前面的Java SPI 和springboot spring.factories其实都可以作为简单的插件开发方案,除此之外确实还有一些框架方便应用的插件化:

JPF-Java Plugin Framework & JSPF-Java Simple Plugin Framework

JPF/JSPF都是相对老的项目,目前不确定是否还在维护,下面是一个JSPF的demo。

PluginManager 类会检查给定路径下的所有jar中继承Plugin接口的类;有点类似DI,在程序运行时能够动态加载不同的实现类,从而实现可拔插。

Public class App {
    public static void main(String[] args){
        PluginManager pm = PluginManagerFactory.createPluginManager();
        pm.addPluginsFrom(new File(“bin/”).toURI().toURL());
        CoolPlugin plugin = pm.getPlugin(CoolPlugin.class);
        System.out.println(plugin.sayHello());
    }
}

@PluginImplementation
public class CoolPluginImpl implements CoolPlugin {
    public String sayHello() {
        return “hello world”;
    }
}

public interface CoolPlugin extends Plugin {
    Public String sayHello();
}

Spring-plugin

spring的一个子项目,帮助用户用最小的代价实现可扩展的架构。用户只需要提供一个classpath下有效的jar包即可。spring的某些组件本身也会集成spring-pluign,提高自身的拓展性。

下面是一个demo,借助了高版本spring-plugin中引入的注解

@SpringBootApplication
@EnablePluginRegistries(WriterPlugin.class)
public class SpringPluginApplication {

	public static void main(String[] args) {
		ConfigurableApplicationContext context = SpringApplication.run(SpringPluginApplication.class, args);
		PluginRegistry<WriterPlugin, String> pluginRegistry =context.getBean(PluginRegistry.class);
		pluginRegistry.getPluginsFor("csv").get(0).write("csv");
	}
}

@Component
class CsvWriter implements WriterPlugin {

	@Override
	public void write(String message) {
		System.out.println("CsvWriter is executing: " + message);
	}

	@Override
	public boolean supports(String s) {
		return s.equalsIgnoreCase("csv");
	}
}

interface WriterPlugin extends Plugin<String> {
	void write(String message);
}

可以看到一个这其实是一个策略模式,即可以在runtime去选择执行逻辑而不是设计阶段。而想把一个新的plugin整合到系统中来,只需要保证context能够加载到这个plugin的实现类即可。更多用法参考官方文档:https://github.com/spring-projects/spring-plugin

PF4J:plugin framework for java

一个我在github上看到的比较新的项目,目前有1.7k stars。

同样,可以把plugin打包成jar包的形式,并且将plugin的metadata存储到jar包内的MANIFEST.MF文件中,这样插件管理类PluginManager就可以在运行时加载插件的信息了。

更多细节参考官方文档:https://github.com/pf4j/pf4j

OSGI:Open Services Gateway Initiative

一个功能强大但是复杂的框架,支持动态化模块管理,即所模块支持运行时动态的插拔和修改,Eclipse插件框架采用的就是OSGI的方案。我在看不少上述方案的文档时,都会拿自己与OSGI对比,结论一般也是:没有OSGI那么强大,但是同样没有那么复杂。

有意思的是,我在看OSGI的相关文档时,看到这样一种说法,OSGI是SOA架构之前的解决方案,那时候需要维护复杂庞大的单体架构软件,OSGI可以更好的进行模块化管理,所以得到流行。

现在应用的插件化是否需要引入OSGI,OSGI带来的有优点是否大于带来的缺点(架构复杂度),是在onboard之前需要考虑的重要因素。

发表在 ElasticSearch, spring相关 | 211条评论

手记-葡萄酒课程

update:4.11.2022

趁着最近时间,昨儿跑去参加了个意大利葡萄酒课程,喝了不少DOCG和超托什么的,那些个黑浆果,坚果,花香。。。屁都没喝出来🙃️。我舌头是废了。

整理了一下笔记,下周的课程也不知道还有没有动力参加了

意大利葡萄酒

世界葡萄酒

发表在 Uncategorized | 161条评论

聊聊构建工具-Ant,Maven,Gradle(上)

(转载请注明作者和出处‘https://fourthringroad.com/’,请勿用于任何商业用途)

一直想写篇文章整理一下几个构建工具,先从Ant 和Maven入手吧。

Make和Ant

我用过最简单的构建工具是make。它的功能就是控制编译的过程,底层本质上是依赖不同的编译器,shell命令,操作系统CLI等等,make就像是一个编排器。它的语法如下:

<target> : <prerequisites> 
[tab]  <commands>

譬如之前一个golang的项目,涉及多个模块的构建。截取部分makefile配置:

manager: bootstrap_node bootstrap_kibana restart_node op_failover_node replace_text_file execute_shell
	gb build cmd/manager

bootstrap_node:
	gb build manager/exe/cmd/bootstrap_node

bootstrap_kibana:
	gb build manager/exe/cmd/bootstrap_kibana

含义是:

如果要使用make manager构建manager模块,那么底层执行的是一个gb vendor编译命令:

gb build cmd/manager

但是在这之前,manager模块还依赖其他模块,包括bootstrap_node bootstrap_kibana restart_node op_failover_node replace_text_file execute_shell的构建,所以make bootstrap_node会在make manager之前执行,它的底层是执行另一个gb vendor编译命令:

gb build manager/exe/cmd/bootstrap_node

当然也可以在<commands>中用shell命令控制编译后的文件目录,文件名称,位置,权限等等。

make很灵活,没有语言限制,但是基本上什么都需要用户自己做。

Ant 是一个基于java的构建工具, 定义使用XML文件(build.xml,类似makefile),里面封装了一些代表某些基本功能的XML标签;Ant同样非常灵活;用户可以定义定义大量的targets以及他们的依赖关系,当构建触发一个上层的target执行时,这个target的底层依赖会提前执行。

复制一个网上的sample:

<project>
    <target name="clean">
        <delete dir="classes" />
    </target>

    <target name="compile" depends="clean">
        <mkdir dir="classes" />
        <javac srcdir="src" destdir="classes" />
    </target>

    <target name="jar" depends="compile">
        <mkdir dir="jar" />
        <jar destfile="jar/HelloWorld.jar" basedir="classes">
            <manifest>
                <attribute name="Main-Class" 
                  value="antExample.HelloWorld" />
            </manifest>
        </jar>
    </target>

    <target name="run" depends="jar">
        <java jar="jar/HelloWorld.jar" fork="true" />
    </target>
</project>

我还在Amazon工作时,内部的构建工具brazil就是基于ant。Ant的缺点是缺失依赖管理功能,brazil对此功能进行了增强,利用dependency标签进行依赖的管理。

灵活对应的缺点就是复杂度。在makefile或者build.xml中,用户需要定义的东西太多,往往需要从target目录的创建开始,包括后续的清理,编译,连接,打包。。。等等,任务的依赖也可能会错综复杂。我看到过一个词来形容build.xml文件-write-only,来形容它糟糕的可读性。

Maven则用另一种思路解决这个问题。

Maven – Convention over configuration 约定优先

在京东云工作时,除了ES源码开发用的gradle,其他大部分java项目都是maven管理的,直观感受就是简单。

maven对构建过程进行了标准化定义,形成了一个约定,所有的用户需要在这个约定上进行使用和开发。本质上就像一个框架,用户使用上更简单了,用户只需要去specify unconventional aspects of the application。

standard conventions包括了:项目结构的约定,生命周期的约定,依赖管理的约定等等多个方面。引入了约定,灵活性肯定会下降,衡量一个框架是否灵活可以用它的可拓展性,maven的可拓展性依赖它的插件机制(maven的所有功能其实都是以插件形式提供的),如果需要进行功能拓展则需要进行插件开发,插件的开发代价就成为了影响灵活性的关键因素。很多人认为相比gradle,maven的‘不灵活’,也正是在于所有的定制化功能都需要进行相对复杂的插件开发(当然还有固定的生命周期等难以改动的conventions)

maven构建生命周期

Maven的构建的生命周期分为三种类型:clean,default,site,分别对应:清理,默认构建,生成项目介绍页面。

其中default显然是最重要的。default生命周期可以细分成多个按顺序执行的阶段:

其中比较重要的过程为

  1. Validate 验证
  2. Compile 编译
  3. Test 测试
  4. Package 打包
  5. Verify 运行集成测试
  6. Install 安装到本地仓库
  7. Deploy 安装到远程仓库

常用的执行命令为:

mvn clean package

含义:(如果是多模块项目,每个子项目都会被遍历执行)依次执行clean,validate,compile,test,package。

maven插件

maven这样形容自己:Maven is – at its heart – a plugin execution framework。可见插件的重要程度。

上述是build lifecycle,是maven对构建流程从顶层的划分。每个具体的阶段(phase)执行什么操作,就是由插件以及插件中的goals决定的。

goal就是一个任务(类似ant中的一个target),它可以被绑定在生命周期的某个阶段,也可以脱离阶段独自存在。譬如我们经常使用的一个命令:

mvn dependency:tree

就是直接调用dependency插件的一个goal。

一个阶段可以包括多个goals,如果没有任何goals,显然这个phase就不会被执行。

同一个phase中的goals执行顺序是根据它们在pom文件中的声明先后。(版本2.0.5及以上)

如何绑定插件的goals到生命周期?

对于default lifecycle,是没有为任何phase绑定默认goals的,有以下两种方式可以绑定:

最简单的方式利用<packaging>标签

以下是packaging标签为jar时绑定的goals:

关于3.8.5的详细插件版本:

当然packaging还有多种属性可以配置,war,pom等。如果没有使用packaging标签,默认意味着设定了packaging类型为jar。

packaging标签是打包组合的绑定方式当然也可以逐个指定plugin以及要绑定的goal

可以用这种方式去指定packaging中指定插件的version,事实上为了开发过程中出现的问题的可复性,我们应该始终在pluginManagement中指定所有plugin的版本号,这是一个很好的习惯,而不是使用一个隐式的默认值;版本管理使用的标签是PluginManagement

除此之外,plugin将它的goal绑定到生命周期中的哪一个阶段,相应的goal就会在对应阶段被执行。这种绑定关系可以是隐式的,也可以是显式的。

同一个阶段多个goals的执行顺序是:packaging绑定的goals先执行,剩下的按照pom的声明顺序。

Clean lifecycle 默认已经绑定了一个goals:

org.apache.maven.plugins:maven-clean-plugin:2.5:clean

Site lifecycle 默认绑定了两个goals:

org.apache.maven.plugins:maven-site-plugin:3.3:site

org.apache.maven.plugins:maven-site-plugin:3.3:deploy

基本原理和示例

插件可以分为两类:构建插件(build plugin)和报告插件(reporting plugin),多数情况下使用前者,下文也围绕构建插件展开。

插件一个goal最重要的‘参数配置’和‘执行逻辑’其实可以简单对应到一个自定义类中,官方称其为mojo(m for maven)。举个例子,下面是一个mojo的定义,和省略的执行逻辑:

/**
 * @goal query
 * @phase package
 */
//上面定义goal的名称为query,和插件默认绑定的生命周期phase为package
public class MyQueryMojo
    extends AbstractMojo
{
    @Parameter(property = "query.url", required = true)
    private String url;
 
    @Parameter(property = "timeout", required = false, defaultValue = "50")
    private int timeout;
 
    @Parameter(property = "options")
    private String[] options;
 
    public void execute()
        throws MojoExecutionException
    {
        ...
    }
}

一种常见的配置方式是利用<configuration>标签:

<build>
  <plugins>
    <plugin>
      <artifactId>maven-myquery-plugin</artifactId>
      <version>1.0</version>
      <configuration>
        <url>http://www.foobar.com/query</url>
        <timeout>10</timeout>
        <options>
          <option>one</option>
          <option>two</option>
          <option>three</option>
        </options>
      </configuration>
    </plugin>
  </plugins>
</build>

另一种方式是通过<execution>标签进行定义,这种定义方式可以进行多次绑定,即绑定到不同的phase,多次执行:

<build> 
  <plugins> 
    <plugin> 
      <artifactId>maven-myquery-plugin</artifactId>  
      <version>1.0</version>  
      <executions> 
        <execution> 
          <id>execution1</id>  
          <phase>test</phase>  
          <configuration> 
            <url>http://www.foo.com/query</url>  
            <timeout>10</timeout>  
            <options> 
              <option>one</option>  
              <option>two</option>  
              <option>three</option> 
            </options> 
          </configuration>  
          <goals> 
            <goal>query</goal> 
          </goals> 
        </execution>  
        <execution> 
          <id>execution2</id>  
          <configuration> 
            <url>http://www.bar.com/query</url>  
            <timeout>15</timeout>  
            <options> 
              <option>four</option>  
              <option>five</option>  
              <option>six</option> 
            </options> 
          </configuration>  
          <goals> 
            <goal>query</goal> 
          </goals> 
        </execution> 
      </executions> 
    </plugin> 
  </plugins> 
</build>

(注意:这里是在<plugins>标签声明,区别于<pluginManagement>标签)

第一个execution绑定的执行phase是test;第二个execution没有绑定phase,则会在plugin的默认phase(package)执行。

上面是从官网扒的一个抽象的例子,下面拿个真实的插件举例:maven-surefire-plugin

这个插件只有一个goal:

surefire:test

这个goal被绑定到了test阶段,是用来执行应用的所有单元测试-它定义会扫描所有以Test开始,或者Test,Tests,TestCase结尾的类。

它有个非常大的优点就是兼容Junit,TestNG等多个单元测试框架。譬如如果用户想使用junit,只需引入junit的依赖

<dependency> 
  <groupId>junit</groupId>  
  <artifactId>junit</artifactId>  
  <version>4.11</version>  
  <scope>test</scope> 
</dependency>

然后在src/test/java下添加测试类。

如果用户使用的是testNG,将上面的依赖替换为testNG的依赖

<dependency> 
  <groupId>org.testng</groupId>  
  <artifactId>testng</artifactId>  
  <version>6.9.8</version>  
  <scope>test</scope> 
</dependency>

如果想做一些testNG框架级别的配置,通过参数配置即可:

<plugin> 
  <artifactId>maven-surefire-plugin</artifactId>  
  <configuration> 
    <suiteXmlFiles> 
      <suiteXmlFile>testng-qa.xml</suiteXmlFile> 
    </suiteXmlFiles>  
    <parallel>methods</parallel>  
    <threadCount>10</threadCount>  
    <argLine>-Dfile.encoding=UTF-8</argLine>  
    <testFailureIgnore>true</testFailureIgnore> 
  </configuration> 
</plugin>

接着聊聊其他方面:

服务架构

几个重要组件:

  • 中央仓库:central repo,服务端,用来存储三方(3rd party)依赖和插件的文件
  • 本地仓库:local repo
  • 私有仓库:公司可以在自己的私有服务器上搭建仓库存储三方依赖和插件
  • 本地客户端:本地访问远端repo的client

目录结构

目录结构也是一个convention,maven做了下面的规定:

配置文件

全局配置文件settings.xml

<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd">
      <localRepository/>
      <interactiveMode/>
      <offline/>
      <pluginGroups/>
      <servers/>
      <mirrors/>
      <proxies/>
      <profiles/>
      <activeProfiles/>
</settings>

譬如我们常配置的中央仓库镜像:

<mirrors> 
  <mirror> 
    <id>alimaven</id>  
    <name>aliyun maven</name>  
    <url>http://maven.aliyun.com/nexus/content/groups/public/</url>  
    <mirrorOf>central</mirrorOf> 
  </mirror> 
</mirrors>

项目的配置文件:pom.xml

这个不详述了,需要知道优先级 pom.xml > settings.xml

依赖

两个比较重要的点:

依赖的scope:

scope用来定义一个依赖的传递性,即在某个阶段是否需要这个依赖的存在。一个依赖包可能在三个阶段被用到:编译,测试,运行时。相应的有以下几个scope:

  1. compile:默认scope;在所有的阶段都会存在
  2. provided:在编译和测试阶段会使用,但是在运行时不会使用;运行时由容器或者jdk提供依赖包。
  3. runtime:编译阶段不会使用,但是测试和运行时会使用。
  4. test:只在测试阶段被使用,例如JUnit和TestNG
  5. system:类似provided,需要提供一个存放jar的目录路径
  6. import:很少用,懒得写了。

使用原则:当然是最小化原则,如果只在测试阶段使用的依赖,应当加上test scope,可以避免无意义的包冲突,减少部署文件的大小。

依赖冲突解决:

人工通过<exclusions>标签进行排除某一个依赖包。通常是引入最新版本,因为java包设计时,通常是向后兼容(Backwards Compatibility)的。

当然,maven也有自动解决冲突的方式:短路径优先,声明优先(路径长度相同,谁先声明听谁的)

一种可控的方式是在父项目中通过dependencyManagement对依赖的版本进行锁定。

父子项目配置

maven中允许给两个项目之间赋予继承关系,即构成父子项目。子项目会继承父项目pom中的一些属性,可以避免重复的定义。

  • 父项目配置:
    • 打包方式:pom
    • 父项目中的基本依赖管理 
    • 父项目中的统一依赖管理<dependencyManagement>(只有在子项目中使用时才会引入)
  • 子项目配置
    • 父项目的基本依赖一定会引入
    • 父项目的统一依赖,可以不写版本号

类似还可以定义聚合项目:将一个庞大复杂的项目拆分成多个模块(通过new ->module的方式),最上级项目为聚合项目,里面的模块都是它的‘子项目’;这样能够方便更好协作/管理。聚合项目在父项目中会有modules标签。

对聚合项目执行打包,会对下属所有模块执行打包。

一些功能性标签

还有一些功通过一些maven定义的标签来实现,譬如Filtering Resources功能

<build> 
  <filters> 
    <filter>src/main/filters/filter.properties</filter> 
  </filters>  
  <resources> 
    <resource> 
      <directory>src/main/resources</directory>  
      <filtering>true</filtering> 
    </resource> 
  </resources> 
</build>

# application.properties
application.name=${project.name}
application.version=${project.version}
message=${my.filter.value}

# filter.properties
my.filter.value=hello!

利用上面的filter标签,可以对资源里的占位符进行值的填充。

 

小结maven,一个规范化的依赖管理/项目构建工具,对于熟悉其定义的conventions(like lifecycle)的用户来讲,项目是非常容易维护的。另一方面这些定义的conventions也限制了它的灵活性。maven的插件机制从一定程度上缓解了这个问题。市面上有大量的插件几乎可以满足所有日常构建需求,但是少数的场景还是需要用户定制,如果你恰巧擅长并喜欢maven插件开发,那么congrats,maven就非常适合你。而对于一些大型复杂的项目,构建过程中需要很多定制化的逻辑,这时候maven的不灵活成为了它很大的短板,于是就有人转向了gradle,后者可以直接用groovy,scala等脚本语言直接定义构建过程。这个就下篇文章再整理吧。

 

发表在 Uncategorized | 留下评论

从Eureka入手聊聊Spring Cloud

(转载请注明作者和出处‘https://fourthringroad.com/’,请勿用于任何商业用途)

之前整理过spring bean管理和web mvc什么的,一直也想写一篇整理整理spring cloud的知识。

在Amazon工作时,搭建微服务有不少技术方案可供选择,Amazon内部有团队对Spring Framework做了一层封装和适配,也成为微服务搭建的选项之一。不过内部对spring别的产品使用较少,我想主要原因是内部存在同类产品,且针对Amazon的技术架构做了一些定制化,更灵活,onboard成本和难度也都控制的不错,所以就只是按需从spring产品中选择了一些纳入进来(当然可能也有商用版权的问题)。18年我所在的团队做的一个CMS系统,使用这个整合Spring Framework的框架搭建了一个Rest风格的服务后端。

在来到京东云后,接手的中间件服务配置中心和调度服务使用了springboot,spring framework和spring cloud,同时在跟应用方打交道过程中,发现90%的客户也都是用的spring全家桶在搭建微服务。

关于Spring团队和产品

从04年发布spring1.0到现在,spring也有快20年的历史了。公司从最早的Interface 21(后改名SpringSource),到12年被VMware收购,再到后来成立Pivotal接着独立上市。相关团队一直致力于java领域的开源开发。现在spring在行业内的流程程度不用赘述,大部分web开发也不用再参照臃肿的J2EE规范。

spring的相关产品也从最初的基础的spring framework发展出spring boot, spring data, spring cloud, spring security…link(https://spring.io/projects)。

关于Spring Cloud

关于Spring Cloud,我理解其实就是将搭建分布式系统中遇到的问题抽象为一些固定模式(pattern),譬如分布式系统如何进行配置管理,服务发现,服务熔断,负载均衡等等。在此基础上给每个模式提供实现的boiler plate。

Boiler Plate类似Template,但是又有区别,template可以理解成参数化的框架,来引导工程师完成系统搭建;但是Boiler Plate在框架上还包括了实现;基本上Spring Cloud的模块我们拿来就可以直接部署,有一个词非常适合形容-开箱即用(out of box )。

题外话,在亚马逊工作的时候boiler plate,out of box都是常见词汇,从侧面也印证了,给用户提供服务时的易用性(容易onboard)是非常重要的考量标准。

spring cloud也跟各个大的云服务厂商有合作,针对不同的托管服务有定制化的功能,譬如针对AWS的定制化:https://spring.io/projects/spring-cloud-aws

先来看看下面这个demo

利用Eureka实现服务发现

云原生的微服务体系里面有一个重要的原则:铭记所有都是不断变化的。在这种背景下,应该避免所有硬编码的服务地址和端口,于是便有了服务发现的需求。

服务发现本身可抽象成一个数据存储服务,用来注册其他服务的信息,并提供查询功能,Eureka就是这样一个服务;从另一个方面讲,可以提供数据存储查询的服务都具备提供服务发现的能力;事实也是这样,ZK和Consul也可以提供类似服务,Spring Cloud也兼容这两种底层实现。

当然,在所有服务中,也都至少要保留‘服务发现服务’的硬编码地址,同时另一个缺点是这给请求增加了一个额外访问,也给系统增加了一个single point of failure;

首先是如何搭建Eureka这个拆箱即用的服务:

@EnableEurekaServer
@SpringBootApplication
public class EurekaTestServerApplication {
	public static void main(String[] args) {
		SpringApplication.run(EurekaTestServerApplication.class, args);
	}
}

配置:

server:
  port: 8761

eureka:
  client:
    registerWithEureka: false
    fetchRegistry: false
    serviceUrl: #Eureka集群配置,单机配置自己,默认8761
      defaultZone: http://localhost:8761/eureka/

上面就是所有需要的代码。

启动并注册两个服务

Service-1

@EnableDiscoveryClient
//@EnableEurekaClient
@SpringBootApplication
public class EurekaTestClientApplication {

	public static void main(String[] args) {
		SpringApplication.run(EurekaTestClientApplication.class, args);
	}

}

需要说明@EnableDiscoveryClient相比于@EnableEurekaClient可以兼容更多底层实现。

配置

server:
  port: 8090

spring:
  application:
    name: Service-1

eureka:
  client:
    registerWithEureka: true
    fetchRegistry: true
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/
  instance:
    instance‐id: ${spring.application.name}

在Eureka的管理界面上就可以看到这两个服务的信息了:

上面的过程可以很好的解释了,

为什么使用Spring Cloud

当工程师需要构建云原生应用时,会发现自己需要在整套分布式系统中实现以下功能:

  • 配置管理
  • 服务发现
  • CircuitBreaker
  • 路由和消息通信(Routing and messaging)
  • API网关
  • 分布式追踪

等等

当你在FAANG这样的公司工作的时候,你可能发现有足够的底层服务提供上述功能,而且你可以用很小的代价就将其集成进来。但是事实上,有更多的公司不具备这样的条件,而是需要工程师重复的构造这些轮子。于是spring cloud就将这些功能做了上层抽象,并提供了相应的,非常容易集成的,解决方案,

  • 配置管理:springconfig,可以与Git,SVN,FS或DB结合来管理配置
  • 服务发现: Eureka, Zookeeper, Consul
  • CircuitBreaker:Hystrix
  • 路由和消息通信(Routing and messaging):
    • Routing and LB:Ribbon(实现client side LB) & Feign
    • Messaging:: RabbitMQ or Kafka
  • API网关:Zuul/Spring Cloud Gateway
  • 分布式追踪:Spring Cloud Sleuth / Zipkin

Spring Cloud的组件在设计时就已经考虑了云原生应用的特性:跨故障域,动态,高可用,高拓展…所以用户可以将设计的重心放在实现业务的组件上面。

利用OpenFeign进行服务间通信

接着上面的demo,当我启动两个服务之后,服务2对服务1的调用流程就是:

  • 服务2用注册中心client向注册中心发起请求:查找服务1地址
  • 服务2用远程调用client向服务1发起调用请求。

假设服务1对外暴露的接口为

@RestController
public class ServiceController {
    @GetMapping("/ping")
    public String ping() {
        return "service started.";
    }
}

引入Spring Cloud的服务间通信的组件OpenFeign后的实现代码为:

//定义访问Service-1的client与Rest接口的映射
@FeignClient("Service-1")
public interface Service1Client {
    @GetMapping("/ping")
    public String ping();
}

//注入
@Autowired
private Service1Client service1Client;

//使用
Object result = service1Client.ping();

代码非常简洁,启动和初始化过程几乎都被隐藏,用户只需要定义方法映射以及执行调用。同时Feign Client的底层还利用了ribbon,所以具备客户端负载均衡的能力。

这是使用Spring Cloud生态的另一个好处:它不仅仅功能完善,不同组件之间也能够很好的配合衔接。用户能够以非常小的代价集成一个新的功能模块。

发表在 spring相关 | 2条评论

聊聊流程标准化

(转载请注明作者和出处‘https://fourthringroad.com/’,请勿用于任何商业用途)

刚才在整理资料的时候翻出来了去年年中的时候为中间件团队做流程标准化的工作的一些手记,里面零零散散的记载了很多日常工作中觉得可以优化的点:

  • ES大量手工运维,很多可以自动化的任务都被忽视/backlog后搁置
  • 与用户沟通非常低效,没有固定office hour,没有预定会议,全是客户通过聊天软件进行联系,我们对客户遇到的问题没有提前的了解,客户自己对自己的问题也没有充足的自我分析。这不是一个云服务团队应该有的运作模式。
  • 基本没有CI/CD,或者只是一个空架子,每周发现的bug即使是很小,也需要额外排期进行处理。

等等等等。

我的工作方式深受在Amazon的经历影响。来到京东云后,当时的初衷是希望能够通过规范,标准化的流程来解决一些问题,提高工作效率。现在回想起来,这个事情其实也是对快工作五年的自己的一个阶段性总结,它在是在回答自己的一个问题:

一个工程师应该用什么方式去开展工作?

上图是当时画的一个脑图,包含了我认为产品开发流程最基本也是最重要的点,对它们进行规范化是收益最大的。团队有一些现存的规范在保障日常开发,当时做这个事情有一个基本原则:不为了规范化而规范化。

这意味着能重复利用的现有规范就重复利用,不强迫进行没有收益的规范化,制定规范的同时,让遵守者能够理解规范化的意义等等。

下面简单就其中一些环节聊聊我的感受。

产品设计阶段

产品设计阶段最重要的是如何发掘真正有价值的需求,构思出能满足需求的产品初级形态。这是一个需要产品经理,市场经理,BI,研发等多个角色介入的阶段。这个阶段一个重要的产出就是一份经过谨慎评审的PRD。里面应该包括关于痛点,用户群体,竞品等多个方面的分析,需求详细说明,原型设计等等细节。显然这是一个值得规范化的过程。可以通过PRD模版和内设一系列的检查节点(可以是提问形式)来规范化设计人员的设计过程。

研发阶段

研发阶段可以细分为设计和实现。

设计阶段的重要性往往会被忽视,我们可以看到大量的项目在没有经过谨慎设计的情况下就开始实施,最终导致后期进展困难。将困难前置,即重视设计过程,其实是保障了后期研发速度的。设计阶段可以通过对设计文档规范化来保证其质量,例如,我所在的团队现有“架构设计实践规范”里面指导工程师如何完成一次好的设计并进行必须的评审

代码规范是开发实现阶段需要考虑的关键点。包括

  • 代码风格:如何用统一的,兼顾性能,可读性,可拓展性,可维护性的方式进行coding,类似例如阿里巴巴的Java开发手册
  • 代码质量:如何保证代码的质量,手段有例如构建过程中的代码检查工具,IDE的检查插件,第三方代码审查工具例如SonarQube等等,对代码语法,测试覆盖率,耦合关系,命名方式等进行约束。
  • 代码提交规范:代码review规范等等。
  • 代码仓库管理规范:如何管理代码分支,tag,版本等等。

这里面每一个点都值得展开。例如,组里的代码评审的Merge Request往往包含几千行,一个请求包括多个Commit点,commit信息模糊,测试信息缺失等问题,Reviewer也没有明确的责任说明。两者都凭工程师的自身素质和自觉性进行自我监督。使得代码评审形同虚设,很难对代码质量进行保障。这就可以通过规范代码Review流程来改善。

测试阶段

由于微服务,DevOps等领域的发展的发展,现在很多团队规模都不大,在Amazon的时候我们采用的机制叫做two pizza team(googleable),可能不会都配备专门的测试,运维人员。公司内部的测试和运维人员职责也在向自动化转变。譬如谷歌诞生的岗位SRE。

我所在的云团队里还没有这方面的转型,测试环节还是会由Dev人员交接给QA人员。这中间其实存在很大的浪费,代码改动中的一些重要的测试点也可能因为Dev和QA的信息不对称而被遗漏。所以需要有严格的测试流程来保障。

在设计这块儿时其实是希望资深的QA人员介入的,我大致上的思路是:

  • 确定测试涵盖范围和测试点:单元,继承,回归,端到端,性能,破坏性,AB测试等等
  • 标准的测试报告模版 & 后续评审流程。

针对具体的一个改动,设计测试流程的环节需要Dev和QA共同参加,并且应该involve更多相关人员进行评审,保证设计的质量。执行完成测试后,也需要进行严格评审。最终的测试报告是在上线前权衡风险的重要参考。

上线阶段

上线是风险最大的阶段,需要有严格的上线流程和审批机制。在不具备完整CI/CD前提下,生产环境的上线需要人工参与评审。内容应该包括所有让评审人理解上线改动和测试细节的信息。可以通过模版+强制审批流来规范化这个步骤。

上线完成应该有相应的声明告知团队和相关人员。

运维阶段

我所在的团队花在运维上的精力一度大得惊人,这是一件让人头疼的事情,造成的原因是多方面的,但是这个不是我在这里要谈论的重点。

如何规范化运维流程?

运维SOP是每个服务必须具备的,每一个工程师值班时遇到问题第一手资料。所有的新服务发布,新功能上线,都应该保证SOP有相应的更新,并定期进行评审。

应急预案往往是用来应对某些重要节点可能出现的更严重的状况,比如618大促出现机房故障,流量激增压垮LB等等。应急预案应该在重要节点前进行评审。

值班规范包括如何轮换,以及值班人员的标准化操作。如何进行告警升级,如何求助,如何记录问题。每周值班结束,应该有评审会议回顾这一周值班中重要的事件。值班不是目的,通过值班这个过程来持续优化服务,这个才是。

故障复盘:当发生一定影响范围的故障时,尤其是造成损失时,需要我们进行复盘。同样可以通过一定的文档模版和评审会议来规范化,反思在之前的环节中出现了什么问题,并进行优化。

这里的运维,主要是指是人工的运维(不是DevOps里面的自动化)。在运维的过程中,应该始终记住,我们运维的目的是不再运维。如果在一月份我们需要10人天进行运维,十二月份这个服务只需要3人天,那么说明我们的服务通过运维正在变得更稳定。

其他

其他规范下的内容,其实通过字面意思也能理解。但是以我目前的体验,是规范化及其容易忽略的点。

譬如拿第一条:站会/Agile(Scrum)规范

站会的频率是什么样的?时间怎么安排?站会要解决什么问题?站会每个人应该同步什么信息,又如何保证每个人完成同步?Scrum会议如何规划项目?如何评估项目权重和个人时间?怎样保证Sprint内目标能够按时完成?出现delay应该如何处理?

我每周乃至每天都在参加这些会议,但是如果你回头仔细想,大家对这个环节利用率是层次不齐的,在某些环节甚至做的非常糟糕,完全是形式上的敷衍。这个显然就是规范化能够优化的地方。

最后想说两点,一个是自动化的CI/CD,如果团队这方面做得很好,那么很多人工监督审批的流程都可被简化;二是上面说的都是值得规范化的点,但是规范化的落地是更重要的点。方式无非就是那几点:文档/模版,人工培训,自动化/人工监督 等等。规范出来了不意味着成功,只有它被团队接受,并真的提高了效率,那才标志着成效。

反思:为什么最后落地失败了

遗憾的是最终这个流程规范化项目最后落地失败了。现在回想,失败是由多个因素导致的:

团队动荡:21年年中之后,云与数科合并,后又纳入科技子集团,涉及了很多产品线的融合,相关同事离职,团队目标调整。。。等等各种不稳定因素,阻碍了这个事情的推进。

没有足够的支持:需要在整个部门统一的流程应该严肃设计,上向下推广,再从下向上不断根据反馈修订,这是一个长期的过程。需要产研的多个部门支持,尤其是涉及内部开发工具&基础设施的部门;它会占用个人和团队的大量时间。遗憾的是这个事情并没有获得很多重视,我的身份也没有赋予我足够的权利干预,团队也没有给我schedule足够的时间。

个人因素:这是最重要的原因。任何妥协,最终都可以归结到个人我可以发挥主观能动性,可以不断的跟大领导沟通,可以发挥影响力让更多人关注这个项目,在会议上不断的强调这个项目的重要性,争取更多的资源,可以在sprint上推掉别的任务,可以跨team寻求合作与支持。上述困难都可以被克服。但是我没有这样做,这才是最后搁浅的真正原因。

流程标准化是任何一个希望可持续发展的公司必须接受的工作方式。国内的企业往往对其没有足够的重视,当然这跟中国企业发展时间短,公司不成熟有一定关系,但是我认为更多的是领导团队认知和管理能力的局限性。JD的管理方式应该是国内企业比较有代表性的,有利有弊;关于弊端,在这一年感触颇深,这里聊两点和流程规范相关的:

  • 人治大于规范:关于管理方式/工作方式,成熟的公司应该客观统一的标准,并至上而下的推行;采用人治大于规范,就会带来混乱。每个管理人员都有自己的管理方式和标准(感受非常明显)。层层leader都笃信自己实行的管理和工作方式:one-one,宣讲,绩效目标设定与结果交流,升职流程,各种评审,开发流程,上线流程,没有统一的标准,很多只存在形式上的约束。管理需要的是统一的规范,管理人需要让规范落地并且监督所有人实行,规范才是管理的核心。
  • 急功近利,过度看中结果,而不是过程这个是难以规范化的更深层次的原因,我平时听到的最多的就是对结果的询问:这个什么时候完成,这个什么时候上线。尽可能在规划上堆满任务,用一个一个人天把任务占满。团队没有人主动提出对自己技术方案的分享讨论;几千行diff的code review完全就是走过场,有些服务上线完全没有cr;文档工作,评审工作都不尽如人意;代码问题也是塞进backlog,实在不行就紧急修复上线;个人目标来回改变,团队目标来回改变。在我入职的一年时间里部门没有培养任何应届生,实习生。所有人都在被短期结果牵着鼻子走。只有正确的过程,才会把一切引向正确的结果

我尝试过跟不少人交流,也能听到这般那般的解释。这些解释并不是搪塞。流程规范,不像开发了一个新特性一样能给团队带来直接收益,也很难言简意赅的让更上层领导理解它的重要性,毕竟一个不那么规范的流程,或者皇帝新衣的(形式上的)规范流程,一样可以让工作跑起来。那么领导为什么要放弃更快的更容易出成绩的增长点去做流程规范化呢。更尤其是,当你尝试去优化一个已经存在的规范时,仿佛就是对现有规范的追责。所以,这种费力且见效时间长的事情是需要公司从上层去推动的,这不是一两个领导加几个员工能够推动的事情,几个人干了半年,工作重心一调整,所有的努力都荒废了。

这其实才是当时我放弃的深层原因。

发表在 软件工程 | 32条评论

【p4】配置中心代码实现

(转载请注明作者和出处‘https://fourthringroad.com/’,请勿用于任何商业用途)

ooooookay, 整理一下P1-P3里的内容,把它精简成以下需求:

  1. 我有一个配置中心服务,托管用户配置信息。
  2. 用户能够用一个注解,将配置信息同步到应用运行时内存的配置bean
  3. 当配置中心的配置修改发生时,自动将新的配置同步到相关bean

很简单,来写个demo。

客户端

首先是注解的设计,前面已经讲过了,不赘述:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface ConfigAnnotation {

    //配置的ID
    String value();

    enum Format {XML, JSON, YML, PROPERTIES}

    //配置文件的类型
    Format format() default Format.PROPERTIES;
}

然后是如何获取配置,以及如何解析配置:

获取配置内容的client,先提供client的抽象

public interface ConfigClient {
    String getConfig(String configId);
}

如何解析配置的相关类,这里提供了Properties文件解析器,其实就是转化为一个string-string的键值map

/**
 * 解析配置文件.
 * 出于demo目的,这里只提供将其转化为Map的接口,用于支持K-V配置的解析,例如java Properties文件解析.
 */
public interface Resolver {
    Map<String, String> resolve(String config);
}
public class ResolverFactory {

    private static final Map<Format, Resolver> resolvers = new ConcurrentHashMap<>();

    public static Resolver getResolver(Format format) {
        switch (format) {
            case XML:
                //TBD
                break;
            case JSON:
                //TBD
                break;
            case YML:
                //TBD
                break;
            case PROPERTIES:
                resolvers.putIfAbsent(Format.PROPERTIES, new PropertiesResolver());
                return resolvers.get(Format.PROPERTIES);
        }
        throw new IllegalStateException("不支持这种格式: " + format);
    }

    public static class PropertiesResolver implements Resolver {
        @Override
        public Map<String, String> resolve(String config) {

            final Properties properties = new Properties();
            try {
                properties.load(new ByteArrayInputStream(config.getBytes(StandardCharsets.UTF_8)));
            } catch (IOException e) {
                throw new IllegalStateException("不能解析Config");
            }
            return (Map) properties;
        }
    }
}

然后是最重要的一步,介入bean的生命周期,给bean赋值并动态更新

可以看到从两个地方介入了bean的构建过程:

首先是postProcessProperties,对bean的初值进行了赋予,从ConfigManager获得配置信息,解析,然后给bean赋值使用

接着是postProcessAfterInitialization,在bean初始化完成之后,注册到ConfigManager,后者用观察者模式,当检测到配置修改后,相应的也会更新bean实例的属性。

@Component
public class ConfigAnnotationBeanPostProcessor implements InstantiationAwareBeanPostProcessor {

    private final ConfigManager configManager;

    public ConfigAnnotationBeanPostProcessor(ConfigManager configManager) {
        this.configManager = configManager;
    }

    @Override
    public PropertyValues postProcessProperties(PropertyValues pvs, Object bean, String beanName) throws BeansException {

        final Class<?> cls = bean.getClass();

        if (cls.isAnnotationPresent(ConfigAnnotation.class)) {
            final ConfigAnnotation configAnnotation = cls.getAnnotation(ConfigAnnotation.class);

            final String config = configManager.getConfig(configAnnotation.value());
            final Map<String, String> resolvedProperties = ResolverFactory.getResolver(configAnnotation.format())
                    .resolve(config);

            //简便起见,不考虑父类的属性
            final Field[] fields = cls.getDeclaredFields();
            final MutablePropertyValues mutablePropertyValues = new MutablePropertyValues(pvs);
            for (Field field : fields) {
                final String fieldName = field.getName();
                final String fieldValue = resolvedProperties.get(fieldName);
                if (fieldValue != null) {
                    mutablePropertyValues.add(fieldName, fieldValue);
                }
            }
            return mutablePropertyValues;
        }

        return null;
    }

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        final Class<?> cls = bean.getClass();
        if (cls.isAnnotationPresent(ConfigAnnotation.class)) {
            final ConfigAnnotation configAnnotation = cls.getAnnotation(ConfigAnnotation.class);
            configManager.register(configAnnotation.value(), bean, configAnnotation.format());
        }
        return bean;
    }

这里提到的ConfigManager其实功能就是上面两个:周期性的调用ConfigClient的接口去获取配置,保证内存cache中是最新的配置;当检测到配置修改后,修改对应bean中的属性。

/**
 * delegate ConfigClient 来更新内存中的配置
 * 当配置发生改变时通知对应的实例
 */
@Component
public class ConfigManager {

    private final ConfigClient configClient;

    private final Map<String, String> cache = new ConcurrentHashMap<>();

    private final Map<String, Map.Entry<Object, Format>> observers = new ConcurrentHashMap<>();

    public ConfigManager(ConfigClient configClient) {
        this.configClient = configClient;

        final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
        scheduler.scheduleAtFixedRate(() -> {
            try {
                cache.forEach(
                        (configId, oldConfig) -> {
                            final String newConfig = configClient.getConfig(configId);
                            if (!oldConfig.equals(newConfig)) {
                                cache.put(configId, newConfig);
                                notifyObserver(configId, newConfig);
                            }
                        });
            } catch (Throwable t) {
                t.printStackTrace();
            }
        }, 0, 1, TimeUnit.SECONDS);
    }

    public String getConfig(final String configId) {
        if (cache.containsKey(configId)) {
            return cache.get(configId);
        } else {
            final String config = configClient.getConfig(configId);
            cache.put(configId, config);
            return config;
        }
    }

    public void register(final String configId, final Object bean, final Format format) {
        observers.put(configId, new AbstractMap.SimpleEntry<Object, Format>(bean, format));
    }

    private void notifyObserver(String configId, String config) {
        if (observers.containsKey(configId)) {
            Object bean = observers.get(configId).getKey();
            Format format = observers.get(configId).getValue();
            final Map<String, String> resolvedProperties = ResolverFactory.getResolver(format).resolve(config);
            final Class<?> cls = bean.getClass();
            final Field[] fields = cls.getDeclaredFields();
            for (Field field : fields) {
                field.setAccessible(true);
                final String fieldName = field.getName();
                final String fieldValue = resolvedProperties.get(fieldName);
                if (fieldValue != null) {
                    try {
                        field.set(bean, fieldValue);
                    } catch (IllegalAccessException e) {
                        e.printStackTrace();
                    }
                }
                field.setAccessible(false);
            }
        }
    }
}

最后给我们的工具包增加一个对外输出的配置文件

因为没有搭建真的服务,所有在这里ConfigClient提供了一个stub实现,每调用5次接口,就会切换到一个不同响应配置文件,来模拟配置文件改动的情况。

@Configuration
@ComponentScan("com.sihuan.prototype")
public class ConfigClientConfiguration {

    @Bean
    public ConfigClient configClient() {
        return new StubConfigClient();
    }

    /**
     * 出于测试目的,这里提供一个stub来提供ConfigClient的功能
     */
    public static class StubConfigClient implements ConfigClient {
        private final AtomicInteger counter = new AtomicInteger();

        @Override
        public String getConfig(String configId) {
            if ("testConfig".equals(configId)) {
                return counter.addAndGet(1) / 5 % 2 == 0 ?
                        "name=bob\n" +
                                "job=developer\n" +
                                "company=google\n" +
                                "salary=99999" :
                        "name=jack\n" +
                                "job=sales\n" +
                                "company=apple\n" +
                                "salary=88888";
            }
            throw new RuntimeException();
        }
    }
}

OK,完成之后用户就可以使用了。

用户应用使用

直接定义一个bean,并用上我们的注解

/**
 * 真实情况fields不会都是String,出于演示目的,这里简化了复杂类型转换的场景
 */
@Component
@ConfigAnnotation(value = "testConfig", format = ConfigAnnotation.Format.PROPERTIES)
public class ConfigBean {
    private String name;
    private String job;
    private String company;
    private String salary;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getJob() {
        return job;
    }

    public void setJob(String job) {
        this.job = job;
    }

    public String getCompany() {
        return company;
    }

    public void setCompany(String company) {
        this.company = company;
    }

    public String getSalary() {
        return salary;
    }

    public void setSalary(String salary) {
        this.salary = salary;
    }

    @Override
    public String toString() {
        return "ConfigBean{" +
                "name='" + name + '\'' +
                ", job='" + job + '\'' +
                ", company='" + company + '\'' +
                ", salary='" + salary + '\'' +
                '}';
    }
}

写个main函数来测试:

public class App {
    public static void main(String[] args) throws InterruptedException {
        final AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(CustomerConfiguration.class);

        final ConfigBean configBean = (ConfigBean) context.getBean("configBean");

        while (true) {
            TimeUnit.SECONDS.sleep(1);
            System.out.println(configBean);
        }
    }

    @Configuration
    @Import(ConfigClientConfiguration.class)
    @ComponentScan(value = "org.test")
    public static class CustomerConfiguration {
    }
}

打印结果为:

ConfigBean{name='bob', job='developer', company='google', salary='99999'}
ConfigBean{name='bob', job='developer', company='google', salary='99999'}
ConfigBean{name='bob', job='developer', company='google', salary='99999'}
ConfigBean{name='jack', job='sales', company='apple', salary='88888'}
ConfigBean{name='jack', job='sales', company='apple', salary='88888'}
ConfigBean{name='jack', job='sales', company='apple', salary='88888'}
ConfigBean{name='jack', job='sales', company='apple', salary='88888'}
ConfigBean{name='jack', job='sales', company='apple', salary='88888'}
ConfigBean{name='bob', job='developer', company='google', salary='99999'}
ConfigBean{name='bob', job='developer', company='google', salary='99999'}
ConfigBean{name='bob', job='developer', company='google', salary='99999'}
.......

OK,仅仅通过一个注解帮助我们的客户获取了配置,映射到自定义类中,并且可以实现实时的更新。这个实现方案没有考虑性能和很多细节,真实的方案比这个复杂很多,但是也就意味着,在真正的方案中会给用户节省更多的重复开发,降低onboard难度。

发表在 spring相关 | 137条评论

【p3】Java自定义注解

(转载请注明作者和出处‘https://fourthringroad.com/’,请勿用于任何商业用途)

我在犹豫是否要给注解单独写一章,毕竟不是项目重点;但是想了想,简单梳理一下吧,当是温故知新了。

注解是简化代码,提高可读性很好的途径,能够以更好隐藏底层逻辑,实现不同模块的低耦合;对于用户来讲,如果你的服务以注解的方式提供支持,显然能够降低onboard的代码量以及对于用户服务的侵入性。

关于注解的一些基础知识,简单列举一下:

  • 说白了注解的本质就是给代码对象(类,方法,参数。。。等)加上一些额外信息,这些额外信息会被一些处理器在处理这些代码对象时使用。这里所谓的额外信息,除了注解本身之外,还有注解提供的配置参数,后者类型包括:基本数据类型,其他注解,String,enum和它们的数组等。
  • 元注解用于修饰其他注解,常用的有:
    • @Target:定义注解修饰的对象是什么,包括TYPE, x.FIELD, x.METHOD, x.CONSTRUCTOR, x.PARAMETER 等等。
    • @Retention: 定义注解的生命周期,包括SOURCE, x.CLASS, x.RUNTIME
    • @Inherited: 注解关系是否具有继承性,即子类是否继承父类的接口
  • 注解本身不支持继承(extends),但是所有的注解在编译后会继承lang.annotaion.Annotation接口。所以如何获取注解提供的额外信息?显然答案是反射;简单的反射API如下
    • 判断某个类,属性,方法时候有某个注解,例如getClass().isAnnorationPresent(XxAnnotation);
    • 读取注解:XxAnnotationxxAnnotation = xx.getClass().getAnnoration(XxAnnotation.class);进一步可以通过xxAnnoration实例获得注解的配置参数
    • 更多的方法可以参考java.lang.reflect下的接口AnnotatedElement

OK,上面是一些注解基础知识,如果我们想像在文章p1中讲的那样,自定义一个配置解析的注解,需要怎么做?

@配置专用Annotation(配置ID=“xx” 解析格式=“json/xml/yml...”)
@Component
ConfigBean {
    String foo;
    Long bar;
}

首先确定需要什么元注解:

  • 我们需要注解在运行时能够使用,那么就需要定义元注解@Retention,并执行RetentionPolicy为RUNTIME
  • 我们需要将配置文件映射到ConfigBean,所以注解使用的对象是类,即ElementType=TYPE

然后确定我们的注解需要提供什么信息:

应用需要跟配置中心服务进行交互,所以需要配置中心服务端地址,同时还需要应用名称和配置的ID来定位一个具体的配置文件;如何解析一个配置文件(xml,json,yml…);配置中心服务端地址和应用名称是公用的,所以可以放置到公共的配置文件中;在注解层面需要提供的就是配置的ID和解析方式。

最后我们需要实现处理器

在运行时,如何将带有自定义注解的ConfigBean与相应配置文件同步?显然我们需要自定义一个处理器,并且在Bean的合适的生命周期节点执行这个处理器的逻辑,这个处理器的逻辑为:解析注解配置,获得需要同步的配置信息以及解析方式,向配置中心发起获取配置信息,在拿到配置后,用匹配的解析器解析配置文件并赋予ConfigBean。

代码架构如下:

//注解本身
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface ConfigAnnotation {

    //配置的ID
    String value();

    enum Format {XML, JSON, YML, PROPERTIES}

    //配置文件的类型
    Format format() default Format.PROPERTIES;
}
//在实体类使用注解
@ConfigAnnotation(value = "xx.json", format = ConfigAnnotation.Format.JSON)
public class ConfigBean {

    private String foo;

    private Integer bar;
}
//注解处理器的逻辑
public class Processor {

    private ConfigService configService;

    private Resolver resolver;

    public Object getInstance(String reference) throws ClassNotFoundException {
        final Class<?> aClass = Class.forName(reference);

        if (aClass.isAnnotationPresent(ConfigAnnotation.class)) {
            ConfigAnnotation configAnnotation = aClass.getAnnotation(ConfigAnnotation.class);

            //读取配置属性
            final String configID = configAnnotation.value();
            final ConfigAnnotation.Format format = configAnnotation.format();

            //获取配置
            final String config = this.configService.getConfig(configID);

            //解析配置
            return this.resolver.resolve(config, aClass);
        }

        throw new IllegalStateException("处理失败");
    }
}

作为服务的提供方,处理器的逻辑被我完全隐藏了起来,用户只需要简单的利用我定义的注解进行服务接入,OK,世界变得更美好了。

发表在 spring相关 | 6条评论

【p2】关于Spring如何管理Bean的生命周期

(转载请注明作者和出处‘https://fourthringroad.com/’,请勿用于任何商业用途)

上篇文章大概描述了背景,这篇讲讲spring如何管理bean生命周期以及预留的各种hooks。

在进入正题之前先确认第一个问题:

spring管理bean的容器是在哪儿?

作为最核心的接口,我们可以从ApplicatonContext入手,从类的继承关系上可以看出它具备的能力包括:资源加载(ResourceLoader),事件发布(EventPublisher),消息解析(MessageSource),类工厂(BeanFactory)等等

显然其中的工厂类ListableBeanFactory跟Bean的构造最直接相关:

简单浏览ApplicationContext实现类的代码,就可以发现AC本质上是Delegate一个DefaultListableBeanFactory实例去填充上面BeanFactory和ListableBeanFactory中的方法具体逻辑。例如在下面的实现类中就有一个DefaultListableBeanFactory属性:

我们转向DefaultListableBeanFactory,它涉及功能更多,实现的接口与抽象类也更复杂,拆这么细应该也是遵循了设计模式的接口隔离原则,方便后期的维护拓展。它继承的其中一个类是DefaultSingletonBeanRegistry,我们发现这里有存储单例类实例的Map(prototype显然不需要存储):singletonObjets & earlySingletonObjects。

所以问题的答案就是众人皆知的BeanFactory,它的实现类具备了创建,查询以及存储上下文中beans的能力。bean生命周期显然跟它的子类息息相关。

Spring Bean的生命周期

我们先从ApplicationContext最常见的实现类ClassPathXmlApplicationContext的构造函数入手,可以很快定位启动的入口函数AbstractApplicationContext#refresh,这个方法的成功执行意味着spring上下文的成功加载。我们对其中的两个个重要方法进行分析:

BeanFactory实例的构造 – obtainFreshBeanFactory()

文章的开头提到了BeanFactory是bean生命周期直接相关的工厂类,显然AC在启动过程中,初始化BeanFactory过程中做了什么操作是很重要的。我们从这个方法进入可以追踪到如下的初始化代码:

可以看到在创建一个DefaultListableBeanFactory实例之后,有一个加载所有BeanDefinition的操作-loadBeanDefinitions(beanFactory);这个BeanDefinition很好理解,其实就是关于一个Bean的所有定义,BeanFactory会通过这个定义去构造Bean的实例。因为我们使用的是ClassPathXmlApplicationContext,那么假定前提就是我们对bean的定义位于XML配置文件中,这个loadBeanDefinitions方法的实现就位于AbstractXmlApplicationContext类中,如下我们可以看到有构造XML相关的Reader来执行加载过程:

经过了这一步,就拿到了所有bean的定义,那么就可以开始bean的初始化过程了。

普通Bean的初始化 – finishBeanFactoryInitialization(beanFactory)

普通单例类的初始化逻辑的入口位于倒数第二个方法,finishBeanFactoryInitialization(beanFactory);这个方法调用链的底层,是调用BeanFactory#getBean创建bean实例;进一步深入,BeanFactory的子类AbstractAutowireCapableBeanFactory中的方法createBean以及doCreateBean会被调用,如下:

执行完这个流程之后,一个bean就被初始化完成并放入缓存,等待运行时使用以及最后的销毁,实际上这就是是bean的生命周期,红框中包括了这个流程中的核心逻辑,如果接着上文中加载BeanDefinition之后,把接下来代码用一个流程图来展示:

红框中是四个关键步骤:

  • 实例化创建:创建一个bean实例
  • 赋值:给bean实例的属性赋值。
  • 初始化:执行初始化函数(如果有的话)
  • 销毁:执行销毁函数(如果有的话)

其他无色流程框的都是可以自定义的扩展点。spring给四个关键环节提供最简单的接入方法,分别是:默认构造函数,setter方法,init-method, destroy-method。假如使用者完全不介入spring bean的生命周期,显然和new一个对象并进行使用几乎是一样的。

可以看到无色方框是围绕四个关键步骤的扩展点,意味着我们可以在bean的不同阶段进行干预。

除此之外还有一些小的扩展点其实也都是围绕上面的主流程,譬如假如一个bean实现了InitializingBean接:

那么里面的afterPropertiesSet()方法的调用点在哪里呢?实际上就在Initialization步骤里执行init-method之前:

对BeanFactory进行处理的Hooks:

值得一提还有一个针对BeanFactory的钩子:我们在refresh方法中,可以看到在构造BeanFactory的实例之后,有一步:invokeBeanFactoryPostProcessors(),在里面预留了对BeanDefinitionRegistryPostProcessor和BeanFactoryPostProcessor相应方法的调用,用于对BeanFactory实例进行额外处理。

例子 – ApplicationContextAwareProcesser

在refresh的主流程步骤prepareBeanFactory中,注册了一个spring 官方的BeanPostProcessor- ApplicationContextAwareProcesser,我们来看看它对bean的生命周期进行了什么操作

可以看到它在bean的初始化阶段发生前调用了invokeAwareInterfaces方法,在这个方法里面,依次判断了当前bean是否实现/继承了xxAware接口/类,如果是,则调用相应的set方法把xx实例设置到bean的属性中。这实际上解释了为什么我们在日常使用中,一个类实现了ApplicationContextAware接口,在初始化过程中,以及后续runtime时期就可以在bean内部使用ApplicationContext实例。正是因为ApplicationContextAwareProcesser这个处理器在bean初始化过程中做了手脚。

小结

Spring bean的生命周期其实就是由很多扩展点构成,这里把整体梳理了一遍,但是针对每个拓展点没有深入的讲,有一些小的拓展点也没有提及,用的时候再深入研究即可,没有必要死记硬背。

Spring的拓展性一直为人称道,其实就是很好的遵循了一些编程思想,比如面向接口编程,接口隔离,以及采用了类似Service Provider Interface(SPI)的模式。这些方法也常常在一些扩展性好的,所谓的面向插件编程的系统上见到,比如前一阵在做ES插件开发的时候就深有体会。

但是回过头,像前文提到的一样,这些功能用户可以根据说明文档就很好的把它当成黑盒使用吗?显然是很难的,阅读源码基本上是每一个用户必经之路,这也就是Spring框架,至少在bean管理方面,我认为比较繁重的原因。

发表在 spring相关 | 2条评论