无服务器模型将底层计算基础结构中的代码抽象化,使开发人员无需进行大量设置,可专注于业务逻辑。 由于只需为代码执行资源和使用时长付费,无服务器代码降低了成本。
无服务器事件驱动模型适用于某个事件触发定义的操作的情况。 例如,接收传入的设备消息会触发存储供稍后使用,或者数据库更新会触发一些进一步的处理。
为了帮助探索 Azure 中的 Azure 无服务器技术,Microsoft 开发并测试了一个使用 Azure Functions 的无服务器应用程序。 本文将引导了解无服务器 Functions 解决方案的代码,并介绍设计决策、实现细节以及可能会遇到的一些“难点”。
探索解决方案
解决方案由两部分构成,介绍了一个假设的无人机交付系统。 无人机将“正在飞行”状态发送到云,云会存储这些消息供以后使用。 通过 Web 应用,用户可检索消息来获取设备的最新状态。
可以从 GitHub 下载此解决方案的代码。
本演练假定你基本熟悉以下技术:
你不一定要是 Functions 或事件中心方面的专家,但应该对其功能具有一定的了解。 下面是可帮助你入门的一些有用资源:
了解方案
Fabrikam 需要管理无人机交货服务使用的无人机群。 该应用程序包括两个主要功能区域:
事件引入。 在飞行期间,无人机会将状态消息发送到一个云终结点。 应用程序会引入和处理这些消息,并将结果写入后端数据库 (Azure Cosmos DB)。 设备以协议缓冲区 (protobuf) 格式发送消息。 Protobuf 是一种高效的自述性序列化格式。
这些消息包含部分更新。 每架无人机按固定的间隔发送包含所有状态字段的“关键帧”消息。 在关键帧之间,状态消息仅包含自上次发送消息以来更改的字段。 这是许多 IoT 设备的典型行为,这样可以节省带宽和能源。
Web 应用。 用户可以通过一个 Web 应用程序查找设备,以及查询设备的上次已知状态。 用户必须登录到应用程序并使用 Microsoft Entra ID 进行身份验证。 该应用程序仅允许已获授权的用户请求访问应用。
下面是该 Web 应用的屏幕截图,其中显示了查询结果:
设计应用程序
Fabrikam 决定使用 Azure Functions 来实现应用程序业务逻辑。 Azure Functions 是“函数即服务”(FaaS) 的一个例子。 在此计算模型中,函数是在云中部署的、在托管环境中运行的代码片段。 此托管环境抽象化运行代码的服务器。
为何要选择无服务器方案?
使用 Functions 的无服务器体系结构是事件驱动式体系结构的一个例子。 函数代码由函数外部的某个事件触发,在本例中,该事件为来自无人机的消息,或者来自客户端应用程序的 HTTP 请求。 使用函数应用就不需要为触发器编写任何代码。 只需编写响应触发器时要运行的代码。 这意味着,你可以专注于业务逻辑,而无需编写大量的代码来处理消息传送等基础结构问题。
使用无服务器体系结构还能在操作方面带来一些优势:
- 无需管理服务器。
- 按需动态分配计算资源。
- 只需支付执行代码时使用的计算资源的费用。
- 根据流量按需缩放计算资源。
体系结构
下图从较高层面显示了应用程序的体系结构:
在一个数据流中,箭头显示从设备到事件中心并触发函数应用的消息。 在应用中,一个箭头显示传输到存储队列的“死信”消息,另一个箭头显示写入 Azure Cosmos DB。 在另一个数据流中,箭头显示客户端 Web 应用通过 CDN 从 Blob 存储静态 Web 托管获取静态文件。 另一个箭头显示通过 API Management 进行的客户端 HTTP 请求。 在 API Management 中,一个箭头显示从 Azure Cosmos DB 触发和读取数据的函数应用。 另一个箭头显示通过 Microsoft Entra ID 进行身份验证。 还有一个用户登录到 Microsoft Entra ID。
事件引入:
- Azure 事件中心引入无人机消息。
- 事件中心生成包含消息数据的事件流。
- 这些事件触发 Azure Functions 应用来处理这些消息。
- 结果存储在 Azure Cosmos DB 中。
Web 应用:
- 通过 CDN 从 Blob 存储提供静态文件。
- 一个用户使用 Microsoft Entra ID 登录到 Web 应用。
- Azure API 管理充当公开 REST API 终结点的网关。
- 来自客户端的 HTTP 请求触发一个 Azure Functions 应用,该应用从 Azure Cosmos DB 读取数据并返回结果。
此应用程序基于以下参考体系结构。
可以阅读前面的文章,详细了解高级体系结构、解决方案中使用的 Azure 服务,以及可伸缩性、安全性和可靠性的注意事项。
无人机遥测函数
首先让我们看看用于处理来自事件中心的无人机消息的函数。 该函数在名为 RawTelemetryFunction
的类中定义:
namespace DroneTelemetryFunctionApp
{
public class RawTelemetryFunction
{
private readonly ITelemetryProcessor telemetryProcessor;
private readonly IStateChangeProcessor stateChangeProcessor;
private readonly TelemetryClient telemetryClient;
public RawTelemetryFunction(ITelemetryProcessor telemetryProcessor, IStateChangeProcessor stateChangeProcessor, TelemetryClient telemetryClient)
{
this.telemetryProcessor = telemetryProcessor;
this.stateChangeProcessor = stateChangeProcessor;
this.telemetryClient = telemetryClient;
}
}
...
}
此类包含多个依赖项,它们将通过依赖项注入注入到构造函数中:
ITelemetryProcessor
和IStateChangeProcessor
接口定义两个帮助器对象。 可以看到,这些对象完成了大部分工作。TelemetryClient 是 Application Insights SDK(经典 API)的一部分。 它用于向 Application Insights 发送自定义应用程序指标。
稍后,我们将了解如何配置依赖项注入。 目前只是假设这些依赖项存在。
配置事件中心触发器
函数中的逻辑是以名为 RunAsync
的异步方法实现的。 下面是方法签名:
[FunctionName("RawTelemetryFunction")]
[StorageAccount("DeadLetterStorage")]
public async Task RunAsync(
[EventHubTrigger("%EventHubName%", Connection = "EventHubConnection", ConsumerGroup ="%EventHubConsumerGroup%")]EventData[] messages,
[Queue("deadletterqueue")] IAsyncCollector<DeadLetterMessage> deadLetterMessages,
ILogger logger)
{
// implementation goes here
}
该方法采用以下参数:
-
messages
是事件中心消息的数组。 -
deadLetterMessages
是一个 Azure 存储队列,用于存储死信消息。 -
logging
提供一个日志记录接口用于写入应用程序日志。 这些日志将发送到 Azure Monitor。
EventHubTrigger
参数中的 messages
特性配置触发器。 该特性的属性指定事件中心名称、连接字符串和使用者组。 (使用者组是独立的事件中心事件流视图。这种抽象允许同一事件中心有多个使用者。)
请注意在某些特性属性中的百分比符号 (%)。 这些符号表示相应的属性指定了应用设置名称,而实际值将在运行时从应用设置中提取。 如果不使用百分比符号,则属性将提供文本值。
Connection
属性例外。 此属性始终指定应用设置名称,而永远不会指定文本值,因此不需要百分比符号。 做出这种区分的原因在于,连接字符串属于机密,永远不可签入到源代码中。
尽管另外两个属性(事件中心名称和使用者组)不像连接字符串那样属于敏感数据,但更好的做法依然是将它们放入应用设置,而不是将其硬编码。 这样,无需重新编译应用即可更新这些属性。
有关配置此触发器的详细信息,请参阅 Azure Functions 的 Azure 事件中心绑定。
消息处理逻辑
下面是处理消息批的 RawTelemetryFunction.RunAsync
方法的实现:
[FunctionName("RawTelemetryFunction")]
[StorageAccount("DeadLetterStorage")]
public async Task RunAsync(
[EventHubTrigger("%EventHubName%", Connection = "EventHubConnection", ConsumerGroup ="%EventHubConsumerGroup%")]EventData[] messages,
[Queue("deadletterqueue")] IAsyncCollector<DeadLetterMessage> deadLetterMessages,
ILogger logger)
{
telemetryClient.GetMetric("EventHubMessageBatchSize").TrackValue(messages.Length);
foreach (var message in messages)
{
DeviceState deviceState = null;
try
{
deviceState = telemetryProcessor.Deserialize(message.Body.Array, logger);
try
{
await stateChangeProcessor.UpdateState(deviceState, logger);
}
catch (Exception ex)
{
logger.LogError(ex, "Error updating status document", deviceState);
await deadLetterMessages.AddAsync(new DeadLetterMessage { Exception = ex, EventData = message, DeviceState = deviceState });
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error deserializing message", message.SystemProperties.PartitionKey, message.SystemProperties.SequenceNumber);
await deadLetterMessages.AddAsync(new DeadLetterMessage { Exception = ex, EventData = message });
}
}
}
调用该函数时,messages
参数会包含来自事件中心的消息数组。 分批处理消息通常比一次读取一条消息的性能更好。 但是,必须确保该函数具有复原能力,并且可以正常处理故障和异常。 否则,如果该函数在批处理的中途引发未经处理的异常,则可能会丢失剩余的消息。
错误处理部分更详细地讨论了相关的注意事项。
但如果忽略异常处理,则每条消息的处理逻辑就很简单:
- 调用
ITelemetryProcessor.Deserialize
反序列化包含设备状态更改的消息。 - 调用
IStateChangeProcessor.UpdateState
处理状态更改。
让我们更细致地探讨这两个方法,从 Deserialize
方法开始。
Deserialize 方法
TelemetryProcess.Deserialize
方法采用包含消息有效负载的字节数组。 它会反序列化此有效负载,并返回表示无人机状态的 DeviceState
对象。 该状态可以表示部分更新,只包含自上次已知状态后的增量数据。 因此,该方法需要处理反序列化有效负载中的 null
字段。
public class TelemetryProcessor : ITelemetryProcessor
{
private readonly ITelemetrySerializer<DroneState> serializer;
public TelemetryProcessor(ITelemetrySerializer<DroneState> serializer)
{
this.serializer = serializer;
}
public DeviceState Deserialize(byte[] payload, ILogger log)
{
DroneState restored = serializer.Deserialize(payload);
log.LogInformation("Deserialize message for device ID {DeviceId}", restored.DeviceId);
var deviceState = new DeviceState();
deviceState.DeviceId = restored.DeviceId;
if (restored.Battery != null)
{
deviceState.Battery = restored.Battery;
}
if (restored.FlightMode != null)
{
deviceState.FlightMode = (int)restored.FlightMode;
}
if (restored.Position != null)
{
deviceState.Latitude = restored.Position.Value.Latitude;
deviceState.Longitude = restored.Position.Value.Longitude;
deviceState.Altitude = restored.Position.Value.Altitude;
}
if (restored.Health != null)
{
deviceState.AccelerometerOK = restored.Health.Value.AccelerometerOK;
deviceState.GyrometerOK = restored.Health.Value.GyrometerOK;
deviceState.MagnetometerOK = restored.Health.Value.MagnetometerOK;
}
return deviceState;
}
}
此方法使用另一个帮助器接口 ITelemetrySerializer<T>
来反序列化原始消息。 然后,结果将转换为更易于使用的 POCO 模型。 此设计有助于将处理逻辑与序列化实现详细信息隔离开来。
ITelemetrySerializer<T>
接口在某个共享库中定义。设备模拟器也会使用该库来生成模拟设备事件并将其发送到事件中心。
using System;
namespace Serverless.Serialization
{
public interface ITelemetrySerializer<T>
{
T Deserialize(byte[] message);
ArraySegment<byte> Serialize(T message);
}
}
UpdateState 方法
StateChangeProcessor.UpdateState
方法应用状态更改。 每架无人机的上次已知状态作为 JSON 文档存储在 Azure Cosmos DB 中。 由于无人机会发送部分更新,因此,应用程序在获取更新时,不能仅仅覆盖文档。 而是需要提取以前的状态,合并字段,然后执行更新插入操作。
public class StateChangeProcessor : IStateChangeProcessor
{
private IDocumentClient client;
private readonly string cosmosDBDatabase;
private readonly string cosmosDBCollection;
public StateChangeProcessor(IDocumentClient client, IOptions<StateChangeProcessorOptions> options)
{
this.client = client;
this.cosmosDBDatabase = options.Value.COSMOSDB_DATABASE_NAME;
this.cosmosDBCollection = options.Value.COSMOSDB_DATABASE_COL;
}
public async Task<ResourceResponse<Document>> UpdateState(DeviceState source, ILogger log)
{
log.LogInformation("Processing change message for device ID {DeviceId}", source.DeviceId);
DeviceState target = null;
try
{
var response = await client.ReadDocumentAsync(UriFactory.CreateDocumentUri(cosmosDBDatabase, cosmosDBCollection, source.DeviceId),
new RequestOptions { PartitionKey = new PartitionKey(source.DeviceId) });
target = (DeviceState)(dynamic)response.Resource;
// Merge properties
target.Battery = source.Battery ?? target.Battery;
target.FlightMode = source.FlightMode ?? target.FlightMode;
target.Latitude = source.Latitude ?? target.Latitude;
target.Longitude = source.Longitude ?? target.Longitude;
target.Altitude = source.Altitude ?? target.Altitude;
target.AccelerometerOK = source.AccelerometerOK ?? target.AccelerometerOK;
target.GyrometerOK = source.GyrometerOK ?? target.GyrometerOK;
target.MagnetometerOK = source.MagnetometerOK ?? target.MagnetometerOK;
}
catch (DocumentClientException ex)
{
if (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
target = source;
}
}
var collectionLink = UriFactory.CreateDocumentCollectionUri(cosmosDBDatabase, cosmosDBCollection);
return await client.UpsertDocumentAsync(collectionLink, target);
}
}
此代码使用 IDocumentClient
接口从 Azure Cosmos DB 提取文档。 如果该文档存在,则新的状态值将合并到现有文档中。 否则会创建一个新文档。
UpsertDocumentAsync
方法会处理好这两种情况。
此代码已根据文档存在且可合并的情况进行优化。 给定的无人机发出第一条遥测消息时,ReadDocumentAsync
方法会引发异常,因为该无人机没有任何文档。 收到第一条消息后,将有可用的文档。
请注意,此类使用依赖项注入来为 Azure Cosmos DB 注入 IDocumentClient
,并注入一个包含配置设置的 IOptions<T>
。 稍后我们将了解如何设置依赖项注入。
注意
Azure Functions 支持 Azure Cosmos DB 的输出绑定。 此绑定可让函数应用在 Azure Cosmos DB 中写入文档,而无需编写任何代码。 但是,输出绑定不适用于此特定方案,因为其中使用了所需的自定义更新插入逻辑。
错误处理。
如前所述,RawTelemetryFunction
函数应用会在循环中处理消息批。 这意味着,该函数需要正常处理任何异常,并继续处理该批中的剩余消息。 否则可能会丢弃消息。
如果在处理消息时遇到异常,该函数会将该消息放入死信队列:
catch (Exception ex)
{
logger.LogError(ex, "Error deserializing message", message.SystemProperties.PartitionKey, message.SystemProperties.SequenceNumber);
await deadLetterMessages.AddAsync(new DeadLetterMessage { Exception = ex, EventData = message });
}
死信队列是使用存储队列的输出绑定定义的:
[FunctionName("RawTelemetryFunction")]
[StorageAccount("DeadLetterStorage")] // App setting that holds the connection string
public async Task RunAsync(
[EventHubTrigger("%EventHubName%", Connection = "EventHubConnection", ConsumerGroup ="%EventHubConsumerGroup%")]EventData[] messages,
[Queue("deadletterqueue")] IAsyncCollector<DeadLetterMessage> deadLetterMessages, // output binding
ILogger logger)
此处的 Queue
特性指定输出绑定,StorageAccount
特性指定包含存储帐户连接字符串的应用设置的名称。
设置依赖项注入
以下代码设置 RawTelemetryFunction
函数的依赖项注入:
[assembly: FunctionsStartup(typeof(DroneTelemetryFunctionApp.Startup))]
namespace DroneTelemetryFunctionApp
{
public class Startup : FunctionsStartup
{
public override void Configure(IFunctionsHostBuilder builder)
{
builder.Services.AddOptions<StateChangeProcessorOptions>()
.Configure<IConfiguration>((configSection, configuration) =>
{
configuration.Bind(configSection);
});
builder.Services.AddTransient<ITelemetrySerializer<DroneState>, TelemetrySerializer<DroneState>>();
builder.Services.AddTransient<ITelemetryProcessor, TelemetryProcessor>();
builder.Services.AddTransient<IStateChangeProcessor, StateChangeProcessor>();
builder.Services.AddSingleton<CosmosClient>(ctx => {
var config = ctx.GetService<IConfiguration>();
var cosmosDBEndpoint = config.GetValue<string>("CosmosDBEndpoint");
return new CosmosClient(
accountEndpoint: cosmosDBEndpoint,
new DefaultAzureCredential());
});
}
}
}
针对 .NET 编写的 Azure Functions 可以使用 ASP.NET Core 依赖项注入框架。 基本思路是为程序集声明一个启动方法。 该方法采用 IFunctionsHostBuilder
接口,该接口用于声明 DI 的依赖项。 可以通过针对 Add*
对象调用 Services
方法来完成此操作。 添加依赖项时,请指定其生存期:
- 每次请求 Transient 对象,都会创建这些对象。
- 每次执行函数后,都会创建 Scoped 对象。
- 在函数宿主的生存期内,每次执行函数时都会重复使用 Singleton 对象。
在此示例中,TelemetryProcessor
和 StateChangeProcessor
对象声明为暂时性对象。 这种声明适用于轻型无状态服务。 另一方面,为获得最佳性能,DocumentClient
类应是单一实例。 有关详细信息,请参阅 Azure Cosmos DB 和 .NET 的性能提示。
如果参考前面的 RawTelemetryFunction 代码的话,将会看到,有另一个依赖项未出现在 DI 设置代码中,即,用于记录应用程序指标的 TelemetryClient
类。 Functions 运行时会自动将此类注册到 DI 容器中,因此你无需显式注册。
有关 Azure Functions 中的 DI 的详细信息,请参阅以下文章:
在 DI 中传递配置设置
有时,必须使用某些配置值初始化某个对象。 一般情况下,这些设置应来自应用设置或来自 Azure Key Vault(使用机密时)。
此应用程序包含两个示例。 首先,DocumentClient
类采用 Azure Cosmos DB 服务终结点和密钥。 对于此对象,应用程序将注册 DI 容器要调用的 lambda。 此 lambda 使用 IConfiguration
接口读取配置值:
builder.Services.AddSingleton<CosmosClient>(ctx => {
var config = ctx.GetService<IConfiguration>();
var cosmosDBEndpoint = config.GetValue<string>("CosmosDBEndpoint");
return new CosmosClient(
accountEndpoint: cosmosDBEndpoint,
new DefaultAzureCredential());
});
第二个示例是 StateChangeProcessor
类。 对于此对象,我们将使用称作选项模式的方法。 工作原理如下:
定义一个包含配置设置的
T
类。 在本例中,配置设置为 Azure Cosmos DB 数据库名称和集合名称。public class StateChangeProcessorOptions { public string COSMOSDB_DATABASE_NAME { get; set; } public string COSMOSDB_DATABASE_COL { get; set; } }
将
T
类添加为 DI 的 options 类。builder.Services.AddOptions<StateChangeProcessorOptions>() .Configure<IConfiguration>((configSection, configuration) => { configuration.Bind(configSection); });
在所要配置的类的构造函数中,包含
IOptions<T>
参数。public StateChangeProcessor(IDocumentClient client, IOptions<StateChangeProcessorOptions> options)
DI 系统会自动在 options 类中填充配置值,并将此类传递给构造函数。
此方法有几个优点:
- 从配置值的源代码中分离该类。
- 轻松设置不同的配置源,例如环境变量或 JSON 配置文件。
- 简化单元测试。
- 使用强类型化的 options 类。与只传入标量值相比,这样更不容易出错。
GetStatus 函数
此解决方案中的另一个 Functions 应用实现一个简单的 REST API 来获取无人机的上次已知状态。 此函数在名为 GetStatusFunction
的类中定义。 下面是该函数的完整代码:
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
using System.Security.Claims;
using System.Threading.Tasks;
namespace DroneStatusFunctionApp
{
public static class GetStatusFunction
{
public const string GetDeviceStatusRoleName = "GetStatus";
[FunctionName("GetStatusFunction")]
public static IActionResult Run(
[HttpTrigger(AuthorizationLevel.Function, "get", Route = null)]HttpRequest req,
[CosmosDB(
databaseName: "%COSMOSDB_DATABASE_NAME%",
collectionName: "%COSMOSDB_DATABASE_COL%",
ConnectionStringSetting = "COSMOSDB_CONNECTION_STRING",
Id = "{Query.deviceId}",
PartitionKey = "{Query.deviceId}")] dynamic deviceStatus,
ClaimsPrincipal principal,
ILogger log)
{
log.LogInformation("Processing GetStatus request.");
if (!principal.IsAuthorizedByRoles(new[] { GetDeviceStatusRoleName }, log))
{
return new UnauthorizedResult();
}
string deviceId = req.Query["deviceId"];
if (deviceId == null)
{
return new BadRequestObjectResult("Missing DeviceId");
}
if (deviceStatus == null)
{
return new NotFoundResult();
}
else
{
return new OkObjectResult(deviceStatus);
}
}
}
}
该函数使用 HTTP 触发器来处理 HTTP GET 请求。 该函数使用 Azure Cosmos DB 输入绑定来提取请求的文档。 需要注意的一点是,在该函数内部执行授权逻辑之前,此绑定就会运行。 如果未经授权的用户请求文档,函数绑定仍会提取该文档。 然后,授权代码会返回 401,因此该用户看不到该文档。 是否接受此行为取决于具体的要求。 例如,此方法可能会增大审核敏感数据访问权限的难度。
身份验证和授权
该 Web 应用使用 Microsoft Entra ID 对用户进行身份验证。 由于应用是在浏览器中运行的单页应用程序(SPA),因此 授权代码流 是合适的:
- Web 应用将用户重定向到标识提供者(在本例中为 Microsoft Entra ID)。
- 用户输入其凭据。
- 标识提供者使用授权代码重定向回 Web 应用,稍后可以交换访问令牌。
- Web 应用向 Web API 发送请求,并在授权标头中包含资源的访问令牌。
可将函数应用程序配置为在不使用任何代码的情况下对用户进行身份验证。 有关详细信息,请参阅 Azure 应用服务中的身份验证和授权。
另一方面,授权通常需要某种业务逻辑。 Microsoft Entra ID 支持基于声明的身份验证。 在此模型中,用户的标识表示为来自标识提供者的一组声明。 声明可以是有关用户的任何信息片段,例如其姓名或电子邮件地址。
访问令牌包含一部分用户声明。 这些声明包括用户分配到的任何应用程序角色。
函数的 principal
参数是一个 ClaimsPrincipal 对象,其中包含来自访问令牌的声明。 每个声明是声明类型和声明值的键/值对。 应用程序使用这些信息来为请求授权。
以下扩展方法测试 ClaimsPrincipal
对象是否包含一组角色。 如果缺少任何指定的角色,此方法将返回 false
。 如果此方法返回 false,则函数将返回 HTTP 401(未授权)。
namespace DroneStatusFunctionApp
{
public static class ClaimsPrincipalAuthorizationExtensions
{
public static bool IsAuthorizedByRoles(
this ClaimsPrincipal principal,
string[] roles,
ILogger log)
{
var principalRoles = new HashSet<string>(principal.Claims.Where(kvp => kvp.Type == "roles").Select(kvp => kvp.Value));
var missingRoles = roles.Where(r => !principalRoles.Contains(r)).ToArray();
if (missingRoles.Length > 0)
{
log.LogWarning("The principal does not have the required {roles}", string.Join(", ", missingRoles));
return false;
}
return true;
}
}
}
有关此应用程序中的身份验证和授权的详细信息,请参阅参考体系结构的安全注意事项部分。
后续步骤
了解此参考解决方案的工作原理后,请了解类似解决方案的最佳做法和建议。 有关无服务器 Web 应用,请参阅 Azure 上的无服务器 Web 应用程序。
Azure Functions 只是一种 Azure 计算选项。 如需在选择计算技术时获取帮助,请参阅为应用程序选择 Azure 计算服务。
相关资源
- 有关在本地和云中开发无服务器解决方案的深入讨论,请阅读无服务器应用:体系结构、模式和 Azure 实现。
- 详细了解事件驱动的体系结构样式。