一文回顾.NET Core 基础设施演进之路
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一文回顾.NET Core 基础设施演进之路相关的知识,希望对你有一定的参考价值。
参考技术A
作者丨Matt Mitchell
译者丨平川
随着.NET Core 3.0 预览版 6 的推出,我们认为有必要简要回顾一下基础设施系统的 历史 ,以及在过去一年左右时间里所做的重大改进。如果你对构建基础设施感兴趣,或者希望了解如何构建像.NET Core 这样大的产品,那么这篇文章将非常有趣。
从 3 年前开始,.NET Core 项目就与传统的微软项目有很大的不同。
我们早期的基础设施决策是围绕必要性和便利性做出的。我们使用 Jenkins 进行 GitHub PR 和 CI 验证,因为它支持跨平台的 OSS 开发。我们的官方构建版本位于 Azure DevOps(当时称为 VSTS)和 TeamCity(由 ASP 使用)中,其中有签名和其他关键的交付基础设施。我们搭配使用手动更新包依赖项版本和自动化 GitHub PR 的方法将存储库集成在一起。团队独立地构建了他们需要的工具来进行打包、布局、本地化,以及在大型开发项目中出现的所有其他常见任务。虽然不是很理想,但在某种程度上,这在早期已经运行得足够好了。随着项目从.NET Core 1.0 和 1.1 发展到 2.0 以及更高版本,我们希望投资于进一步整合的技术栈、更快的交付周期和更简单的服务。我们希望每天多次使用最新的运行时来生成一个新的 SDK。我们希望所有这些都不降低独立存储库的开发速度。
.NET Core 面临的许多基础设施方面的挑战都源于存储库结构的隔离和分布式特性。尽管多年来它变化很大,但该产品是由 20 到 30 个独立的 Git 存储库组成(ASP.NET Core 直到最近还比它多得多)。一方面,拥有许多独立的开发竖井会使这些竖井中的开发非常高效;开发人员可以在库中快速迭代,而不用担心栈的其他部分。另一方面,它使得整个项目的创新和集成效率大大降低。下面是一些例子:
在所有这些情况下,都有可能在许多层面上出现失败,从而进一步减缓进程。随着.NET Core 3.0 计划的正式启动,很明显,如果不对基础设施进行重大更改,我们就无法创建所需范围的版本。
为了减轻痛苦,我们三管齐下:
Arcade
在.NET Core 3.0 之前,有 3 到 5 种不同的工具实现分散在不同的存储库中,这和你如何计算有关。
虽然在这个世界上,每个团队都可以定制他们的工具,只构建他们需要的东西,但这确实有一些显著的缺点:
开发人员在存储库之间切换时效率更低
例如:当开发人员从 dotnet/corefx 切换到 dotnet/core-sdk 时,存储库的“语言”是不同的。她输入什么来构建和测试?日志放在哪里?如果她需要在存储库中添加一个新项目,该如何做?
需要的每个特性都要构建 N 次
例如:.NET Core 生成了大量的 NuGet 包。虽然有一些变化(例如,共享运行时包如出自 dotnet/core-setup 的 Microsoft.NETCore.App 就与 Microsoft.AspNet.WebApi.Client 等“普通”包的构建方式不同),但生成它们的步骤非常相似。遗憾的是,由于存储库在布局、项目结构等方面的差异,如何实现这些打包任务方面也产生了差异。存储库如何定义应该生成什么包、这些包中包含什么、它们的元数据等等。如果没有共享工具,团队通常更容易实现另一个打包任务,而不是重用另一个。这当然会导致资源压力。
借助 Arcade,我们努力使所有的存储库采用公共的布局、存储库“语言”和任务集(可能的话)。这并非没有陷阱。任何一种共享工具最终都会解决一些“刚刚好”问题。如果共享工具过于规范,那么在任何规模的项目中进行所需的定制都将变得非常困难,并且更新该工具也将变得非常困难。使用新的更新很容易破坏存储库。构建工具遭受了这种痛苦。使用它的存储库与它紧密耦合,以至于它不仅不能用于其他存储库,而且对构建工具进行任何更改常常会以意想不到的方式伤害用户。如果共享工具不够规范,那么存储库在使用工具时往往会出现差异,并且推出更新通常需要在每个单独的存储库中做大量的工作。那么,为什么要共享工具呢?
实际上,Arcade 尝试同时使用了这两种方法。它将公共存储库“语言”定义为脚本集(请参阅 eng/common)、公共存储库布局和作为 MSBuild SDK 推出的公共构建目标集。选择完全采用 Arcade 的存储库具有可预测的行为,使得更改很容易在存储库之间传播。不希望这样做的存储库可以从提供基本功能(如签名和打包)的各种 MSBuild 任务包中进行选择,这些任务包在所有存储库中看起来都是一样的。当我们对这些任务进行更改时,我们会尽力避免破坏性更改。
让我们来看看 Arcade 提供的主要特性,以及它们如何集成到我们更大的基础设施中。
Azure DevOps
如上所述,较大的团队通过 2.2 版本使用了一个 CI 系统的组合:
许多差异仅仅是出于必要性。Azure DevOps 不支持公共 GitHub PR/CI 验证,所以 ASP.NET Core 转向 AppVeyor 和 Travis 来填补这个空白,而.NET Core 则投资于 Jenkins。经典 Azure DevOps 对构建编排没有太多的支持,所以 ASP.NET Core 团队求助于 TeamCity,而.NET Core 团队则在 Azure DevOps 之上构建了一个名为 PipeBuild 的工具来帮助克服困难。所有这些差异都是非常昂贵的,即使是以一些不明显的方式:
当 Azure DevOps 开始推出基于 YAML 的构建管道和对公共 GitHub 项目的支持时,随着.NET Core 3.0 的启动,我们意识到,我们拥有一个独特的机会。有了这种新的支持,我们可以将现在所有的工作流从单独的系统转移到现代的 Azure DevOps 中,并对我们处理正式 CI 和 PR 工作流的方式进行一些更改。我们的工作大致如下:
到目前为止,所有主要的.NET Core 3.0 存储库都在 Azure DevOps 上进行公共 PR 和正式 CI。一个很好的例子是 dotnet/arcade 本身的正式构建 /PR 管道。
Maestro 和依赖流
.NET Core 3.0 基础架构的最后一块拼图就是我们所说的依赖流。这并不是.NET Core 独有的概念。除非它们是完全自包含的,否则大多数软件项目都包含对其他软件的某种版本化引用。在.NET Core 中,这些包通常表现为 NuGet 包。当我们需要库提供的新特性或修复时,我们通过更新项目中引用的版本号来获取这些新更新。当然,这些包也可能有对其他包的版本化引用,那些其他包可能有更多的引用,等等。这就形成了一张图。当每个存储库拉取其输入依赖项的新版本时,更改将在图中流动。
一个复杂的图
大多数软件项目的主要开发生命周期(开发人员经常从事的工作)通常涉及少量相互关联的存储库。输入依赖关系通常是稳定的,更新很少。当他们确实需要更改的时候,通常是手工操作。开发人员评估输入包的可用版本,选择合适的版本,然后提交更新。但在.NET Core 中并非如此。组件需要独立,以不同的节奏交付,并具有高效的内循环开发体验,这导致了大量具有大量相互依赖关系的存储库。相互依赖关系也形成了一个相当深的图:
Dotnet/core-sdk 存储库作为所有子组件的聚合点。我们提供了一个特定的 dotnet/core-sdk 构建,它描述了所有其他引用的组件。
我们还希望新的输出能够快速通过这个图,以便尽可能多地验证最终产品。例如,我们期望 ASP.NET Core 或.NET Core 运行时的最新片段尽可能多地在 SDK 中表现自己。本质上,这意味着定期快节奏地更新每个存储库中的依赖项。在一个足够大的图中,就像.NET Core 一样,这很快就变成了一个不可能手工完成的任务。这种规模的软件项目可能会通过以下几种方法来解决这个问题:
.NET Core 已经尝试了所有 3 种方法。我们在 1.x 的早期漂移版本。在 2.0 中实现了一定程度的自动化依赖流,并为 2.1 和 2.2 构建了一个复合构建。在 3.0 中,我们决定大量投资于自动化依赖流,放弃其他方法。我们想在一些重要的方面改进我们以前的 2.0 基础设施:
这些概念的设计使得存储库所有者不需要栈或其他团队流程的全局知识就可以参与依赖流。他们只需要知道三件事:
.NET Core 3 开发通道的流图,包括.NET Core 3 Dev 流的其他通道(例如,Arcade 的“.NET Tools Latest”)。
一致和不一致
非一致性会导致哪些问题? 不一致性表示可能的错误状态。举个例子,让我们看看 Microsoft.NETCore.App。这个包表示特定的 API 表面。虽然存储库依赖关系图中可能会引用 Microsoft.NETCore.App 的多个版本,但 SDK 只提供一个。这个运行时必须满足可在该运行时上执行的间接引用组件(例如 WinForms 和 WPF)的所有需求。如果运行时不满足这些需求(例如破坏性 API 变更),可能就会发生故障。在不一致的图中,因为所有存储库都没有使用相同版本的 Microsoft.NETCore.App,有可能错过了一个破坏性的变更。
这是否意味着不一致始终是一种错误状态? 不。例如,我们假设图中 Microsoft.NETCore.App 的不一致只代表一个非破坏性 JIT Bug 修复 coreclr 中的一个变更。从技术上讲,微软没有必要在图中的每一点上获取新的 Microsoft.NETCore.App。只需针对新的运行时交付相同的组件就足够了。
如果不一致只是偶尔的问题,那么我们为什么还要努力才能推出一致的产品呢? 因为很难确定什么时候不一致无关紧要。简单地将一致性作为所需状态进行交付,要比试图理解不一致的组件之间的任何语义差异对最终产品所产生的影响更容易。这是可以做到的,但是从构建频率来说,它是时间密集型的,并且容易出错。强制将一致性作为默认状态更安全。
依赖流的好处
随着存储库图越来越大,所有这些自动化和跟踪都有许多明显的优势。它为我们解决日常生活中的实际问题提供了很多可能性。虽然我们刚刚开始 探索 这个领域,但系统已经可以开始回答一些有趣的问题,并处理以下场景:
随着.NET Core 3.0 逐步结束,我们正在寻找新的领域来改进。虽然计划仍处于(非常)初期的阶段,但我们预计在以下几个关键领域进行投资:
多年来,我们已经对基础设施进行了相当大的改进。从 Jenkins 到 Azure DevOps,从手工依赖流到 Maestro++,从许多工具实现到一个工具实现,我们对.Net Core 3.0 所做的改变是一个巨大的进步。我们已经为开发和交付比以往任何时候都更可靠、更令人兴奋的产品做好了准备。
原文链接:
https://devblogs.microsoft.com/dotnet/the-evolving-infrastructure-of-net-core/
Dubbo 优雅停机演进之路
点击蓝色“Java极客技术”关注我哟
加个“星标”,一起快乐成长
一、前言
在 『ShutdownHook- Java 优雅停机解决方案』 一文中我们聊到了 Java 实现优雅停机原理。接下来我们就跟根据上面知识点,深入 Dubbo 内部,去了解一下 Dubbo 如何实现优雅停机。
二、Dubbo 优雅停机待解决的问题
为了实现优雅停机,Dubbo 需要解决一些问题:
-
新的请求不能再发往正在停机的 Dubbo 服务提供者。 -
若关闭服务提供者,已经接收到服务请求,需要处理完毕才能下线服务。 -
若关闭服务消费者,已经发出的服务请求,需要等待响应返回。
解决以上三个问题,才能使停机对业务影响降低到最低,做到优雅停机。
三、2.5.X
Dubbo 优雅停机在 2.5.X 版本实现比较完整,这个版本的实现相对简单,比较容易理解。所以我们先以 Dubbo 2.5.X 版本源码为基础,先来看一下 Dubbo 如何实现优雅停机。
3.1、优雅停机总体实现方案
优雅停机入口类位于 AbstractConfig
静态代码中,源码如下:
static {
Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
public void run() {
if (logger.isInfoEnabled()) {
logger.info("Run shutdown hook now.");
}
ProtocolConfig.destroyAll();
}
}, "DubboShutdownHook"));
}
这里将会注册一个 ShutdownHook
,一旦应用停机将会触发调用 ProtocolConfig.destroyAll()
。
ProtocolConfig.destroyAll()
源码如下:
public static void destroyAll() {
// 防止并发调用
if (!destroyed.compareAndSet(false, true)) {
return;
}
// 先注销注册中心
AbstractRegistryFactory.destroyAll();
// Wait for registry notification
try {
Thread.sleep(ConfigUtils.getServerShutdownTimeout());
} catch (InterruptedException e) {
logger.warn("Interrupted unexpectedly when waiting for registry notification during shutdown process!");
}
ExtensionLoader<Protocol> loader = ExtensionLoader.getExtensionLoader(Protocol.class);
// 再注销 Protocol
for (String protocolName : loader.getLoadedExtensions()) {
try {
Protocol protocol = loader.getLoadedExtension(protocolName);
if (protocol != null) {
protocol.destroy();
}
} catch (Throwable t) {
logger.warn(t.getMessage(), t);
}
}
}
从上面可以看到,Dubbo 优雅停机主要分为两步:
-
注销注册中心 -
注销所有 Protocol
3.2、注销注册中心
注销注册中心源码如下:
public static void destroyAll() {
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Close all registries " + getRegistries());
}
// Lock up the registry shutdown process
LOCK.lock();
try {
for (Registry registry : getRegistries()) {
try {
registry.destroy();
} catch (Throwable e) {
LOGGER.error(e.getMessage(), e);
}
}
REGISTRIES.clear();
} finally {
// Release the lock
LOCK.unlock();
}
}
这个方法将会将会注销内部生成注册中心服务。注销注册中心内部逻辑比较简单,这里就不再深入源码,直接用图片展示。
ps: 源码位于:
AbstractRegistry
以 ZK 为例,Dubbo 将会删除其对应服务节点,然后取消订阅。由于 ZK 节点信息变更,ZK 服务端将会通知 dubbo 消费者下线该服务节点,最后再关闭服务与 ZK 连接。
通过注册中心,Dubbo 可以及时通知消费者下线服务,新的请求也不再发往下线的节点,也就解决上面提到的第一个问题:新的请求不能再发往正在停机的 Dubbo 服务提供者。
但是这里还是存在一些弊端,由于网络的隔离,ZK 服务端与 Dubbo 连接可能存在一定延迟,ZK 通知可能不能在第一时间通知消费端。考虑到这种情况,在注销注册中心之后,加入等待进制,代码如下:
// Wait for registry notification
try {
Thread.sleep(ConfigUtils.getServerShutdownTimeout());
} catch (InterruptedException e) {
logger.warn("Interrupted unexpectedly when waiting for registry notification during shutdown process!");
}
默认等待时间为 10000ms,可以通过设置 dubbo.service.shutdown.wait
覆盖默认参数。10s 只是一个经验值,可以根据实际情设置。不过这个等待时间设置比较讲究,不能设置成太短,太短将会导致消费端还未收到 ZK 通知,提供者就停机了。也不能设置太长,太长又会导致关停应用时间变长,影响发布体验。
3.3、注销 Protocol
ExtensionLoader<Protocol> loader = ExtensionLoader.getExtensionLoader(Protocol.class);
for (String protocolName : loader.getLoadedExtensions()) {
try {
Protocol protocol = loader.getLoadedExtension(protocolName);
if (protocol != null) {
protocol.destroy();
}
} catch (Throwable t) {
logger.warn(t.getMessage(), t);
}
}
loader#getLoadedExtensions
将会返回两种 Protocol
子类,分别为 DubboProtocol
与 InjvmProtocol
。
DubboProtocol
用于服务端请求交互,而 InjvmProtocol
用于内部请求交互。如果应用调用自己提供 Dubbo 服务,不会再执行网络调用,直接执行内部方法。
这里我们主要来分析一下 DubboProtocol
内部逻辑。
DubboProtocol#destroy
源码:
public void destroy() {
// 关闭 Server
for (String key : new ArrayList<String>(serverMap.keySet())) {
ExchangeServer server = serverMap.remove(key);
if (server != null) {
try {
if (logger.isInfoEnabled()) {
logger.info("Close dubbo server: " + server.getLocalAddress());
}
server.close(ConfigUtils.getServerShutdownTimeout());
} catch (Throwable t) {
logger.warn(t.getMessage(), t);
}
}
}
// 关闭 Client
for (String key : new ArrayList<String>(referenceClientMap.keySet())) {
ExchangeClient client = referenceClientMap.remove(key);
if (client != null) {
try {
if (logger.isInfoEnabled()) {
logger.info("Close dubbo connect: " + client.getLocalAddress() + "-->" + client.getRemoteAddress());
}
client.close(ConfigUtils.getServerShutdownTimeout());
} catch (Throwable t) {
logger.warn(t.getMessage(), t);
}
}
}
for (String key : new ArrayList<String>(ghostClientMap.keySet())) {
ExchangeClient client = ghostClientMap.remove(key);
if (client != null) {
try {
if (logger.isInfoEnabled()) {
logger.info("Close dubbo connect: " + client.getLocalAddress() + "-->" + client.getRemoteAddress());
}
client.close(ConfigUtils.getServerShutdownTimeout());
} catch (Throwable t) {
logger.warn(t.getMessage(), t);
}
}
}
stubServiceMethodsMap.clear();
super.destroy();
}
Dubbo 默认使用 Netty 作为其底层的通讯框架,分为 Server
与 Client
。Server
用于接收其他消费者 Client
发出的请求。
上面源码中首先关闭 Server
,停止接收新的请求,然后再关闭 Client
。这样做就降低服务被消费者调用的可能性。
3.4、关闭 Server
首先将会调用 HeaderExchangeServer#close
,源码如下:
public void close(final int timeout) {
startClose();
if (timeout > 0) {
final long max = (long) timeout;
final long start = System.currentTimeMillis();
if (getUrl().getParameter(Constants.CHANNEL_SEND_READONLYEVENT_KEY, true)) {
// 发送 READ_ONLY 事件
sendChannelReadOnlyEvent();
}
while (HeaderExchangeServer.this.isRunning()
&& System.currentTimeMillis() - start < max) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
logger.warn(e.getMessage(), e);
}
}
}
// 关闭定时心跳检测
doClose();
server.close(timeout);
}
private void doClose() {
if (!closed.compareAndSet(false, true)) {
return;
}
stopHeartbeatTimer();
try {
scheduled.shutdown();
} catch (Throwable t) {
logger.warn(t.getMessage(), t);
}
}
这里将会向服务消费者发送 READ_ONLY
事件。消费者接受之后,主动排除这个节点,将请求发往其他正常节点。这样又进一步降低了注册中心通知延迟带来的影响。
接下来将会关闭心跳检测,关闭底层通讯框架 NettyServer。这里将会调用 NettyServer#close
方法,这个方法实际在 AbstractServer
处实现。
AbstractServer#close
源码如下:
public void close(int timeout) {
ExecutorUtil.gracefulShutdown(executor, timeout);
close();
}
这里首先关闭业务线程池,这个过程将会尽可能将线程池中的任务执行完毕,再关闭线程池,最后在再关闭 Netty 通讯底层 Server。
Dubbo 默认将会把请求/心跳等请求派发到业务线程池中处理。
关闭 Server,优雅等待线程池关闭,解决了上面提到的第二个问题:若关闭服务提供者,已经接收到服务请求,需要处理完毕才能下线服务。
Dubbo 服务提供者关闭流程如图:
ps:为了方便调试源码,附上 Server 关闭调用链。
DubboProtocol#destroy
->HeaderExchangeServer#close
->AbstractServer#close
->NettyServer#doClose
3.5 关闭 Client
Client 关闭方式大致同 Server,这里主要介绍一下处理已经发出请求逻辑,代码位于HeaderExchangeChannel#close
。
// graceful close
public void close(int timeout) {
if (closed) {
return;
}
closed = true;
if (timeout > 0) {
long start = System.currentTimeMillis();
// 等待发送的请求响应信息
while (DefaultFuture.hasFuture(channel)
&& System.currentTimeMillis() - start < timeout) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
logger.warn(e.getMessage(), e);
}
}
}
close();
}
关闭 Client 的时候,如果还存在未收到响应的信息请求,将会等待一定时间,直到确认所有请求都收到响应,或者等待时间超过超时时间。
ps:Dubbo 请求会暂存在
DefaultFuture
Map 中,所以只要简单判断一下 Map 就能知道请求是否都收到响应。
通过这一点我们就解决了第三个问题:若关闭服务消费者,已经发出的服务请求,需要等待响应返回。
Dubbo 优雅停机总体流程如图所示。
ps: Client 关闭调用链如下所示:
DubboProtocol#close
->ReferenceCountExchangeClient#close
->HeaderExchangeChannel#close
->AbstractClient#close
2.7.X
Dubbo 一般与 Spring 框架一起使用,2.5.X 版本的停机过程可能导致优雅停机失效。这是因为 Spring 框架关闭时也会触发相应的 ShutdownHook 事件,注销相关 Bean。这个过程若 Spring 率先执行停机,注销相关 Bean。而这时 Dubbo 关闭事件中引用到 Spring 中 Bean,这就将会使停机过程中发生异常,导致优雅停机失效。
为了解决该问题,Dubbo 在 2.6.X 版本开始重构这部分逻辑,并且不断迭代,直到 2.7.X 版本。
新版本新增 ShutdownHookListener
,继承 Spring ApplicationListener
接口,用以监听 Spring 相关事件。这里 ShutdownHookListener
仅仅监听 Spring 关闭事件,当 Spring 开始关闭,将会触发 ShutdownHookListener
内部逻辑。
public class SpringExtensionFactory implements ExtensionFactory {
private static final Logger logger = LoggerFactory.getLogger(SpringExtensionFactory.class);
private static final Set<ApplicationContext> CONTEXTS = new ConcurrentHashSet<ApplicationContext>();
private static final ApplicationListener SHUTDOWN_HOOK_LISTENER = new ShutdownHookListener();
public static void addApplicationContext(ApplicationContext context) {
CONTEXTS.add(context);
if (context instanceof ConfigurableApplicationContext) {
// 注册 ShutdownHook
((ConfigurableApplicationContext) context).registerShutdownHook();
// 取消 AbstractConfig 注册的 ShutdownHook 事件
DubboShutdownHook.getDubboShutdownHook().unregister();
}
BeanFactoryUtils.addApplicationListener(context, SHUTDOWN_HOOK_LISTENER);
}
// 继承 ApplicationListener,这个监听器将会监听容器关闭事件
private static class ShutdownHookListener implements ApplicationListener {
@Override
public void onApplicationEvent(ApplicationEvent event) {
if (event instanceof ContextClosedEvent) {
DubboShutdownHook shutdownHook = DubboShutdownHook.getDubboShutdownHook();
shutdownHook.doDestroy();
}
}
}
}
当 Spring 框架开始初始化之后,将会触发 SpringExtensionFactory
逻辑,之后将会注销 AbstractConfig
注册 ShutdownHook
,然后增加 ShutdownHookListener
。这样就完美解决上面『双 hook』 问题。
最后
优雅停机看起来实现不难,但是里面设计细枝末节却非常多,一个点实现有问题,就会导致优雅停机失效。如果你也正在实现优雅停机,不妨参考一下 Dubbo 的实现逻辑。
帮助文章
1、强烈推荐阅读 kirito 大神文章:一文聊透 Dubbo 优雅停机
以上是关于一文回顾.NET Core 基础设施演进之路的主要内容,如果未能解决你的问题,请参考以下文章