This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

构建模块

Dapr 提供的功能,旨在解决分布式应用程序开发中的常见挑战

概念部分中,您可以查看 Dapr 构建模块的高级概述

展示不同 Dapr API 构建模块的图示

1 - 服务调用

实现直接且安全的服务间方法调用

1.1 - 服务调用概述

服务调用API模块的概述

通过服务调用,您的应用程序可以使用标准的gRPCHTTP协议可靠且安全地与其他应用程序进行通信。

在许多基于微服务的应用程序中,多个服务需要能够相互通信。这种服务间通信要求应用程序开发人员处理以下问题:

  • 服务发现。 如何找到我的不同服务?
  • 标准化服务间的API调用。 如何在服务之间调用方法?
  • 安全的服务间通信。 如何通过加密安全地调用其他服务并对方法应用访问控制?
  • 缓解请求超时或失败。 如何处理重试和瞬态错误?
  • 实现可观测性和追踪。 如何使用追踪查看带有指标的调用图以诊断生产中的问题?

服务调用API

Dapr通过提供一个类似反向代理的服务调用API来解决这些挑战,该API内置了服务发现,并利用了分布式追踪、指标、错误处理、加密等功能。

Dapr采用sidecar架构。要使用Dapr调用应用程序:

  • 您在Dapr实例上使用invoke API。
  • 每个应用程序与其自己的Dapr实例通信。
  • Dapr实例相互发现并通信。

以下概述视频和演示展示了Dapr服务调用的工作原理。

下图概述了Dapr的服务调用在两个集成Dapr的应用程序之间的工作原理。

显示服务调用步骤的图示
  1. 服务A发起一个HTTP或gRPC调用,目标是服务B。调用发送到本地Dapr sidecar。
  2. Dapr使用正在运行的名称解析组件在给定的托管平台上发现服务B的位置。
  3. Dapr将消息转发到服务B的Dapr sidecar
    • 注意:所有Dapr sidecar之间的调用都通过gRPC进行以提高性能。只有服务与Dapr sidecar之间的调用可以是HTTP或gRPC。
  4. 服务B的Dapr sidecar将请求转发到服务B上的指定端点(或方法)。服务B然后运行其业务逻辑代码。
  5. 服务B向服务A发送响应。响应发送到服务B的sidecar。
  6. Dapr将响应转发到服务A的Dapr sidecar。
  7. 服务A接收响应。

您还可以使用服务调用API调用非Dapr HTTP端点。例如,您可能只在整个应用程序的一部分中使用Dapr,可能无法访问代码以迁移现有应用程序以使用Dapr,或者只是需要调用外部HTTP服务。阅读“如何:使用HTTP调用非Dapr端点”以获取更多信息。

功能

服务调用提供了多种功能,使您可以轻松地在应用程序之间调用方法或调用外部HTTP端点。

HTTP和gRPC服务调用

  • HTTP:如果您已经在应用程序中使用HTTP协议,使用Dapr HTTP头可能是最简单的入门方式。您无需更改现有的端点URL;只需添加dapr-app-id头即可开始。有关更多信息,请参阅使用HTTP调用服务
  • gRPC:Dapr允许用户保留自己的proto服务并以gRPC的方式工作。这意味着您可以使用服务调用来调用现有的gRPC应用程序,而无需包含任何Dapr SDK或自定义gRPC服务。有关更多信息,请参阅Dapr和gRPC的操作教程

服务到服务的安全性

通过Dapr Sentry服务,所有Dapr应用程序之间的调用都可以通过托管平台上的相互(mTLS)认证来实现安全,包括自动证书轮换。

有关更多信息,请阅读服务到服务的安全性文章。

包括重试的弹性

在调用失败和瞬态错误的情况下,服务调用提供了一种弹性功能,可以在回退时间段内自动重试。要了解更多信息,请参阅弹性文章

具有可观测性的追踪和指标

默认情况下,所有应用程序之间的调用都会被追踪,并收集指标以提供应用程序的洞察和诊断。这在生产场景中特别重要,提供了服务之间调用的调用图和指标。有关更多信息,请阅读可观测性

访问控制

通过访问策略,应用程序可以控制:

  • 哪些应用程序被允许调用它们。
  • 应用程序被授权做什么。

例如,您可以限制包含人员信息的敏感应用程序不被未授权的应用程序访问。结合服务到服务的安全通信,您可以提供软多租户部署。

有关更多信息,请阅读服务调用的访问控制允许列表文章。

命名空间范围

您可以将应用程序限定到命名空间以进行部署和安全,并在部署到不同命名空间的服务之间进行调用。有关更多信息,请阅读跨命名空间的服务调用文章。

使用mDNS的轮询负载均衡

Dapr通过mDNS协议提供服务调用请求的轮询负载均衡,例如在单台机器或多台联网的物理机器上。

下图显示了其工作原理的示例。如果您有一个应用程序实例,应用程序ID为FrontEnd,以及三个应用程序实例,应用程序ID为Cart,并且您从FrontEnd应用程序调用Cart应用程序,Dapr在三个实例之间进行轮询。这些实例可以在同一台机器上或不同的机器上。

显示服务调用步骤的图示

注意:应用程序ID在_应用程序_中是唯一的,而不是应用程序实例。无论该应用程序存在多少个实例(由于扩展),它们都将共享相同的应用程序ID。

可交换的服务发现

Dapr可以在多种托管平台上运行。为了启用可交换的服务发现,Dapr使用名称解析组件。例如,Kubernetes名称解析组件使用Kubernetes DNS服务来解析在集群中运行的其他应用程序的位置。

自托管机器可以使用mDNS名称解析组件。作为替代方案,您可以使用SQLite名称解析组件在单节点环境中运行Dapr,并用于本地开发场景。属于集群的Dapr sidecar将其信息存储在本地机器上的SQLite数据库中。

Consul名称解析组件特别适合多机部署,并且可以在任何托管环境中使用,包括Kubernetes、多台虚拟机或自托管。

HTTP服务调用的流式处理

您可以在HTTP服务调用中将数据作为流处理。这可以在使用Dapr通过HTTP调用另一个服务时提供性能和内存利用率的改进,尤其是在请求或响应体较大的情况下。

下图演示了数据流的六个步骤。

显示表中描述的服务调用步骤的图示
  1. 请求:“应用程序A"到"Dapr sidecar A”
  2. 请求:“Dapr sidecar A"到"Dapr sidecar B”
  3. 请求:“Dapr sidecar B"到"应用程序B”
  4. 响应:“应用程序B"到"Dapr sidecar B”
  5. 响应:“Dapr sidecar B"到"Dapr sidecar A”
  6. 响应:“Dapr sidecar A"到"应用程序A”

示例架构

按照上述调用顺序,假设您有如Hello World教程中描述的应用程序,其中一个Python应用程序调用一个Node.js应用程序。在这种情况下,Python应用程序将是"服务A",Node.js应用程序将是"服务B"。

下图再次显示了本地机器上的1-7序列,显示了API调用:

  1. Node.js应用程序的Dapr应用程序ID为nodeapp。Python应用程序通过POST http://localhost:3500/v1.0/invoke/nodeapp/method/neworder调用Node.js应用程序的neworder方法,该请求首先发送到Python应用程序的本地Dapr sidecar。
  2. Dapr使用名称解析组件(在这种情况下是自托管时的mDNS)发现Node.js应用程序的位置,该组件在您的本地机器上运行。
  3. Dapr使用刚刚接收到的位置将请求转发到Node.js应用程序的sidecar。
  4. Node.js应用程序的sidecar将请求转发到Node.js应用程序。Node.js应用程序执行其业务逻辑,记录传入消息,然后将订单ID持久化到Redis(图中未显示)。
  5. Node.js应用程序通过Node.js sidecar向Python应用程序发送响应。
  6. Dapr将响应转发到Python Dapr sidecar。
  7. Python应用程序接收响应。

试用服务调用

快速入门和教程

Dapr文档包含多个利用服务调用构建模块的快速入门,适用于不同的示例架构。为了直观地理解服务调用API及其功能,我们建议从我们的快速入门开始:

快速入门/教程描述
服务调用快速入门这个快速入门让您直接与服务调用构建模块进行交互。
Hello World教程本教程展示了如何在本地机器上运行服务调用和状态管理构建模块。
Hello World Kubernetes教程本教程演示了如何在Kubernetes中使用Dapr,并涵盖了服务调用和状态管理构建模块。

直接在您的应用程序中开始使用服务调用

想跳过快速入门?没问题。您可以直接在应用程序中试用服务调用构建模块,以安全地与其他服务通信。在Dapr安装完成后,您可以通过以下方式开始使用服务调用API。

使用以下方式调用服务:

  • HTTP和gRPC服务调用(推荐的设置方法)
  • 直接调用API - 除了代理,还有一个选项可以直接调用服务调用API以调用GET端点。只需将您的地址URL更新为localhost:<dapr-http-port>,您就可以直接调用API。您还可以在上面链接的HTTP代理文档中阅读更多关于此的信息。
  • SDKs - 如果您正在使用Dapr SDK,您可以直接通过SDK使用服务调用。选择您需要的SDK,并使用Dapr客户端调用服务。有关更多信息,请阅读Dapr SDKs

为了快速测试,尝试使用Dapr CLI进行服务调用:

  • Dapr CLI命令 - 一旦设置了Dapr CLI,使用dapr invoke --method <method-name>命令以及方法标志和感兴趣的方法。有关更多信息,请阅读Dapr CLI

下一步

1.2 - 操作指南:使用HTTP调用服务

通过服务调用实现服务之间的通信

本文演示了如何部署服务,每个服务都有一个唯一的应用程序ID,以便其他服务可以通过HTTP进行服务调用来发现并调用它们的端点。

示例服务的服务调用图示

为服务选择一个ID

Dapr允许您为应用程序分配一个全局唯一的ID。无论应用程序有多少实例,该ID都代表应用程序的状态。

dapr run --app-id checkout --app-protocol http --dapr-http-port 3500 -- python3 checkout/app.py

dapr run --app-id order-processor --app-port 8001  --app-protocol http --dapr-http-port 3501 -- python3 order-processor/app.py

如果您的应用程序使用TLS,您可以通过设置--app-protocol https来告诉Dapr通过TLS连接调用您的应用程序:

dapr run --app-id checkout --app-protocol https --dapr-http-port 3500 -- python3 checkout/app.py

dapr run --app-id order-processor --app-port 8001 --app-protocol https --dapr-http-port 3501 -- python3 order-processor/app.py
dapr run --app-id checkout --app-protocol http --dapr-http-port 3500 -- npm start

dapr run --app-id order-processor --app-port 5001  --app-protocol http --dapr-http-port 3501 -- npm start

如果您的应用程序使用TLS,您可以通过设置--app-protocol https来告诉Dapr通过TLS连接调用您的应用程序:

dapr run --app-id checkout --dapr-http-port 3500 --app-protocol https -- npm start

dapr run --app-id order-processor --app-port 5001 --dapr-http-port 3501 --app-protocol https -- npm start
dapr run --app-id checkout --app-protocol http --dapr-http-port 3500 -- dotnet run

dapr run --app-id order-processor --app-port 7001 --app-protocol http --dapr-http-port 3501 -- dotnet run

如果您的应用程序使用TLS,您可以通过设置--app-protocol https来告诉Dapr通过TLS连接调用您的应用程序:

dapr run --app-id checkout --dapr-http-port 3500 --app-protocol https -- dotnet run

dapr run --app-id order-processor --app-port 7001 --dapr-http-port 3501 --app-protocol https -- dotnet run
dapr run --app-id checkout --app-protocol http --dapr-http-port 3500 -- java -jar target/CheckoutService-0.0.1-SNAPSHOT.jar

dapr run --app-id order-processor --app-port 9001 --app-protocol http --dapr-http-port 3501 -- java -jar target/OrderProcessingService-0.0.1-SNAPSHOT.jar

如果您的应用程序使用TLS,您可以通过设置--app-protocol https来告诉Dapr通过TLS连接调用您的应用程序:

dapr run --app-id checkout --dapr-http-port 3500 --app-protocol https -- java -jar target/CheckoutService-0.0.1-SNAPSHOT.jar

dapr run --app-id order-processor --app-port 9001 --dapr-http-port 3501 --app-protocol https -- java -jar target/OrderProcessingService-0.0.1-SNAPSHOT.jar
dapr run --app-id checkout --dapr-http-port 3500 -- go run .

dapr run --app-id order-processor --app-port 6006 --app-protocol http --dapr-http-port 3501 -- go run .

如果您的应用程序使用TLS,您可以通过设置--app-protocol https来告诉Dapr通过TLS连接调用您的应用程序:

dapr run --app-id checkout --dapr-http-port 3500 --app-protocol https -- go run .

dapr run --app-id order-processor --app-port 6006 --dapr-http-port 3501 --app-protocol https -- go run .

在Kubernetes中部署时设置app-id

在Kubernetes中,在您的pod上设置dapr.io/app-id注解:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: <language>-app
  namespace: default
  labels:
    app: <language>-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: <language>-app
  template:
    metadata:
      labels:
        app: <language>-app
      annotations:
        dapr.io/enabled: "true"
        dapr.io/app-id: "order-processor"
        dapr.io/app-port: "6001"
...

如果您的应用程序使用TLS连接,您可以通过app-protocol: "https"注解告诉Dapr通过TLS调用您的应用程序(完整列表在此)。请注意,Dapr不会验证应用程序提供的TLS证书。

调用服务

要使用Dapr调用应用程序,您可以在任何Dapr实例上使用invoke API。sidecar编程模型鼓励每个应用程序与其自己的Dapr实例交互。Dapr的sidecar会自动发现并相互通信。

以下是利用Dapr SDK进行服务调用的代码示例。

# 依赖
import random
from time import sleep
import logging
import requests

# 代码
logging.basicConfig(level = logging.INFO) 
while True:
    sleep(random.randrange(50, 5000) / 1000)
    orderId = random.randint(1, 1000)
    # 调用服务
    result = requests.post(
       url='%s/orders' % (base_url),
       data=json.dumps(order),
       headers=headers
    )    
    logging.info('Order requested: ' + str(orderId))
    logging.info('Result: ' + str(result))
// 依赖
import axios from "axios";

// 代码
const daprHost = "127.0.0.1"; 

var main = function() {
    for(var i=0;i<10;i++) {
        sleep(5000);
        var orderId = Math.floor(Math.random() * (1000 - 1) + 1);
        start(orderId).catch((e) => {
            console.error(e);
            process.exit(1);
        });
    }
}

// 调用服务
const result = await axios.post('order-processor' , "orders/" + orderId , axiosConfig);
console.log("Order requested: " + orderId);
console.log("Result: " + result.config.data);

function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

main();
// 依赖
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using System.Threading;

// 代码
namespace EventService
{
  class Program
   {
       static async Task Main(string[] args)
       {
          while(true) {
               await Task.Delay(5000);
               var random = new Random();
               var orderId = random.Next(1,1000);

               // 使用Dapr SDK调用方法
               var order = new Order(orderId.ToString());

               var httpClient = DaprClient.CreateInvokeHttpClient();
               var response = await httpClient.PostAsJsonAsync("http://order-processor/orders", order);               
               var result = await response.Content.ReadAsStringAsync();
               
               Console.WriteLine("Order requested: " + orderId);
               Console.WriteLine("Result: " + result);
   	    }
       }
   }
}
// 依赖
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.TimeUnit;

// 代码
@SpringBootApplication
public class CheckoutServiceApplication {
    private static final HttpClient httpClient = HttpClient.newBuilder()
            .version(HttpClient.Version.HTTP_2)
            .connectTimeout(Duration.ofSeconds(10))
            .build();

    public static void main(String[] args) throws InterruptedException, IOException {
        while (true) {
            TimeUnit.MILLISECONDS.sleep(5000);
            Random random = new Random();
            int orderId = random.nextInt(1000 - 1) + 1;

            // 创建一个Map来表示请求体
            Map<String, Object> requestBody = new HashMap<>();
            requestBody.put("orderId", orderId);
            // 根据需要向requestBody Map添加其他字段

            HttpRequest request = HttpRequest.newBuilder()
                    .POST(HttpRequest.BodyPublishers.ofString(new JSONObject(requestBody).toString()))
                    .uri(URI.create(dapr_url))
                    .header("Content-Type", "application/json")
                    .header("dapr-app-id", "order-processor")
                    .build();

            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());

            System.out.println("Order passed: " + orderId);
            TimeUnit.MILLISECONDS.sleep(1000);

            log.info("Order requested: " + orderId);
            log.info("Result: " + response.body());
        }
    }
}
package main

import (
	"fmt"
	"io"
	"log"
	"math/rand"
	"net/http"
	"os"
	"time"
)

func main() {
	daprHttpPort := os.Getenv("DAPR_HTTP_PORT")
	if daprHttpPort == "" {
		daprHttpPort = "3500"
	}

	client := &http.Client{
		Timeout: 15 * time.Second,
	}

	for i := 0; i < 10; i++ {
		time.Sleep(5000)
		orderId := rand.Intn(1000-1) + 1

		url := fmt.Sprintf("http://localhost:%s/checkout/%v", daprHttpPort, orderId)
		req, err := http.NewRequest(http.MethodGet, url, nil)
		if err != nil {
			panic(err)
		}

		// 将目标应用程序ID添加为头的一部分
		req.Header.Add("dapr-app-id", "order-processor")

		// 调用服务
		resp, err := client.Do(req)
		if err != nil {
			log.Fatal(err.Error())
		}

		b, err := io.ReadAll(resp.Body)
		if err != nil {
			panic(err)
		}

		fmt.Println(string(b))
	}
}

其他URL格式

要调用’GET’端点:

curl http://localhost:3602/v1.0/invoke/checkout/method/checkout/100

为了尽量减少URL路径的更改,Dapr提供了以下方式来调用服务API:

  1. 将URL中的地址更改为localhost:<dapr-http-port>
  2. 添加一个dapr-app-id头来指定目标服务的ID,或者通过HTTP基本认证传递ID:http://dapr-app-id:<service-id>@localhost:3602/path

例如,以下命令:

curl http://localhost:3602/v1.0/invoke/checkout/method/checkout/100

等同于:

curl -H 'dapr-app-id: checkout' 'http://localhost:3602/checkout/100' -X POST

或:

curl 'http://dapr-app-id:checkout@localhost:3602/checkout/100' -X POST

使用CLI:

dapr invoke --app-id checkout --method checkout/100

在URL中包含查询字符串

您还可以在URL末尾附加查询字符串或片段,Dapr将其原样传递。这意味着如果您需要在服务调用中传递一些不属于有效负载或路径的附加参数,可以通过在URL末尾附加一个?,然后是用=号分隔的键/值对,并用&分隔。例如:

curl 'http://dapr-app-id:checkout@localhost:3602/checkout/100?basket=1234&key=abc' -X POST

命名空间

支持命名空间的平台上运行时,您可以在应用程序ID中包含目标应用程序的命名空间。例如,按照<app>.<namespace>格式,使用checkout.production

在此示例中,使用命名空间调用服务将如下所示:

curl http://localhost:3602/v1.0/invoke/checkout.production/method/checkout/100 -X POST

有关命名空间的更多信息,请参阅跨命名空间API规范

查看跟踪和日志

我们上面的示例向您展示了如何直接调用本地或Kubernetes中运行的不同服务。Dapr:

  • 输出指标、跟踪和日志信息,
  • 允许您可视化服务之间的调用图并记录错误,
  • 可选地,记录有效负载体。

有关跟踪和日志的更多信息,请参阅可观察性文章。

相关链接

1.3 - 如何使用 gRPC 调用服务

通过服务调用在服务之间进行通信

本文介绍如何通过 Dapr 使用 gRPC 进行服务间通信。

通过 Dapr 的 gRPC 代理功能,您可以使用现有的基于 proto 的 gRPC 服务,并让流量通过 Dapr sidecar。这为开发人员带来了以下 Dapr 服务调用 的优势:

  1. 双向认证
  2. 跟踪
  3. 指标
  4. 访问控制列表
  5. 网络级别的弹性
  6. 基于 API 令牌的认证

Dapr 支持代理所有类型的 gRPC 调用,包括一元和流式调用

第一步:运行 gRPC 服务器

以下示例来自 “hello world” grpc-go 示例。虽然此示例使用 Go 语言,但相同的概念适用于所有支持 gRPC 的编程语言。

package main

import (
	"context"
	"log"
	"net"

	"google.golang.org/grpc"
	pb "google.golang.org/grpc/examples/helloworld/helloworld"
)

const (
	port = ":50051"
)

// server 用于实现 helloworld.GreeterServer。
type server struct {
	pb.UnimplementedGreeterServer
}

// SayHello 实现 helloworld.GreeterServer
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
	log.Printf("Received: %v", in.GetName())
	return &pb.HelloReply{Message: "Hello " + in.GetName()}, nil
}

func main() {
	lis, err := net.Listen("tcp", port)
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}
	s := grpc.NewServer()
	pb.RegisterGreeterServer(s, &server{})
	log.Printf("server listening at %v", lis.Addr())
	if err := s.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}

这个 Go 应用实现了 Greeter proto 服务并提供了一个 SayHello 方法。

使用 Dapr CLI 运行 gRPC 服务器

dapr run --app-id server --app-port 50051 -- go run main.go

使用 Dapr CLI,我们为应用分配了一个唯一的 ID,server,通过 --app-id 标志指定。

第二步:调用服务

以下示例展示了如何使用 Dapr 从 gRPC 客户端发现 Greeter 服务。 注意,客户端不是直接通过端口 50051 调用目标服务,而是通过端口 50007 调用其本地 Dapr sidecar,这样就提供了所有的服务调用功能,包括服务发现、跟踪、mTLS 和重试。

package main

import (
	"context"
	"log"
	"time"

	"google.golang.org/grpc"
	pb "google.golang.org/grpc/examples/helloworld/helloworld"
	"google.golang.org/grpc/metadata"
)

const (
	address = "localhost:50007"
)

func main() {
	// 设置与服务器的连接。
	conn, err := grpc.Dial(address, grpc.WithInsecure(), grpc.WithBlock())
	if err != nil {
		log.Fatalf("did not connect: %v", err)
	}
	defer conn.Close()
	c := pb.NewGreeterClient(conn)

	ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
	defer cancel()

	ctx = metadata.AppendToOutgoingContext(ctx, "dapr-app-id", "server")
	r, err := c.SayHello(ctx, &pb.HelloRequest{Name: "Darth Tyrannus"})
	if err != nil {
		log.Fatalf("could not greet: %v", err)
	}

	log.Printf("Greeting: %s", r.GetMessage())
}

以下行告诉 Dapr 发现并调用名为 server 的应用:

ctx = metadata.AppendToOutgoingContext(ctx, "dapr-app-id", "server")

所有 gRPC 支持的语言都允许添加元数据。以下是一些示例:

Metadata headers = new Metadata();
Metadata.Key<String> jwtKey = Metadata.Key.of("dapr-app-id", "server");

GreeterService.ServiceBlockingStub stub = GreeterService.newBlockingStub(channel);
stub = MetadataUtils.attachHeaders(stub, header);
stub.SayHello(new HelloRequest() { Name = "Darth Malak" });
var metadata = new Metadata
{
	{ "dapr-app-id", "server" }
};

var call = client.SayHello(new HelloRequest { Name = "Darth Nihilus" }, metadata);
metadata = (('dapr-app-id', 'server'),)
response = stub.SayHello(request={ name: 'Darth Revan' }, metadata=metadata)
const metadata = new grpc.Metadata();
metadata.add('dapr-app-id', 'server');

client.sayHello({ name: "Darth Malgus" }, metadata)
metadata = { 'dapr-app-id' : 'server' }
response = service.sayHello({ 'name': 'Darth Bane' }, metadata)
grpc::ClientContext context;
context.AddMetadata("dapr-app-id", "server");

使用 Dapr CLI 运行客户端

dapr run --app-id client --dapr-grpc-port 50007 -- go run main.go

查看遥测

如果您在本地运行 Dapr 并安装了 Zipkin,请在浏览器中打开 http://localhost:9411 并查看客户端和服务器之间的跟踪。

部署到 Kubernetes

在您的部署上设置以下 Dapr 注解:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: grpc-app
  namespace: default
  labels:
    app: grpc-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: grpc-app
  template:
    metadata:
      labels:
        app: grpc-app
      annotations:
        dapr.io/enabled: "true"
        dapr.io/app-id: "server"
        dapr.io/app-protocol: "grpc"
        dapr.io/app-port: "50051"
...

dapr.io/app-protocol: "grpc" 注解告诉 Dapr 使用 gRPC 调用应用。

如果您的应用使用 TLS 连接,您可以通过 app-protocol: "grpcs" 注解告诉 Dapr 通过 TLS 调用您的应用(完整列表在此)。注意,Dapr 不会验证应用提供的 TLS 证书。

命名空间

支持命名空间的平台上运行时,您可以在应用 ID 中包含目标应用的命名空间:myApp.production

例如,在不同命名空间中调用 gRPC 服务器:

ctx = metadata.AppendToOutgoingContext(ctx, "dapr-app-id", "server.production")

有关命名空间的更多信息,请参阅跨命名空间 API 规范

第三步:查看跟踪和日志

上面的示例展示了如何直接调用本地或 Kubernetes 中运行的不同服务。Dapr 输出指标、跟踪和日志信息,允许您可视化服务之间的调用图、记录错误并可选地记录负载体。

有关跟踪和日志的更多信息,请参阅可观测性文章。

流式 RPC 的代理

使用 Dapr 代理 gRPC 的流式 RPC 调用时,您必须设置一个额外的元数据选项 dapr-stream,值为 true

例如:

ctx = metadata.AppendToOutgoingContext(ctx, "dapr-app-id", "server")
ctx = metadata.AppendToOutgoingContext(ctx, "dapr-stream", "true")
Metadata headers = new Metadata();
Metadata.Key<String> jwtKey = Metadata.Key.of("dapr-app-id", "server");
Metadata.Key<String> jwtKey = Metadata.Key.of("dapr-stream", "true");
var metadata = new Metadata
{
	{ "dapr-app-id", "server" },
	{ "dapr-stream", "true" }
};
metadata = (('dapr-app-id', 'server'), ('dapr-stream', 'true'),)
const metadata = new grpc.Metadata();
metadata.add('dapr-app-id', 'server');
metadata.add('dapr-stream', 'true');
metadata = { 'dapr-app-id' : 'server' }
metadata = { 'dapr-stream' : 'true' }
grpc::ClientContext context;
context.AddMetadata("dapr-app-id", "server");
context.AddMetadata("dapr-stream", "true");

流式 gRPC 和弹性

在代理流式 gRPC 时,由于其长时间存在的特性,弹性策略仅应用于“初始握手”。因此:

  • 如果流在初始握手后中断,Dapr 不会自动重新建立。您的应用将被通知流已结束,并需要重新创建它。
  • 重试策略仅影响初始连接“握手”。如果您的弹性策略包括重试,Dapr 将检测到建立与目标应用的初始连接失败,并将重试直到成功(或直到策略中定义的重试次数耗尽)。
  • 同样,弹性策略中定义的超时仅适用于初始“握手”。连接建立后,超时不再影响流。

相关链接

社区通话演示

观看此视频了解如何使用 Dapr 的 gRPC 代理功能:

1.4 - 如何:使用HTTP调用非Dapr端点

从Dapr应用程序中通过服务调用访问非Dapr端点

本文介绍如何通过Dapr使用HTTP调用非Dapr端点。

通过Dapr的服务调用API,您可以与使用或不使用Dapr的端点进行通信。使用Dapr调用非Dapr端点不仅提供了一致的API,还带来了以下Dapr服务调用的优势:

  • 应用弹性策略
  • 通过跟踪和指标实现调用的可观测性
  • 通过访问控制实现安全性
  • 利用中间件管道组件
  • 服务发现
  • 使用请求头进行身份验证

通过HTTP调用外部服务或非Dapr端点

有时您可能需要调用非Dapr的HTTP端点,例如:

  • 您可能只在应用程序的一部分中使用Dapr,尤其是在涉及旧系统时
  • 您可能无法访问代码以将现有应用程序迁移到Dapr
  • 您需要调用外部的HTTP服务

通过定义HTTPEndpoint资源,您可以声明性地配置与非Dapr端点的交互方式。然后,您可以使用服务调用URL来访问非Dapr端点。或者,您可以直接在服务调用URL中使用非Dapr的完全限定域名(FQDN)端点URL。

HttpEndpoint、FQDN URL和appId的优先级

在进行服务调用时,Dapr运行时遵循以下优先级顺序:

  1. 是否为命名的HTTPEndpoint资源?
  2. 是否为带有http://https://前缀的FQDN URL?
  3. 是否为appID

服务调用与非Dapr HTTP端点

下图概述了Dapr在调用非Dapr端点时的工作流程。

显示服务调用到非Dapr端点步骤的图示
  1. 服务A发起一个HTTP调用,目标是服务B(一个非Dapr端点)。调用被发送到本地的Dapr sidecar。
  2. Dapr通过HTTPEndpoint或FQDN URL定位服务B的位置,然后将消息转发给服务B。
  3. 服务B向服务A的Dapr sidecar发送响应。
  4. 服务A接收响应。

使用HTTPEndpoint资源或FQDN URL调用非Dapr端点

在与Dapr应用程序或非Dapr应用程序通信时,有两种方法可以调用非Dapr端点。Dapr应用程序可以通过以下方式之一调用非Dapr端点:

  • 使用命名的HTTPEndpoint资源,定义一个HTTPEndpoint资源类型。请参阅HTTPEndpoint参考中的示例。

    localhost:3500/v1.0/invoke/<HTTPEndpoint-name>/method/<my-method>
    

    例如,使用名为"palpatine"的HTTPEndpoint资源和名为"Order66"的方法:

    curl http://localhost:3500/v1.0/invoke/palpatine/method/order66
    
  • 使用指向非Dapr端点的FQDN URL。

    localhost:3500/v1.0/invoke/<URL>/method/<my-method>
    

    例如,使用名为https://darthsidious.starwars的FQDN资源:

    curl http://localhost:3500/v1.0/invoke/https://darthsidious.starwars/method/order66
    

使用appId调用启用Dapr的应用程序

AppID用于通过appIDmy-method调用Dapr应用程序。阅读如何:使用HTTP调用服务指南以获取更多信息。例如:

localhost:3500/v1.0/invoke/<appID>/method/<my-method>
curl http://localhost:3602/v1.0/invoke/orderprocessor/method/checkout

TLS认证

使用HTTPEndpoint资源允许您根据远程端点的认证要求使用根证书、客户端证书和私钥的任意组合。

使用根证书的示例

apiVersion: dapr.io/v1alpha1
kind: HTTPEndpoint
metadata:
  name: "external-http-endpoint-tls"
spec:
  baseUrl: https://service-invocation-external:443
  headers:
  - name: "Accept-Language"
    value: "en-US"
  clientTLS:
    rootCA:
      secretKeyRef:
        name: dapr-tls-client
        key: ca.crt

使用客户端证书和私钥的示例

apiVersion: dapr.io/v1alpha1
kind: HTTPEndpoint
metadata:
  name: "external-http-endpoint-tls"
spec:
  baseUrl: https://service-invocation-external:443
  headers:
  - name: "Accept-Language"
    value: "en-US"
  clientTLS:
    certificate:
      secretKeyRef:
        name: dapr-tls-client
        key: tls.crt
    privateKey:
      secretKeyRef:
        name: dapr-tls-key
        key: tls.key

相关链接

社区电话演示

观看此视频以了解如何使用服务调用来调用非Dapr端点。

1.5 - 指南:跨命名空间进行服务调用

在不同命名空间之间进行服务调用

在本文中,您将学习如何在不同命名空间之间进行服务调用。默认情况下,service-invocation支持通过简单引用应用程序ID(如nodeapp)来调用同一命名空间内的服务:

localhost:3500/v1.0/invoke/nodeapp/method/neworder

service-invocation也支持跨命名空间的调用。在所有支持的平台上,Dapr应用程序ID遵循包含目标命名空间的有效FQDN格式。您可以同时指定:

  • 应用程序ID(如nodeapp),以及
  • 应用程序所在的命名空间(如production)。

示例 1

调用位于production命名空间中nodeappneworder方法:

localhost:3500/v1.0/invoke/nodeapp.production/method/neworder

在使用service-invocation调用不同命名空间中的应用程序时,您需要使用命名空间来限定它。这在Kubernetes集群中的跨命名空间调用中非常有用。

示例 2

调用位于production命名空间中myappping方法:

https://localhost:3500/v1.0/invoke/myapp.production/method/ping

示例 3

使用curl命令从外部DNS地址(例如api.demo.dapr.team)调用与示例2相同的ping方法,并提供Dapr API令牌进行身份验证:

MacOS/Linux:

curl -i -d '{ "message": "hello" }' \
     -H "Content-type: application/json" \
     -H "dapr-api-token: ${API_TOKEN}" \
     https://api.demo.dapr.team/v1.0/invoke/myapp.production/method/ping

2 - 消息发布与订阅

服务之间安全且可扩展的消息传递

2.1 - 发布-订阅模式概述

pubsub API 的基本构建块概述

发布-订阅模式(pubsub)使微服务能够通过消息进行事件驱动的架构通信。

  • 生产者或发布者将消息写入输入通道并发送到主题,而不关心哪个应用程序会接收它们。
  • 消费者或订阅者订阅主题并从输出通道接收消息,而不关心哪个服务生成了这些消息。

消息代理会将每条消息从发布者的输入通道复制到所有对该消息感兴趣的订阅者的输出通道。这种模式在需要将微服务解耦时特别有用。



pubsub API

在 Dapr 中,pubsub API:

  • 提供一个平台无关的 API 来发送和接收消息。
  • 确保消息至少被传递一次。
  • 与多种消息代理和队列系统集成。

您可以在运行时配置 Dapr pubsub 组件来使用特定的消息代理,这种可插拔性使您的服务更具可移植性和灵活性。

在 Dapr 中使用 pubsub 时:

  1. 您的服务通过网络调用 Dapr pubsub 构建块 API。
  2. pubsub 构建块调用封装特定消息代理的 Dapr pubsub 组件。
  3. 为了接收主题上的消息,Dapr 代表您的服务订阅 pubsub 组件,并在消息到达时将其传递到您的服务的端点。

以下概述视频和演示展示了 Dapr pubsub 的工作原理。

在下图中,“shipping”服务和“email”服务都已订阅由“cart”服务发布的主题。每个服务加载指向相同 pubsub 消息代理组件的 pubsub 组件配置文件;例如:Redis Streams、NATS Streaming、Azure Service Bus 或 GCP pubsub。

在下图中,Dapr API 将“cart”服务的“order”主题发布到“shipping”和“email”订阅服务的“order”端点。

查看 Dapr 支持的 pubsub 组件的完整列表

特性

pubsub API 构建块为您的应用程序带来了多个特性。

使用 Cloud Events 发送消息

为了启用消息路由并为服务之间的每条消息提供额外的上下文,Dapr 使用 CloudEvents 1.0 规范 作为其消息格式。任何应用程序通过 Dapr 发送到主题的消息都会自动包装在 Cloud Events 信封中,使用 Content-Type 头值 作为 datacontenttype 属性。

有关更多信息,请阅读 使用 CloudEvents 进行消息传递,或 发送不带 CloudEvents 的原始消息

与不使用 Dapr 和 CloudEvents 的应用程序通信

如果您的一个应用程序使用 Dapr 而另一个不使用,您可以为发布者或订阅者禁用 CloudEvent 包装。这允许在无法一次性采用 Dapr 的应用程序中部分采用 Dapr pubsub。

有关更多信息,请阅读 如何在没有 CloudEvents 的情况下使用 pubsub

设置消息内容类型

发布消息时,指定发送数据的内容类型很重要。除非指定,否则 Dapr 将假定为 text/plain

  • HTTP 客户端:可以在 Content-Type 头中设置内容类型
  • gRPC 客户端和 SDK:有一个专用的内容类型参数

消息传递

原则上,Dapr 认为消息一旦被订阅者处理并以非错误响应进行响应,就已成功传递。为了更细粒度的控制,Dapr 的 pubsub API 还提供了明确的状态,定义在响应负载中,订阅者可以用这些状态向 Dapr 指示特定的处理指令(例如,RETRYDROP)。

使用主题订阅接收消息

Dapr 应用程序可以通过支持相同功能的三种订阅类型订阅已发布的主题:声明式、流式和编程式。

订阅类型描述
声明式订阅在外部文件中定义。声明式方法消除了代码中的 Dapr 依赖性,并允许现有应用程序订阅主题,而无需更改代码。
流式订阅在用户代码中定义。流式订阅是动态的,意味着它们允许在运行时添加或删除订阅。它们不需要应用程序中的订阅端点(这是编程式和声明式订阅所需的),使其易于在代码中配置。流式订阅也不需要应用程序配置 sidecar 来接收消息。由于消息被发送到消息处理程序代码,因此流式订阅中没有路由或批量订阅的概念。
编程式订阅在用户代码中定义。编程式方法实现了静态订阅,并需要在代码中有一个端点。

有关更多信息,请阅读 关于订阅类型的订阅

重新加载主题订阅

要重新加载以编程方式或声明式定义的主题订阅,需要重新启动 Dapr sidecar。 通过启用 HotReload 功能门,可以使 Dapr sidecar 动态重新加载更改的声明式主题订阅,而无需重新启动。 主题订阅的热重载目前是一个预览功能。 重新加载订阅时,正在传输的消息不受影响。

消息路由

Dapr 提供 基于内容的路由 模式。Pubsub 路由 是此模式的实现,允许开发人员使用表达式根据其内容将 CloudEvents 路由到应用程序中的不同 URI/路径和事件处理程序。如果没有路由匹配,则使用可选的默认路由。随着您的应用程序扩展以支持多个事件版本或特殊情况,这很有用。

此功能适用于声明式和编程式订阅方法。

有关消息路由的更多信息,请阅读 Dapr pubsub API 参考

使用死信主题处理失败的消息

有时,由于各种可能的问题,例如生产者或消费者应用程序中的错误条件或导致应用程序代码出现问题的意外状态更改,消息无法被处理。Dapr 允许开发人员设置死信主题来处理无法传递到应用程序的消息。此功能适用于所有 pubsub 组件,并防止消费者应用程序无休止地重试失败的消息。有关更多信息,请阅读 死信主题

启用外发模式

Dapr 使开发人员能够使用外发模式在事务性状态存储和任何消息代理之间实现单一事务。有关更多信息,请阅读 如何启用事务性外发消息

命名空间消费者组

Dapr 通过 命名空间消费者组 解决大规模多租户问题。只需在组件元数据中包含 "{namespace}" 值,即可允许具有相同 app-id 的多个命名空间的应用程序发布和订阅相同的消息代理。

至少一次保证

Dapr 保证消息传递的至少一次语义。当应用程序使用 pubsub API 向主题发布消息时,Dapr 确保消息至少一次传递给每个订阅者。

即使消息传递失败,或者您的应用程序崩溃,Dapr 也会尝试重新传递消息,直到成功传递。

所有 Dapr pubsub 组件都支持至少一次保证。

消费者组和竞争消费者模式

Dapr 处理消费者组和竞争消费者模式的负担。在竞争消费者模式中,使用单个消费者组的多个应用程序实例竞争消息。当副本使用相同的 app-id 而没有显式消费者组覆盖时,Dapr 强制执行竞争消费者模式。

当同一应用程序的多个实例(具有相同的 app-id)订阅一个主题时,Dapr 将每条消息仅传递给该应用程序的一个实例。此概念在下图中进行了说明。



同样,如果两个不同的应用程序(具有不同的 app-id)订阅同一主题,Dapr 将每条消息仅传递给每个应用程序的一个实例

并非所有 Dapr pubsub 组件都支持竞争消费者模式。目前,以下(非详尽)pubsub 组件支持此功能:

为增强安全性设置主题范围

默认情况下,与 pubsub 组件实例关联的所有主题消息对配置了该组件的每个应用程序都是可用的。您可以使用 Dapr 主题范围限制哪个应用程序可以发布或订阅主题。有关更多信息,请阅读:pubsub 主题范围

消息生存时间(TTL)

Dapr 可以在每条消息的基础上设置超时消息,这意味着如果消息未从 pubsub 组件中读取,则消息将被丢弃。此超时消息可防止未读消息的积累。如果消息在队列中的时间超过配置的 TTL,则标记为死信。有关更多信息,请阅读 pubsub 消息 TTL

发布和订阅批量消息

Dapr 支持在单个请求中发送和接收多条消息。当编写需要发送或接收大量消息的应用程序时,使用批量操作可以通过减少请求总数来实现高吞吐量。有关更多信息,请阅读 pubsub 批量消息

使用 StatefulSets 扩展订阅者

在 Kubernetes 上运行时,使用 StatefulSets 结合 {podName} 标记,订阅者可以为每个实例拥有一个粘性 consumerID。请参阅 如何使用 StatefulSets 水平扩展订阅者

试用 pubsub

快速入门和教程

想要测试 Dapr pubsub API 吗?通过以下快速入门和教程来查看 pubsub 的实际应用:

快速入门/教程描述
Pubsub 快速入门使用发布和订阅 API 发送和接收消息。
Pubsub 教程演示如何使用 Dapr 启用 pubsub 应用程序。使用 Redis 作为 pubsub 组件。

直接在您的应用中开始使用 pubsub

想要跳过快速入门?没问题。您可以直接在应用程序中试用 pubsub 构建块来发布消息并订阅主题。在 安装 Dapr 后,您可以从 pubsub 如何指南 开始使用 pubsub API。

下一步

2.2 - 如何:发布消息并订阅主题

学习如何使用一个服务向主题发送消息,并在另一个服务中订阅该主题

既然您已经了解了Dapr pubsub构建块的功能,接下来我们来看看如何在您的服务中应用它。下面的代码示例描述了一个使用两个服务处理订单的应用程序,每个服务都有Dapr sidecar:

  • 一个结账服务,使用Dapr订阅消息队列中的主题。
  • 一个订单处理服务,使用Dapr向RabbitMQ发布消息。
示例服务的状态管理图

Dapr会自动将用户的负载封装在符合CloudEvents v1.0的格式中,并使用Content-Type头的值作为datacontenttype属性。了解更多关于CloudEvents的消息。

以下示例展示了如何在您的应用程序中发布和订阅名为orders的主题。

设置Pub/Sub组件

第一步是设置pubsub组件:

当您运行dapr init时,Dapr会创建一个默认的Redis pubsub.yaml并在您的本地机器上运行一个Redis容器,位置如下:

  • 在Windows上,位于%UserProfile%\.dapr\components\pubsub.yaml
  • 在Linux/MacOS上,位于~/.dapr/components/pubsub.yaml

使用pubsub.yaml组件,您可以轻松地更换底层组件而无需更改应用程序代码。在此示例中,使用RabbitMQ。

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: order-pub-sub
spec:
  type: pubsub.rabbitmq
  version: v1
  metadata:
  - name: host
    value: "amqp://localhost:5672"
  - name: durable
    value: "false"
  - name: deletedWhenUnused
    value: "false"
  - name: autoAck
    value: "false"
  - name: reconnectWait
    value: "0"
  - name: concurrency
    value: parallel
scopes:
  - orderprocessing
  - checkout

您可以通过创建一个包含该文件的组件目录(在此示例中为myComponents)并使用dapr run CLI命令的--resources-path标志来覆盖此文件。

dapr run --app-id myapp --resources-path ./myComponents -- dotnet run
dapr run --app-id myapp --resources-path ./myComponents -- mvn spring-boot:run
dapr run --app-id myapp --resources-path ./myComponents -- python3 app.py
dapr run --app-id myapp --resources-path ./myComponents -- go run app.go
dapr run --app-id myapp --resources-path ./myComponents -- npm start

要将其部署到Kubernetes集群中,请填写以下YAML中的pub/sub组件metadata连接详细信息,保存为pubsub.yaml,然后运行kubectl apply -f pubsub.yaml

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: order-pub-sub
spec:
  type: pubsub.rabbitmq
  version: v1
  metadata:
  - name: connectionString
    value: "amqp://localhost:5672"
  - name: protocol
    value: amqp  
  - name: hostname
    value: localhost 
  - name: username
    value: username
  - name: password
    value: password 
  - name: durable
    value: "false"
  - name: deletedWhenUnused
    value: "false"
  - name: autoAck
    value: "false"
  - name: reconnectWait
    value: "0"
  - name: concurrency
    value: parallel
scopes:
  - orderprocessing
  - checkout

订阅主题

Dapr提供了三种方法来订阅主题:

  • 声明式,在外部文件中定义订阅。
  • 流式,在用户代码中定义订阅。
  • 编程式,在用户代码中定义订阅。

声明式、流式和编程式订阅文档中了解更多信息。此示例演示了声明式订阅。

创建一个名为subscription.yaml的文件并粘贴以下内容:

apiVersion: dapr.io/v2alpha1
kind: Subscription
metadata:
  name: order-pub-sub
spec:
  topic: orders
  routes: 
    default: /checkout
  pubsubname: order-pub-sub
scopes:
- orderprocessing
- checkout

上面的示例显示了对主题orders的事件订阅,针对pubsub组件order-pub-sub

  • route字段指示Dapr将所有主题消息发送到应用程序中的/checkout端点。
  • scopes字段指定此订阅适用于ID为orderprocessingcheckout的应用程序。

subscription.yaml放在与您的pubsub.yaml组件相同的目录中。当Dapr启动时,它会加载订阅和组件。

以下是利用Dapr SDK订阅您在subscription.yaml中定义的主题的代码示例。

//依赖项 
using System.Collections.Generic;
using System.Threading.Tasks;
using System;
using Microsoft.AspNetCore.Mvc;
using Dapr;
using Dapr.Client;

//代码
namespace CheckoutService.controller
{
    [ApiController]
    public class CheckoutServiceController : Controller
    {
         //订阅一个主题 
        [Topic("order-pub-sub", "orders")]
        [HttpPost("checkout")]
        public void getCheckout([FromBody] int orderId)
        {
            Console.WriteLine("订阅者接收到 : " + orderId);
        }
    }
}

导航到包含上述代码的目录,然后运行以下命令以启动Dapr sidecar和订阅者应用程序:

dapr run --app-id checkout --app-port 6002 --dapr-http-port 3602 --dapr-grpc-port 60002 --app-protocol https dotnet run
//依赖项
import io.dapr.Topic;
import io.dapr.client.domain.CloudEvent;
import org.springframework.web.bind.annotation.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Mono;

//代码
@RestController
public class CheckoutServiceController {

    private static final Logger log = LoggerFactory.getLogger(CheckoutServiceController.class);
     //订阅一个主题
    @Topic(name = "orders", pubsubName = "order-pub-sub")
    @PostMapping(path = "/checkout")
    public Mono<Void> getCheckout(@RequestBody(required = false) CloudEvent<String> cloudEvent) {
        return Mono.fromRunnable(() -> {
            try {
                log.info("订阅者接收到: " + cloudEvent.getData());
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        });
    }
}

导航到包含上述代码的目录,然后运行以下命令以启动Dapr sidecar和订阅者应用程序:

dapr run --app-id checkout --app-port 6002 --dapr-http-port 3602 --dapr-grpc-port 60002 mvn spring-boot:run
#依赖项
from cloudevents.sdk.event import v1
from dapr.ext.grpc import App
import logging
import json

#代码
app = App()
logging.basicConfig(level = logging.INFO)
#订阅一个主题 
@app.subscribe(pubsub_name='order-pub-sub', topic='orders')
def mytopic(event: v1.Event) -> None:
    data = json.loads(event.Data())
    logging.info('订阅者接收到: ' + str(data))

app.run(6002)

导航到包含上述代码的目录,然后运行以下命令以启动Dapr sidecar和订阅者应用程序:

dapr run --app-id checkout --app-port 6002 --dapr-http-port 3602 --app-protocol grpc -- python3 CheckoutService.py
//依赖项
import (
	"log"
	"net/http"
	"context"

	"github.com/dapr/go-sdk/service/common"
	daprd "github.com/dapr/go-sdk/service/http"
)

//代码
var sub = &common.Subscription{
	PubsubName: "order-pub-sub",
	Topic:      "orders",
	Route:      "/checkout",
}

func main() {
	s := daprd.NewService(":6002")
   //订阅一个主题
	if err := s.AddTopicEventHandler(sub, eventHandler); err != nil {
		log.Fatalf("添加主题订阅时出错: %v", err)
	}
	if err := s.Start(); err != nil && err != http.ErrServerClosed {
		log.Fatalf("监听时出错: %v", err)
	}
}

func eventHandler(ctx context.Context, e *common.TopicEvent) (retry bool, err error) {
	log.Printf("订阅者接收到: %s", e.Data)
	return false, nil
}

导航到包含上述代码的目录,然后运行以下命令以启动Dapr sidecar和订阅者应用程序:

dapr run --app-id checkout --app-port 6002 --dapr-http-port 3602 --dapr-grpc-port 60002 go run CheckoutService.go
//依赖项
import { DaprServer, CommunicationProtocolEnum } from '@dapr/dapr'; 

//代码
const daprHost = "127.0.0.1"; 
const serverHost = "127.0.0.1";
const serverPort = "6002"; 

start().catch((e) => {
    console.error(e);
    process.exit(1);
});

async function start(orderId) {
    const server = new DaprServer({
        serverHost,
        serverPort,
        communicationProtocol: CommunicationProtocolEnum.HTTP,
        clientOptions: {
          daprHost,
          daprPort: process.env.DAPR_HTTP_PORT,
        },
    });
    //订阅一个主题
    await server.pubsub.subscribe("order-pub-sub", "orders", async (orderId) => {
        console.log(`订阅者接收到: ${JSON.stringify(orderId)}`)
    });
    await server.start();
}

导航到包含上述代码的目录,然后运行以下命令以启动Dapr sidecar和订阅者应用程序:

dapr run --app-id checkout --app-port 6002 --dapr-http-port 3602 --dapr-grpc-port 60002 npm start

发布消息

启动一个名为orderprocessing的Dapr实例:

dapr run --app-id orderprocessing --dapr-http-port 3601

然后向orders主题发布消息:

dapr publish --publish-app-id orderprocessing --pubsub order-pub-sub --topic orders --data '{"orderId": "100"}'
curl -X POST http://localhost:3601/v1.0/publish/order-pub-sub/orders -H "Content-Type: application/json" -d '{"orderId": "100"}'
Invoke-RestMethod -Method Post -ContentType 'application/json' -Body '{"orderId": "100"}' -Uri 'http://localhost:3601/v1.0/publish/order-pub-sub/orders'

以下是利用Dapr SDK发布主题的代码示例。

//依赖项
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Dapr.Client;
using Microsoft.AspNetCore.Mvc;
using System.Threading;

//代码
namespace EventService
{
    class Program
    {
        static async Task Main(string[] args)
        {
           string PUBSUB_NAME = "order-pub-sub";
           string TOPIC_NAME = "orders";
           while(true) {
                System.Threading.Thread.Sleep(5000);
                Random random = new Random();
                int orderId = random.Next(1,1000);
                CancellationTokenSource source = new CancellationTokenSource();
                CancellationToken cancellationToken = source.Token;
                using var client = new DaprClientBuilder().Build();
                //使用Dapr SDK发布主题
                await client.PublishEventAsync(PUBSUB_NAME, TOPIC_NAME, orderId, cancellationToken);
                Console.WriteLine("发布的数据: " + orderId);
		        }
        }
    }
}

导航到包含上述代码的目录,然后运行以下命令以启动Dapr sidecar和发布者应用程序:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 --app-protocol https dotnet run
//依赖项
import io.dapr.client.DaprClient;
import io.dapr.client.DaprClientBuilder;
import io.dapr.client.domain.Metadata;
import static java.util.Collections.singletonMap;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Random;
import java.util.concurrent.TimeUnit;

//代码
@SpringBootApplication
public class OrderProcessingServiceApplication {

	private static final Logger log = LoggerFactory.getLogger(OrderProcessingServiceApplication.class);

	public static void main(String[] args) throws InterruptedException{
		String MESSAGE_TTL_IN_SECONDS = "1000";
		String TOPIC_NAME = "orders";
		String PUBSUB_NAME = "order-pub-sub";

		while(true) {
			TimeUnit.MILLISECONDS.sleep(5000);
			Random random = new Random();
			int orderId = random.nextInt(1000-1) + 1;
			DaprClient client = new DaprClientBuilder().build();
      //使用Dapr SDK发布主题
			client.publishEvent(
					PUBSUB_NAME,
					TOPIC_NAME,
					orderId,
					singletonMap(Metadata.TTL_IN_SECONDS, MESSAGE_TTL_IN_SECONDS)).block();
			log.info("发布的数据:" + orderId);
		}
	}
}

导航到包含上述代码的目录,然后运行以下命令以启动Dapr sidecar和发布者应用程序:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 mvn spring-boot:run
#依赖项  
import random
from time import sleep    
import requests
import logging
import json
from dapr.clients import DaprClient

#代码
logging.basicConfig(level = logging.INFO)
while True:
    sleep(random.randrange(50, 5000) / 1000)
    orderId = random.randint(1, 1000)
    PUBSUB_NAME = 'order-pub-sub'
    TOPIC_NAME = 'orders'
    with DaprClient() as client:
        #使用Dapr SDK发布主题
        result = client.publish_event(
            pubsub_name=PUBSUB_NAME,
            topic_name=TOPIC_NAME,
            data=json.dumps(orderId),
            data_content_type='application/json',
        )
    logging.info('发布的数据: ' + str(orderId))

导航到包含上述代码的目录,然后运行以下命令以启动Dapr sidecar和发布者应用程序:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --app-protocol grpc python3 OrderProcessingService.py
//依赖项
import (
	"context"
	"log"
	"math/rand"
	"time"
	"strconv"
	dapr "github.com/dapr/go-sdk/client"
)

//代码
var (
	PUBSUB_NAME = "order-pub-sub"
	TOPIC_NAME  = "orders"
)

func main() {
	for i := 0; i < 10; i++ {
		time.Sleep(5000)
		orderId := rand.Intn(1000-1) + 1
		client, err := dapr.NewClient()
		if err != nil {
			panic(err)
		}
		defer client.Close()
		ctx := context.Background()
    //使用Dapr SDK发布主题
		if err := client.PublishEvent(ctx, PUBSUB_NAME, TOPIC_NAME, []byte(strconv.Itoa(orderId))); 
		err != nil {
			panic(err)
		}

		log.Println("发布的数据: " + strconv.Itoa(orderId))
	}
}

导航到包含上述代码的目录,然后运行以下命令以启动Dapr sidecar和发布者应用程序:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 go run OrderProcessingService.go
//依赖项
import { DaprServer, DaprClient, CommunicationProtocolEnum } from '@dapr/dapr'; 

const daprHost = "127.0.0.1"; 

var main = function() {
    for(var i=0;i<10;i++) {
        sleep(5000);
        var orderId = Math.floor(Math.random() * (1000 - 1) + 1);
        start(orderId).catch((e) => {
            console.error(e);
            process.exit(1);
        });
    }
}

async function start(orderId) {
    const PUBSUB_NAME = "order-pub-sub"
    const TOPIC_NAME  = "orders"
    const client = new DaprClient({
        daprHost,
        daprPort: process.env.DAPR_HTTP_PORT, 
        communicationProtocol: CommunicationProtocolEnum.HTTP
    });
    console.log("发布的数据:" + orderId)
    //使用Dapr SDK发布主题
    await client.pubsub.publish(PUBSUB_NAME, TOPIC_NAME, orderId);
}

function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

main();

导航到包含上述代码的目录,然后运行以下命令以启动Dapr sidecar和发布者应用程序:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 npm start

消息确认和重试

为了告诉Dapr消息已成功处理,返回200 OK响应。如果Dapr收到的返回状态码不是200,或者您的应用程序崩溃,Dapr将尝试根据至少一次语义重新传递消息。

演示视频

观看此演示视频以了解更多关于Dapr的pubsub消息传递。

下一步

2.3 - 使用 CloudEvents 进行消息传递

了解 Dapr 使用 CloudEvents 的原因,它们在 Dapr 发布订阅中的工作原理,以及如何创建 CloudEvents。

为了实现消息路由并为每条消息提供额外的上下文,Dapr 采用 CloudEvents 1.0 规范 作为其消息格式。通过 Dapr 发送到主题的任何消息都会自动被包装在 CloudEvents 信封中,使用 Content-Type 头部值 作为 datacontenttype 属性。

Dapr 使用 CloudEvents 为事件负载提供额外的上下文,从而实现以下功能:

  • 跟踪
  • 事件数据的正确反序列化的内容类型
  • 发送应用程序的验证

您可以选择以下三种方法之一通过发布订阅发布 CloudEvent:

  1. 发送一个发布订阅事件,然后由 Dapr 包装在 CloudEvent 信封中。
  2. 通过覆盖标准 CloudEvent 属性来替换 Dapr 提供的特定 CloudEvents 属性。
  3. 将您自己的 CloudEvent 信封作为发布订阅事件的一部分编写。

Dapr 生成的 CloudEvents 示例

向 Dapr 发送发布操作会自动将其包装在一个包含以下字段的 CloudEvent 信封中:

  • id
  • source
  • specversion
  • type
  • traceparent
  • traceid
  • tracestate
  • topic
  • pubsubname
  • time
  • datacontenttype (可选)

以下示例演示了 Dapr 为发布到 orders 主题的操作生成的 CloudEvent,其中包括:

  • 一个 W3C traceid,唯一标识消息
  • data 和 CloudEvent 的字段,其中数据内容被序列化为 JSON
{
  "topic": "orders",
  "pubsubname": "order_pub_sub",
  "traceid": "00-113ad9c4e42b27583ae98ba698d54255-e3743e35ff56f219-01",
  "tracestate": "",
  "data": {
    "orderId": 1
  },
  "id": "5929aaac-a5e2-4ca1-859c-edfe73f11565",
  "specversion": "1.0",
  "datacontenttype": "application/json; charset=utf-8",
  "source": "checkout",
  "type": "com.dapr.event.sent",
  "time": "2020-09-23T06:23:21Z",
  "traceparent": "00-113ad9c4e42b27583ae98ba698d54255-e3743e35ff56f219-01"
}

作为另一个 v1.0 CloudEvent 的示例,以下显示了在 CloudEvent 消息中以 JSON 序列化的 XML 内容的数据:

{
  "topic": "orders",
  "pubsubname": "order_pub_sub",
  "traceid": "00-113ad9c4e42b27583ae98ba698d54255-e3743e35ff56f219-01",
  "tracestate": "",
  "data" : "<note><to></to><from>user2</from><message>Order</message></note>",
  "id" : "id-1234-5678-9101",
  "specversion" : "1.0",
  "datacontenttype" : "text/xml",
  "subject" : "Test XML Message",
  "source" : "https://example.com/message",
  "type" : "xml.message",
   "time" : "2020-09-23T06:23:21Z"
}

替换 Dapr 生成的 CloudEvents 值

Dapr 自动生成多个 CloudEvent 属性。您可以通过提供以下可选元数据键/值来替换这些生成的 CloudEvent 属性:

  • cloudevent.id: 覆盖 id
  • cloudevent.source: 覆盖 source
  • cloudevent.type: 覆盖 type
  • cloudevent.traceid: 覆盖 traceid
  • cloudevent.tracestate: 覆盖 tracestate
  • cloudevent.traceparent: 覆盖 traceparent

使用这些元数据属性替换 CloudEvents 属性的能力适用于所有发布订阅组件。

示例

例如,要替换代码中上述 CloudEvent 示例中的 sourceid 值:

with DaprClient() as client:
    order = {'orderId': i}
    # 使用 Dapr 发布订阅发布事件/消息
    result = client.publish_event(
        pubsub_name='order_pub_sub',
        topic_name='orders',
        publish_metadata={'cloudevent.id': 'd99b228f-6c73-4e78-8c4d-3f80a043d317', 'cloudevent.source': 'payment'}
    )
var order = new Order(i);
using var client = new DaprClientBuilder().Build();

// 覆盖 cloudevent 元数据
var metadata = new Dictionary<string,string>() {
    { "cloudevent.source", "payment" },
    { "cloudevent.id", "d99b228f-6c73-4e78-8c4d-3f80a043d317" }
}

// 使用 Dapr 发布订阅发布事件/消息
await client.PublishEventAsync("order_pub_sub", "orders", order, metadata);
Console.WriteLine("Published data: " + order);

await Task.Delay(TimeSpan.FromSeconds(1));

然后 JSON 负载反映新的 sourceid 值:

{
  "topic": "orders",
  "pubsubname": "order_pub_sub",
  "traceid": "00-113ad9c4e42b27583ae98ba698d54255-e3743e35ff56f219-01",
  "tracestate": "",
  "data": {
    "orderId": 1
  },
  "id": "d99b228f-6c73-4e78-8c4d-3f80a043d317",
  "specversion": "1.0",
  "datacontenttype": "application/json; charset=utf-8",
  "source": "payment",
  "type": "com.dapr.event.sent",
  "time": "2020-09-23T06:23:21Z",
  "traceparent": "00-113ad9c4e42b27583ae98ba698d54255-e3743e35ff56f219-01"
}

发布您自己的 CloudEvent

如果您想使用自己的 CloudEvent,请确保将 datacontenttype 指定为 application/cloudevents+json

如果应用程序编写的 CloudEvent 不包含 CloudEvent 规范中最低要求的字段,则消息将被拒绝。如果缺少,Dapr 会将以下字段添加到 CloudEvent 中:

  • time
  • traceid
  • traceparent
  • tracestate
  • topic
  • pubsubname
  • source
  • type
  • specversion

您可以向自定义 CloudEvent 添加不属于官方 CloudEvent 规范的其他字段。Dapr 将按原样传递这些字段。

示例

发布一个 CloudEvent 到 orders 主题:

dapr publish --publish-app-id orderprocessing --pubsub order-pub-sub --topic orders --data '{\"orderId\": \"100\"}'

发布一个 CloudEvent 到 orders 主题:

curl -X POST http://localhost:3601/v1.0/publish/order-pub-sub/orders -H "Content-Type: application/cloudevents+json" -d '{"specversion" : "1.0", "type" : "com.dapr.cloudevent.sent", "source" : "testcloudeventspubsub", "subject" : "Cloud Events Test", "id" : "someCloudEventId", "time" : "2021-08-02T09:00:00Z", "datacontenttype" : "application/cloudevents+json", "data" : {"orderId": "100"}}'

发布一个 CloudEvent 到 orders 主题:

Invoke-RestMethod -Method Post -ContentType 'application/cloudevents+json' -Body '{"specversion" : "1.0", "type" : "com.dapr.cloudevent.sent", "source" : "testcloudeventspubsub", "subject" : "Cloud Events Test", "id" : "someCloudEventId", "time" : "2021-08-02T09:00:00Z", "datacontenttype" : "application/cloudevents+json", "data" : {"orderId": "100"}}' -Uri 'http://localhost:3601/v1.0/publish/order-pub-sub/orders'

事件去重

使用 Dapr 创建的 CloudEvents 时,信封中包含一个 id 字段,应用程序可以使用该字段执行消息去重。Dapr 不会自动进行去重处理。Dapr 支持使用本身具备消息去重功能的消息代理。

下一步

2.4 - 发布和订阅非CloudEvents消息

了解何时可能不使用CloudEvents以及如何禁用它们。

在将Dapr集成到您的应用程序时,由于兼容性原因或某些应用程序不使用Dapr,某些服务可能仍需要通过不封装在CloudEvents中的pub/sub消息进行通信。这些消息被称为“原始”pub/sub消息。Dapr允许应用程序发布和订阅原始事件,这些事件未封装在CloudEvent中以实现兼容性。

发布原始消息

Dapr应用程序可以将原始事件发布到pub/sub主题中,而不需要CloudEvent封装,以便与非Dapr应用程序兼容。

显示当订阅者不使用Dapr或CloudEvent时如何使用Dapr发布的图示

要禁用CloudEvent封装,请在发布请求中将rawPayload元数据设置为true。这样,订阅者可以接收这些消息而无需解析CloudEvent架构。

curl -X "POST" http://localhost:3500/v1.0/publish/pubsub/TOPIC_A?metadata.rawPayload=true -H "Content-Type: application/json" -d '{"order-number": "345"}'
from dapr.clients import DaprClient

with DaprClient() as d:
    req_data = {
        'order-number': '345'
    }
    # 创建一个带有内容类型和主体的类型化消息
    resp = d.publish_event(
        pubsub_name='pubsub',
        topic_name='TOPIC_A',
        data=json.dumps(req_data),
        publish_metadata={'rawPayload': 'true'}
    )
    # 打印请求
    print(req_data, flush=True)
<?php

require_once __DIR__.'/vendor/autoload.php';

$app = \Dapr\App::create();
$app->run(function(\DI\FactoryInterface $factory) {
    $publisher = $factory->make(\Dapr\PubSub\Publish::class, ['pubsub' => 'pubsub']);
    $publisher->topic('TOPIC_A')->publish('data', ['rawPayload' => 'true']);
});

订阅原始消息

Dapr应用程序还可以订阅来自不使用CloudEvent封装的现有pub/sub主题的原始事件。

显示当发布者不使用Dapr或CloudEvent时如何使用Dapr订阅的图示

以编程方式订阅原始事件

在以编程方式订阅时,添加rawPayload的额外元数据条目,以便Dapr sidecar自动将负载封装到与当前Dapr SDK兼容的CloudEvent中。

import flask
from flask import request, jsonify
from flask_cors import CORS
import json
import sys

app = flask.Flask(__name__)
CORS(app)

@app.route('/dapr/subscribe', methods=['GET'])
def subscribe():
    subscriptions = [{'pubsubname': 'pubsub',
                      'topic': 'deathStarStatus',
                      'route': 'dsstatus',
                      'metadata': {
                          'rawPayload': 'true',
                      } }]
    return jsonify(subscriptions)

@app.route('/dsstatus', methods=['POST'])
def ds_subscriber():
    print(request.json, flush=True)
    return json.dumps({'success':True}), 200, {'ContentType':'application/json'}

app.run()
<?php

require_once __DIR__.'/vendor/autoload.php';

$app = \Dapr\App::create(configure: fn(\DI\ContainerBuilder $builder) => $builder->addDefinitions(['dapr.subscriptions' => [
    new \Dapr\PubSub\Subscription(pubsubname: 'pubsub', topic: 'deathStarStatus', route: '/dsstatus', metadata: [ 'rawPayload' => 'true'] ),
]]));

$app->post('/dsstatus', function(
    #[\Dapr\Attributes\FromBody]
    \Dapr\PubSub\CloudEvent $cloudEvent,
    \Psr\Log\LoggerInterface $logger
    ) {
        $logger->alert('Received event: {event}', ['event' => $cloudEvent]);
        return ['status' => 'SUCCESS'];
    }
);

$app->start();

声明式订阅原始事件

同样,您可以通过在订阅规范中添加rawPayload元数据条目来声明式地订阅原始事件。

apiVersion: dapr.io/v2alpha1
kind: Subscription
metadata:
  name: myevent-subscription
spec:
  topic: deathStarStatus
  routes: 
    default: /dsstatus
  pubsubname: pubsub
  metadata:
    rawPayload: "true"
scopes:
- app1
- app2

下一步

2.5 - 操作指南:将消息路由到不同的事件处理程序

学习如何根据 CloudEvent 字段将主题中的消息路由到不同的事件处理程序

pubsub 路由实现了基于内容的路由,这是一种使用 DSL 而不是命令式应用程序代码的消息模式。通过 pubsub 路由,您可以根据 CloudEvents 的内容,将消息路由到应用程序中的不同 URI/路径和事件处理程序。如果没有匹配的路由,则可以使用可选的默认路由。随着您的应用程序扩展以支持多个事件版本或特殊情况,这种方法将非常有用。

虽然可以通过代码实现路由,但将路由规则与应用程序分离可以提高可移植性。

此功能适用于声明式和编程式订阅方法,但不适用于流式订阅。

声明式订阅

对于声明式订阅,使用 dapr.io/v2alpha1 作为 apiVersion。以下是使用路由的 subscriptions.yaml 示例:

apiVersion: dapr.io/v2alpha1
kind: Subscription
metadata:
  name: myevent-subscription
spec:
  pubsubname: pubsub
  topic: inventory
  routes:
    rules:
      - match: event.type == "widget"
        path: /widgets
      - match: event.type == "gadget"
        path: /gadgets
    default: /products
scopes:
  - app1
  - app2

编程式订阅

在编程方法中,返回的是 routes 结构而不是 route。JSON 结构与声明式 YAML 匹配:

import flask
from flask import request, jsonify
from flask_cors import CORS
import json
import sys

app = flask.Flask(__name__)
CORS(app)

@app.route('/dapr/subscribe', methods=['GET'])
def subscribe():
    subscriptions = [
      {
        'pubsubname': 'pubsub',
        'topic': 'inventory',
        'routes': {
          'rules': [
            {
              'match': 'event.type == "widget"',
              'path': '/widgets'
            },
            {
              'match': 'event.type == "gadget"',
              'path': '/gadgets'
            },
          ],
          'default': '/products'
        }
      }]
    return jsonify(subscriptions)

@app.route('/products', methods=['POST'])
def ds_subscriber():
    print(request.json, flush=True)
    return json.dumps({'success':True}), 200, {'ContentType':'application/json'}
app.run()
const express = require('express')
const bodyParser = require('body-parser')
const app = express()
app.use(bodyParser.json({ type: 'application/*+json' }));

const port = 3000

app.get('/dapr/subscribe', (req, res) => {
  res.json([
    {
      pubsubname: "pubsub",
      topic: "inventory",
      routes: {
        rules: [
          {
            match: 'event.type == "widget"',
            path: '/widgets'
          },
          {
            match: 'event.type == "gadget"',
            path: '/gadgets'
          },
        ],
        default: '/products'
      }
    }
  ]);
})

app.post('/products', (req, res) => {
  console.log(req.body);
  res.sendStatus(200);
});

app.listen(port, () => console.log(`consumer app listening on port ${port}!`))
        [Topic("pubsub", "inventory", "event.type ==\"widget\"", 1)]
        [HttpPost("widgets")]
        public async Task<ActionResult<Stock>> HandleWidget(Widget widget, [FromServices] DaprClient daprClient)
        {
            // Logic
            return stock;
        }

        [Topic("pubsub", "inventory", "event.type ==\"gadget\"", 2)]
        [HttpPost("gadgets")]
        public async Task<ActionResult<Stock>> HandleGadget(Gadget gadget, [FromServices] DaprClient daprClient)
        {
            // Logic
            return stock;
        }

        [Topic("pubsub", "inventory")]
        [HttpPost("products")]
        public async Task<ActionResult<Stock>> HandleProduct(Product product, [FromServices] DaprClient daprClient)
        {
            // Logic
            return stock;
        }
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"

	"github.com/gorilla/mux"
)

const appPort = 3000

type subscription struct {
	PubsubName string            `json:"pubsubname"`
	Topic      string            `json:"topic"`
	Metadata   map[string]string `json:"metadata,omitempty"`
	Routes     routes            `json:"routes"`
}

type routes struct {
	Rules   []rule `json:"rules,omitempty"`
	Default string `json:"default,omitempty"`
}

type rule struct {
	Match string `json:"match"`
	Path  string `json:"path"`
}

// This handles /dapr/subscribe
func configureSubscribeHandler(w http.ResponseWriter, _ *http.Request) {
	t := []subscription{
		{
			PubsubName: "pubsub",
			Topic:      "inventory",
			Routes: routes{
				Rules: []rule{
					{
						Match: `event.type == "widget"`,
						Path:  "/widgets",
					},
					{
						Match: `event.type == "gadget"`,
						Path:  "/gadgets",
					},
				},
				Default: "/products",
			},
		},
	}

	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(t)
}

func main() {
	router := mux.NewRouter().StrictSlash(true)
	router.HandleFunc("/dapr/subscribe", configureSubscribeHandler).Methods("GET")
	log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", appPort), router))
}
<?php

require_once __DIR__.'/vendor/autoload.php';

$app = \Dapr\App::create(configure: fn(\DI\ContainerBuilder $builder) => $builder->addDefinitions(['dapr.subscriptions' => [
    new \Dapr\PubSub\Subscription(pubsubname: 'pubsub', topic: 'inventory', routes: (
      rules: => [
        ('match': 'event.type == "widget"', path: '/widgets'),
        ('match': 'event.type == "gadget"', path: '/gadgets'),
      ]
      default: '/products')),
]]));
$app->post('/products', function(
    #[\Dapr\Attributes\FromBody]
    \Dapr\PubSub\CloudEvent $cloudEvent,
    \Psr\Log\LoggerInterface $logger
    ) {
        $logger->alert('Received event: {event}', ['event' => $cloudEvent]);
        return ['status' => 'SUCCESS'];
    }
);
$app->start();

通用表达式语言 (CEL)

在这些示例中,根据 event.type,应用程序将被调用于:

  • /widgets
  • /gadgets
  • /products

表达式是用通用表达式语言 (CEL)编写的,其中 event 代表云事件。表达式中可以引用 CloudEvents 核心规范中的任何属性。

示例表达式

匹配“重要”消息:

has(event.data.important) && event.data.important == true

匹配大于 $10,000 的存款:

event.type == "deposit" && int(event.data.amount) > 10000

匹配消息的多个版本:

event.type == "mymessage.v1"
event.type == "mymessage.v2"

CloudEvent 属性

作为参考,以下属性来自 CloudEvents 规范。

事件数据

data

根据术语 data 的定义,CloudEvents 可能 包含有关事件发生的领域特定信息。当存在时,此信息将被封装在 data 中。

  • 描述: 事件负载。此规范对信息类型没有限制。它被编码为一种媒体格式,由 datacontenttype 属性指定(例如 application/json),并在这些相应属性存在时遵循 dataschema 格式。
  • 约束:
    • 可选

必需属性

以下属性在所有 CloudEvents 中是必需的:

id

  • 类型: String
  • 描述: 标识事件。生产者 必须 确保 source + id 对于每个不同的事件都是唯一的。如果由于网络错误而重新发送重复事件,则它可能具有相同的 id。消费者可以假设具有相同 sourceid 的事件是重复的。
  • 约束:
    • 必需
    • 必须是非空字符串
    • 必须在生产者范围内唯一
  • 示例:
    • 由生产者维护的事件计数器
    • UUID

source

  • 类型: URI-reference

  • 描述: 标识事件发生的上下文。通常包括以下信息:

    • 事件源的类型
    • 发布事件的组织
    • 产生事件的过程

    URI 中编码的数据的确切语法和语义由事件生产者定义。

    生产者 必须 确保 source + id 对于每个不同的事件都是唯一的。

    应用程序可以:

    • 为每个不同的生产者分配一个唯一的 source,以便更容易生成唯一的 ID,并防止其他生产者具有相同的 source
    • 使用 UUID、URN、DNS 权威或应用程序特定方案创建唯一的 source 标识符。

    一个 source 可能包含多个生产者。在这种情况下,生产者 必须 合作以确保 source + id 对于每个不同的事件都是唯一的。

  • 约束:

    • 必需
    • 必须是非空 URI-reference
    • 推荐使用绝对 URI
  • 示例:

    • 具有 DNS 权威的互联网范围唯一 URI:
    • 具有 UUID 的全球唯一 URN:
      • urn:uuid:6e8bc430-9c3a-11d9-9669-0800200c9a66
    • 应用程序特定标识符:
      • /cloudevents/spec/pull/123
      • /sensors/tn-1234567/alerts
      • 1-555-123-4567

specversion

  • 类型: String

  • 描述: 事件使用的 CloudEvents 规范版本。这使得上下文的解释成为可能。合规的事件生产者 必须 在引用此版本的规范时使用 1.0 值。

    目前,此属性仅包含“主要”和“次要”版本号。这允许在不更改此属性值的情况下对规范进行补丁更改。

    注意:对于“候选发布”版本,可能会使用后缀进行测试。

  • 约束:

    • 必需
    • 必须是非空字符串

type

  • 类型: String
  • 描述: 包含描述与原始事件相关的事件类型的值。通常,此属性用于路由、可观察性、策略执行等。格式由生产者定义,可能包括 type 的版本信息。有关更多信息,请参阅CloudEvents 的版本控制
  • 约束:
    • 必需
    • 必须是非空字符串
    • 应该以反向 DNS 名称为前缀。前缀域决定了定义此事件类型语义的组织。
  • 示例:
    • com.github.pull_request.opened
    • com.example.object.deleted.v2

可选属性

以下属性在 CloudEvents 中是可选的。有关可选定义的更多信息,请参阅符号约定部分。

datacontenttype

  • 类型: String 根据 RFC 2046

  • 描述: data 值的内容类型。此属性使 data 能够携带任何类型的内容,其中格式和编码可能与所选事件格式不同。

    例如,使用 JSON 信封格式呈现的事件可能在 data 中携带 XML 负载。此属性被设置为 "application/xml",通知消费者。

    不同 datacontenttype 值的数据内容呈现规则在事件格式规范中定义。例如,JSON 事件格式在第 3.1 节中定义了关系。

    对于某些二进制模式协议绑定,此字段直接映射到相应协议的内容类型元数据属性。您可以在相应协议中找到二进制模式和内容类型元数据映射的规范规则。

    在某些事件格式中,您可以省略 datacontenttype 属性。例如,如果 JSON 格式事件没有 datacontenttype 属性,则意味着 data 是符合 "application/json" 媒体类型的 JSON 值。换句话说:没有 datacontenttype 的 JSON 格式事件与 datacontenttype="application/json" 的事件完全等效。

    当将没有 datacontenttype 属性的事件消息转换为不同格式或协议绑定时,目标 datacontenttype 应明确设置为源的隐含 datacontenttype

  • 约束:

    • 可选
    • 如果存在,必须符合 RFC 2046 中指定的格式
  • 有关媒体类型示例,请参阅 IANA 媒体类型

dataschema

  • 类型: URI
  • 描述: 标识 data 遵循的模式。与模式不兼容的更改应通过不同的 URI 反映。有关更多信息,请参阅CloudEvents 的版本控制
  • 约束:
    • 可选
    • 如果存在,必须是非空 URI

subject

  • 类型: String

  • 描述: 这描述了事件生产者(由 source 标识)上下文中的事件主题。在发布-订阅场景中,订阅者通常会订阅由 source 发出的事件。如果 source 上下文具有内部子结构,则仅 source 标识符可能不足以作为任何特定事件的限定符。

    在上下文元数据中(而不是仅在 data 负载中)识别事件的主题在通用订阅过滤场景中很有帮助,其中中间件无法解释 data 内容。在上述示例中,订阅者可能只对名称以 ‘.jpg’ 或 ‘.jpeg’ 结尾的 blob 感兴趣。使用 subject 属性,您可以为该事件子集构建简单而高效的字符串后缀过滤器。

  • 约束:

    • 可选
    • 如果存在,必须是非空字符串
  • 示例:
    订阅者可能会注册对在 blob 存储容器中创建新 blob 时的兴趣。在这种情况下:

    • 事件 source 标识订阅范围(存储容器)
    • 事件 type 标识“blob 创建”事件
    • 事件 id 唯一标识事件实例,以区分同名 blob 的单独创建事件。

    新创建的 blob 的名称在 subject 中传递:

time

  • 类型: Timestamp
  • 描述: 事件发生的时间戳。如果无法确定事件发生的时间,则此属性可以由 CloudEvents 生产者设置为其他时间(例如当前时间)。然而,所有相同 source 的生产者 必须 在这方面保持一致。换句话说,要么他们都使用事件发生的实际时间,要么他们都使用相同的算法来确定使用的值。
  • 约束:
    • 可选
    • 如果存在,必须符合 RFC 3339 中指定的格式

社区电话演示

观看此视频以了解如何使用 pubsub 进行消息路由:

下一步

2.6 - 声明式、流式和编程式订阅类型

了解更多关于允许您订阅消息主题的订阅类型。

发布/订阅 API 订阅类型

Dapr 应用程序可以通过三种订阅类型来订阅已发布的主题,这三种类型支持相同的功能:声明式、流式和编程式。

订阅类型描述
声明式订阅在外部文件中定义。声明式方法将 Dapr 的依赖从代码中移除,允许现有应用程序无需更改代码即可订阅主题。
流式订阅在应用程序代码中定义。流式订阅是动态的,允许在运行时添加或删除订阅。它们不需要在应用程序中设置订阅端点(这是编程式和声明式订阅所需的),使其在代码中易于配置。流式订阅也不需要应用程序配置 sidecar 来接收消息。
编程式订阅在应用程序代码中定义。编程式方法实现了静态订阅,并需要在代码中设置一个端点。

下面的示例演示了通过 orders 主题在 checkout 应用程序和 orderprocessing 应用程序之间的发布/订阅消息。示例首先以声明式,然后以编程式演示了相同的 Dapr 发布/订阅组件。

声明式订阅

您可以使用外部组件文件声明性地订阅一个主题。此示例使用名为 subscription.yaml 的 YAML 组件文件:

apiVersion: dapr.io/v2alpha1
kind: Subscription
metadata:
  name: order
spec:
  topic: orders
  routes:
    default: /orders
  pubsubname: pubsub
scopes:
- orderprocessing

这里的订阅名为 order

  • 使用名为 pubsub 的发布/订阅组件订阅名为 orders 的主题。
  • 设置 route 字段以将所有主题消息发送到应用程序中的 /orders 端点。
  • 设置 scopes 字段以将此订阅的访问范围仅限于 ID 为 orderprocessing 的应用程序。

运行 Dapr 时,设置 YAML 组件文件路径以指向 Dapr 的组件。

dapr run --app-id myapp --resources-path ./myComponents -- dotnet run
dapr run --app-id myapp --resources-path ./myComponents -- mvn spring-boot:run
dapr run --app-id myapp --resources-path ./myComponents -- python3 app.py
dapr run --app-id myapp --resources-path ./myComponents -- npm start
dapr run --app-id myapp --resources-path ./myComponents -- go run app.go

在 Kubernetes 中,将组件应用到集群:

kubectl apply -f subscription.yaml

在您的应用程序代码中,订阅 Dapr 发布/订阅组件中指定的主题。

 //订阅一个主题 
[HttpPost("orders")]
public void getCheckout([FromBody] int orderId)
{
    Console.WriteLine("Subscriber received : " + orderId);
}
import io.dapr.client.domain.CloudEvent;

 //订阅一个主题
@PostMapping(path = "/orders")
public Mono<Void> getCheckout(@RequestBody(required = false) CloudEvent<String> cloudEvent) {
    return Mono.fromRunnable(() -> {
        try {
            log.info("Subscriber received: " + cloudEvent.getData());
        } 
    });
}
from cloudevents.sdk.event import v1

#订阅一个主题 
@app.route('/orders', methods=['POST'])
def checkout(event: v1.Event) -> None:
    data = json.loads(event.Data())
    logging.info('Subscriber received: ' + str(data))
const express = require('express')
const bodyParser = require('body-parser')
const app = express()
app.use(bodyParser.json({ type: 'application/*+json' }));

// 监听声明式路由
app.post('/orders', (req, res) => {
  console.log(req.body);
  res.sendStatus(200);
});
//订阅一个主题
var sub = &common.Subscription{
	PubsubName: "pubsub",
	Topic:      "orders",
	Route:      "/orders",
}

func eventHandler(ctx context.Context, e *common.TopicEvent) (retry bool, err error) {
	log.Printf("Subscriber received: %s", e.Data)
	return false, nil
}

/orders 端点与订阅中定义的 route 匹配,这是 Dapr 发送所有主题消息的地方。

流式订阅

流式订阅是在应用程序代码中定义的订阅,可以在运行时动态停止和启动。 消息由应用程序从 Dapr 拉取。这意味着不需要端点来订阅主题,并且可以在没有任何应用程序配置在 sidecar 上的情况下进行订阅。 可以同时订阅任意数量的发布/订阅和主题。 由于消息被发送到给定的消息处理代码,因此没有路由或批量订阅的概念。

注意: 每个应用程序一次只能订阅一个发布/订阅/主题对。

下面的示例展示了不同的流式订阅主题的方法。

您可以使用 subscribe 方法,该方法返回一个 Subscription 对象,并允许您通过调用 next_message 方法从流中拉取消息。这在主线程中运行,并可能在等待消息时阻塞主线程。

import time

from dapr.clients import DaprClient
from dapr.clients.grpc.subscription import StreamInactiveError

counter = 0


def process_message(message):
    global counter
    counter += 1
    # 在此处处理消息
    print(f'Processing message: {message.data()} from {message.topic()}...')
    return 'success'


def main():
    with DaprClient() as client:
        global counter

        subscription = client.subscribe(
            pubsub_name='pubsub', topic='orders', dead_letter_topic='orders_dead'
        )

        try:
            while counter < 5:
                try:
                    message = subscription.next_message()

                except StreamInactiveError as e:
                    print('Stream is inactive. Retrying...')
                    time.sleep(1)
                    continue
                if message is None:
                    print('No message received within timeout period.')
                    continue

                # 处理消息
                response_status = process_message(message)

                if response_status == 'success':
                    subscription.respond_success(message)
                elif response_status == 'retry':
                    subscription.respond_retry(message)
                elif response_status == 'drop':
                    subscription.respond_drop(message)

        finally:
            print("Closing subscription...")
            subscription.close()


if __name__ == '__main__':
    main()

您还可以使用 subscribe_with_handler 方法,该方法接受一个回调函数,该函数为从流中接收到的每条消息执行。此方法在单独的线程中运行,因此不会阻塞主线程。

import time

from dapr.clients import DaprClient
from dapr.clients.grpc._response import TopicEventResponse

counter = 0


def process_message(message):
    # 在此处处理消息
    global counter
    counter += 1
    print(f'Processing message: {message.data()} from {message.topic()}...')
    return TopicEventResponse('success')


def main():
    with (DaprClient() as client):
        # 这将启动一个新线程,该线程将监听消息
        # 并在 `process_message` 函数中处理它们
        close_fn = client.subscribe_with_handler(
            pubsub_name='pubsub', topic='orders', handler_fn=process_message,
            dead_letter_topic='orders_dead'
        )

        while counter < 5:
            time.sleep(1)

        print("Closing subscription...")
        close_fn()


if __name__ == '__main__':
    main()

了解更多关于使用 Python SDK 客户端的流式订阅。

package main

import (
	"context"
	"log"

	"github.com/dapr/go-sdk/client"
)

func main() {
	cl, err := client.NewClient()
	if err != nil {
		log.Fatal(err)
	}

	sub, err := cl.Subscribe(context.Background(), client.SubscriptionOptions{
		PubsubName: "pubsub",
		Topic:      "orders",
	})
	if err != nil {
		panic(err)
	}
	// 必须始终调用 Close。
	defer sub.Close()

	for {
		msg, err := sub.Receive()
		if err != nil {
			panic(err)
		}

		// 处理事件

		// 我们 _必须_ 始终表示消息处理的结果,否则
		// 消息将不会被视为已处理,并将被重新传递或
		// 死信。
		// msg.Retry()
		// msg.Drop()
		if err := msg.Success(); err != nil {
			panic(err)
		}
	}
}

package main

import (
	"context"
	"log"

	"github.com/dapr/go-sdk/client"
	"github.com/dapr/go-sdk/service/common"
)

func main() {
	cl, err := client.NewClient()
	if err != nil {
		log.Fatal(err)
	}

	stop, err := cl.SubscribeWithHandler(context.Background(),
		client.SubscriptionOptions{
			PubsubName: "pubsub",
			Topic:      "orders",
		},
		eventHandler,
	)
	if err != nil {
		panic(err)
	}

	// 必须始终调用 Stop。
	defer stop()

	<-make(chan struct{})
}

func eventHandler(e *common.TopicEvent) common.SubscriptionResponseStatus {
	// 在此处处理消息
    // common.SubscriptionResponseStatusRetry
    // common.SubscriptionResponseStatusDrop
			common.SubscriptionResponseStatusDrop, status)
	}

	return common.SubscriptionResponseStatusSuccess
}

演示

观看 此视频以了解流式订阅的概述

编程式订阅

动态编程式方法在代码中返回 routes JSON 结构,与声明式方法的 route YAML 结构不同。

注意: 编程式订阅仅在应用程序启动时读取一次。您不能 动态 添加新的编程式订阅,只能在编译时添加新的。

在下面的示例中,您在应用程序代码中定义了在上面的声明式 YAML 订阅中找到的值。

[Topic("pubsub", "orders")]
[HttpPost("/orders")]
public async Task<ActionResult<Order>>Checkout(Order order, [FromServices] DaprClient daprClient)
{
    // 逻辑
    return order;
}

// Dapr 订阅在 [Topic] 中将 orders 主题路由到此路由
app.MapPost("/orders", [Topic("pubsub", "orders")] (Order order) => {
    Console.WriteLine("Subscriber received : " + order);
    return Results.Ok(order);
});

上面定义的两个处理程序还需要映射以配置 dapr/subscribe 端点。这是在定义端点时在应用程序启动代码中完成的。

app.UseEndpoints(endpoints =>
{
    endpoints.MapSubscribeHandler();
});
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

@Topic(name = "orders", pubsubName = "pubsub")
@PostMapping(path = "/orders")
public Mono<Void> handleMessage(@RequestBody(required = false) CloudEvent<String> cloudEvent) {
  return Mono.fromRunnable(() -> {
    try {
      System.out.println("Subscriber received: " + cloudEvent.getData());
      System.out.println("Subscriber received: " + OBJECT_MAPPER.writeValueAsString(cloudEvent));
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
  });
}
@app.route('/dapr/subscribe', methods=['GET'])
def subscribe():
    subscriptions = [
      {
        'pubsubname': 'pubsub',
        'topic': 'orders',
        'routes': {
          'rules': [
            {
              'match': 'event.type == "order"',
              'path': '/orders'
            },
          ],
          'default': '/orders'
        }
      }]
    return jsonify(subscriptions)

@app.route('/orders', methods=['POST'])
def ds_subscriber():
    print(request.json, flush=True)
    return json.dumps({'success':True}), 200, {'ContentType':'application/json'}
app.run()
const express = require('express')
const bodyParser = require('body-parser')
const app = express()
app.use(bodyParser.json({ type: 'application/*+json' }));

const port = 3000

app.get('/dapr/subscribe', (req, res) => {
  res.json([
    {
      pubsubname: "pubsub",
      topic: "orders",
      routes: {
        rules: [
          {
            match: 'event.type == "order"',
            path: '/orders'
          },
        ],
        default: '/products'
      }
    }
  ]);
})

app.post('/orders', (req, res) => {
  console.log(req.body);
  res.sendStatus(200);
});

app.listen(port, () => console.log(`consumer app listening on port ${port}!`))
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"

	"github.com/gorilla/mux"
)

const appPort = 3000

type subscription struct {
	PubsubName string            `json:"pubsubname"`
	Topic      string            `json:"topic"`
	Metadata   map[string]string `json:"metadata,omitempty"`
	Routes     routes            `json:"routes"`
}

type routes struct {
	Rules   []rule `json:"rules,omitempty"`
	Default string `json:"default,omitempty"`
}

type rule struct {
	Match string `json:"match"`
	Path  string `json:"path"`
}

// 处理 /dapr/subscribe
func configureSubscribeHandler(w http.ResponseWriter, _ *http.Request) {
	t := []subscription{
		{
			PubsubName: "pubsub",
			Topic:      "orders",
			Routes: routes{
				Rules: []rule{
					{
						Match: `event.type == "order"`,
						Path:  "/orders",
					},
				},
				Default: "/orders",
			},
		},
	}

	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(t)
}

func main() {
	router := mux.NewRouter().StrictSlash(true)
	router.HandleFunc("/dapr/subscribe", configureSubscribeHandler).Methods("GET")
	log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", appPort), router))
}

下一步

2.7 - 死信主题

通过订阅死信主题来处理无法投递的消息

介绍

在某些情况下,应用程序可能由于各种原因无法处理消息。例如,可能会出现获取处理消息所需数据的临时问题,或者应用程序的业务逻辑失败并返回错误。死信主题用于处理这些无法投递的消息,并将其转发到订阅应用程序。这可以减轻应用程序处理失败消息的负担,使开发人员可以编写代码从死信主题中读取消息,修复后重新发送,或者选择放弃这些消息。

死信主题通常与重试策略和处理死信主题消息的订阅一起使用。

当配置了死信主题时,任何无法投递到应用程序的消息都会被放置在死信主题中,以便转发到处理这些消息的订阅。这可以是同一个应用程序或完全不同的应用程序。

即使底层系统不支持,Dapr 也为其所有的 pubsub 组件启用了死信主题。例如,AWS SNS 组件有一个死信队列,RabbitMQ有死信主题。您需要确保正确配置这些组件。

下图展示了死信主题的工作原理。首先,消息从 orders 主题的发布者发送。Dapr 代表订阅者应用程序接收消息,但 orders 主题的消息未能投递到应用程序的 /checkout 端点,即使经过重试也是如此。由于投递失败,消息被转发到 poisonMessages 主题,该主题将其投递到 /failedMessages 端点进行处理,在这种情况下是在同一个应用程序上。failedMessages 处理代码可以选择丢弃消息或重新发送新消息。

使用声明式订阅配置死信主题

以下 YAML 显示了如何为从 orders 主题消费的消息配置名为 poisonMessages 的死信主题。此订阅的范围限定为具有 checkout ID 的应用程序。

apiVersion: dapr.io/v2alpha1
kind: Subscription
metadata:
  name: order
spec:
  topic: orders
  routes: 
    default: /checkout
  pubsubname: pubsub
  deadLetterTopic: poisonMessages
scopes:
- checkout

使用流式订阅配置死信主题

	var deadLetterTopic = "poisonMessages"
	sub, err := cl.Subscribe(context.Background(), client.SubscriptionOptions{
		PubsubName:      "pubsub",
		Topic:           "orders",
		DeadLetterTopic: &deadLetterTopic,
	})

使用编程订阅配置死信主题

/subscribe 端点返回的 JSON 显示了如何为从 orders 主题消费的消息配置名为 poisonMessages 的死信主题。

app.get('/dapr/subscribe', (_req, res) => {
    res.json([
        {
            pubsubname: "pubsub",
            topic: "orders",
            route: "/checkout",
            deadLetterTopic: "poisonMessages"
        }
    ]);
});

重试和死信主题

默认情况下,当设置了死信主题时,任何失败的消息会立即进入死信主题。因此,建议在订阅中使用死信主题时始终设置重试策略。 要在将消息发送到死信主题之前启用消息重试,请对 pubsub 组件应用 重试策略

此示例显示了如何为 pubsub pubsub 组件设置名为 pubsubRetry 的常量重试策略,每 5 秒应用一次,最多尝试投递 10 次。

apiVersion: dapr.io/v1alpha1
kind: Resiliency
metadata:
  name: myresiliency
spec:
  policies:
    retries:
      pubsubRetry:
        policy: constant
        duration: 5s
        maxRetries: 10
  targets:
    components:
      pubsub:
        inbound:
          retry: pubsubRetry

配置处理死信主题的订阅

请记得配置一个订阅来处理死信主题。例如,您可以创建另一个声明式订阅,在同一个或不同的应用程序上接收这些消息。下面的示例显示了 checkout 应用程序通过另一个订阅订阅 poisonMessages 主题,并将这些消息发送到 /failedmessages 端点进行处理。

apiVersion: dapr.io/v2alpha1
kind: Subscription
metadata:
  name: deadlettertopics
spec:
  topic: poisonMessages
  routes:
    rules:
      - match:
        path: /failedMessages
  pubsubname: pubsub
scopes:
- checkout

演示

观看此视频以了解死信主题的概述:

下一步

2.8 - 如何:设置 pub/sub 命名空间消费者组

了解如何在组件中使用基于元数据的命名空间消费者组

您已经配置了 Dapr 的 pub/sub API 构建块,并且您的应用程序正在使用集中式消息代理顺利地发布和订阅主题。如果您想为应用程序执行简单的 A/B 测试、蓝/绿部署,甚至金丝雀部署,该怎么办?即使使用 Dapr,这也可能很困难。

Dapr 通过其 pub/sub 命名空间消费者组机制解决了大规模的多租户问题。

没有命名空间消费者组

假设您有一个 Kubernetes 集群,其中两个应用程序(App1 和 App2)部署在同一个命名空间(namespace-a)中。App2 发布到一个名为 order 的主题,而 App1 订阅名为 order 的主题。这将创建两个以您的应用程序命名的消费者组(App1 和 App2)。

显示基本 pubsub 过程的图示。

为了在使用集中式消息代理时进行简单的测试和部署,您创建了另一个命名空间,其中包含两个具有相同 app-id 的应用程序,App1 和 App2。

Dapr 使用单个应用程序的 app-id 创建消费者组,因此消费者组名称将保持为 App1 和 App2。

显示没有 Dapr 命名空间消费者组的多租户复杂性的图示。

为了避免这种情况,您需要在代码中“潜入”一些东西来更改 app-id,具体取决于您运行的命名空间。这种方法既麻烦又容易出错。

使用命名空间消费者组

Dapr 不仅允许您使用 UUID 和 pod 名称的 consumerID 更改消费者组的行为,还提供了一个存在于 pub/sub 组件元数据中的 命名空间机制。例如,使用 Redis 作为您的消息代理:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: pubsub
spec:
  type: pubsub.redis
  version: v1
  metadata:
  - name: redisHost
    value: localhost:6379
  - name: redisPassword
    value: ""
  - name: consumerID
    value: "{namespace}"

通过将 consumerID 配置为 {namespace} 值,您可以在不同的命名空间中使用相同的 app-id 和相同的主题。

显示命名空间消费者组如何帮助多租户的图示。

在上图中,您有两个命名空间,每个命名空间都有相同 app-id 的应用程序,发布和订阅相同的集中式消息代理 orders。然而这次,Dapr 创建了以它们运行的命名空间为前缀的消费者组名称。

无需更改您的代码或 app-id,命名空间消费者组允许您:

  • 添加更多命名空间
  • 保持相同的主题
  • 在命名空间之间保持相同的 app-id
  • 保持整个部署管道完整

只需在您的组件元数据中包含 "{namespace}" 消费者组机制。您无需在元数据中手动编码命名空间。Dapr 会自动识别其运行的命名空间并为您填充命名空间值,就像由运行时注入的动态元数据值一样。

演示

观看 此视频以了解 pub/sub 多租户的概述

下一步

2.9 - 如何:使用StatefulSets水平扩展订阅者

学习如何使用StatefulSet进行订阅,并通过一致的消费者ID水平扩展

与在Deployments中Pod是临时的不同,StatefulSets通过为每个Pod保持固定的身份,使得在Kubernetes上可以部署有状态应用程序。

以下是一个使用Dapr的StatefulSet示例:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: python-subscriber
spec:
  selector:
    matchLabels:
      app: python-subscriber  # 必须匹配.spec.template.metadata.labels
  serviceName: "python-subscriber"
  replicas: 3
  template:
    metadata:
      labels:
        app: python-subscriber # 必须匹配.spec.selector.matchLabels
      annotations:
        dapr.io/enabled: "true"
        dapr.io/app-id: "python-subscriber"
        dapr.io/app-port: "5001"
    spec:
      containers:
      - name: python-subscriber
        image: ghcr.io/dapr/samples/pubsub-python-subscriber:latest
        ports:
        - containerPort: 5001
        imagePullPolicy: Always

在通过Dapr订阅pubsub主题时,应用程序可以定义一个consumerID,这个ID决定了订阅者在队列或主题中的位置。利用StatefulSets中Pod的固定身份,您可以为每个Pod分配一个唯一的consumerID,从而实现订阅者应用程序的水平扩展。Dapr会跟踪每个Pod的名称,并可以在组件中使用{podName}标记来声明。

当扩展某个主题的订阅者数量时,每个Dapr组件都有特定的设置来决定其行为。通常,对于多个消费者有两种选择:

  • 广播:发布到主题的每条消息将被所有订阅者接收。
  • 共享:一条消息仅由一个订阅者接收(而不是所有订阅者)。

Kafka通过consumerID为每个订阅者分配独立的位置。当实例重新启动时,它会使用相同的consumerID继续从上次的位置处理消息,而不会遗漏任何消息。以下组件示例展示了如何让多个Pod使用Kafka组件:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: pubsub
spec:
  type: pubsub.kafka
  version: v1
  metadata:
  - name: brokers
    value: my-cluster-kafka-bootstrap.kafka.svc.cluster.local:9092
  - name: consumerID
    value: "{podName}"
  - name: authRequired
    value: "false"

MQTT3协议支持共享主题,允许多个订阅者“竞争”处理来自主题的消息,这意味着每条消息仅由其中一个订阅者处理。例如:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: mqtt-pubsub
spec:
  type: pubsub.mqtt3
  version: v1
  metadata:
    - name: consumerID
      value: "{podName}"
    - name: cleanSession
      value: "true"
    - name: url
      value: "tcp://admin:public@localhost:1883"
    - name: qos
      value: 1
    - name: retain
      value: "false"

下一步

2.10 - 限制 Pub/sub 主题访问

通过范围控制将 pub/sub 主题限制为特定应用程序

介绍

命名空间或组件范围可以用来限制组件的访问权限,使其仅对特定应用程序可用。这些应用程序范围的设置确保只有具有特定 ID 的应用程序才能使用该组件。

除了这种通用的组件范围外,还可以对 pub/sub 组件进行以下限制:

  • 哪些主题可以被使用(发布或订阅)
  • 哪些应用程序被允许发布到特定主题
  • 哪些应用程序被允许订阅特定主题

这被称为 pub/sub 主题范围控制

为每个 pub/sub 组件定义 pub/sub 范围。您可能有一个名为 pubsub 的 pub/sub 组件,它有一组范围,另一个 pubsub2 则有不同的范围。

要使用此主题范围,可以为 pub/sub 组件设置三个元数据属性:

  • spec.metadata.publishingScopes
    • 一个以分号分隔的应用程序列表和以逗号分隔的主题列表,允许该应用程序发布到该主题列表
    • 如果在 publishingScopes 中未指定任何内容(默认行为),则所有应用程序都可以发布到所有主题
    • 要拒绝应用程序发布到任何主题的能力,请将主题列表留空(app1=;app2=topic2
    • 例如,app1=topic1;app2=topic2,topic3;app3= 将允许 app1 仅发布到 topic1,app2 仅发布到 topic2 和 topic3,app3 则不能发布到任何主题。
  • spec.metadata.subscriptionScopes
    • 一个以分号分隔的应用程序列表和以逗号分隔的主题列表,允许该应用程序订阅该主题列表
    • 如果在 subscriptionScopes 中未指定任何内容(默认行为),则所有应用程序都可以订阅所有主题
    • 例如,app1=topic1;app2=topic2,topic3 将允许 app1 仅订阅 topic1,app2 仅订阅 topic2 和 topic3
  • spec.metadata.allowedTopics
    • 一个为所有应用程序允许的主题的逗号分隔列表。
    • 如果未设置 allowedTopics(默认行为),则所有主题都是有效的。如果存在,subscriptionScopespublishingScopes 仍然生效。
    • publishingScopessubscriptionScopes 可以与 allowedTopics 结合使用以添加细粒度限制
  • spec.metadata.protectedTopics
    • 一个为所有应用程序保护的主题的逗号分隔列表。
    • 如果一个主题被标记为保护,则必须通过 publishingScopessubscriptionScopes 明确授予应用程序发布或订阅权限才能发布/订阅该主题。

这些元数据属性可用于所有 pub/sub 组件。以下示例使用 Redis 作为 pub/sub 组件。

示例 1:限制主题访问

在某些情况下,限制哪些应用程序可以发布/订阅主题是有用的,例如当您有包含敏感信息的主题时,只有一部分应用程序被允许发布或订阅这些主题。

它也可以用于所有主题,以始终拥有一个“真实来源”,以了解哪些应用程序作为发布者/订阅者使用哪些主题。

以下是三个应用程序和三个主题的示例:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: pubsub
spec:
  type: pubsub.redis
  version: v1
  metadata:
  - name: redisHost
    value: "localhost:6379"
  - name: redisPassword
    value: ""
  - name: publishingScopes
    value: "app1=topic1;app2=topic2,topic3;app3="
  - name: subscriptionScopes
    value: "app2=;app3=topic1"

下表显示了哪些应用程序被允许发布到主题:

topic1topic2topic3
app1
app2
app3

下表显示了哪些应用程序被允许订阅主题:

topic1topic2topic3
app1
app2
app3

注意:如果应用程序未列出(例如 subscriptionScopes 中的 app1),则允许其订阅所有主题。因为未使用 allowedTopics,且 app1 没有任何订阅范围,它也可以使用上面未列出的其他主题。

示例 2:限制允许的主题

如果 Dapr 应用程序向其发送消息,则会创建一个主题。在某些情况下,这种主题创建应该受到管理。例如:

  • Dapr 应用程序在生成主题名称时的错误可能导致创建无限数量的主题
  • 精简主题名称和总数,防止主题无限增长

在这些情况下可以使用 allowedTopics

以下是三个允许的主题的示例:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: pubsub
spec:
  type: pubsub.redis
  version: v1
  metadata:
  - name: redisHost
    value: "localhost:6379"
  - name: redisPassword
    value: ""
  - name: allowedTopics
    value: "topic1,topic2,topic3"

所有应用程序都可以使用这些主题,但仅限于这些主题,不允许其他主题。

示例 3:结合 allowedTopics 和范围

有时您希望结合两者范围,从而仅拥有一组固定的允许主题,并指定对某些应用程序的范围。

以下是三个应用程序和两个主题的示例:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: pubsub
spec:
  type: pubsub.redis
  version: v1
  metadata:
  - name: redisHost
    value: "localhost:6379"
  - name: redisPassword
    value: ""
  - name: allowedTopics
    value: "A,B"
  - name: publishingScopes
    value: "app1=A"
  - name: subscriptionScopes
    value: "app1=;app2=A"

注意:第三个应用程序未列出,因为如果应用程序未在范围内指定,则允许其使用所有主题。

下表显示了哪个应用程序被允许发布到主题:

ABC
app1
app2
app3

下表显示了哪个应用程序被允许订阅主题:

ABC
app1
app2
app3

示例 4:将主题标记为保护

如果您的主题涉及敏感数据,则每个新应用程序必须在 publishingScopessubscriptionScopes 中明确列出,以确保其无法读取或写入该主题。或者,您可以将主题指定为“保护”(使用 protectedTopics),并仅授予真正需要的特定应用程序访问权限。

以下是三个应用程序和三个主题的示例,其中两个主题是保护的:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: pubsub
spec:
  type: pubsub.redis
  version: v1
  metadata:
  - name: redisHost
    value: "localhost:6379"
  - name: redisPassword
    value: ""
  - name: protectedTopics
    value: "A,B"
  - name: publishingScopes
    value: "app1=A,B;app2=B"
  - name: subscriptionScopes
    value: "app1=A,B;app2=B"

在上面的示例中,主题 A 和 B 被标记为保护。因此,即使 app3 未列在 publishingScopessubscriptionScopes 中,它也无法与这些主题交互。

下表显示了哪个应用程序被允许发布到主题:

ABC
app1
app2
app3

下表显示了哪个应用程序被允许订阅主题:

ABC
app1
app2
app3

演示

下一步

2.11 - 消息生存时间 (TTL)

在发布/订阅消息中使用生存时间。

介绍

Dapr 支持为每条消息设置生存时间 (TTL)。这意味着应用程序可以为每条消息指定生存时间,过期后订阅者将不会收到这些消息。

所有 Dapr 发布/订阅组件 都兼容消息 TTL,因为 Dapr 在运行时内处理 TTL 逻辑。只需在发布消息时设置 ttlInSeconds 元数据即可。

在某些组件中,例如 Kafka,可以通过 retention.ms 在主题中配置生存时间,详见文档。使用 Dapr 的消息 TTL,使用 Kafka 的应用程序现在可以为每条消息设置生存时间,而不仅限于每个主题。

原生消息 TTL 支持

当发布/订阅组件原生支持消息生存时间时,Dapr 仅转发生存时间配置而不添加额外逻辑,保持行为的可预测性。这在组件以不同方式处理过期消息时非常有用。例如,在 Azure Service Bus 中,过期消息会被存储在死信队列中,而不是简单地删除。

支持的组件

Azure Service Bus

Azure Service Bus 支持实体级别的生存时间。这意味着消息有默认的生存时间,但也可以在发布时设置为更短的时间跨度。Dapr 传播消息的生存时间元数据,并让 Azure Service Bus 直接处理过期。

非 Dapr 订阅者

如果消息由不使用 Dapr 的订阅者消费,过期消息不会自动丢弃,因为过期是由 Dapr 运行时在 Dapr sidecar 接收到消息时处理的。然而,订阅者可以通过在云事件中添加逻辑来处理 expiration 属性,以编程方式丢弃过期消息,该属性遵循 RFC3339 格式。

当非 Dapr 订阅者使用诸如 Azure Service Bus 等原生处理消息 TTL 的组件时,他们不会收到过期消息。在这种情况下,不需要额外的逻辑。

示例

消息 TTL 可以在发布请求的元数据中设置:

curl -X "POST" http://localhost:3500/v1.0/publish/pubsub/TOPIC_A?metadata.ttlInSeconds=120 -H "Content-Type: application/json" -d '{"order-number": "345"}'
from dapr.clients import DaprClient

with DaprClient() as d:
    req_data = {
        'order-number': '345'
    }
    # 创建一个带有内容类型和主体的类型化消息
    resp = d.publish_event(
        pubsub_name='pubsub',
        topic='TOPIC_A',
        data=json.dumps(req_data),
        publish_metadata={'ttlInSeconds': '120'}
    )
    # 打印请求
    print(req_data, flush=True)
<?php

require_once __DIR__.'/vendor/autoload.php';

$app = \Dapr\App::create();
$app->run(function(\DI\FactoryInterface $factory) {
    $publisher = $factory->make(\Dapr\PubSub\Publish::class, ['pubsub' => 'pubsub']);
    $publisher->topic('TOPIC_A')->publish('data', ['ttlInSeconds' => '120']);
});

请参阅本指南以获取发布/订阅 API 的参考。

下一步

2.12 - 批量发布和订阅消息

了解如何在Dapr中使用批量发布和订阅API。

通过批量发布和订阅API,您可以在单个请求中发布和订阅多个消息。在开发需要发送或接收大量消息的应用程序时,使用批量操作可以通过减少Dapr sidecar、应用程序和底层pubsub代理之间的请求总数来提高吞吐量。

批量发布消息

批量发布消息时的限制

批量发布API允许您通过单个请求将多个消息发布到一个主题。它是非事务性的,这意味着在一个批量请求中,某些消息可能会成功发布,而某些可能会失败。如果有消息发布失败,批量发布操作将返回失败消息的列表。

批量发布操作不保证消息的顺序。

示例

import io.dapr.client.DaprClientBuilder;
import io.dapr.client.DaprPreviewClient;
import io.dapr.client.domain.BulkPublishResponse;
import io.dapr.client.domain.BulkPublishResponseFailedEntry;
import java.util.ArrayList;
import java.util.List;

class BulkPublisher {
  private static final String PUBSUB_NAME = "my-pubsub-name";
  private static final String TOPIC_NAME = "topic-a";

  public void publishMessages() {
    try (DaprPreviewClient client = (new DaprClientBuilder()).buildPreviewClient()) {
      // 创建要发布的消息列表
      List<String> messages = new ArrayList<>();
      for (int i = 0; i < 10; i++) {
        String message = String.format("这是消息 #%d", i);
        messages.add(message);
      }

      // 使用批量发布API发布消息列表
      BulkPublishResponse<String> res = client.publishEvents(PUBSUB_NAME, TOPIC_NAME, "text/plain", messages).block();
    }
  }
}

import { DaprClient } from "@dapr/dapr";

const pubSubName = "my-pubsub-name";
const topic = "topic-a";

async function start() {
    const client = new DaprClient();

    // 向主题发布多个消息。
    await client.pubsub.publishBulk(pubSubName, topic, ["message 1", "message 2", "message 3"]);

    // 使用显式批量发布消息向主题发布多个消息。
    const bulkPublishMessages = [
    {
      entryID: "entry-1",
      contentType: "application/json",
      event: { hello: "foo message 1" },
    },
    {
      entryID: "entry-2",
      contentType: "application/cloudevents+json",
      event: {
        specversion: "1.0",
        source: "/some/source",
        type: "example",
        id: "1234",
        data: "foo message 2",
        datacontenttype: "text/plain"
      },
    },
    {
      entryID: "entry-3",
      contentType: "text/plain",
      event: "foo message 3",
    },
  ];
  await client.pubsub.publishBulk(pubSubName, topic, bulkPublishMessages);
}

start().catch((e) => {
  console.error(e);
  process.exit(1);
});
using System;
using System.Collections.Generic;
using Dapr.Client;

const string PubsubName = "my-pubsub-name";
const string TopicName = "topic-a";
IReadOnlyList<object> BulkPublishData = new List<object>() {
    new { Id = "17", Amount = 10m },
    new { Id = "18", Amount = 20m },
    new { Id = "19", Amount = 30m }
};

using var client = new DaprClientBuilder().Build();

var res = await client.BulkPublishEventAsync(PubsubName, TopicName, BulkPublishData);
if (res == null) {
    throw new Exception("从dapr返回的响应为空");
}
if (res.FailedEntries.Count > 0)
{
    Console.WriteLine("某些事件发布失败!");
    foreach (var failedEntry in res.FailedEntries)
    {
        Console.WriteLine("EntryId: " + failedEntry.Entry.EntryId + " 错误信息: " +
                          failedEntry.ErrorMessage);
    }
}
else
{
    Console.WriteLine("所有事件已发布!");
}
import requests
import json

base_url = "http://localhost:3500/v1.0-alpha1/publish/bulk/{}/{}"
pubsub_name = "my-pubsub-name"
topic_name = "topic-a"
payload = [
  {
    "entryId": "ae6bf7c6-4af2-11ed-b878-0242ac120002",
    "event": "first text message",
    "contentType": "text/plain"
  },
  {
    "entryId": "b1f40bd6-4af2-11ed-b878-0242ac120002",
    "event": {
      "message": "second JSON message"
    },
    "contentType": "application/json"
  }
]

response = requests.post(base_url.format(pubsub_name, topic_name), json=payload)
print(response.status_code)
package main

import (
  "fmt"
  "strings"
  "net/http"
  "io/ioutil"
)

const (
  pubsubName = "my-pubsub-name"
  topicName = "topic-a"
  baseUrl = "http://localhost:3500/v1.0-alpha1/publish/bulk/%s/%s"
)

func main() {
  url := fmt.Sprintf(baseUrl, pubsubName, topicName)
  method := "POST"
  payload := strings.NewReader(`[
        {
            "entryId": "ae6bf7c6-4af2-11ed-b878-0242ac120002",
            "event":  "first text message",
            "contentType": "text/plain"
        },
        {
            "entryId": "b1f40bd6-4af2-11ed-b878-0242ac120002",
            "event":  {
                "message": "second JSON message"
            },
            "contentType": "application/json"
        }
]`)

  client := &http.Client {}
  req, _ := http.NewRequest(method, url, payload)

  req.Header.Add("Content-Type", "application/json")
  res, err := client.Do(req)
  // ...
}
curl -X POST http://localhost:3500/v1.0-alpha1/publish/bulk/my-pubsub-name/topic-a \
  -H 'Content-Type: application/json' \
  -d '[
        {
            "entryId": "ae6bf7c6-4af2-11ed-b878-0242ac120002",
            "event":  "first text message",
            "contentType": "text/plain"
        },
        {
            "entryId": "b1f40bd6-4af2-11ed-b878-0242ac120002",
            "event":  {
                "message": "second JSON message"
            },
            "contentType": "application/json"
        },
      ]'
Invoke-RestMethod -Method Post -ContentType 'application/json' -Uri 'http://localhost:3500/v1.0-alpha1/publish/bulk/my-pubsub-name/topic-a' `
-Body '[
        {
            "entryId": "ae6bf7c6-4af2-11ed-b878-0242ac120002",
            "event":  "first text message",
            "contentType": "text/plain"
        },
        {
            "entryId": "b1f40bd6-4af2-11ed-b878-0242ac120002",
            "event":  {
                "message": "second JSON message"
            },
            "contentType": "application/json"
        },
      ]'

批量订阅消息

批量订阅API允许您在单个请求中从一个主题订阅多个消息。正如我们从如何:发布和订阅主题中所知,有三种方式可以订阅主题:

  • 声明式 - 订阅在外部文件中定义。
  • 编程式 - 订阅在代码中定义。
  • 流式 - 不支持批量订阅,因为消息被发送到处理程序代码。

要批量订阅主题,我们只需使用bulkSubscribe规范属性,如下所示:

apiVersion: dapr.io/v2alpha1
kind: Subscription
metadata:
  name: order-pub-sub
spec:
  topic: orders
  routes:
    default: /checkout
  pubsubname: order-pub-sub
  bulkSubscribe:
    enabled: true
    maxMessagesCount: 100
    maxAwaitDurationMs: 40
scopes:
- orderprocessing
- checkout

在上面的示例中,bulkSubscribe是_可选的_。如果您使用bulkSubscribe,那么:

  • enabled是必需的,用于启用或禁用此主题的批量订阅。
  • 您可以选择配置批量消息中传递的最大消息数(maxMessagesCount)。 对于不支持批量订阅的组件,maxMessagesCount的默认值为100,即应用程序和Dapr之间的默认批量事件。请参阅组件如何处理发布和订阅批量消息。 如果组件支持批量订阅,则该参数的默认值可以在该组件文档中找到。
  • 您可以选择提供在批量消息发送到应用程序之前的最大等待时间(maxAwaitDurationMs)。 对于不支持批量订阅的组件,maxAwaitDurationMs的默认值为1000,即应用程序和Dapr之间的默认批量事件。请参阅组件如何处理发布和订阅批量消息。 如果组件支持批量订阅,则该参数的默认值可以在该组件文档中找到。

应用程序接收与批量消息中的每个条目(单个消息)关联的EntryId。应用程序必须使用此EntryId来传达该特定条目的状态。如果应用程序未能通知EntryId状态,则被视为RETRY

需要发送一个带有每个条目处理状态的JSON编码的有效负载体:

{
  "statuses":
  [
    {
    "entryId": "<entryId1>",
    "status": "<status>"
    },
    {
    "entryId": "<entryId2>",
    "status": "<status>"
    }
  ]
}

可能的状态值:

状态描述
SUCCESS消息处理成功
RETRY消息由Dapr重试
DROP记录警告并丢弃消息

请参阅批量订阅的预期HTTP响应以获取更多见解。

示例

以下代码示例演示如何使用批量订阅。

import io.dapr.Topic;
import io.dapr.client.domain.BulkSubscribeAppResponse;
import io.dapr.client.domain.BulkSubscribeAppResponseEntry;
import io.dapr.client.domain.BulkSubscribeAppResponseStatus;
import io.dapr.client.domain.BulkSubscribeMessage;
import io.dapr.client.domain.BulkSubscribeMessageEntry;
import io.dapr.client.domain.CloudEvent;
import io.dapr.springboot.annotations.BulkSubscribe;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import reactor.core.publisher.Mono;

class BulkSubscriber {
  @BulkSubscribe()
  // @BulkSubscribe(maxMessagesCount = 100, maxAwaitDurationMs = 40)
  @Topic(name = "topicbulk", pubsubName = "orderPubSub")
  @PostMapping(path = "/topicbulk")
  public Mono<BulkSubscribeAppResponse> handleBulkMessage(
          @RequestBody(required = false) BulkSubscribeMessage<CloudEvent<String>> bulkMessage) {
    return Mono.fromCallable(() -> {
      List<BulkSubscribeAppResponseEntry> entries = new ArrayList<BulkSubscribeAppResponseEntry>();
      for (BulkSubscribeMessageEntry<?> entry : bulkMessage.getEntries()) {
        try {
          CloudEvent<?> cloudEvent = (CloudEvent<?>) entry.getEvent();
          System.out.printf("批量订阅者收到: %s\n", cloudEvent.getData());
          entries.add(new BulkSubscribeAppResponseEntry(entry.getEntryId(), BulkSubscribeAppResponseStatus.SUCCESS));
        } catch (Exception e) {
          e.printStackTrace();
          entries.add(new BulkSubscribeAppResponseEntry(entry.getEntryId(), BulkSubscribeAppResponseStatus.RETRY));
        }
      }
      return new BulkSubscribeAppResponse(entries);
    });
  }
}

import { DaprServer } from "@dapr/dapr";

const pubSubName = "orderPubSub";
const topic = "topicbulk";

const daprHost = process.env.DAPR_HOST || "127.0.0.1";
const daprPort = process.env.DAPR_HTTP_PORT || "3502";
const serverHost = process.env.SERVER_HOST || "127.0.0.1";
const serverPort = process.env.APP_PORT || 5001;

async function start() {
    const server = new DaprServer({
        serverHost,
        serverPort,
        clientOptions: {
            daprHost,
            daprPort,
        },
    });

    // 使用默认配置向主题发布多个消息。
    await client.pubsub.bulkSubscribeWithDefaultConfig(pubSubName, topic, (data) => console.log("订阅者收到: " + JSON.stringify(data)));

    // 使用特定的maxMessagesCount和maxAwaitDurationMs向主题发布多个消息。
    await client.pubsub.bulkSubscribeWithConfig(pubSubName, topic, (data) => console.log("订阅者收到: " + JSON.stringify(data)), 100, 40);
}
using Microsoft.AspNetCore.Mvc;
using Dapr.AspNetCore;
using Dapr;

namespace DemoApp.Controllers;

[ApiController]
[Route("[controller]")]
public class BulkMessageController : ControllerBase
{
    private readonly ILogger<BulkMessageController> logger;

    public BulkMessageController(ILogger<BulkMessageController> logger)
    {
        this.logger = logger;
    }

    [BulkSubscribe("messages", 10, 10)]
    [Topic("pubsub", "messages")]
    public ActionResult<BulkSubscribeAppResponse> HandleBulkMessages([FromBody] BulkSubscribeMessage<BulkMessageModel<BulkMessageModel>> bulkMessages)
    {
        List<BulkSubscribeAppResponseEntry> responseEntries = new List<BulkSubscribeAppResponseEntry>();
        logger.LogInformation($"收到 {bulkMessages.Entries.Count()} 条消息");
        foreach (var message in bulkMessages.Entries)
        {
            try
            {
                logger.LogInformation($"收到一条数据为 '{message.Event.Data.MessageData}' 的消息");
                responseEntries.Add(new BulkSubscribeAppResponseEntry(message.EntryId, BulkSubscribeAppResponseStatus.SUCCESS));
            }
            catch (Exception e)
            {
                logger.LogError(e.Message);
                responseEntries.Add(new BulkSubscribeAppResponseEntry(message.EntryId, BulkSubscribeAppResponseStatus.RETRY));
            }
        }
        return new BulkSubscribeAppResponse(responseEntries);
    }
    public class BulkMessageModel
    {
        public string MessageData { get; set; }
    }
}

目前,您只能使用HTTP客户端在Python中进行批量订阅。

import json
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/dapr/subscribe', methods=['GET'])
def subscribe():
    # 定义批量订阅配置
    subscriptions = [{
        "pubsubname": "pubsub",
        "topic": "TOPIC_A",
        "route": "/checkout",
        "bulkSubscribe": {
            "enabled": True,
            "maxMessagesCount": 3,
            "maxAwaitDurationMs": 40
        }
    }]
    print('Dapr pub/sub已订阅: ' + json.dumps(subscriptions))
    return jsonify(subscriptions)


# 定义处理传入消息的端点
@app.route('/checkout', methods=['POST'])
def checkout():
    messages = request.json
    print(messages)
    for message in messages:
        print(f"收到消息: {message}")
    return json.dumps({'success': True}), 200, {'ContentType': 'application/json'}

if __name__ == '__main__':
    app.run(port=5000)

组件如何处理发布和订阅批量消息

对于事件发布/订阅,涉及两种网络传输。

  1. 从/到应用程序到/从Dapr
  2. 从/到Dapr到/从pubsub代理

这些是可以进行优化的机会。当优化时,进行批量请求,从而减少总体调用次数,从而提高吞吐量并提供更好的延迟。

启用批量发布和/或批量订阅时,应用程序和Dapr sidecar之间的通信(上面第1点)针对所有组件进行了优化。

从Dapr sidecar到pubsub代理的优化取决于许多因素,例如:

  • 代理必须本质上支持批量pubsub
  • Dapr组件必须更新以支持代理提供的批量API的使用

目前,以下组件已更新以支持此级别的优化:

组件批量发布批量订阅
Kafka
Azure Servicebus
Azure Eventhubs

演示

观看以下关于批量pubsub的演示和演讲。

KubeCon Europe 2023 演讲

Dapr社区电话#77 演讲

相关链接

3 - 工作流

在各种微服务中进行逻辑编排

3.1 - 工作流概述

Dapr 工作流概述

Dapr 工作流让开发人员能够可靠地编写业务逻辑和集成。由于 Dapr 工作流是有状态的,它们支持长时间运行和容错应用程序,非常适合编排微服务。Dapr 工作流与其他 Dapr 构建块(如服务调用、发布订阅、状态管理和绑定)无缝协作。

Dapr 工作流的耐用性和弹性功能包括:

  • 提供内置的工作流运行时以驱动 Dapr 工作流执行。
  • 提供用于在代码中编写工作流的 SDK,支持多种编程语言。
  • 提供用于管理工作流(启动、查询、暂停/恢复、触发事件、终止、清除)的 HTTP 和 gRPC API。
  • 通过工作流组件与其他工作流运行时集成。
显示 Dapr 工作流基础的图示

Dapr 工作流可以应用于以下场景:

  • 涉及库存管理、支付系统和运输服务之间编排的订单处理。
  • 协调多个部门和参与者任务的人力资源入职工作流。
  • 在全国餐饮连锁店中协调数字菜单更新的推出。
  • 涉及基于 API 的分类和存储的图像处理工作流。

功能

工作流和活动

使用 Dapr 工作流,您可以编写活动,然后在工作流中编排这些活动。工作流活动是:

  • 工作流中的基本工作单元
  • 用于调用其他(Dapr)服务、与状态存储交互以及发布订阅代理。

了解更多关于工作流活动的信息。

子工作流

除了活动之外,您还可以编写工作流以调度其他工作流作为子工作流。子工作流具有独立于启动它的父工作流的实例 ID、历史记录和状态,除了终止父工作流会终止由其创建的所有子工作流这一事实。子工作流还支持自动重试策略。

了解更多关于子工作流的信息。

定时器和提醒

与 Dapr actor 相同,您可以为任何时间范围安排类似提醒的持久延迟。

了解更多关于工作流定时器提醒

使用 HTTP 调用管理工作流

当您使用工作流代码创建应用程序并使用 Dapr 运行它时,您可以调用驻留在应用程序中的特定工作流。每个单独的工作流可以:

  • 通过 POST 请求启动或终止
  • 通过 POST 请求触发以传递命名事件
  • 通过 POST 请求暂停然后恢复
  • 通过 POST 请求从您的状态存储中清除
  • 通过 GET 请求查询工作流状态

了解更多关于如何使用 HTTP 调用管理工作流的信息。

工作流模式

Dapr 工作流简化了微服务架构中复杂的、有状态的协调需求。以下部分描述了可以从 Dapr 工作流中受益的几种应用程序模式。

了解更多关于不同类型的工作流模式

工作流 SDK

Dapr 工作流 编写 SDK 是特定语言的 SDK,包含用于实现工作流逻辑的类型和函数。工作流逻辑存在于您的应用程序中,并由运行在 Dapr sidecar 中的 Dapr 工作流引擎通过 gRPC 流进行编排。

支持的 SDK

您可以使用以下 SDK 编写工作流。

语言栈
Pythondapr-ext-workflow
JavaScriptDaprWorkflowClient
.NETDapr.Workflow
Javaio.dapr.workflows
Goworkflow

试用工作流

快速入门和教程

想要测试工作流?通过以下快速入门和教程来查看工作流的实际应用:

快速入门/教程描述
工作流快速入门运行一个包含四个工作流活动的工作流应用程序,查看 Dapr 工作流的实际应用
工作流 Python SDK 示例了解如何使用 Python dapr-ext-workflow 包创建和调用 Dapr 工作流。
工作流 JavaScript SDK 示例了解如何使用 JavaScript SDK 创建和调用 Dapr 工作流。
工作流 .NET SDK 示例了解如何使用 ASP.NET Core web API 创建和调用 Dapr 工作流。
工作流 Java SDK 示例了解如何使用 Java io.dapr.workflows 包创建和调用 Dapr 工作流。
工作流 Go SDK 示例了解如何使用 Go workflow 包创建和调用 Dapr 工作流。

直接在您的应用中开始使用工作流

想要跳过快速入门?没问题。您可以直接在您的应用程序中试用工作流构建块。在Dapr 安装完成后,您可以开始使用工作流,从如何编写工作流开始。

限制

  • 状态存储: 由于某些数据库选择的底层限制,通常是 NoSQL 数据库,您可能会遇到存储内部状态的限制。例如,CosmosDB 在单个请求中最多只能有 100 个状态的单操作项限制。

观看演示

观看此视频以了解 Dapr 工作流的概述

下一步

工作流功能和概念 >>

相关链接

3.2 - 功能和概念

详细了解 Dapr 工作流的功能和概念

在您已经从高层次了解了工作流构建块之后,让我们深入探讨 Dapr 工作流引擎和 SDK 所包含的功能和概念。Dapr 工作流在所有支持的语言中都提供了几个核心功能和概念。

工作流

Dapr 工作流是您编写的函数,用于定义一系列按特定顺序执行的任务。Dapr 工作流引擎负责调度和执行这些任务,包括管理故障和重试。如果托管工作流的应用程序在多台机器上扩展,工作流引擎还可以在多台机器上负载均衡工作流及其任务的执行。

工作流可以调度多种类型的任务,包括:

  • 用于执行自定义逻辑的活动
  • 用于将工作流休眠任意时间长度的持久计时器
  • 用于将较大的工作流分解为较小部分的子工作流
  • 用于阻塞工作流直到接收到外部事件信号的外部事件等待器。这些任务在其相应的部分中有更详细的描述。

工作流标识

每个您定义的工作流都有一个类型名称,工作流的每次执行都需要一个唯一的_实例 ID_。工作流实例 ID 可以由您的应用程序代码生成,这在工作流对应于业务实体(如文档或作业)时很有用,或者可以是自动生成的 UUID。工作流的实例 ID 对于调试以及使用工作流 API管理工作流非常有用。

在任何给定时间,只能存在一个具有给定 ID 的工作流实例。然而,如果一个工作流实例完成或失败,其 ID 可以被新的工作流实例重用。但请注意,新工作流实例实际上会在配置的状态存储中替换旧的实例。

工作流重放

Dapr 工作流通过使用一种称为事件溯源的技术来维护其执行状态。工作流引擎不是将工作流的当前状态存储为快照,而是管理一个仅追加的历史事件日志,描述工作流所采取的各种步骤。当使用工作流 SDK 时,这些历史事件会在工作流“等待”计划任务的结果时自动存储。

当工作流“等待”计划任务时,它会从内存中卸载自己,直到任务完成。一旦任务完成,工作流引擎会再次调度工作流函数运行。此时的工作流函数执行被称为_重放_。

当工作流函数被重放时,它会从头开始再次运行。然而,当它遇到已经完成的任务时,工作流引擎不会再次调度该任务,而是:

  1. 将已完成任务的存储结果返回给工作流。
  2. 继续执行直到下一个“等待”点。

这种“重放”行为会持续到工作流函数完成或因错误而失败。

通过这种重放技术,工作流能够从任何“等待”点恢复执行,就像它从未从内存中卸载过一样。即使是先前运行的局部变量的值也可以恢复,而无需工作流引擎了解它们存储了什么数据。这种恢复状态的能力使 Dapr 工作流具有_持久性_和_容错性_。

无限循环和永恒工作流

工作流重放部分所述,工作流维护其所有操作的仅写事件溯源历史日志。为了避免资源使用失控,工作流必须限制其调度的操作数量。例如,确保您的工作流不会:

  • 在其实现中使用无限循环
  • 调度数千个任务。

您可以使用以下两种技术来编写可能需要调度极大量任务的工作流:

  1. 使用 continue-as-new API
    每个工作流 SDK 都公开了一个 continue-as-new API,工作流可以调用该 API 以使用新的输入和历史记录重新启动自己。continue-as-new API 特别适合实现“永恒工作流”,如监控代理,否则将使用 while (true) 类构造实现。使用 continue-as-new 是保持工作流历史记录小的好方法。

    continue-as-new API 会截断现有历史记录,并用新的历史记录替换它。

  2. 使用子工作流
    每个工作流 SDK 都公开了一个用于创建子工作流的 API。子工作流的行为与任何其他工作流相同,只是它由父工作流调度。子工作流具有:

    • 自己的历史记录
    • 在多台机器上分布工作流函数执行的好处。

    如果一个工作流需要调度数千个或更多任务,建议将这些任务分布在子工作流中,以免单个工作流的历史记录大小过大。

更新工作流代码

由于工作流是长时间运行且持久的,因此更新工作流代码必须非常小心。如工作流确定性限制部分所述,工作流代码必须是确定性的。如果系统中有任何未完成的工作流实例,更新工作流代码必须保留这种确定性。否则,更新工作流代码可能会导致下次这些工作流执行时出现运行时故障。

查看已知限制

工作流活动

工作流活动是工作流中的基本工作单元,是在业务流程中被编排的任务。例如,您可能会创建一个工作流来处理订单。任务可能涉及检查库存、向客户收费和创建发货。每个任务将是一个单独的活动。这些活动可以串行执行、并行执行或两者的某种组合。

与工作流不同,活动在您可以在其中执行的工作类型上没有限制。活动经常用于进行网络调用或运行 CPU 密集型操作。活动还可以将数据返回给工作流。

Dapr 工作流引擎保证每个被调用的活动在工作流的执行过程中至少执行一次。由于活动仅保证至少一次执行,建议尽可能将活动逻辑实现为幂等。

子工作流

除了活动之外,工作流还可以调度其他工作流作为_子工作流_。子工作流具有独立于启动它的父工作流的实例 ID、历史记录和状态。

子工作流有许多好处:

  • 您可以将大型工作流拆分为一系列较小的子工作流,使您的代码更易于维护。
  • 您可以在多个计算节点上同时分布工作流逻辑,这在您的工作流逻辑需要协调大量任务时很有用。
  • 您可以通过保持父工作流的历史记录较小来减少内存使用和 CPU 开销。

子工作流的返回值是其输出。如果子工作流因异常而失败,则该异常会像活动任务失败时一样显示给父工作流。子工作流还支持自动重试策略。

终止父工作流会终止由工作流实例创建的所有子工作流。有关更多信息,请参阅终止工作流 API

持久计时器

Dapr 工作流允许您为任何时间范围安排类似提醒的持久延迟,包括分钟、天甚至年。这些_持久计时器_可以由工作流安排以实现简单的延迟或为其他异步任务设置临时超时。更具体地说,持久计时器可以设置为在特定日期触发或在指定持续时间后触发。持久计时器的最大持续时间没有限制,它们在内部由内部 actor 提醒支持。例如,跟踪服务 30 天免费订阅的工作流可以使用在工作流创建后 30 天触发的持久计时器实现。工作流在等待持久计时器触发时可以安全地从内存中卸载。

重试策略

工作流支持活动和子工作流的持久重试策略。工作流重试策略与Dapr 弹性策略在以下方面是分开的和不同的。

  • 工作流重试策略由工作流作者在代码中配置,而 Dapr 弹性策略由应用程序操作员在 YAML 中配置。
  • 工作流重试策略是持久的,并在应用程序重启时保持其状态,而 Dapr 弹性策略不是持久的,必须在应用程序重启后重新应用。
  • 工作流重试策略由活动和子工作流中的未处理错误/异常触发,而 Dapr 弹性策略由操作超时和连接故障触发。

重试在内部使用持久计时器实现。这意味着工作流在等待重试触发时可以安全地从内存中卸载,从而节省系统资源。这也意味着重试之间的延迟可以任意长,包括分钟、小时甚至天。

可以同时使用工作流重试策略和 Dapr 弹性策略。例如,如果工作流活动使用 Dapr 客户端调用服务,则 Dapr 客户端使用配置的弹性策略。有关示例的更多信息,请参阅快速入门:服务到服务的弹性。但是,如果活动本身因任何原因失败,包括耗尽弹性策略的重试次数,则工作流的弹性策略会启动。

由于工作流重试策略是在代码中配置的,因此具体的开发者体验可能会因工作流 SDK 的版本而异。通常,工作流重试策略可以通过以下参数进行配置。

参数描述
最大尝试次数执行活动或子工作流的最大次数。
首次重试间隔第一次重试前的等待时间。
退避系数用于确定退避增长率的系数。例如,系数为 2 会使每次后续重试的等待时间加倍。
最大重试间隔每次后续重试前的最大等待时间。
重试超时重试的总体超时,无论配置的最大尝试次数如何。

外部事件

有时工作流需要等待由外部系统引发的事件。例如,如果订单处理工作流中的总成本超过某个阈值,审批工作流可能需要人类明确批准订单请求。另一个例子是一个问答游戏编排工作流,它在等待所有参与者提交他们对问答问题的答案时暂停。这些中间执行输入被称为_外部事件_。

外部事件具有_名称_和_负载_,并传递给单个工作流实例。工作流可以创建“等待外部事件”任务,订阅外部事件并_等待_这些任务以阻止执行,直到接收到事件。然后,工作流可以读取这些事件的负载,并决定采取哪些下一步。外部事件可以串行或并行处理。外部事件可以由其他工作流或工作流代码引发。

工作流还可以等待同名的多个外部事件信号,在这种情况下,它们会以先进先出 (FIFO) 的方式分派给相应的工作流任务。如果工作流接收到外部事件信号,但尚未创建“等待外部事件”任务,则事件将保存到工作流的历史记录中,并在工作流请求事件后立即消费。

了解有关外部系统交互的更多信息。

工作流后端

Dapr 工作流依赖于 Go 的持久任务框架(即 durabletask-go)作为执行工作流的核心引擎。该引擎设计为支持多种后端实现。例如,durabletask-go 仓库包括一个 SQLite 实现,Dapr 仓库包括一个 actor 实现。

默认情况下,Dapr 工作流支持 actor 后端,该后端稳定且可扩展。然而,您可以选择 Dapr 工作流中支持的其他后端。例如,SQLite(待定未来版本)可以是本地开发和测试的后端选项。

后端实现在很大程度上与您看到的工作流核心引擎或编程模型解耦。后端主要影响:

  • 如何存储工作流状态
  • 如何在副本之间协调工作流执行

从这个意义上说,它类似于 Dapr 的状态存储抽象,但专为工作流设计。无论使用哪个后端,所有 API 和编程模型功能都是相同的。

清除

工作流状态可以从状态存储中清除,清除其所有历史记录并删除与特定工作流实例相关的所有元数据。清除功能用于已运行到 COMPLETEDFAILEDTERMINATED 状态的工作流。

工作流 API 参考指南中了解更多信息。

限制

工作流确定性和代码限制

为了利用工作流重放技术,您的工作流代码需要是确定性的。为了使您的工作流代码确定性,您可能需要绕过一些限制。

工作流函数必须调用确定性 API。

生成随机数、随机 UUID 或当前日期的 API 是_非确定性_的。要解决此限制,您可以:

  • 在活动函数中使用这些 API,或
  • (首选)使用 SDK 提供的内置等效 API。例如,每个创作 SDK 都提供了一个以确定性方式检索当前时间的 API。

例如,不要这样做:

// 不要这样做!
DateTime currentTime = DateTime.UtcNow;
Guid newIdentifier = Guid.NewGuid();
string randomString = GetRandomString();
// 不要这样做!
Instant currentTime = Instant.now();
UUID newIdentifier = UUID.randomUUID();
String randomString = getRandomString();
// 不要这样做!
const currentTime = new Date();
const newIdentifier = uuidv4();
const randomString = getRandomString();
// 不要这样做!
const currentTime = time.Now()

这样做:

// 这样做!!
DateTime currentTime = context.CurrentUtcDateTime;
Guid newIdentifier = context.NewGuid();
string randomString = await context.CallActivityAsync<string>("GetRandomString");
// 这样做!!
Instant currentTime = context.getCurrentInstant();
Guid newIdentifier = context.newGuid();
String randomString = context.callActivity(GetRandomString.class.getName(), String.class).await();
// 这样做!!
const currentTime = context.getCurrentUtcDateTime();
const randomString = yield context.callActivity(getRandomString);
const currentTime = ctx.CurrentUTCDateTime()

工作流函数必须仅_间接_与外部状态交互。

外部数据包括任何不存储在工作流状态中的数据。工作流不得与全局变量、环境变量、文件系统交互或进行网络调用。

相反,工作流应通过工作流输入、活动任务和外部事件处理_间接_与外部状态交互。

例如,不要这样做:

// 不要这样做!
string configuration = Environment.GetEnvironmentVariable("MY_CONFIGURATION")!;
string data = await new HttpClient().GetStringAsync("https://example.com/api/data");
// 不要这样做!
String configuration = System.getenv("MY_CONFIGURATION");

HttpRequest request = HttpRequest.newBuilder().uri(new URI("https://postman-echo.com/post")).GET().build();
HttpResponse<String> response = HttpClient.newBuilder().build().send(request, HttpResponse.BodyHandlers.ofString());
// 不要这样做!
// 访问环境变量(Node.js)
const configuration = process.env.MY_CONFIGURATION;

fetch('https://postman-echo.com/get')
  .then(response => response.text())
  .then(data => {
    console.log(data);
  })
  .catch(error => {
    console.error('Error:', error);
  });
// 不要这样做!
resp, err := http.Get("http://example.com/api/data")

这样做:

// 这样做!!
string configuration = workflowInput.Configuration; // 假想的工作流输入参数
string data = await context.CallActivityAsync<string>("MakeHttpCall", "https://example.com/api/data");
// 这样做!!
String configuration = ctx.getInput(InputType.class).getConfiguration(); // 假想的工作流输入参数
String data = ctx.callActivity(MakeHttpCall.class, "https://example.com/api/data", String.class).await();
// 这样做!!
const configuration = workflowInput.getConfiguration(); // 假想的工作流输入参数
const data = yield ctx.callActivity(makeHttpCall, "https://example.com/api/data");
// 这样做!!
err := ctx.CallActivity(MakeHttpCallActivity, workflow.ActivityInput("https://example.com/api/data")).Await(&output)

工作流函数必须仅在工作流调度线程上执行。

每种语言 SDK 的实现要求所有工作流函数操作在函数被调度的同一线程(goroutine 等)上运行。工作流函数绝不能:

  • 调度后台线程,或
  • 使用调度回调函数在另一个线程上运行的 API。

不遵循此规则可能导致未定义的行为。任何后台处理都应委托给活动任务,这些任务可以串行或并行调度运行。

例如,不要这样做:

// 不要这样做!
Task t = Task.Run(() => context.CallActivityAsync("DoSomething"));
await context.CreateTimer(5000).ConfigureAwait(false);
// 不要这样做!
new Thread(() -> {
    ctx.callActivity(DoSomethingActivity.class.getName()).await();
}).start();
ctx.createTimer(Duration.ofSeconds(5)).await();

不要将 JavaScript 工作流声明为 async。Node.js 运行时不保证异步函数是确定性的。

// 不要这样做!
go func() {
  err := ctx.CallActivity(DoSomething).Await(nil)
}()
err := ctx.CreateTimer(time.Second).Await(nil)

这样做:

// 这样做!!
Task t = context.CallActivityAsync("DoSomething");
await context.CreateTimer(5000).ConfigureAwait(true);
// 这样做!!
ctx.callActivity(DoSomethingActivity.class.getName()).await();
ctx.createTimer(Duration.ofSeconds(5)).await();

由于 Node.js 运行时不保证异步函数是确定性的,因此始终将 JavaScript 工作流声明为同步生成器函数。

// 这样做!
task := ctx.CallActivity(DoSomething)
task.Await(nil)

更新工作流代码

确保您对工作流代码所做的更新保持其确定性。以下是可能破坏工作流确定性的代码更新示例:

  • 更改工作流函数签名
    更改工作流或活动函数的名称、输入或输出被视为重大更改,必须避免。

  • 更改工作流任务的数量或顺序
    更改工作流任务的数量或顺序会导致工作流实例的历史记录不再与代码匹配,可能导致运行时错误或其他意外行为。

要解决这些限制:

  • 不要更新现有工作流代码,而是保持现有工作流代码不变,并创建包含更新的新工作流定义。
  • 上游创建工作流的代码应仅更新以创建新工作流的实例。
  • 保留旧代码以确保现有工作流实例可以继续运行而不受干扰。如果已知旧工作流逻辑的所有实例都已完成,则可以安全地删除旧工作流代码。

下一步

工作流模式 >>

相关链接

3.3 - 工作流模式

编写不同类型的工作流模式

Dapr 工作流简化了微服务架构中复杂且有状态的协调需求。以下部分描述了几种可以从 Dapr 工作流中受益的应用程序模式。

任务链

在任务链模式中,工作流中的多个步骤按顺序运行,一个步骤的输出可以作为下一个步骤的输入。任务链工作流通常涉及创建一系列需要对某些数据执行的操作,例如过滤、转换和归约。

显示任务链工作流模式如何工作的图示

在某些情况下,工作流的步骤可能需要在多个微服务之间进行协调。为了提高可靠性和可扩展性,您还可能使用队列来触发各个步骤。

虽然模式简单,但实现中隐藏了许多复杂性。例如:

  • 如果某个微服务长时间不可用,会发生什么?
  • 可以自动重试失败的步骤吗?
  • 如果不能,如何促进先前完成步骤的回滚(如果适用)?
  • 除了实现细节之外,是否有办法可视化工作流,以便其他工程师可以理解它的作用和工作原理?

Dapr 工作流通过允许您在所选编程语言中将任务链模式简洁地实现为简单函数来解决这些复杂性,如以下示例所示。

import dapr.ext.workflow as wf


def task_chain_workflow(ctx: wf.DaprWorkflowContext, wf_input: int):
    try:
        result1 = yield ctx.call_activity(step1, input=wf_input)
        result2 = yield ctx.call_activity(step2, input=result1)
        result3 = yield ctx.call_activity(step3, input=result2)
    except Exception as e:
        yield ctx.call_activity(error_handler, input=str(e))
        raise
    return [result1, result2, result3]


def step1(ctx, activity_input):
    print(f'步骤 1: 接收到输入: {activity_input}.')
    # 执行一些操作
    return activity_input + 1


def step2(ctx, activity_input):
    print(f'步骤 2: 接收到输入: {activity_input}.')
    # 执行一些操作
    return activity_input * 2


def step3(ctx, activity_input):
    print(f'步骤 3: 接收到输入: {activity_input}.')
    # 执行一些操作
    return activity_input ^ 2


def error_handler(ctx, error):
    print(f'执行错误处理程序: {error}.')
    # 执行一些补偿操作

注意 工作流重试策略将在 Python SDK 的未来版本中提供。

import { DaprWorkflowClient, WorkflowActivityContext, WorkflowContext, WorkflowRuntime, TWorkflow } from "@dapr/dapr";

async function start() {
  // 更新 gRPC 客户端和工作者以使用本地地址和端口
  const daprHost = "localhost";
  const daprPort = "50001";
  const workflowClient = new DaprWorkflowClient({
    daprHost,
    daprPort,
  });
  const workflowRuntime = new WorkflowRuntime({
    daprHost,
    daprPort,
  });

  const hello = async (_: WorkflowActivityContext, name: string) => {
    return `Hello ${name}!`;
  };

  const sequence: TWorkflow = async function* (ctx: WorkflowContext): any {
    const cities: string[] = [];

    const result1 = yield ctx.callActivity(hello, "Tokyo");
    cities.push(result1);
    const result2 = yield ctx.callActivity(hello, "Seattle");
    cities.push(result2);
    const result3 = yield ctx.callActivity(hello, "London");
    cities.push(result3);

    return cities;
  };

  workflowRuntime.registerWorkflow(sequence).registerActivity(hello);

  // 将工作者启动包装在 try-catch 块中以处理启动期间的任何错误
  try {
    await workflowRuntime.start();
    console.log("工作流运行时启动成功");
  } catch (error) {
    console.error("启动工作流运行时时出错:", error);
  }

  // 调度新的编排
  try {
    const id = await workflowClient.scheduleNewWorkflow(sequence);
    console.log(`编排已调度,ID: ${id}`);

    // 等待编排完成
    const state = await workflowClient.waitForWorkflowCompletion(id, undefined, 30);

    console.log(`编排完成!结果: ${state?.serializedOutput}`);
  } catch (error) {
    console.error("调度或等待编排时出错:", error);
  }

  await workflowRuntime.stop();
  await workflowClient.stop();

  // 停止 dapr sidecar
  process.exit(0);
}

start().catch((e) => {
  console.error(e);
  process.exit(1);
});
// 支持长时间中断的指数退避重试策略
var retryOptions = new WorkflowTaskOptions
{
    RetryPolicy = new WorkflowRetryPolicy(
        firstRetryInterval: TimeSpan.FromMinutes(1),
        backoffCoefficient: 2.0,
        maxRetryInterval: TimeSpan.FromHours(1),
        maxNumberOfAttempts: 10),
};

try
{
    var result1 = await context.CallActivityAsync<string>("Step1", wfInput, retryOptions);
    var result2 = await context.CallActivityAsync<byte[]>("Step2", result1, retryOptions);
    var result3 = await context.CallActivityAsync<long[]>("Step3", result2, retryOptions);
    return string.Join(", ", result4);
}
catch (TaskFailedException) // 任务失败会作为 TaskFailedException 显示
{
    // 重试过期 - 应用自定义补偿逻辑
    await context.CallActivityAsync<long[]>("MyCompensation", options: retryOptions);
    throw;
}

注意 在上面的示例中,"Step1""Step2""Step3""MyCompensation" 代表工作流活动,它们是您代码中实际实现工作流步骤的函数。为了简洁起见,这些活动实现未包含在此示例中。

public class ChainWorkflow extends Workflow {
    @Override
    public WorkflowStub create() {
        return ctx -> {
            StringBuilder sb = new StringBuilder();
            String wfInput = ctx.getInput(String.class);
            String result1 = ctx.callActivity("Step1", wfInput, String.class).await();
            String result2 = ctx.callActivity("Step2", result1, String.class).await();
            String result3 = ctx.callActivity("Step3", result2, String.class).await();
            String result = sb.append(result1).append(',').append(result2).append(',').append(result3).toString();
            ctx.complete(result);
        };
    }
}

    class Step1 implements WorkflowActivity {

        @Override
        public Object run(WorkflowActivityContext ctx) {
            Logger logger = LoggerFactory.getLogger(Step1.class);
            logger.info("Starting Activity: " + ctx.getName());
            // Do some work
            return null;
        }
    }

    class Step2 implements WorkflowActivity {

        @Override
        public Object run(WorkflowActivityContext ctx) {
            Logger logger = LoggerFactory.getLogger(Step2.class);
            logger.info("Starting Activity: " + ctx.getName());
            // Do some work
            return null;
        }
    }

    class Step3 implements WorkflowActivity {

        @Override
        public Object run(WorkflowActivityContext ctx) {
            Logger logger = LoggerFactory.getLogger(Step3.class);
            logger.info("Starting Activity: " + ctx.getName());
            // Do some work
            return null;
        }
    }
func TaskChainWorkflow(ctx *workflow.WorkflowContext) (any, error) {
	var input int
	if err := ctx.GetInput(&input); err != nil {
		return "", err
	}
	var result1 int
	if err := ctx.CallActivity(Step1, workflow.ActivityInput(input)).Await(&result1); err != nil {
		return nil, err
	}
	var result2 int
	if err := ctx.CallActivity(Step2, workflow.ActivityInput(input)).Await(&result2); err != nil {
		return nil, err
	}
	var result3 int
	if err := ctx.CallActivity(Step3, workflow.ActivityInput(input)).Await(&result3); err != nil {
		return nil, err
	}
	return []int{result1, result2, result3}, nil
}
func Step1(ctx workflow.ActivityContext) (any, error) {
	var input int
	if err := ctx.GetInput(&input); err != nil {
		return "", err
	}
	fmt.Printf("步骤 1: 接收到输入: %s", input)
	return input + 1, nil
}
func Step2(ctx workflow.ActivityContext) (any, error) {
	var input int
	if err := ctx.GetInput(&input); err != nil {
		return "", err
	}
	fmt.Printf("步骤 2: 接收到输入: %s", input)
	return input * 2, nil
}
func Step3(ctx workflow.ActivityContext) (any, error) {
	var input int
	if err := ctx.GetInput(&input); err != nil {
		return "", err
	}
	fmt.Printf("步骤 3: 接收到输入: %s", input)
	return int(math.Pow(float64(input), 2)), nil
}

如您所见,工作流被表达为所选编程语言中的简单语句序列。这使得组织中的任何工程师都可以快速理解端到端的流程,而不必了解端到端的系统架构。

在幕后,Dapr 工作流运行时:

  • 负责执行工作流并确保其运行到完成。
  • 自动保存进度。
  • 如果工作流进程本身因任何原因失败,自动从上次完成的步骤恢复工作流。
  • 允许在目标编程语言中自然地表达错误处理,使您可以轻松实现补偿逻辑。
  • 提供内置的重试配置原语,以简化为工作流中的各个步骤配置复杂重试策略的过程。

扇出/扇入

在扇出/扇入设计模式中,您可以在多个工作者上同时执行多个任务,等待它们完成,并对结果进行一些聚合。

显示扇出/扇入工作流模式如何工作的图示

除了前一个模式中提到的挑战外,在手动实现扇出/扇入模式时还有几个重要问题需要考虑:

  • 如何控制并行度?
  • 如何知道何时触发后续聚合步骤?
  • 如果并行步骤的数量是动态的怎么办?

Dapr 工作流提供了一种将扇出/扇入模式表达为简单函数的方法,如以下示例所示:

import time
from typing import List
import dapr.ext.workflow as wf


def batch_processing_workflow(ctx: wf.DaprWorkflowContext, wf_input: int):
    # 获取一批 N 个工作项以并行处理
    work_batch = yield ctx.call_activity(get_work_batch, input=wf_input)

    # 调度 N 个并行任务以处理工作项并等待所有任务完成
    parallel_tasks = [ctx.call_activity(process_work_item, input=work_item) for work_item in work_batch]
    outputs = yield wf.when_all(parallel_tasks)

    # 聚合结果并将其发送到另一个活动
    total = sum(outputs)
    yield ctx.call_activity(process_results, input=total)


def get_work_batch(ctx, batch_size: int) -> List[int]:
    return [i + 1 for i in range(batch_size)]


def process_work_item(ctx, work_item: int) -> int:
    print(f'处理工作项: {work_item}.')
    time.sleep(5)
    result = work_item * 2
    print(f'工作项 {work_item} 已处理. 结果: {result}.')
    return result


def process_results(ctx, final_result: int):
    print(f'最终结果: {final_result}.')
import {
  Task,
  DaprWorkflowClient,
  WorkflowActivityContext,
  WorkflowContext,
  WorkflowRuntime,
  TWorkflow,
} from "@dapr/dapr";

// 将整个代码包装在一个立即调用的异步函数中
async function start() {
  // 更新 gRPC 客户端和工作者以使用本地地址和端口
  const daprHost = "localhost";
  const daprPort = "50001";
  const workflowClient = new DaprWorkflowClient({
    daprHost,
    daprPort,
  });
  const workflowRuntime = new WorkflowRuntime({
    daprHost,
    daprPort,
  });

  function getRandomInt(min: number, max: number): number {
    return Math.floor(Math.random() * (max - min + 1)) + min;
  }

  async function getWorkItemsActivity(_: WorkflowActivityContext): Promise<string[]> {
    const count: number = getRandomInt(2, 10);
    console.log(`生成 ${count} 个工作项...`);

    const workItems: string[] = Array.from({ length: count }, (_, i) => `工作项 ${i}`);
    return workItems;
  }

  function sleep(ms: number): Promise<void> {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }

  async function processWorkItemActivity(context: WorkflowActivityContext, item: string): Promise<number> {
    console.log(`处理工作项: ${item}`);

    // 模拟一些需要可变时间的工作
    const sleepTime = Math.random() * 5000;
    await sleep(sleepTime);

    // 返回给定工作项的结果,在这种情况下也是一个随机数
    // 有关工作流中随机数的更多信息,请查看
    // https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-code-constraints?tabs=csharp#random-numbers
    return Math.floor(Math.random() * 11);
  }

  const workflow: TWorkflow = async function* (ctx: WorkflowContext): any {
    const tasks: Task<any>[] = [];
    const workItems = yield ctx.callActivity(getWorkItemsActivity);
    for (const workItem of workItems) {
      tasks.push(ctx.callActivity(processWorkItemActivity, workItem));
    }
    const results: number[] = yield ctx.whenAll(tasks);
    const sum: number = results.reduce((accumulator, currentValue) => accumulator + currentValue, 0);
    return sum;
  };

  workflowRuntime.registerWorkflow(workflow);
  workflowRuntime.registerActivity(getWorkItemsActivity);
  workflowRuntime.registerActivity(processWorkItemActivity);

  // 将工作者启动包装在 try-catch 块中以处理启动期间的任何错误
  try {
    await workflowRuntime.start();
    console.log("工作者启动成功");
  } catch (error) {
    console.error("启动工作者时出错:", error);
  }

  // 调度新的编排
  try {
    const id = await workflowClient.scheduleNewWorkflow(workflow);
    console.log(`编排已调度,ID: ${id}`);

    // 等待编排完成
    const state = await workflowClient.waitForWorkflowCompletion(id, undefined, 30);

    console.log(`编排完成!结果: ${state?.serializedOutput}`);
  } catch (error) {
    console.error("调度或等待编排时出错:", error);
  }

  // 停止工作者和客户端
  await workflowRuntime.stop();
  await workflowClient.stop();

  // 停止 dapr sidecar
  process.exit(0);
}

start().catch((e) => {
  console.error(e);
  process.exit(1);
});
// 获取要并行处理的 N 个工作项的列表。
object[] workBatch = await context.CallActivityAsync<object[]>("GetWorkBatch", null);

// 调度并行任务,但不等待它们完成。
var parallelTasks = new List<Task<int>>(workBatch.Length);
for (int i = 0; i < workBatch.Length; i++)
{
    Task<int> task = context.CallActivityAsync<int>("ProcessWorkItem", workBatch[i]);
    parallelTasks.Add(task);
}

// 一切都已调度。在此处等待,直到所有并行任务完成。
await Task.WhenAll(parallelTasks);

// 聚合所有 N 个输出并发布结果。
int sum = parallelTasks.Sum(t => t.Result);
await context.CallActivityAsync("PostResults", sum);
public class FaninoutWorkflow extends Workflow {
    @Override
    public WorkflowStub create() {
        return ctx -> {
            // 获取要并行处理的 N 个工作项的列表。
            Object[] workBatch = ctx.callActivity("GetWorkBatch", Object[].class).await();
            // 调度并行任务,但不等待它们完成。
            List<Task<Integer>> tasks = Arrays.stream(workBatch)
                    .map(workItem -> ctx.callActivity("ProcessWorkItem", workItem, int.class))
                    .collect(Collectors.toList());
            // 一切都已调度。在此处等待,直到所有并行任务完成。
            List<Integer> results = ctx.allOf(tasks).await();
            // 聚合所有 N 个输出并发布结果。
            int sum = results.stream().mapToInt(Integer::intValue).sum();
            ctx.complete(sum);
        };
    }
}
func BatchProcessingWorkflow(ctx *workflow.WorkflowContext) (any, error) {
	var input int
	if err := ctx.GetInput(&input); err != nil {
		return 0, err
	}
	var workBatch []int
	if err := ctx.CallActivity(GetWorkBatch, workflow.ActivityInput(input)).Await(&workBatch); err != nil {
		return 0, err
	}
	parallelTasks := workflow.NewTaskSlice(len(workBatch))
	for i, workItem := range workBatch {
		parallelTasks[i] = ctx.CallActivity(ProcessWorkItem, workflow.ActivityInput(workItem))
	}
	var outputs int
	for _, task := range parallelTasks {
		var output int
		err := task.Await(&output)
		if err == nil {
			outputs += output
		} else {
			return 0, err
		}
	}
	if err := ctx.CallActivity(ProcessResults, workflow.ActivityInput(outputs)).Await(nil); err != nil {
		return 0, err
	}
	return 0, nil
}
func GetWorkBatch(ctx workflow.ActivityContext) (any, error) {
	var batchSize int
	if err := ctx.GetInput(&batchSize); err != nil {
		return 0, err
	}
	batch := make([]int, batchSize)
	for i := 0; i < batchSize; i++ {
		batch[i] = i
	}
	return batch, nil
}
func ProcessWorkItem(ctx workflow.ActivityContext) (any, error) {
	var workItem int
	if err := ctx.GetInput(&workItem); err != nil {
		return 0, err
	}
	fmt.Printf("处理工作项: %d\n", workItem)
	time.Sleep(time.Second * 5)
	result := workItem * 2
	fmt.Printf("工作项 %d 已处理. 结果: %d\n", workItem, result)
	return result, nil
}
func ProcessResults(ctx workflow.ActivityContext) (any, error) {
	var finalResult int
	if err := ctx.GetInput(&finalResult); err != nil {
		return 0, err
	}
	fmt.Printf("最终结果: %d\n", finalResult)
	return finalResult, nil
}

此示例的关键要点是:

  • 扇出/扇入模式可以使用普通编程构造表达为简单函数
  • 并行任务的数量可以是静态的或动态的
  • 工作流本身能够聚合并行执行的结果

此外,工作流的执行是持久的。如果一个工作流启动了 100 个并行任务执行,并且只有 40 个在进程崩溃前完成,工作流会自动重新启动并仅调度剩余的 60 个任务。

可以进一步使用简单的、特定语言的构造来限制并发度。下面的示例代码说明了如何将扇出的程度限制为仅 5 个并发活动执行:


// 回顾之前的示例...
// 获取要并行处理的 N 个工作项的列表。
object[] workBatch = await context.CallActivityAsync<object[]>("GetWorkBatch", null);

const int MaxParallelism = 5;
var results = new List<int>();
var inFlightTasks = new HashSet<Task<int>>();
foreach(var workItem in workBatch)
{
  if (inFlightTasks.Count >= MaxParallelism)
  {
    var finishedTask = await Task.WhenAny(inFlightTasks);
    results.Add(finishedTask.Result);
    inFlightTasks.Remove(finishedTask);
  }

  inFlightTasks.Add(context.CallActivityAsync<int>("ProcessWorkItem", workItem));
}
results.AddRange(await Task.WhenAll(inFlightTasks));

var sum = results.Sum(t => t);
await context.CallActivityAsync("PostResults", sum);

以这种方式限制并发度对于限制对共享资源的争用可能很有用。例如,如果活动需要调用具有自身并发限制的外部资源(如数据库或外部 API),则确保不超过指定数量的活动同时调用该资源可能很有用。

异步 HTTP API

异步 HTTP API 通常使用异步请求-回复模式实现。传统上实现此模式涉及以下步骤:

  1. 客户端向 HTTP API 端点(启动 API)发送请求
  2. 启动 API 将消息写入后端队列,从而触发长时间运行操作的开始
  3. 在调度后端操作后,启动 API 立即向客户端返回 HTTP 202 响应,其中包含可用于轮询状态的标识符
  4. 状态 API 查询包含长时间运行操作状态的数据库
  5. 客户端重复轮询 状态 API,直到某个超时到期或收到“完成”响应

以下图示说明了端到端流程。

显示异步请求响应模式如何工作的图示

实现异步请求-回复模式的挑战在于它涉及使用多个 API 和状态存储。它还涉及正确实现协议,以便客户端知道如何自动轮询状态并知道操作何时完成。

Dapr 工作流 HTTP API 开箱即支持异步请求-回复模式,无需编写任何代码或进行任何状态管理。

以下 curl 命令说明了工作流 API 如何支持此模式。

curl -X POST http://localhost:3500/v1.0/workflows/dapr/OrderProcessingWorkflow/start?instanceID=12345678 -d '{"Name":"Paperclips","Quantity":1,"TotalCost":9.95}'

上一个命令将导致以下响应 JSON:

{"instanceID":"12345678"}

HTTP 客户端然后可以使用工作流实例 ID 构建状态查询 URL,并反复轮询,直到在负载中看到“COMPLETE”、“FAILURE”或“TERMINATED”状态。

curl http://localhost:3500/v1.0/workflows/dapr/12345678

以下是进行中的工作流状态可能的样子。

{
  "instanceID": "12345678",
  "workflowName": "OrderProcessingWorkflow",
  "createdAt": "2023-05-03T23:22:11.143069826Z",
  "lastUpdatedAt": "2023-05-03T23:22:22.460025267Z",
  "runtimeStatus": "RUNNING",
  "properties": {
    "dapr.workflow.custom_status": "",
    "dapr.workflow.input": "{\"Name\":\"Paperclips\",\"Quantity\":1,\"TotalCost\":9.95}"
  }
}

如上例所示,工作流的运行时状态为 RUNNING,这让客户端知道它应该继续轮询。

如果工作流已完成,状态可能如下所示。

{
  "instanceID": "12345678",
  "workflowName": "OrderProcessingWorkflow",
  "createdAt": "2023-05-03T23:30:11.381146313Z",
  "lastUpdatedAt": "2023-05-03T23:30:52.923870615Z",
  "runtimeStatus": "COMPLETED",
  "properties": {
    "dapr.workflow.custom_status": "",
    "dapr.workflow.input": "{\"Name\":\"Paperclips\",\"Quantity\":1,\"TotalCost\":9.95}",
    "dapr.workflow.output": "{\"Processed\":true}"
  }
}

如上例所示,工作流的运行时状态现在为 COMPLETED,这意味着客户端可以停止轮询更新。

监控

监控模式是一个通常包括以下步骤的重复过程:

  1. 检查系统状态
  2. 根据该状态采取某些行动 - 例如发送通知
  3. 休眠一段时间
  4. 重复

下图提供了此模式的粗略说明。

显示监控模式如何工作的图示

根据业务需求,可能只有一个监控器,也可能有多个监控器,每个业务实体(例如股票)一个。此外,休眠时间可能需要根据情况进行更改。这些要求使得使用基于 cron 的调度系统不切实际。

Dapr 工作流通过允许您实现_永恒工作流_本地支持此模式。Dapr 工作流公开了一个 continue-as-new API,工作流作者可以使用该 API 从头开始使用新输入重新启动工作流函数,而不是编写无限循环(这是一种反模式)。

from dataclasses import dataclass
from datetime import timedelta
import random
import dapr.ext.workflow as wf


@dataclass
class JobStatus:
    job_id: str
    is_healthy: bool


def status_monitor_workflow(ctx: wf.DaprWorkflowContext, job: JobStatus):
    # 轮询与此 job 关联的状态端点
    status = yield ctx.call_activity(check_status, input=job)
    if not ctx.is_replaying:
        print(f"Job '{job.job_id}' is {status}.")

    if status == "healthy":
        job.is_healthy = True
        next_sleep_interval = 60  # 在健康状态下检查频率较低
    else:
        if job.is_healthy:
            job.is_healthy = False
            ctx.call_activity(send_alert, input=f"Job '{job.job_id}' is unhealthy!")
        next_sleep_interval = 5  # 在不健康状态下检查频率较高

    yield ctx.create_timer(fire_at=ctx.current_utc_datetime + timedelta(minutes=next_sleep_interval))

    # 使用新的 JobStatus 输入从头开始重新启动
    ctx.continue_as_new(job)


def check_status(ctx, _) -> str:
    return random.choice(["healthy", "unhealthy"])


def send_alert(ctx, message: str):
    print(f'*** Alert: {message}')
const statusMonitorWorkflow: TWorkflow = async function* (ctx: WorkflowContext): any {
    let duration;
    const status = yield ctx.callActivity(checkStatusActivity);
    if (status === "healthy") {
      // 在健康状态下检查频率较低
      // 设置持续时间为 1 小时
      duration = 60 * 60;
    } else {
      yield ctx.callActivity(alertActivity, "job unhealthy");
      // 在不健康状态下检查频率较高
      // 设置持续时间为 5 分钟
      duration = 5 * 60;
    }

    // 将工作流置于休眠状态,直到确定的时间
    ctx.createTimer(duration);

    // 使用更新的状态从头开始重新启动
    ctx.continueAsNew();
  };
public override async Task<object> RunAsync(WorkflowContext context, MyEntityState myEntityState)
{
    TimeSpan nextSleepInterval;

    var status = await context.CallActivityAsync<string>("GetStatus");
    if (status == "healthy")
    {
        myEntityState.IsHealthy = true;

        // 在健康状态下检查频率较低
        nextSleepInterval = TimeSpan.FromMinutes(60);
    }
    else
    {
        if (myEntityState.IsHealthy)
        {
            myEntityState.IsHealthy = false;
            await context.CallActivityAsync("SendAlert", myEntityState);
        }

        // 在不健康状态下检查频率较高
        nextSleepInterval = TimeSpan.FromMinutes(5);
    }

    // 将工作流置于休眠状态,直到确定的时间
    await context.CreateTimer(nextSleepInterval);

    // 使用更新的状态从头开始重新启动
    context.ContinueAsNew(myEntityState);
    return null;
}

此示例假设您有一个预定义的 MyEntityState 类,其中包含一个布尔 IsHealthy 属性。

public class MonitorWorkflow extends Workflow {

  @Override
  public WorkflowStub create() {
    return ctx -> {

      Duration nextSleepInterval;

      var status = ctx.callActivity(DemoWorkflowStatusActivity.class.getName(), DemoStatusActivityOutput.class).await();
      var isHealthy = status.getIsHealthy();

      if (isHealthy) {
        // 在健康状态下检查频率较低
        nextSleepInterval = Duration.ofMinutes(60);
      } else {

        ctx.callActivity(DemoWorkflowAlertActivity.class.getName()).await();

        // 在不健康状态下检查频率较高
        nextSleepInterval = Duration.ofMinutes(5);
      }

      // 将工作流置于休眠状态,直到确定的时间
      try {
        ctx.createTimer(nextSleepInterval);
      } catch (InterruptedException e) {
        throw new RuntimeException(e);
      }

      // 使用更新的状态从头开始重新启动
      ctx.continueAsNew();
    }
  }
}
type JobStatus struct {
	JobID     string `json:"job_id"`
	IsHealthy bool   `json:"is_healthy"`
}
func StatusMonitorWorkflow(ctx *workflow.WorkflowContext) (any, error) {
	var sleepInterval time.Duration
	var job JobStatus
	if err := ctx.GetInput(&job); err != nil {
		return "", err
	}
	var status string
	if err := ctx.CallActivity(CheckStatus, workflow.ActivityInput(job)).Await(&status); err != nil {
		return "", err
	}
	if status == "healthy" {
		job.IsHealthy = true
		sleepInterval = time.Minutes * 60
	} else {
		if job.IsHealthy {
			job.IsHealthy = false
			err := ctx.CallActivity(SendAlert, workflow.ActivityInput(fmt.Sprintf("Job '%s' is unhealthy!", job.JobID))).Await(nil)
			if err != nil {
				return "", err
			}
		}
		sleepInterval = time.Minutes * 5
	}
	if err := ctx.CreateTimer(sleepInterval).Await(nil); err != nil {
		return "", err
	}
	ctx.ContinueAsNew(job, false)
	return "", nil
}
func CheckStatus(ctx workflow.ActivityContext) (any, error) {
	statuses := []string{"healthy", "unhealthy"}
	return statuses[rand.Intn(1)], nil
}
func SendAlert(ctx workflow.ActivityContext) (any, error) {
	var message string
	if err := ctx.GetInput(&message); err != nil {
		return "", err
	}
	fmt.Printf("*** Alert: %s", message)
	return "", nil
}

实现监控模式的工作流可以永远循环,也可以通过不调用 continue-as-new 来优雅地终止自身。

外部系统交互

在某些情况下,工作流可能需要暂停并等待外部系统执行某些操作。例如,工作流可能需要暂停并等待接收到付款。在这种情况下,支付系统可能会在收到付款时将事件发布到 pub/sub 主题,并且该主题上的侦听器可以使用触发事件工作流 API向工作流触发事件。

另一个非常常见的场景是工作流需要暂停并等待人类,例如在批准采购订单时。Dapr 工作流通过外部事件功能支持此事件模式。

以下是涉及人类的采购订单工作流示例:

  1. 收到采购订单时触发工作流。
  2. 工作流中的规则确定需要人类执行某些操作。例如,采购订单成本超过某个自动批准阈值。
  3. 工作流发送请求人类操作的通知。例如,它向指定的审批人发送带有批准链接的电子邮件。
  4. 工作流暂停并等待人类通过点击链接批准或拒绝订单。
  5. 如果在指定时间内未收到批准,工作流将恢复并执行某些补偿逻辑,例如取消订单。

下图说明了此流程。

显示外部系统交互模式如何与人类交互的图示

以下示例代码显示了如何使用 Dapr 工作流实现此模式。

from dataclasses import dataclass
from datetime import timedelta
import dapr.ext.workflow as wf


@dataclass
class Order:
    cost: float
    product: str
    quantity: int

    def __str__(self):
        return f'{self.product} ({self.quantity})'


@dataclass
class Approval:
    approver: str

    @staticmethod
    def from_dict(dict):
        return Approval(**dict)


def purchase_order_workflow(ctx: wf.DaprWorkflowContext, order: Order):
    # 低于 $1000 的订单自动批准
    if order.cost < 1000:
        return "Auto-approved"

    # $1000 或以上的订单需要经理批准
    yield ctx.call_activity(send_approval_request, input=order)

    # 必须在 24 小时内收到批准,否则将被取消。
    approval_event = ctx.wait_for_external_event("approval_received")
    timeout_event = ctx.create_timer(timedelta(hours=24))
    winner = yield wf.when_any([approval_event, timeout_event])
    if winner == timeout_event:
        return "Cancelled"

    # 订单已获批准
    yield ctx.call_activity(place_order, input=order)
    approval_details = Approval.from_dict(approval_event.get_result())
    return f"Approved by '{approval_details.approver}'"


def send_approval_request(_, order: Order) -> None:
    print(f'*** 发送审批请求: {order}')


def place_order(_, order: Order) -> None:
    print(f'*** 下订单: {order}')
import {
  Task,
  DaprWorkflowClient,
  WorkflowActivityContext,
  WorkflowContext,
  WorkflowRuntime,
  TWorkflow,
} from "@dapr/dapr";
import * as readlineSync from "readline-sync";

// 将整个代码包装在一个立即调用的异步函数中
async function start() {
  class Order {
    cost: number;
    product: string;
    quantity: number;
    constructor(cost: number, product: string, quantity: number) {
      this.cost = cost;
      this.product = product;
      this.quantity = quantity;
    }
  }

  function sleep(ms: number): Promise<void> {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }

  // 更新 gRPC 客户端和工作者以使用本地地址和端口
  const daprHost = "localhost";
  const daprPort = "50001";
  const workflowClient = new DaprWorkflowClient({
    daprHost,
    daprPort,
  });
  const workflowRuntime = new WorkflowRuntime({
    daprHost,
    daprPort,
  });

  // 发送审批请求给经理的活动函数
  const sendApprovalRequest = async (_: WorkflowActivityContext, order: Order) => {
    // 模拟一些需要时间的工作
    await sleep(3000);
    console.log(`发送审批请求: ${order.product}`);
  };

  // 下订单的活动函数
  const placeOrder = async (_: WorkflowActivityContext, order: Order) => {
    console.log(`下订单: ${order.product}`);
  };

  // 表示采购订单工作流的编排函数
  const purchaseOrderWorkflow: TWorkflow = async function* (ctx: WorkflowContext, order: Order): any {
    // 低于 $1000 的订单自动批准
    if (order.cost < 1000) {
      return "Auto-approved";
    }

    // $1000 或以上的订单需要经理批准
    yield ctx.callActivity(sendApprovalRequest, order);

    // 必须在 24 小时内收到批准,否则将被取消。
    const tasks: Task<any>[] = [];
    const approvalEvent = ctx.waitForExternalEvent("approval_received");
    const timeoutEvent = ctx.createTimer(24 * 60 * 60);
    tasks.push(approvalEvent);
    tasks.push(timeoutEvent);
    const winner = ctx.whenAny(tasks);

    if (winner == timeoutEvent) {
      return "Cancelled";
    }

    yield ctx.callActivity(placeOrder, order);
    const approvalDetails = approvalEvent.getResult();
    return `Approved by ${approvalDetails.approver}`;
  };

  workflowRuntime
    .registerWorkflow(purchaseOrderWorkflow)
    .registerActivity(sendApprovalRequest)
    .registerActivity(placeOrder);

  // 将工作者启动包装在 try-catch 块中以处理启动期间的任何错误
  try {
    await workflowRuntime.start();
    console.log("工作者启动成功");
  } catch (error) {
    console.error("启动工作者时出错:", error);
  }

  // 调度新的编排
  try {
    const cost = readlineSync.questionInt("输入订单金额:");
    const approver = readlineSync.question("输入审批人:");
    const timeout = readlineSync.questionInt("输入订单超时时间(秒):");
    const order = new Order(cost, "MyProduct", 1);
    const id = await workflowClient.scheduleNewWorkflow(purchaseOrderWorkflow, order);
    console.log(`编排已调度,ID: ${id}`);

    // 异步提示批准
    promptForApproval(approver, workflowClient, id);

    // 等待编排完成
    const state = await workflowClient.waitForWorkflowCompletion(id, undefined, timeout + 2);

    console.log(`编排完成!结果: ${state?.serializedOutput}`);
  } catch (error) {
    console.error("调度或等待编排时出错:", error);
  }

  // 停止工作者和客户端
  await workflowRuntime.stop();
  await workflowClient.stop();

  // 停止 dapr sidecar
  process.exit(0);
}

async function promptForApproval(approver: string, workflowClient: DaprWorkflowClient, id: string) {
  if (readlineSync.keyInYN("按 [Y] 批准订单... Y/是, N/否")) {
    const approvalEvent = { approver: approver };
    await workflowClient.raiseEvent(id, "approval_received", approvalEvent);
  } else {
    return "订单被拒绝";
  }
}

start().catch((e) => {
  console.error(e);
  process.exit(1);
});
public override async Task<OrderResult> RunAsync(WorkflowContext context, OrderPayload order)
{
    // ...(其他步骤)...

    // 需要对超过某个阈值的订单进行批准
    if (order.TotalCost > OrderApprovalThreshold)
    {
        try
        {
            // 请求人类批准此订单
            await context.CallActivityAsync(nameof(RequestApprovalActivity), order);

            // 暂停并等待人类批准订单
            ApprovalResult approvalResult = await context.WaitForExternalEventAsync<ApprovalResult>(
                eventName: "ManagerApproval",
                timeout: TimeSpan.FromDays(3));
            if (approvalResult == ApprovalResult.Rejected)
            {
                // 订单被拒绝,在此结束工作流
                return new OrderResult(Processed: false);
            }
        }
        catch (TaskCanceledException)
        {
            // 批准超时会导致自动取消订单
            return new OrderResult(Processed: false);
        }
    }

    // ...(其他步骤)...

    // 以成功结果结束工作流
    return new OrderResult(Processed: true);
}

注意 在上面的示例中,RequestApprovalActivity 是要调用的工作流活动的名称,ApprovalResult 是由工作流应用程序定义的枚举。为了简洁起见,这些定义未包含在示例代码中。

public class ExternalSystemInteractionWorkflow extends Workflow {
    @Override
    public WorkflowStub create() {
        return ctx -> {
            // ...其他步骤...
            Integer orderCost = ctx.getInput(int.class);
            // 需要对超过某个阈值的订单进行批准
            if (orderCost > ORDER_APPROVAL_THRESHOLD) {
                try {
                    // 请求人类批准此订单
                    ctx.callActivity("RequestApprovalActivity", orderCost, Void.class).await();
                    // 暂停并等待人类批准订单
                    boolean approved = ctx.waitForExternalEvent("ManagerApproval", Duration.ofDays(3), boolean.class).await();
                    if (!approved) {
                        // 订单被拒绝,在此结束工作流
                        ctx.complete("Process reject");
                    }
                } catch (TaskCanceledException e) {
                    // 批准超时会导致自动取消订单
                    ctx.complete("Process cancel");
                }
            }
            // ...其他步骤...

            // 以成功结果结束工作流
            ctx.complete("Process approved");
        };
    }
}
type Order struct {
	Cost     float64 `json:"cost"`
	Product  string  `json:"product"`
	Quantity int     `json:"quantity"`
}
type Approval struct {
	Approver string `json:"approver"`
}
func PurchaseOrderWorkflow(ctx *workflow.WorkflowContext) (any, error) {
	var order Order
	if err := ctx.GetInput(&order); err != nil {
		return "", err
	}
	// 低于 $1000 的订单自动批准
	if order.Cost < 1000 {
		return "Auto-approved", nil
	}
	// $1000 或以上的订单需要经理批准
	if err := ctx.CallActivity(SendApprovalRequest, workflow.ActivityInput(order)).Await(nil); err != nil {
		return "", err
	}
	// 必须在 24 小时内收到批准,否则将被取消
	var approval Approval
	if err := ctx.WaitForExternalEvent("approval_received", time.Hour*24).Await(&approval); err != nil {
		// 假设发生了超时 - 无论如何;一个错误。
		return "error/cancelled", err
	}
	// 订单已获批准
	if err := ctx.CallActivity(PlaceOrder, workflow.ActivityInput(order)).Await(nil); err != nil {
		return "", err
	}
	return fmt.Sprintf("Approved by %s", approval.Approver), nil
}
func SendApprovalRequest(ctx workflow.ActivityContext) (any, error) {
	var order Order
	if err := ctx.GetInput(&order); err != nil {
		return "", err
	}
	fmt.Printf("*** 发送审批请求: %v\n", order)
	return "", nil
}
func PlaceOrder(ctx workflow.ActivityContext) (any, error) {
	var order Order
	if err := ctx.GetInput(&order); err != nil {
		return "", err
	}
	fmt.Printf("*** 下订单: %v", order)
	return "", nil
}

恢复工作流执行的事件的代码在工作流之外。可以使用触发事件工作流管理 API 将工作流事件传递给等待的工作流实例,如以下示例所示:

from dapr.clients import DaprClient
from dataclasses import asdict

with DaprClient() as d:
    d.raise_workflow_event(
        instance_id=instance_id,
        workflow_component="dapr",
        event_name="approval_received",
        event_data=asdict(Approval("Jane Doe")))
import { DaprClient } from "@dapr/dapr";

  public async raiseEvent(workflowInstanceId: string, eventName: string, eventPayload?: any) {
    this._innerClient.raiseOrchestrationEvent(workflowInstanceId, eventName, eventPayload);
  }
// 向等待的工作流触发工作流事件
await daprClient.RaiseWorkflowEventAsync(
    instanceId: orderId,
    workflowComponent: "dapr",
    eventName: "ManagerApproval",
    eventData: ApprovalResult.Approved);
System.out.println("**SendExternalMessage: RestartEvent**");
client.raiseEvent(restartingInstanceId, "RestartEvent", "RestartEventPayload");
func raiseEvent() {
  daprClient, err := client.NewClient()
  if err != nil {
    log.Fatalf("failed to initialize the client")
  }
  err = daprClient.RaiseEventWorkflow(context.Background(), &client.RaiseEventWorkflowRequest{
    InstanceID: "instance_id",
    WorkflowComponent: "dapr",
    EventName: "approval_received",
    EventData: Approval{
      Approver: "Jane Doe",
    },
  })
  if err != nil {
    log.Fatalf("failed to raise event on workflow")
  }
  log.Println("raised an event on specified workflow")
}

外部事件不必由人类直接触发。它们也可以由其他系统触发。例如,工作流可能需要暂停并等待接收到付款。在这种情况下,支付系统可能会在收到付款时将事件发布到 pub/sub 主题,并且该主题上的侦听器可以使用触发事件工作流 API 向工作流触发事件。

下一步

工作流架构 >>

相关链接

3.4 - 工作流架构

Dapr 工作流引擎架构

Dapr 工作流 允许开发者使用多种编程语言的普通代码定义工作流。工作流引擎运行在 Dapr sidecar 内部,并协调作为应用程序一部分部署的工作流代码。本文描述了:

  • Dapr 工作流引擎的架构
  • 工作流引擎如何与应用程序代码交互
  • 工作流引擎如何融入整体 Dapr 架构
  • 不同的工作流后端如何与工作流引擎协作

有关如何在应用程序中编写 Dapr 工作流的更多信息,请参见 如何:编写工作流

Dapr 工作流引擎的内部支持来自于 Dapr 的 actor 运行时。下图展示了 Kubernetes 模式下的 Dapr 工作流架构:

展示 Kubernetes 模式下工作流架构如何工作的图示

要使用 Dapr 工作流构建块,您需要在应用程序中使用 Dapr 工作流 SDK 编写工作流代码,该 SDK 内部通过 gRPC 流连接到 sidecar。这会注册工作流和任何工作流活动,或工作流可以调度的任务。

引擎直接嵌入在 sidecar 中,并通过 durabletask-go 框架库实现。此框架允许您更换不同的存储提供者,包括为 Dapr 创建的存储提供者,该提供者在幕后利用内部 actor。由于 Dapr 工作流使用 actor,您可以将工作流状态存储在状态存储中。

Sidecar 交互

当工作流应用程序启动时,它使用工作流编写 SDK 向 Dapr sidecar 发送 gRPC 请求,并根据 服务器流式 RPC 模式 获取工作流工作项流。这些工作项可以是从“启动一个新的 X 工作流”(其中 X 是工作流的类型)到“调度活动 Y,输入 Z 以代表工作流 X 运行”的任何内容。

工作流应用程序执行相应的工作流代码,然后将执行结果通过 gRPC 请求发送回 sidecar。

Dapr 工作流引擎协议

所有交互都通过单个 gRPC 通道进行,并由应用程序发起,这意味着应用程序不需要打开任何入站端口。这些交互的细节由特定语言的 Dapr 工作流编写 SDK 内部处理。

工作流和 actor sidecar 交互的区别

如果您熟悉 Dapr actor,您可能会注意到工作流与 actor 的 sidecar 交互方式有一些不同。

Actor工作流
Actor 可以使用 HTTP 或 gRPC 与 sidecar 交互。工作流仅使用 gRPC。由于工作流 gRPC 协议的复杂性,实现工作流时需要一个 SDK。
Actor 操作从 sidecar 推送到应用程序代码。这需要应用程序在特定的 应用端口 上监听。对于工作流,操作是由应用程序使用流协议从 sidecar 拉取的。应用程序不需要监听任何端口即可运行工作流。
Actor 明确地向 sidecar 注册自己。工作流不向 sidecar 注册自己。嵌入的引擎不跟踪工作流类型。这一责任被委托给工作流应用程序及其 SDK。

工作流分布式追踪

工作流引擎使用 durabletask-go 核心通过 Open Telemetry SDKs 写入分布式追踪。这些追踪由 Dapr sidecar 自动捕获并导出到配置的 Open Telemetry 提供者,例如 Zipkin。

引擎管理的每个工作流实例都表示为一个或多个跨度。有一个单一的父跨度表示完整的工作流执行,以及各种任务的子跨度,包括活动任务执行和持久计时器的跨度。

工作流活动代码目前无法访问追踪上下文。

内部工作流 actor

在 Dapr sidecar 内部注册了两种类型的 actor,以支持工作流引擎:

  • dapr.internal.{namespace}.{appID}.workflow
  • dapr.internal.{namespace}.{appID}.activity

{namespace} 值是 Dapr 命名空间,如果没有配置命名空间,则默认为 default{appID} 值是应用程序的 ID。例如,如果您有一个名为 “wfapp” 的工作流应用程序,那么工作流 actor 的类型将是 dapr.internal.default.wfapp.workflow,活动 actor 的类型将是 dapr.internal.default.wfapp.activity

下图展示了在 Kubernetes 场景中内部工作流 actor 如何操作:

展示跨集群内部注册的 actor 的图示

与用户定义的 actor 一样,内部工作流 actor 由 actor 放置服务分布在集群中。它们也维护自己的状态并使用提醒。然而,与存在于应用程序代码中的 actor 不同,这些 内部 actor 嵌入在 Dapr sidecar 中。应用程序代码完全不知道这些 actor 的存在。

工作流 actor

工作流 actor 负责管理应用程序中运行的所有工作流的状态和放置。每当创建一个工作流实例时,就会激活一个新的工作流 actor 实例。工作流 actor 的 ID 是工作流的 ID。这个内部 actor 存储工作流的状态,并通过 actor 放置服务确定工作流代码执行的节点。

每个工作流 actor 使用以下键在配置的状态存储中保存其状态:

描述
inbox-NNNNNN工作流的收件箱实际上是一个驱动工作流执行的 消息 的 FIFO 队列。示例消息包括工作流创建消息、活动任务完成消息等。每条消息都存储在状态存储中的一个键中,名称为 inbox-NNNNNN,其中 NNNNNN 是一个 6 位数,表示消息的顺序。这些状态键在相应的消息被工作流消费后被移除。
history-NNNNNN工作流的历史是一个有序的事件列表,表示工作流的执行历史。历史中的每个键保存单个历史事件的数据。像一个只追加的日志一样,工作流历史事件只会被添加而不会被移除(除非工作流执行“继续为新”操作,这会清除所有历史并使用新输入重新启动工作流)。
customStatus包含用户定义的工作流状态值。每个工作流 actor 实例只有一个 customStatus 键。
metadata以 JSON blob 形式包含有关工作流的元信息,包括收件箱的长度、历史的长度以及表示工作流生成的 64 位整数(用于实例 ID 被重用的情况)。长度信息用于确定在加载或保存工作流状态更新时需要读取或写入哪些键。

下图展示了工作流 actor 的典型生命周期。

Dapr 工作流 Actor 流程图

总结:

  1. 当工作流 actor 收到新消息时被激活。
  2. 新消息触发相关的工作流代码(在您的应用程序中)运行,并将执行结果返回给工作流 actor。
  3. 一旦收到结果,actor 会根据需要调度任何任务。
  4. 调度后,actor 在状态存储中更新其状态。
  5. 最后,actor 进入空闲状态,直到收到另一条消息。在此空闲时间内,sidecar 可能决定从内存中卸载工作流 actor。

活动 actor

活动 actor 负责管理所有工作流活动调用的状态和放置。每当工作流调度一个活动任务时,就会激活一个新的活动 actor 实例。活动 actor 的 ID 是工作流的 ID 加上一个序列号(序列号从 0 开始)。例如,如果一个工作流的 ID 是 876bf371,并且是工作流调度的第三个活动,它的 ID 将是 876bf371::2,其中 2 是序列号。

每个活动 actor 将单个键存储到状态存储中:

描述
activityState键包含活动调用负载,其中包括序列化的活动输入数据。此键在活动调用完成后自动删除。

下图展示了活动 actor 的典型生命周期。

工作流活动 Actor 流程图

活动 actor 是短暂的:

  1. 当工作流 actor 调度一个活动任务时,活动 actor 被激活。
  2. 活动 actor 然后立即调用工作流应用程序以调用相关的活动代码。
  3. 一旦活动代码完成运行并返回其结果,活动 actor 将执行结果的消息发送给父工作流 actor。
  4. 一旦结果被发送,工作流被触发以继续其下一步。

提醒使用和执行保证

Dapr 工作流通过使用 actor 提醒 来确保工作流的容错性,以从瞬态系统故障中恢复。在调用应用程序工作流代码之前,工作流或活动 actor 将创建一个新的提醒。如果应用程序代码执行没有中断,提醒将被删除。然而,如果托管相关工作流或活动的节点或 sidecar 崩溃,提醒将重新激活相应的 actor 并重试执行。

展示调用工作流 actor 过程的图示

状态存储使用

Dapr 工作流在内部使用 actor 来驱动工作流的执行。像任何 actor 一样,这些内部工作流 actor 将其状态存储在配置的状态存储中。任何支持 actor 的状态存储都隐式支持 Dapr 工作流。

工作流 actor 部分所述,工作流通过追加到历史日志中增量保存其状态。工作流的历史日志分布在多个状态存储键中,以便每个“检查点”只需追加最新的条目。

每个检查点的大小由工作流在进入空闲状态之前调度的并发操作数决定。顺序工作流 因此将对状态存储进行较小的批量更新,而 扇出/扇入工作流 将需要更大的批量。批量的大小还受到工作流 调用活动子工作流 时输入和输出大小的影响。

工作流 actor 状态存储交互图示

不同的状态存储实现可能隐式对您可以编写的工作流类型施加限制。例如,Azure Cosmos DB 状态存储将项目大小限制为 2 MB 的 UTF-8 编码 JSON(来源)。活动或子工作流的输入或输出负载作为状态存储中的单个记录存储,因此 2 MB 的项目限制意味着工作流和活动的输入和输出不能超过 2 MB 的 JSON 序列化数据。

同样,如果状态存储对批量事务的大小施加限制,这可能会限制工作流可以调度的并行操作数。

工作流状态可以从状态存储中清除,包括其所有历史记录。每个 Dapr SDK 都公开用于清除特定工作流实例的所有元数据的 API。

工作流可扩展性

由于 Dapr 工作流在内部使用 actor 实现,Dapr 工作流具有与 actor 相同的可扩展性特征。放置服务:

  • 不区分工作流 actor 和您在应用程序中定义的 actor
  • 将使用与 actor 相同的算法对工作流进行负载均衡

工作流的预期可扩展性由以下因素决定:

  • 用于托管工作流应用程序的机器数量
  • 运行工作流的机器上可用的 CPU 和内存资源
  • 为 actor 配置的状态存储的可扩展性
  • actor 放置服务和提醒子系统的可扩展性

目标应用程序中工作流代码的实现细节也在个别工作流实例的可扩展性中起作用。每个工作流实例一次在单个节点上执行,但工作流可以调度在其他节点上运行的活动和子工作流。

工作流还可以调度这些活动和子工作流以并行运行,允许单个工作流可能将计算任务分布在集群中的所有可用节点上。

跨多个 Dapr 实例扩展的工作流和活动 actor 图示

工作流不控制负载在集群中的具体分布方式。例如,如果一个工作流调度 10 个活动任务并行运行,所有 10 个任务可能在多达 10 个不同的计算节点上运行,也可能在少至一个计算节点上运行。实际的扩展行为由 actor 放置服务决定,该服务管理表示工作流每个任务的 actor 的分布。

工作流后端

工作流后端负责协调和保存工作流的状态。在任何给定时间,只能支持一个后端。您可以将工作流后端配置为一个组件,类似于 Dapr 中的任何其他组件。配置要求:

  1. 指定工作流后端的类型。
  2. 提供特定于该后端的配置。

例如,以下示例演示了如何定义一个 actor 后端组件。Dapr 工作流目前默认仅支持 actor 后端,用户不需要定义 actor 后端组件即可使用它。

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: actorbackend
spec:
  type: workflowbackend.actor
  version: v1

工作流延迟

为了提供关于持久性和弹性的保证,Dapr 工作流频繁地写入状态存储并依赖提醒来驱动执行。因此,Dapr 工作流可能不适合对延迟敏感的工作负载。预期的高延迟来源包括:

  • 在持久化工作流状态时来自状态存储的延迟。
  • 在使用大型历史记录重新加载工作流时来自状态存储的延迟。
  • 集群中过多活动提醒导致的延迟。
  • 集群中高 CPU 使用率导致的延迟。

有关工作流 actor 设计如何影响执行延迟的更多详细信息,请参见 提醒使用和执行保证部分

下一步

编写工作流 >>

相关链接

3.5 - 如何:编写一个工作流

学习如何使用Dapr工作流引擎开发和编写工作流

本文提供了如何编写由Dapr工作流引擎执行的工作流的高级概述。

以代码形式编写工作流

Dapr工作流逻辑是通过通用编程语言实现的,这使您可以:

  • 使用您喜欢的编程语言(无需学习新的DSL或YAML模式)。
  • 访问语言的标准库。
  • 构建您自己的库和抽象。
  • 使用调试器并检查本地变量。
  • 为您的工作流编写单元测试,就像应用程序逻辑的其他部分一样。

Dapr sidecar不加载任何工作流定义。相反,sidecar仅负责驱动工作流的执行,而所有具体的工作流任务则由应用程序的一部分来处理。

编写工作流任务

工作流任务是工作流中的基本工作单元,是在业务流程中被编排的任务。

定义您希望工作流执行的工作流任务。任务是一个函数定义,可以接受输入并返回输出。以下示例创建了一个名为hello_act的任务,用于打印当前计数器的值。hello_act是一个从WorkflowActivityContext类派生的函数。

def hello_act(ctx: WorkflowActivityContext, input):
    global counter
    counter += input
    print(f'New counter value is: {counter}!', flush=True)

查看上下文中的hello_act工作流任务。

定义您希望工作流执行的工作流任务。任务被封装在实现工作流任务的WorkflowActivityContext类中。

export default class WorkflowActivityContext {
  private readonly _innerContext: ActivityContext;
  constructor(innerContext: ActivityContext) {
    if (!innerContext) {
      throw new Error("ActivityContext cannot be undefined");
    }
    this._innerContext = innerContext;
  }

  public getWorkflowInstanceId(): string {
    return this._innerContext.orchestrationId;
  }

  public getWorkflowActivityId(): number {
    return this._innerContext.taskId;
  }
}

查看上下文中的工作流任务。

定义您希望工作流执行的工作流任务。任务是一个类定义,可以接受输入并返回输出。任务还可以通过依赖注入与Dapr客户端进行交互。

以下示例中调用的任务是:

  • NotifyActivity:接收新订单的通知。
  • ReserveInventoryActivity:检查是否有足够的库存来满足新订单。
  • ProcessPaymentActivity:处理订单的付款。包括NotifyActivity以发送成功订单的通知。

NotifyActivity

public class NotifyActivity : WorkflowActivity<Notification, object>
{
    //...

    public NotifyActivity(ILoggerFactory loggerFactory)
    {
        this.logger = loggerFactory.CreateLogger<NotifyActivity>();
    }

    //...
}

查看完整的NotifyActivity.cs工作流任务示例。

ReserveInventoryActivity

public class ReserveInventoryActivity : WorkflowActivity<InventoryRequest, InventoryResult>
{
    //...

    public ReserveInventoryActivity(ILoggerFactory loggerFactory, DaprClient client)
    {
        this.logger = loggerFactory.CreateLogger<ReserveInventoryActivity>();
        this.client = client;
    }

    //...

}

查看完整的ReserveInventoryActivity.cs工作流任务示例。

ProcessPaymentActivity

public class ProcessPaymentActivity : WorkflowActivity<PaymentRequest, object>
{
    //...
    public ProcessPaymentActivity(ILoggerFactory loggerFactory)
    {
        this.logger = loggerFactory.CreateLogger<ProcessPaymentActivity>();
    }

    //...

}

查看完整的ProcessPaymentActivity.cs工作流任务示例。

定义您希望工作流执行的工作流任务。任务被封装在实现工作流任务的公共DemoWorkflowActivity类中。

@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
public class DemoWorkflowActivity implements WorkflowActivity {

  @Override
  public DemoActivityOutput run(WorkflowActivityContext ctx) {
    Logger logger = LoggerFactory.getLogger(DemoWorkflowActivity.class);
    logger.info("Starting Activity: " + ctx.getName());

    var message = ctx.getInput(DemoActivityInput.class).getMessage();
    var newMessage = message + " World!, from Activity";
    logger.info("Message Received from input: " + message);
    logger.info("Sending message to output: " + newMessage);

    logger.info("Sleeping for 5 seconds to simulate long running operation...");

    try {
      TimeUnit.SECONDS.sleep(5);
    } catch (InterruptedException e) {
      throw new RuntimeException(e);
    }

    logger.info("Activity finished");

    var output = new DemoActivityOutput(message, newMessage);
    logger.info("Activity returned: " + output);

    return output;
  }
}

查看上下文中的Java SDK工作流任务示例。

定义您希望工作流执行的每个工作流任务。任务输入可以通过ctx.GetInput从上下文中解组。任务应定义为接受ctx workflow.ActivityContext参数并返回接口和错误。

func TestActivity(ctx workflow.ActivityContext) (any, error) {
	var input int
	if err := ctx.GetInput(&input); err != nil {
		return "", err
	}
	
	// Do something here
	return "result", nil
}

查看上下文中的Go SDK工作流任务示例。

编写工作流

接下来,在工作流中注册并调用任务。

hello_world_wf函数是从一个名为DaprWorkflowContext的类派生的,具有输入和输出参数类型。它还包括一个yield语句,该语句完成工作流的繁重工作并调用工作流任务。

def hello_world_wf(ctx: DaprWorkflowContext, input):
    print(f'{input}')
    yield ctx.call_activity(hello_act, input=1)
    yield ctx.call_activity(hello_act, input=10)
    yield ctx.wait_for_external_event("event1")
    yield ctx.call_activity(hello_act, input=100)
    yield ctx.call_activity(hello_act, input=1000)

查看上下文中的hello_world_wf工作流。

接下来,使用WorkflowRuntime类注册工作流并启动工作流运行时。

export default class WorkflowRuntime {

  //..
  // Register workflow implementation for handling orchestrations
  public registerWorkflow(workflow: TWorkflow): WorkflowRuntime {
    const name = getFunctionName(workflow);
    const workflowWrapper = (ctx: OrchestrationContext, input: any): any => {
      const workflowContext = new WorkflowContext(ctx);
      return workflow(workflowContext, input);
    };
    this.worker.addNamedOrchestrator(name, workflowWrapper);
    return this;
  }

  // Register workflow activities
  public registerActivity(fn: TWorkflowActivity<TInput, TOutput>): WorkflowRuntime {
    const name = getFunctionName(fn);
    const activityWrapper = (ctx: ActivityContext, intput: TInput): TOutput => {
      const wfActivityContext = new WorkflowActivityContext(ctx);
      return fn(wfActivityContext, intput);
    };
    this.worker.addNamedActivity(name, activityWrapper);
    return this;
  }

  // Start the workflow runtime processing items and block.
  public async start() {
    await this.worker.start();
  }

}

查看上下文中的WorkflowRuntime

OrderProcessingWorkflow类是从一个名为Workflow的基类派生的,具有输入和输出参数类型。它还包括一个RunAsync方法,该方法完成工作流的繁重工作并调用工作流任务。

 class OrderProcessingWorkflow : Workflow<OrderPayload, OrderResult>
    {
        public override async Task<OrderResult> RunAsync(WorkflowContext context, OrderPayload order)
        {
            //...

            await context.CallActivityAsync(
                nameof(NotifyActivity),
                new Notification($"Received order {orderId} for {order.Name} at {order.TotalCost:c}"));

            //...

            InventoryResult result = await context.CallActivityAsync<InventoryResult>(
                nameof(ReserveInventoryActivity),
                new InventoryRequest(RequestId: orderId, order.Name, order.Quantity));
            //...
            
            await context.CallActivityAsync(
                nameof(ProcessPaymentActivity),
                new PaymentRequest(RequestId: orderId, order.TotalCost, "USD"));

            await context.CallActivityAsync(
                nameof(NotifyActivity),
                new Notification($"Order {orderId} processed successfully!"));

            // End the workflow with a success result
            return new OrderResult(Processed: true);
        }
    }

查看OrderProcessingWorkflow.cs中的完整工作流示例。

接下来,使用WorkflowRuntimeBuilder注册工作流并启动工作流运行时。

public class DemoWorkflowWorker {

  public static void main(String[] args) throws Exception {

    // Register the Workflow with the builder.
    WorkflowRuntimeBuilder builder = new WorkflowRuntimeBuilder().registerWorkflow(DemoWorkflow.class);
    builder.registerActivity(DemoWorkflowActivity.class);

    // Build and then start the workflow runtime pulling and executing tasks
    try (WorkflowRuntime runtime = builder.build()) {
      System.out.println("Start workflow runtime");
      runtime.start();
    }

    System.exit(0);
  }
}

查看上下文中的Java SDK工作流。

定义您的工作流函数,参数为ctx *workflow.WorkflowContext,返回任何和错误。从您的工作流中调用您定义的任务。

func TestWorkflow(ctx *workflow.WorkflowContext) (any, error) {
	var input int
	if err := ctx.GetInput(&input); err != nil {
		return nil, err
	}
	var output string
	if err := ctx.CallActivity(TestActivity, workflow.ActivityInput(input)).Await(&output); err != nil {
		return nil, err
	}
	if err := ctx.WaitForExternalEvent("testEvent", time.Second*60).Await(&output); err != nil {
		return nil, err
	}
	
	if err := ctx.CreateTimer(time.Second).Await(nil); err != nil {
		return nil, nil
	}
	return output, nil
}

查看上下文中的Go SDK工作流。

编写应用程序

最后,使用工作流编写应用程序。

在以下示例中,对于使用Python SDK的基本Python hello world应用程序,您的项目代码将包括:

  • 一个名为DaprClient的Python包,用于接收Python SDK功能。
  • 一个带有扩展的构建器,称为:
  • API调用。在下面的示例中,这些调用启动、暂停、恢复、清除和终止工作流。
from dapr.ext.workflow import WorkflowRuntime, DaprWorkflowContext, WorkflowActivityContext
from dapr.clients import DaprClient

# ...

def main():
    with DaprClient() as d:
        host = settings.DAPR_RUNTIME_HOST
        port = settings.DAPR_GRPC_PORT
        workflowRuntime = WorkflowRuntime(host, port)
        workflowRuntime = WorkflowRuntime()
        workflowRuntime.register_workflow(hello_world_wf)
        workflowRuntime.register_activity(hello_act)
        workflowRuntime.start()

        # Start workflow
        print("==========Start Counter Increase as per Input:==========")
        start_resp = d.start_workflow(instance_id=instanceId, workflow_component=workflowComponent,
                        workflow_name=workflowName, input=inputData, workflow_options=workflowOptions)
        print(f"start_resp {start_resp.instance_id}")

        # ...

        # Pause workflow
        d.pause_workflow(instance_id=instanceId, workflow_component=workflowComponent)
        getResponse = d.get_workflow(instance_id=instanceId, workflow_component=workflowComponent)
        print(f"Get response from {workflowName} after pause call: {getResponse.runtime_status}")

        # Resume workflow
        d.resume_workflow(instance_id=instanceId, workflow_component=workflowComponent)
        getResponse = d.get_workflow(instance_id=instanceId, workflow_component=workflowComponent)
        print(f"Get response from {workflowName} after resume call: {getResponse.runtime_status}")
        
        sleep(1)
        # Raise workflow
        d.raise_workflow_event(instance_id=instanceId, workflow_component=workflowComponent,
                    event_name=eventName, event_data=eventData)

        sleep(5)
        # Purge workflow
        d.purge_workflow(instance_id=instanceId, workflow_component=workflowComponent)
        try:
            getResponse = d.get_workflow(instance_id=instanceId, workflow_component=workflowComponent)
        except DaprInternalError as err:
            if nonExistentIDError in err._message:
                print("Instance Successfully Purged")

        # Kick off another workflow for termination purposes 
        start_resp = d.start_workflow(instance_id=instanceId, workflow_component=workflowComponent,
                        workflow_name=workflowName, input=inputData, workflow_options=workflowOptions)
        print(f"start_resp {start_resp.instance_id}")

        # Terminate workflow
        d.terminate_workflow(instance_id=instanceId, workflow_component=workflowComponent)
        sleep(1)
        getResponse = d.get_workflow(instance_id=instanceId, workflow_component=workflowComponent)
        print(f"Get response from {workflowName} after terminate call: {getResponse.runtime_status}")

        # Purge workflow
        d.purge_workflow(instance_id=instanceId, workflow_component=workflowComponent)
        try:
            getResponse = d.get_workflow(instance_id=instanceId, workflow_component=workflowComponent)
        except DaprInternalError as err:
            if nonExistentIDError in err._message:
                print("Instance Successfully Purged")

        workflowRuntime.shutdown()

if __name__ == '__main__':
    main()

以下示例是一个使用JavaScript SDK的基本JavaScript应用程序。在此示例中,您的项目代码将包括:

  • 一个带有扩展的构建器,称为:
  • API调用。在下面的示例中,这些调用启动、终止、获取状态、暂停、恢复、引发事件和清除工作流。
import { TaskHubGrpcClient } from "@microsoft/durabletask-js";
import { WorkflowState } from "./WorkflowState";
import { generateApiTokenClientInterceptors, generateEndpoint, getDaprApiToken } from "../internal/index";
import { TWorkflow } from "../../types/workflow/Workflow.type";
import { getFunctionName } from "../internal";
import { WorkflowClientOptions } from "../../types/workflow/WorkflowClientOption";

/** DaprWorkflowClient类定义了管理工作流实例的客户端操作。 */

export default class DaprWorkflowClient {
  private readonly _innerClient: TaskHubGrpcClient;

  /** 初始化DaprWorkflowClient的新实例。
   */
  constructor(options: Partial<WorkflowClientOptions> = {}) {
    const grpcEndpoint = generateEndpoint(options);
    options.daprApiToken = getDaprApiToken(options);
    this._innerClient = this.buildInnerClient(grpcEndpoint.endpoint, options);
  }

  private buildInnerClient(hostAddress: string, options: Partial<WorkflowClientOptions>): TaskHubGrpcClient {
    let innerOptions = options?.grpcOptions;
    if (options.daprApiToken !== undefined && options.daprApiToken !== "") {
      innerOptions = {
        ...innerOptions,
        interceptors: [generateApiTokenClientInterceptors(options), ...(innerOptions?.interceptors ?? [])],
      };
    }
    return new TaskHubGrpcClient(hostAddress, innerOptions);
  }

  /**
   * 使用DurableTask客户端调度新的工作流。
   */
  public async scheduleNewWorkflow(
    workflow: TWorkflow | string,
    input?: any,
    instanceId?: string,
    startAt?: Date,
  ): Promise<string> {
    if (typeof workflow === "string") {
      return await this._innerClient.scheduleNewOrchestration(workflow, input, instanceId, startAt);
    }
    return await this._innerClient.scheduleNewOrchestration(getFunctionName(workflow), input, instanceId, startAt);
  }

  /**
   * 终止与提供的实例ID关联的工作流。
   *
   * @param {string} workflowInstanceId - 要终止的工作流实例ID。
   * @param {any} output - 为终止的工作流实例设置的可选输出。
   */
  public async terminateWorkflow(workflowInstanceId: string, output: any) {
    await this._innerClient.terminateOrchestration(workflowInstanceId, output);
  }

  /**
   * 从配置的持久存储中获取工作流实例元数据。
   */
  public async getWorkflowState(
    workflowInstanceId: string,
    getInputsAndOutputs: boolean,
  ): Promise<WorkflowState | undefined> {
    const state = await this._innerClient.getOrchestrationState(workflowInstanceId, getInputsAndOutputs);
    if (state !== undefined) {
      return new WorkflowState(state);
    }
  }

  /**
   * 等待工作流开始运行
   */
  public async waitForWorkflowStart(
    workflowInstanceId: string,
    fetchPayloads = true,
    timeoutInSeconds = 60,
  ): Promise<WorkflowState | undefined> {
    const state = await this._innerClient.waitForOrchestrationStart(
      workflowInstanceId,
      fetchPayloads,
      timeoutInSeconds,
    );
    if (state !== undefined) {
      return new WorkflowState(state);
    }
  }

  /**
   * 等待工作流完成运行
   */
  public async waitForWorkflowCompletion(
    workflowInstanceId: string,
    fetchPayloads = true,
    timeoutInSeconds = 60,
  ): Promise<WorkflowState | undefined> {
    const state = await this._innerClient.waitForOrchestrationCompletion(
      workflowInstanceId,
      fetchPayloads,
      timeoutInSeconds,
    );
    if (state != undefined) {
      return new WorkflowState(state);
    }
  }

  /**
   * 向等待的工作流实例发送事件通知消息
   */
  public async raiseEvent(workflowInstanceId: string, eventName: string, eventPayload?: any) {
    this._innerClient.raiseOrchestrationEvent(workflowInstanceId, eventName, eventPayload);
  }

  /**
   * 从工作流状态存储中清除工作流实例状态。
   */
  public async purgeWorkflow(workflowInstanceId: string): Promise<boolean> {
    const purgeResult = await this._innerClient.purgeOrchestration(workflowInstanceId);
    if (purgeResult !== undefined) {
      return purgeResult.deletedInstanceCount > 0;
    }
    return false;
  }

  /**
   * 关闭内部DurableTask客户端并关闭GRPC通道。
   */
  public async stop() {
    await this._innerClient.stop();
  }
}

在以下Program.cs示例中,对于使用.NET SDK的基本ASP.NET订单处理应用程序,您的项目代码将包括:

  • 一个名为Dapr.Workflow的NuGet包,用于接收.NET SDK功能
  • 一个带有扩展方法的构建器,称为AddDaprWorkflow
    • 这将允许您注册工作流和工作流任务(工作流可以调度的任务)
  • HTTP API调用
    • 一个用于提交新订单
    • 一个用于检查现有订单的状态
using Dapr.Workflow;
//...

// Dapr工作流作为服务配置的一部分注册
builder.Services.AddDaprWorkflow(options =>
{
    // 请注意,也可以将lambda函数注册为工作流或任务实现,而不是类。
    options.RegisterWorkflow<OrderProcessingWorkflow>();

    // 这些是由工作流调用的任务。
    options.RegisterActivity<NotifyActivity>();
    options.RegisterActivity<ReserveInventoryActivity>();
    options.RegisterActivity<ProcessPaymentActivity>();
});

WebApplication app = builder.Build();

// POST启动新的订单工作流实例
app.MapPost("/orders", async (DaprWorkflowClient client, [FromBody] OrderPayload orderInfo) =>
{
    if (orderInfo?.Name == null)
    {
        return Results.BadRequest(new
        {
            message = "Order data was missing from the request",
            example = new OrderPayload("Paperclips", 99.95),
        });
    }

//...
});

// GET获取订单工作流的状态以报告状态
app.MapGet("/orders/{orderId}", async (string orderId, DaprWorkflowClient client) =>
{
    WorkflowState state = await client.GetWorkflowStateAsync(orderId, true);
    if (!state.Exists)
    {
        return Results.NotFound($"No order with ID = '{orderId}' was found.");
    }

    var httpResponsePayload = new
    {
        details = state.ReadInputAs<OrderPayload>(),
        status = state.RuntimeStatus.ToString(),
        result = state.ReadOutputAs<OrderResult>(),
    };

//...
}).WithName("GetOrderInfoEndpoint");

app.Run();

如以下示例所示,使用Java SDK和Dapr工作流的hello-world应用程序将包括:

  • 一个名为io.dapr.workflows.client的Java包,用于接收Java SDK客户端功能。
  • 导入io.dapr.workflows.Workflow
  • 扩展WorkflowDemoWorkflow
  • 使用输入和输出创建工作流。
  • API调用。在下面的示例中,这些调用启动并调用工作流任务。
package io.dapr.examples.workflows;

import com.microsoft.durabletask.CompositeTaskFailedException;
import com.microsoft.durabletask.Task;
import com.microsoft.durabletask.TaskCanceledException;
import io.dapr.workflows.Workflow;
import io.dapr.workflows.WorkflowStub;

import java.time.Duration;
import java.util.Arrays;
import java.util.List;

/**
 * DemoWorkflow的服务器端实现。
 */
public class DemoWorkflow extends Workflow {
  @Override
  public WorkflowStub create() {
    return ctx -> {
      ctx.getLogger().info("Starting Workflow: " + ctx.getName());
      // ...
      ctx.getLogger().info("Calling Activity...");
      var input = new DemoActivityInput("Hello Activity!");
      var output = ctx.callActivity(DemoWorkflowActivity.class.getName(), input, DemoActivityOutput.class).await();
      // ...
    };
  }
}

查看上下文中的完整Java SDK工作流示例。

如以下示例所示,使用Go SDK和Dapr工作流的hello-world应用程序将包括:

  • 一个名为client的Go包,用于接收Go SDK客户端功能。
  • TestWorkflow方法
  • 使用输入和输出创建工作流。
  • API调用。在下面的示例中,这些调用启动并调用工作流任务。
package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/dapr/go-sdk/client"
	"github.com/dapr/go-sdk/workflow"
)

var stage = 0

const (
	workflowComponent = "dapr"
)

func main() {
	w, err := workflow.NewWorker()
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("Worker initialized")

	if err := w.RegisterWorkflow(TestWorkflow); err != nil {
		log.Fatal(err)
	}
	fmt.Println("TestWorkflow registered")

	if err := w.RegisterActivity(TestActivity); err != nil {
		log.Fatal(err)
	}
	fmt.Println("TestActivity registered")

	// Start workflow runner
	if err := w.Start(); err != nil {
		log.Fatal(err)
	}
	fmt.Println("runner started")

	daprClient, err := client.NewClient()
	if err != nil {
		log.Fatalf("failed to intialise client: %v", err)
	}
	defer daprClient.Close()
	ctx := context.Background()

	// Start workflow test
	respStart, err := daprClient.StartWorkflow(ctx, &client.StartWorkflowRequest{
		InstanceID:        "a7a4168d-3a1c-41da-8a4f-e7f6d9c718d9",
		WorkflowComponent: workflowComponent,
		WorkflowName:      "TestWorkflow",
		Options:           nil,
		Input:             1,
		SendRawInput:      false,
	})
	if err != nil {
		log.Fatalf("failed to start workflow: %v", err)
	}
	fmt.Printf("workflow started with id: %v\n", respStart.InstanceID)

	// Pause workflow test
	err = daprClient.PauseWorkflow(ctx, &client.PauseWorkflowRequest{
		InstanceID:        "a7a4168d-3a1c-41da-8a4f-e7f6d9c718d9",
		WorkflowComponent: workflowComponent,
	})

	if err != nil {
		log.Fatalf("failed to pause workflow: %v", err)
	}

	respGet, err := daprClient.GetWorkflow(ctx, &client.GetWorkflowRequest{
		InstanceID:        "a7a4168d-3a1c-41da-8a4f-e7f6d9c718d9",
		WorkflowComponent: workflowComponent,
	})
	if err != nil {
		log.Fatalf("failed to get workflow: %v", err)
	}

	if respGet.RuntimeStatus != workflow.StatusSuspended.String() {
		log.Fatalf("workflow not paused: %v", respGet.RuntimeStatus)
	}

	fmt.Printf("workflow paused\n")

	// Resume workflow test
	err = daprClient.ResumeWorkflow(ctx, &client.ResumeWorkflowRequest{
		InstanceID:        "a7a4168d-3a1c-41da-8a4f-e7f6d9c718d9",
		WorkflowComponent: workflowComponent,
	})

	if err != nil {
		log.Fatalf("failed to resume workflow: %v", err)
	}

	respGet, err = daprClient.GetWorkflow(ctx, &client.GetWorkflowRequest{
		InstanceID:        "a7a4168d-3a1c-41da-8a4f-e7f6d9c718d9",
		WorkflowComponent: workflowComponent,
	})
	if err != nil {
		log.Fatalf("failed to get workflow: %v", err)
	}

	if respGet.RuntimeStatus != workflow.StatusRunning.String() {
		log.Fatalf("workflow not running")
	}

	fmt.Println("workflow resumed")

	fmt.Printf("stage: %d\n", stage)

	// Raise Event Test

	err = daprClient.RaiseEventWorkflow(ctx, &client.RaiseEventWorkflowRequest{
		InstanceID:        "a7a4168d-3a1c-41da-8a4f-e7f6d9c718d9",
		WorkflowComponent: workflowComponent,
		EventName:         "testEvent",
		EventData:         "testData",
		SendRawData:       false,
	})

	if err != nil {
		fmt.Printf("failed to raise event: %v", err)
	}

	fmt.Println("workflow event raised")

	time.Sleep(time.Second) // allow workflow to advance

	fmt.Printf("stage: %d\n", stage)

	respGet, err = daprClient.GetWorkflow(ctx, &client.GetWorkflowRequest{
		InstanceID:        "a7a4168d-3a1c-41da-8a4f-e7f6d9c718d9",
		WorkflowComponent: workflowComponent,
	})
	if err != nil {
		log.Fatalf("failed to get workflow: %v", err)
	}

	fmt.Printf("workflow status: %v\n", respGet.RuntimeStatus)

	// Purge workflow test
	err = daprClient.PurgeWorkflow(ctx, &client.PurgeWorkflowRequest{
		InstanceID:        "a7a4168d-3a1c-41da-8a4f-e7f6d9c718d9",
		WorkflowComponent: workflowComponent,
	})
	if err != nil {
		log.Fatalf("failed to purge workflow: %v", err)
	}

	respGet, err = daprClient.GetWorkflow(ctx, &client.GetWorkflowRequest{
		InstanceID:        "a7a4168d-3a1c-41da-8a4f-e7f6d9c718d9",
		WorkflowComponent: workflowComponent,
	})
	if err != nil && respGet != nil {
		log.Fatal("failed to purge workflow")
	}

	fmt.Println("workflow purged")

	fmt.Printf("stage: %d\n", stage)

	// Terminate workflow test
	respStart, err = daprClient.StartWorkflow(ctx, &client.StartWorkflowRequest{
		InstanceID:        "a7a4168d-3a1c-41da-8a4f-e7f6d9c718d9",
		WorkflowComponent: workflowComponent,
		WorkflowName:      "TestWorkflow",
		Options:           nil,
		Input:             1,
		SendRawInput:      false,
	})
	if err != nil {
		log.Fatalf("failed to start workflow: %v", err)
	}

	fmt.Printf("workflow started with id: %s\n", respStart.InstanceID)

	err = daprClient.TerminateWorkflow(ctx, &client.TerminateWorkflowRequest{
		InstanceID:        "a7a4168d-3a1c-41da-8a4f-e7f6d9c718d9",
		WorkflowComponent: workflowComponent,
	})
	if err != nil {
		log.Fatalf("failed to terminate workflow: %v", err)
	}

	respGet, err = daprClient.GetWorkflow(ctx, &client.GetWorkflowRequest{
		InstanceID:        "a7a4168d-3a1c-41da-8a4f-e7f6d9c718d9",
		WorkflowComponent: workflowComponent,
	})
	if err != nil {
		log.Fatalf("failed to get workflow: %v", err)
	}
	if respGet.RuntimeStatus != workflow.StatusTerminated.String() {
		log.Fatal("failed to terminate workflow")
	}

	fmt.Println("workflow terminated")

	err = daprClient.PurgeWorkflow(ctx, &client.PurgeWorkflowRequest{
		InstanceID:        "a7a4168d-3a1c-41da-8a4f-e7f6d9c718d9",
		WorkflowComponent: workflowComponent,
	})

	respGet, err = daprClient.GetWorkflow(ctx, &client.GetWorkflowRequest{
		InstanceID:        "a7a4168d-3a1c-41da-8a4f-e7f6d9c718d9",
		WorkflowComponent: workflowComponent,
	})
	if err == nil || respGet != nil {
		log.Fatalf("failed to purge workflow: %v", err)
	}

	fmt.Println("workflow purged")

	stage = 0
	fmt.Println("workflow client test")

	wfClient, err := workflow.NewClient()
	if err != nil {
		log.Fatalf("[wfclient] faield to initialize: %v", err)
	}

	id, err := wfClient.ScheduleNewWorkflow(ctx, "TestWorkflow", workflow.WithInstanceID("a7a4168d-3a1c-41da-8a4f-e7f6d9c718d9"), workflow.WithInput(1))
	if err != nil {
		log.Fatalf("[wfclient] failed to start workflow: %v", err)
	}

	fmt.Printf("[wfclient] started workflow with id: %s\n", id)

	metadata, err := wfClient.FetchWorkflowMetadata(ctx, id)
	if err != nil {
		log.Fatalf("[wfclient] failed to get worfklow: %v", err)
	}

	fmt.Printf("[wfclient] workflow status: %v\n", metadata.RuntimeStatus.String())

	if stage != 1 {
		log.Fatalf("Workflow assertion failed while validating the wfclient. Stage 1 expected, current: %d", stage)
	}

	fmt.Printf("[wfclient] stage: %d\n", stage)

	// raise event

	if err := wfClient.RaiseEvent(ctx, id, "testEvent", workflow.WithEventPayload("testData")); err != nil {
		log.Fatalf("[wfclient] failed to raise event: %v", err)
	}

	fmt.Println("[wfclient] event raised")

	// Sleep to allow the workflow to advance
	time.Sleep(time.Second)

	if stage != 2 {
		log.Fatalf("Workflow assertion failed while validating the wfclient. Stage 2 expected, current: %d", stage)
	}

	fmt.Printf("[wfclient] stage: %d\n", stage)

	// stop workflow
	if err := wfClient.TerminateWorkflow(ctx, id); err != nil {
		log.Fatalf("[wfclient] failed to terminate workflow: %v", err)
	}

	fmt.Println("[wfclient] workflow terminated")

	if err := wfClient.PurgeWorkflow(ctx, id); err != nil {
		log.Fatalf("[wfclient] failed to purge workflow: %v", err)
	}

	fmt.Println("[wfclient] workflow purged")

	// stop workflow runtime
	if err := w.Shutdown(); err != nil {
		log.Fatalf("failed to shutdown runtime: %v", err)
	}

	fmt.Println("workflow worker successfully shutdown")
}

func TestWorkflow(ctx *workflow.WorkflowContext) (any, error) {
	var input int
	if err := ctx.GetInput(&input); err != nil {
		return nil, err
	}
	var output string
	if err := ctx.CallActivity(TestActivity, workflow.ActivityInput(input)).Await(&output); err != nil {
		return nil, err
	}

	err := ctx.WaitForExternalEvent("testEvent", time.Second*60).Await(&output)
	if err != nil {
		return nil, err
	}

	if err := ctx.CallActivity(TestActivity, workflow.ActivityInput(input)).Await(&output); err != nil {
		return nil, err
	}

	return output, nil
}

func TestActivity(ctx workflow.ActivityContext) (any, error) {
	var input int
	if err := ctx.GetInput(&input); err != nil {
		return "", err
	}

	stage += input

	return fmt.Sprintf("Stage: %d", stage), nil
}

查看上下文中的完整Go SDK工作流示例。

下一步

现在您已经编写了一个工作流,学习如何管理它。

管理工作流 >>

相关链接

3.6 - 如何:管理工作流

管理和运行工作流

现在您已经在应用程序中编写了工作流及其活动,您可以使用HTTP API调用来启动、终止和获取工作流的信息。有关更多信息,请阅读工作流API参考

在代码中管理您的工作流。在编写工作流指南中的工作流示例中,工作流通过以下API在代码中注册:

  • start_workflow: 启动工作流的一个实例
  • get_workflow: 获取工作流状态的信息
  • pause_workflow: 暂停或挂起一个工作流实例,稍后可以恢复
  • resume_workflow: 恢复一个暂停的工作流实例
  • raise_workflow_event: 在工作流上触发一个事件
  • purge_workflow: 删除与特定工作流实例相关的所有元数据
  • terminate_workflow: 终止或停止特定的工作流实例
from dapr.ext.workflow import WorkflowRuntime, DaprWorkflowContext, WorkflowActivityContext
from dapr.clients import DaprClient

# 合适的参数
instanceId = "exampleInstanceID"
workflowComponent = "dapr"
workflowName = "hello_world_wf"
eventName = "event1"
eventData = "eventData"

# 启动工作流
start_resp = d.start_workflow(instance_id=instanceId, workflow_component=workflowComponent,
                        workflow_name=workflowName, input=inputData, workflow_options=workflowOptions)

# 获取工作流信息
getResponse = d.get_workflow(instance_id=instanceId, workflow_component=workflowComponent)

# 暂停工作流
d.pause_workflow(instance_id=instanceId, workflow_component=workflowComponent)

# 恢复工作流
d.resume_workflow(instance_id=instanceId, workflow_component=workflowComponent)

# 在工作流上触发一个事件
d.raise_workflow_event(instance_id=instanceId, workflow_component=workflowComponent,
                    event_name=eventName, event_data=eventData)

# 清除工作流
d.purge_workflow(instance_id=instanceId, workflow_component=workflowComponent)

# 终止工作流
d.terminate_workflow(instance_id=instanceId, workflow_component=workflowComponent)

在代码中管理您的工作流。在编写工作流指南中的工作流示例中,工作流通过以下API在代码中注册:

  • client.workflow.start: 启动工作流的一个实例
  • client.workflow.get: 获取工作流状态的信息
  • client.workflow.pause: 暂停或挂起一个工作流实例,稍后可以恢复
  • client.workflow.resume: 恢复一个暂停的工作流实例
  • client.workflow.purge: 删除与特定工作流实例相关的所有元数据
  • client.workflow.terminate: 终止或停止特定的工作流实例
import { DaprClient } from "@dapr/dapr";

async function printWorkflowStatus(client: DaprClient, instanceId: string) {
  const workflow = await client.workflow.get(instanceId);
  console.log(
    `工作流 ${workflow.workflowName}, 创建于 ${workflow.createdAt.toUTCString()}, 状态为 ${
      workflow.runtimeStatus
    }`,
  );
  console.log(`附加属性: ${JSON.stringify(workflow.properties)}`);
  console.log("--------------------------------------------------\n\n");
}

async function start() {
  const client = new DaprClient();

  // 启动一个新的工作流实例
  const instanceId = await client.workflow.start("OrderProcessingWorkflow", {
    Name: "Paperclips",
    TotalCost: 99.95,
    Quantity: 4,
  });
  console.log(`已启动工作流实例 ${instanceId}`);
  await printWorkflowStatus(client, instanceId);

  // 暂停一个工作流实例
  await client.workflow.pause(instanceId);
  console.log(`已暂停工作流实例 ${instanceId}`);
  await printWorkflowStatus(client, instanceId);

  // 恢复一个工作流实例
  await client.workflow.resume(instanceId);
  console.log(`已恢复工作流实例 ${instanceId}`);
  await printWorkflowStatus(client, instanceId);

  // 终止一个工作流实例
  await client.workflow.terminate(instanceId);
  console.log(`已终止工作流实例 ${instanceId}`);
  await printWorkflowStatus(client, instanceId);

  // 等待工作流完成,30秒!
  await new Promise((resolve) => setTimeout(resolve, 30000));
  await printWorkflowStatus(client, instanceId);

  // 清除一个工作流实例
  await client.workflow.purge(instanceId);
  console.log(`已清除工作流实例 ${instanceId}`);
  // 这将抛出一个错误,因为工作流实例不再存在。
  await printWorkflowStatus(client, instanceId);
}

start().catch((e) => {
  console.error(e);
  process.exit(1);
});

在代码中管理您的工作流。在编写工作流指南中的OrderProcessingWorkflow示例中,工作流在代码中注册。您现在可以启动、终止并获取正在运行的工作流的信息:

string orderId = "exampleOrderId";
string workflowComponent = "dapr";
string workflowName = "OrderProcessingWorkflow";
OrderPayload input = new OrderPayload("Paperclips", 99.95);
Dictionary<string, string> workflowOptions; // 这是一个可选参数

// 启动工作流。这将返回一个"StartWorkflowResponse",其中包含特定工作流实例的实例ID。
StartWorkflowResponse startResponse = await daprClient.StartWorkflowAsync(orderId, workflowComponent, workflowName, input, workflowOptions);

// 获取工作流的信息。此响应包含工作流的状态、启动时间等信息!
GetWorkflowResponse getResponse = await daprClient.GetWorkflowAsync(orderId, workflowComponent, eventName);

// 终止工作流
await daprClient.TerminateWorkflowAsync(orderId, workflowComponent);

// 触发一个事件(一个传入的采购订单),您的工作流将等待此事件。这将返回等待购买的项目。
await daprClient.RaiseWorkflowEventAsync(orderId, workflowComponent, workflowName, input);

// 暂停
await daprClient.PauseWorkflowAsync(orderId, workflowComponent);

// 恢复
await daprClient.ResumeWorkflowAsync(orderId, workflowComponent);

// 清除工作流,删除与关联实例的所有收件箱和历史信息
await daprClient.PurgeWorkflowAsync(orderId, workflowComponent);

在代码中管理您的工作流。在Java SDK中的工作流示例中,工作流通过以下API在代码中注册:

  • scheduleNewWorkflow: 启动一个新的工作流实例
  • getInstanceState: 获取工作流状态的信息
  • waitForInstanceStart: 暂停或挂起一个工作流实例,稍后可以恢复
  • raiseEvent: 为正在运行的工作流实例触发事件/任务
  • waitForInstanceCompletion: 等待工作流完成其任务
  • purgeInstance: 删除与特定工作流实例相关的所有元数据
  • terminateWorkflow: 终止工作流
  • purgeInstance: 删除与特定工作流相关的所有元数据
package io.dapr.examples.workflows;

import io.dapr.workflows.client.DaprWorkflowClient;
import io.dapr.workflows.client.WorkflowInstanceStatus;

// ...
public class DemoWorkflowClient {

  // ...
  public static void main(String[] args) throws InterruptedException {
    DaprWorkflowClient client = new DaprWorkflowClient();

    try (client) {
      // 启动工作流
      String instanceId = client.scheduleNewWorkflow(DemoWorkflow.class, "input data");
      
      // 获取工作流的状态信息
      WorkflowInstanceStatus workflowMetadata = client.getInstanceState(instanceId, true);

      // 等待或暂停工作流实例启动
      try {
        WorkflowInstanceStatus waitForInstanceStartResult =
            client.waitForInstanceStart(instanceId, Duration.ofSeconds(60), true);
      }

      // 为工作流触发一个事件;您可以并行触发多个事件
      client.raiseEvent(instanceId, "TestEvent", "TestEventPayload");
      client.raiseEvent(instanceId, "event1", "TestEvent 1 Payload");
      client.raiseEvent(instanceId, "event2", "TestEvent 2 Payload");
      client.raiseEvent(instanceId, "event3", "TestEvent 3 Payload");

      // 等待工作流完成任务
      try {
        WorkflowInstanceStatus waitForInstanceCompletionResult =
            client.waitForInstanceCompletion(instanceId, Duration.ofSeconds(60), true);
      } 

      // 清除工作流实例,删除与其相关的所有元数据
      boolean purgeResult = client.purgeInstance(instanceId);

      // 终止工作流实例
      client.terminateWorkflow(instanceToTerminateId, null);

    System.exit(0);
  }
}

在代码中管理您的工作流。在Go SDK中的工作流示例中,工作流通过以下API在代码中注册:

  • StartWorkflow: 启动一个新的工作流实例
  • GetWorkflow: 获取工作流状态的信息
  • PauseWorkflow: 暂停或挂起一个工作流实例,稍后可以恢复
  • RaiseEventWorkflow: 为正在运行的工作流实例触发事件/任务
  • ResumeWorkflow: 等待工作流完成其任务
  • PurgeWorkflow: 删除与特定工作流实例相关的所有元数据
  • TerminateWorkflow: 终止工作流
// 启动工作流
type StartWorkflowRequest struct {
	InstanceID        string // 可选实例标识符
	WorkflowComponent string
	WorkflowName      string
	Options           map[string]string // 可选元数据
	Input             any               // 可选输入
	SendRawInput      bool              // 设置为True以禁用输入的序列化
}

type StartWorkflowResponse struct {
	InstanceID string
}

// 获取工作流状态
type GetWorkflowRequest struct {
	InstanceID        string
	WorkflowComponent string
}

type GetWorkflowResponse struct {
	InstanceID    string
	WorkflowName  string
	CreatedAt     time.Time
	LastUpdatedAt time.Time
	RuntimeStatus string
	Properties    map[string]string
}

// 清除工作流
type PurgeWorkflowRequest struct {
	InstanceID        string
	WorkflowComponent string
}

// 终止工作流
type TerminateWorkflowRequest struct {
	InstanceID        string
	WorkflowComponent string
}

// 暂停工作流
type PauseWorkflowRequest struct {
	InstanceID        string
	WorkflowComponent string
}

// 恢复工作流
type ResumeWorkflowRequest struct {
	InstanceID        string
	WorkflowComponent string
}

// 为正在运行的工作流触发一个事件
type RaiseEventWorkflowRequest struct {
	InstanceID        string
	WorkflowComponent string
	EventName         string
	EventData         any
	SendRawData       bool // 设置为True以禁用数据的序列化
}

使用HTTP调用管理您的工作流。下面的示例将编写工作流示例中的属性与一个随机实例ID号结合使用。

启动工作流

要使用ID 12345678启动您的工作流,请运行:

POST http://localhost:3500/v1.0/workflows/dapr/OrderProcessingWorkflow/start?instanceID=12345678

请注意,工作流实例ID只能包含字母数字字符、下划线和破折号。

终止工作流

要使用ID 12345678终止您的工作流,请运行:

POST http://localhost:3500/v1.0/workflows/dapr/12345678/terminate

触发一个事件

对于支持订阅外部事件的工作流组件,例如Dapr工作流引擎,您可以使用以下“触发事件”API将命名事件传递给特定的工作流实例。

POST http://localhost:3500/v1.0/workflows/<workflowComponentName>/<instanceID>/raiseEvent/<eventName>

eventName可以是任何自定义的事件名称。

暂停或恢复工作流

为了计划停机时间、等待输入等,您可以暂停然后恢复工作流。要暂停ID为12345678的工作流,直到触发恢复,请运行:

POST http://localhost:3500/v1.0/workflows/dapr/12345678/pause

要恢复ID为12345678的工作流,请运行:

POST http://localhost:3500/v1.0/workflows/dapr/12345678/resume

清除工作流

清除API可用于从底层状态存储中永久删除工作流元数据,包括任何存储的输入、输出和工作流历史记录。这通常对于实施数据保留策略和释放资源很有用。

只有处于COMPLETED、FAILED或TERMINATED状态的工作流实例可以被清除。如果工作流处于其他状态,调用清除将返回错误。

POST http://localhost:3500/v1.0/workflows/dapr/12345678/purge

获取工作流信息

要获取ID为12345678的工作流信息(输出和输入),请运行:

GET http://localhost:3500/v1.0/workflows/dapr/12345678

工作流API参考指南中了解更多关于这些HTTP调用的信息。

下一步

4 - 状态管理

创建持久运行的有状态服务

4.1 - 状态管理概述

状态管理API模块概述

您的应用程序可以利用Dapr的状态管理API在支持的状态存储中保存、读取和查询键/值对。通过状态存储组件,您可以构建有状态且长时间运行的应用程序,例如购物车或游戏的会话状态。如下图所示:

  • 使用HTTP POST来保存或查询键/值对。
  • 使用HTTP GET来读取特定键并返回其值。

以下视频和演示概述了Dapr状态管理的工作原理。

功能

通过状态管理API模块,您的应用程序可以利用一些通常复杂且容易出错的功能,包括:

  • 设置并发控制和数据一致性的选项。
  • 执行批量更新操作CRUD,包括多个事务操作。
  • 查询和过滤键/值数据。

以下是状态管理API的一些功能:

可插拔的状态存储

Dapr的数据存储被设计为组件,可以在不更改服务代码的情况下进行替换。查看支持的状态存储以获取更多信息。

可配置的状态存储行为

使用Dapr,您可以在状态操作请求中附加元数据,描述您期望请求如何被处理。您可以附加:

  • 并发性要求
  • 一致性要求

默认情况下,您的应用程序应假设数据存储是最终一致的,并使用最后写入胜出并发模式

并非所有存储都是平等的。为了确保您的应用程序的可移植性,您可以查询存储的元数据能力,并使您的代码适应不同的存储能力。

并发

Dapr支持使用ETags的乐观并发控制(OCC)。当请求状态值时,Dapr总是将ETag属性附加到返回的状态中。当用户代码:

  • 更新状态时,期望通过请求体附加ETag。
  • 删除状态时,期望通过If-Match头附加ETag。

当提供的ETag与状态存储中的ETag匹配时,写入操作成功。

为什么Dapr选择乐观并发控制(OCC)

在许多应用程序中,数据更新冲突很少见,因为客户端通常根据业务上下文分区以操作不同的数据。然而,如果您的应用程序选择使用ETags,不匹配的ETags可能导致请求被拒绝。建议您在代码中使用重试策略,以在使用ETags时补偿冲突。

如果您的应用程序在写入请求中省略ETags,Dapr在处理请求时会跳过ETag检查。这使得最后写入胜出模式成为可能,与使用ETags的首次写入胜出模式相比。

阅读API参考以了解如何设置并发选项。

一致性

Dapr支持强一致性最终一致性,最终一致性是默认行为。

  • 强一致性:Dapr在确认写入请求之前等待所有副本(或指定的法定人数)确认。
  • 最终一致性:Dapr在底层数据存储接受写入请求后立即返回,即使这只是一个副本。

阅读API参考以了解如何设置一致性选项。

设置内容类型

状态存储组件可能会根据内容类型不同地维护和操作数据。Dapr支持在状态管理API中作为请求元数据的一部分传递内容类型。

设置内容类型是_可选的_,组件决定是否使用它。Dapr仅提供将此信息传递给组件的手段。

  • 使用HTTP API:通过URL查询参数metadata.contentType设置内容类型。例如,http://localhost:3500/v1.0/state/store?metadata.contentType=application/json
  • 使用gRPC API:通过在请求元数据中添加键/值对"contentType" : <content type>来设置内容类型。

多重操作

Dapr支持两种类型的多读或多写操作:批量事务性。阅读API参考以了解如何使用批量和多选项。

批量读取操作

您可以将多个读取请求分组为批量(或批次)操作。在批量操作中,Dapr将读取请求作为单独的请求提交到底层数据存储,并将它们作为单个结果返回。

事务性操作

您可以将写入、更新和删除操作分组为一个请求,然后作为一个原子事务处理。请求将作为一组事务性操作成功或失败。

actor状态

事务性状态存储可用于存储actor状态。要指定用于actor的状态存储,请在状态存储组件的元数据部分中将属性actorStateStore的值指定为true。actor状态以特定方案存储在事务性状态存储中,允许进行一致的查询。所有actor只能使用一个状态存储组件作为状态存储。阅读state API参考actors API参考以了解有关actor状态存储的更多信息。

actor状态的生存时间(TTL)

在保存actor状态时,您应始终设置TTL元数据字段(ttlInSeconds)或在您选择的SDK中使用等效的API调用,以确保状态最终被移除。阅读actors概述以获取更多信息。

状态加密

Dapr支持应用程序状态的自动客户端加密,并支持密钥轮换。这在所有Dapr状态存储上都支持。有关更多信息,请阅读如何:加密应用程序状态主题。

应用程序之间的共享状态

不同应用程序在共享状态时的需求各不相同。在一种情况下,您可能希望将所有状态封装在给定应用程序中,并让Dapr为您管理访问。在另一种情况下,您可能希望两个应用程序在同一状态上工作,以获取和保存相同的键。

Dapr使状态能够:

  • 隔离到一个应用程序。
  • 在应用程序之间的状态存储中共享。
  • 在不同状态存储之间的多个应用程序之间共享。

有关更多详细信息,请阅读如何:在应用程序之间共享状态

启用外发模式

Dapr使开发人员能够使用外发模式在事务性状态存储和任何消息代理之间实现单一事务。有关更多信息,请阅读如何启用事务性外发消息

查询状态

有两种方法可以查询状态:

  • 使用Dapr运行时提供的状态管理查询API。
  • 使用存储的原生SDK直接查询状态存储。

查询API

使用_可选的_状态管理查询API,您可以查询状态存储中保存的键/值数据,无论底层数据库或存储技术如何。使用状态管理查询API,您可以过滤、排序和分页键/值数据。有关更多详细信息,请阅读如何:查询状态

直接查询状态存储

Dapr在不进行任何转换的情况下保存和检索状态值。您可以直接从底层状态存储查询和聚合状态。例如,要获取与应用程序ID “myApp” 相关的所有状态键,请在Redis中使用:

KEYS "myApp*"
查询actor状态

如果数据存储支持SQL查询,您可以使用SQL查询actor的状态。例如:

SELECT * FROM StateTable WHERE Id='<app-id>||<actor-type>||<actor-id>||<key>'

您还可以通过对actor实例执行聚合查询来避免actor框架的常见轮次并发限制。例如,要计算所有温度计actor的平均温度,请使用:

SELECT AVG(value) FROM StateTable WHERE Id LIKE '<app-id>||<thermometer>||*||temperature'

状态生存时间(TTL)

Dapr支持每个状态设置请求的生存时间(TTL)。这意味着应用程序可以为每个存储的状态设置生存时间,这些状态在过期后无法检索。

状态管理API

状态管理API可以在状态管理API参考中找到,该参考描述了如何通过提供键来检索、保存、删除和查询状态值。

试用状态管理

快速入门和教程

想要测试Dapr状态管理API吗?通过以下快速入门和教程,看看状态管理的实际应用:

快速入门/教程描述
状态管理快速入门使用状态管理API创建有状态的应用程序。
Hello World推荐
演示如何在本地运行Dapr。突出显示服务调用和状态管理。
Hello World Kubernetes推荐
演示如何在Kubernetes中运行Dapr。突出显示服务调用和_状态管理_。

直接在您的应用中开始使用状态管理

想要跳过快速入门?没问题。您可以直接在您的应用程序中试用状态管理模块。在Dapr安装后,您可以从状态管理如何指南开始使用状态管理API。

下一步

4.2 - 操作指南:保存和获取状态

使用键值对持久化状态

状态管理是新应用程序、遗留应用程序、单体应用程序或微服务应用程序的常见需求之一。处理和测试不同的数据库库,以及处理重试和故障,可能既困难又耗时。

在本指南中,您将学习如何使用键/值状态API来保存、获取和删除应用程序的状态。

下面的代码示例描述了一个处理订单的应用程序,该应用程序使用Dapr sidecar。订单处理服务通过Dapr将状态存储在Redis状态存储中。

示例服务的状态管理图示

设置状态存储

状态存储组件是Dapr用于与数据库通信的资源。

在本指南中,我们将使用Redis状态存储,但您也可以选择支持列表中的其他状态存储。

当您在selfhost模式下运行dapr init时,Dapr会在您的本地机器上创建一个默认的Redis statestore.yaml并运行一个Redis状态存储,位置如下:

  • 在Windows上,位于%UserProfile%\.dapr\components\statestore.yaml
  • 在Linux/MacOS上,位于~/.dapr/components/statestore.yaml

通过使用statestore.yaml组件,您可以在不更改应用程序代码的情况下轻松更换底层组件。

要将其部署到Kubernetes集群中,请在下面的YAML中填写您的状态存储组件metadata连接详细信息,保存为statestore.yaml,然后运行kubectl apply -f statestore.yaml

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: statestore
spec:
  type: state.redis
  version: v1
  metadata:
  - name: redisHost
    value: localhost:6379
  - name: redisPassword
    value: ""

请参阅如何在Kubernetes上设置不同的状态存储以获取更多信息。

保存和检索单个状态

以下示例展示了如何使用Dapr状态管理API保存和检索单个键/值对。


// 依赖项
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Dapr.Client;
using Microsoft.AspNetCore.Mvc;
using System.Threading;
using System.Text.Json;

// 代码
namespace EventService
{
    class Program
    {
        static async Task Main(string[] args)
        {
            string DAPR_STORE_NAME = "statestore";
            while(true) {
                System.Threading.Thread.Sleep(5000);
                using var client = new DaprClientBuilder().Build();
                Random random = new Random();
                int orderId = random.Next(1,1000);
                // 使用Dapr SDK保存和获取状态
                await client.SaveStateAsync(DAPR_STORE_NAME, "order_1", orderId.ToString());
                await client.SaveStateAsync(DAPR_STORE_NAME, "order_2", orderId.ToString());
                var result = await client.GetStateAsync<string>(DAPR_STORE_NAME, "order_1");
                Console.WriteLine("获取后的结果: " + result);
            }
        }
    }
}

要为上述示例应用程序启动一个Dapr sidecar,运行类似以下的命令:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 dotnet run
// 依赖项
import io.dapr.client.DaprClient;
import io.dapr.client.DaprClientBuilder;
import io.dapr.client.domain.State;
import io.dapr.client.domain.TransactionalStateOperation;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Mono;
import java.util.Random;
import java.util.concurrent.TimeUnit;

// 代码
@SpringBootApplication
public class OrderProcessingServiceApplication {

	private static final Logger log = LoggerFactory.getLogger(OrderProcessingServiceApplication.class);

	private static final String STATE_STORE_NAME = "statestore";

	public static void main(String[] args) throws InterruptedException{
		while(true) {
			TimeUnit.MILLISECONDS.sleep(5000);
			Random random = new Random();
			int orderId = random.nextInt(1000-1) + 1;
			DaprClient client = new DaprClientBuilder().build();
            // 使用Dapr SDK保存和获取状态
			client.saveState(STATE_STORE_NAME, "order_1", Integer.toString(orderId)).block();
			client.saveState(STATE_STORE_NAME, "order_2", Integer.toString(orderId)).block();
			Mono<State<String>> result = client.getState(STATE_STORE_NAME, "order_1", String.class);
			log.info("获取后的结果" + result);
		}
	}

}

要为上述示例应用程序启动一个Dapr sidecar,运行类似以下的命令:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 mvn spring-boot:run
# 依赖项
import random
from time import sleep    
import requests
import logging
from dapr.clients import DaprClient
from dapr.clients.grpc._state import StateItem
from dapr.clients.grpc._request import TransactionalStateOperation, TransactionOperationType

# 代码
logging.basicConfig(level = logging.INFO)
DAPR_STORE_NAME = "statestore"
while True:
    sleep(random.randrange(50, 5000) / 1000)
    orderId = random.randint(1, 1000)
    with DaprClient() as client:
        # 使用Dapr SDK保存和获取状态
        client.save_state(DAPR_STORE_NAME, "order_1", str(orderId)) 
        result = client.get_state(DAPR_STORE_NAME, "order_1")
        logging.info('获取后的结果: ' + result.data.decode('utf-8'))

要为上述示例应用程序启动一个Dapr sidecar,运行类似以下的命令:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 -- python3 OrderProcessingService.py
// 依赖项
import (
	"context"
	"log"
	"math/rand"
	"strconv"
	"time"

	dapr "github.com/dapr/go-sdk/client"
)

// 代码
func main() {
	const STATE_STORE_NAME = "statestore"
	rand.Seed(time.Now().UnixMicro())
	for i := 0; i < 10; i++ {
		orderId := rand.Intn(1000-1) + 1
		client, err := dapr.NewClient()
		if err != nil {
			panic(err)
		}
		defer client.Close()
		ctx := context.Background()
		err = client.SaveState(ctx, STATE_STORE_NAME, "order_1", []byte(strconv.Itoa(orderId)), nil)
		if err != nil {
			panic(err)
		}
		result, err := client.GetState(ctx, STATE_STORE_NAME, "order_1", nil)
		if err != nil {
			panic(err)
		}
		log.Println("获取后的结果:", string(result.Value))
		time.Sleep(2 * time.Second)
	}
}

要为上述示例应用程序启动一个Dapr sidecar,运行类似以下的命令:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 go run OrderProcessingService.go
// 依赖项
import { DaprClient, HttpMethod, CommunicationProtocolEnum } from '@dapr/dapr'; 

// 代码
const daprHost = "127.0.0.1"; 
var main = function() {
    for(var i=0;i<10;i++) {
        sleep(5000);
        var orderId = Math.floor(Math.random() * (1000 - 1) + 1);
        start(orderId).catch((e) => {
            console.error(e);
            process.exit(1);
        });
    }
}

async function start(orderId) {
    const client = new DaprClient({
        daprHost,
        daprPort: process.env.DAPR_HTTP_PORT,
        communicationProtocol: CommunicationProtocolEnum.HTTP,
    });
    const STATE_STORE_NAME = "statestore";
    // 使用Dapr SDK保存和获取状态
    await client.state.save(STATE_STORE_NAME, [
        {
            key: "order_1",
            value: orderId.toString()
        },
        {
            key: "order_2",
            value: orderId.toString()
        }
    ]);
    var result = await client.state.get(STATE_STORE_NAME, "order_1");
    console.log("获取后的结果: " + result);
}

function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

main();

要为上述示例应用程序启动一个Dapr sidecar,运行类似以下的命令:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 npm start

启动一个Dapr sidecar:

dapr run --app-id orderprocessing --dapr-http-port 3601

在一个单独的终端中,将一个键/值对保存到您的状态存储中:

curl -X POST -H "Content-Type: application/json" -d '[{ "key": "order_1", "value": "250"}]' http://localhost:3601/v1.0/state/statestore

现在获取您刚刚保存的状态:

curl http://localhost:3601/v1.0/state/statestore/order_1

重新启动您的sidecar并尝试再次检索状态,以观察状态与应用程序分开持久化。

启动一个Dapr sidecar:

dapr --app-id orderprocessing --dapr-http-port 3601 run

在一个单独的终端中,将一个键/值对保存到您的状态存储中:

Invoke-RestMethod -Method Post -ContentType 'application/json' -Body '[{"key": "order_1", "value": "250"}]' -Uri 'http://localhost:3601/v1.0/state/statestore'

现在获取您刚刚保存的状态:

Invoke-RestMethod -Uri 'http://localhost:3601/v1.0/state/statestore/order_1'

重新启动您的sidecar并尝试再次检索状态,以观察状态与应用程序分开持久化。

删除状态

以下是利用Dapr SDK删除状态的代码示例。

// 依赖项
using Dapr.Client;

// 代码
namespace EventService
{
    class Program
    {
        static async Task Main(string[] args)
        {
            string DAPR_STORE_NAME = "statestore";
            // 使用Dapr SDK删除状态
            using var client = new DaprClientBuilder().Build();
            await client.DeleteStateAsync(DAPR_STORE_NAME, "order_1", cancellationToken: cancellationToken);
        }
    }
}

要为上述示例应用程序启动一个Dapr sidecar,运行类似以下的命令:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 dotnet run
// 依赖项
import io.dapr.client.DaprClient;
import io.dapr.client.DaprClientBuilder;
import org.springframework.boot.autoconfigure.SpringBootApplication;

// 代码
@SpringBootApplication
public class OrderProcessingServiceApplication {
	public static void main(String[] args) throws InterruptedException{
        String STATE_STORE_NAME = "statestore";

        // 使用Dapr SDK删除状态
        DaprClient client = new DaprClientBuilder().build();
        String storedEtag = client.getState(STATE_STORE_NAME, "order_1", String.class).block().getEtag();
        client.deleteState(STATE_STORE_NAME, "order_1", storedEtag, null).block();
	}
}

要为上述示例应用程序启动一个Dapr sidecar,运行类似以下的命令:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 mvn spring-boot:run
# 依赖项
from dapr.clients.grpc._request import TransactionalStateOperation, TransactionOperationType

# 代码
logging.basicConfig(level = logging.INFO)
DAPR_STORE_NAME = "statestore"

# 使用Dapr SDK删除状态
with DaprClient() as client:
    client.delete_state(store_name=DAPR_STORE_NAME, key="order_1")

要为上述示例应用程序启动一个Dapr sidecar,运行类似以下的命令:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 -- python3 OrderProcessingService.py
// 依赖项
import (
	"context"
	dapr "github.com/dapr/go-sdk/client"

)

// 代码
func main() {
    STATE_STORE_NAME := "statestore"
    // 使用Dapr SDK删除状态
    client, err := dapr.NewClient()
    if err != nil {
        panic(err)
    }
    defer client.Close()
    ctx := context.Background()

    if err := client.DeleteState(ctx, STATE_STORE_NAME, "order_1"); err != nil {
        panic(err)
    }
}

要为上述示例应用程序启动一个Dapr sidecar,运行类似以下的命令:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 go run OrderProcessingService.go
// 依赖项
import { DaprClient, HttpMethod, CommunicationProtocolEnum } from '@dapr/dapr'; 

// 代码
const daprHost = "127.0.0.1"; 
var main = function() {
    const STATE_STORE_NAME = "statestore";
    // 使用Dapr SDK保存和获取状态
    const client = new DaprClient({
        daprHost,
        daprPort: process.env.DAPR_HTTP_PORT,
        communicationProtocol: CommunicationProtocolEnum.HTTP,
    });
    
    await client.state.delete(STATE_STORE_NAME, "order_1"); 
}

main();

要为上述示例应用程序启动一个Dapr sidecar,运行类似以下的命令:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 npm start

使用上面相同的Dapr实例运行:

curl -X DELETE 'http://localhost:3601/v1.0/state/statestore/order_1'

尝试再次获取状态。注意没有返回值。

使用上面相同的Dapr实例运行:

Invoke-RestMethod -Method Delete -Uri 'http://localhost:3601/v1.0/state/statestore/order_1'

尝试再次获取状态。注意没有返回值。

保存和检索多个状态

以下是利用Dapr SDK保存和检索多个状态的代码示例。

// 依赖项
using Dapr.Client;
// 代码
namespace EventService
{
    class Program
    {
        static async Task Main(string[] args)
        {
            string DAPR_STORE_NAME = "statestore";
            // 使用Dapr SDK检索多个状态
            using var client = new DaprClientBuilder().Build();
            IReadOnlyList<BulkStateItem> multipleStateResult = await client.GetBulkStateAsync(DAPR_STORE_NAME, new List<string> { "order_1", "order_2" }, parallelism: 1);
        }
    }
}

要为上述示例应用程序启动一个Dapr sidecar,运行类似以下的命令:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 dotnet run

上述示例返回一个BulkStateItem,其中包含您保存到状态的值的序列化格式。如果您希望SDK在每个批量响应项中反序列化值,您可以使用以下代码:

// 依赖项
using Dapr.Client;
// 代码
namespace EventService
{
    class Program
    {
        static async Task Main(string[] args)
        {
            string DAPR_STORE_NAME = "statestore";
            // 使用Dapr SDK检索多个状态
            using var client = new DaprClientBuilder().Build();
            IReadOnlyList<BulkStateItem<Widget>> mulitpleStateResult = await client.GetBulkStateAsync<Widget>(DAPR_STORE_NAME, new List<string> { "widget_1", "widget_2" }, parallelism: 1);
        }
    }

    class Widget
    {
        string Size { get; set; }
        string Color { get; set; }        
    }
}
// 依赖项
import io.dapr.client.DaprClient;
import io.dapr.client.DaprClientBuilder;
import io.dapr.client.domain.State;
import java.util.Arrays;

// 代码
@SpringBootApplication
public class OrderProcessingServiceApplication {

	private static final Logger log = LoggerFactory.getLogger(OrderProcessingServiceApplication.class);

	public static void main(String[] args) throws InterruptedException{
        String STATE_STORE_NAME = "statestore";
        // 使用Dapr SDK检索多个状态
        DaprClient client = new DaprClientBuilder().build();
        Mono<List<State<String>>> resultBulk = client.getBulkState(STATE_STORE_NAME,
        Arrays.asList("order_1", "order_2"), String.class);
	}
}

要为上述示例应用程序启动一个Dapr sidecar,运行类似以下的命令:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 mvn spring-boot:run
# 依赖项
from dapr.clients import DaprClient
from dapr.clients.grpc._state import StateItem

# 代码
logging.basicConfig(level = logging.INFO)
DAPR_STORE_NAME = "statestore"
orderId = 100
# 使用Dapr SDK保存和检索多个状态
with DaprClient() as client:
    client.save_bulk_state(store_name=DAPR_STORE_NAME, states=[StateItem(key="order_2", value=str(orderId))])
    result = client.get_bulk_state(store_name=DAPR_STORE_NAME, keys=["order_1", "order_2"], states_metadata={"metakey": "metavalue"}).items
    logging.info('批量获取后的结果: ' + str(result)) 

要为上述示例应用程序启动一个Dapr sidecar,运行类似以下的命令:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 -- python3 OrderProcessingService.py
// 依赖项
import (
	"context"
	"log"
	"math/rand"
	"strconv"
	"time"

	dapr "github.com/dapr/go-sdk/client"
)

// 代码
func main() {
	const STATE_STORE_NAME = "statestore"
	rand.Seed(time.Now().UnixMicro())
	for i := 0; i < 10; i++ {
		orderId := rand.Intn(1000-1) + 1
		client, err := dapr.NewClient()
		if err != nil {
			panic(err)
		}
		defer client.Close()
		ctx := context.Background()
		err = client.SaveState(ctx, STATE_STORE_NAME, "order_1", []byte(strconv.Itoa(orderId)), nil)
		if err != nil {
			panic(err)
		}
		keys := []string{"key1", "key2", "key3"}
        items, err := client.GetBulkState(ctx, STATE_STORE_NAME, keys, nil, 100)
		if err != nil {
			panic(err)
		}
		for _, item := range items {
			log.Println("从GetBulkState获取的项:", string(item.Value))
		}
	}
} 

要为上述示例应用程序启动一个Dapr sidecar,运行类似以下的命令:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 go run OrderProcessingService.go
// 依赖项
import { DaprClient, HttpMethod, CommunicationProtocolEnum } from '@dapr/dapr'; 

// 代码
const daprHost = "127.0.0.1"; 
var main = function() {
    const STATE_STORE_NAME = "statestore";
    var orderId = 100;
    // 使用Dapr SDK保存和检索多个状态
    const client = new DaprClient({
        daprHost,
        daprPort: process.env.DAPR_HTTP_PORT,
        communicationProtocol: CommunicationProtocolEnum.HTTP,
    });

    await client.state.save(STATE_STORE_NAME, [
        {
            key: "order_1",
            value: orderId.toString()
        },
        {
            key: "order_2",
            value: orderId.toString()
        }
    ]);
    result = await client.state.getBulk(STATE_STORE_NAME, ["order_1", "order_2"]);
}

main();

要为上述示例应用程序启动一个Dapr sidecar,运行类似以下的命令:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 npm start

使用上面相同的Dapr实例,将两个键/值对保存到您的状态存储中:

curl -X POST -H "Content-Type: application/json" -d '[{ "key": "order_1", "value": "250"}, { "key": "order_2", "value": "550"}]' http://localhost:3601/v1.0/state/statestore

现在获取您刚刚保存的状态:

curl -X POST -H "Content-Type: application/json" -d '{"keys":["order_1", "order_2"]}' http://localhost:3601/v1.0/state/statestore/bulk

使用上面相同的Dapr实例,将两个键/值对保存到您的状态存储中:

Invoke-RestMethod -Method Post -ContentType 'application/json' -Body '[{ "key": "order_1", "value": "250"}, { "key": "order_2", "value": "550"}]' -Uri 'http://localhost:3601/v1.0/state/statestore'

现在获取您刚刚保存的状态:

Invoke-RestMethod -Method Post -ContentType 'application/json' -Body '{"keys":["order_1", "order_2"]}' -Uri 'http://localhost:3601/v1.0/state/statestore/bulk'

执行状态事务

以下是利用Dapr SDK执行状态事务的代码示例。

// 依赖项
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Dapr.Client;
using Microsoft.AspNetCore.Mvc;
using System.Threading;
using System.Text.Json;

// 代码
namespace EventService
{
    class Program
    {
        static async Task Main(string[] args)
        {
            string DAPR_STORE_NAME = "statestore";
            while(true) {
                System.Threading.Thread.Sleep(5000);
                Random random = new Random();
                int orderId = random.Next(1,1000);
                using var client = new DaprClientBuilder().Build();
                var requests = new List<StateTransactionRequest>()
                {
                    new StateTransactionRequest("order_3", JsonSerializer.SerializeToUtf8Bytes(orderId.ToString()), StateOperationType.Upsert),
                    new StateTransactionRequest("order_2", null, StateOperationType.Delete)
                };
                CancellationTokenSource source = new CancellationTokenSource();
                CancellationToken cancellationToken = source.Token;
                // 使用Dapr SDK执行状态事务
                await client.ExecuteStateTransactionAsync(DAPR_STORE_NAME, requests, cancellationToken: cancellationToken);
                Console.WriteLine("订单请求: " + orderId);
                Console.WriteLine("结果: " + result);
            }
        }
    }
}

要为上述示例应用程序启动一个Dapr sidecar,运行类似以下的命令:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 dotnet run
// 依赖项
import io.dapr.client.DaprClient;
import io.dapr.client.DaprClientBuilder;
import io.dapr.client.domain.State;
import io.dapr.client.domain.TransactionalStateOperation;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Mono;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.TimeUnit;

// 代码
@SpringBootApplication
public class OrderProcessingServiceApplication {

	private static final Logger log = LoggerFactory.getLogger(OrderProcessingServiceApplication.class);

	private static final String STATE_STORE_NAME = "statestore";

	public static void main(String[] args) throws InterruptedException{
		while(true) {
			TimeUnit.MILLISECONDS.sleep(5000);
			Random random = new Random();
			int orderId = random.nextInt(1000-1) + 1;
			DaprClient client = new DaprClientBuilder().build();
			List<TransactionalStateOperation<?>> operationList = new ArrayList<>();
			operationList.add(new TransactionalStateOperation<>(TransactionalStateOperation.OperationType.UPSERT,
					new State<>("order_3", Integer.toString(orderId), "")));
			operationList.add(new TransactionalStateOperation<>(TransactionalStateOperation.OperationType.DELETE,
					new State<>("order_2")));
            // 使用Dapr SDK执行状态事务
			client.executeStateTransaction(STATE_STORE_NAME, operationList).block();
			log.info("订单请求: " + orderId);
		}
	}

}

要为上述示例应用程序启动一个Dapr sidecar,运行类似以下的命令:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 mvn spring-boot:run
# 依赖项
import random
from time import sleep    
import requests
import logging
from dapr.clients import DaprClient
from dapr.clients.grpc._state import StateItem
from dapr.clients.grpc._request import TransactionalStateOperation, TransactionOperationType

# 代码
logging.basicConfig(level = logging.INFO)    
DAPR_STORE_NAME = "statestore"
while True:
    sleep(random.randrange(50, 5000) / 1000)
    orderId = random.randint(1, 1000)
    with DaprClient() as client:
        # 使用Dapr SDK执行状态事务
        client.execute_state_transaction(store_name=DAPR_STORE_NAME, operations=[
            TransactionalStateOperation(
                operation_type=TransactionOperationType.upsert,
                key="order_3",
                data=str(orderId)),
            TransactionalStateOperation(key="order_3", data=str(orderId)),
            TransactionalStateOperation(
                operation_type=TransactionOperationType.delete,
                key="order_2",
                data=str(orderId)),
            TransactionalStateOperation(key="order_2", data=str(orderId))
        ])

    client.delete_state(store_name=DAPR_STORE_NAME, key="order_1")
    logging.basicConfig(level = logging.INFO)
    logging.info('订单请求: ' + str(orderId))
    logging.info('结果: ' + str(result))

要为上述示例应用程序启动一个Dapr sidecar,运行类似以下的命令:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 -- python3 OrderProcessingService.py
// 依赖项
package main

import (
	"context"
	"log"
	"math/rand"
	"strconv"
	"time"

	dapr "github.com/dapr/go-sdk/client"
)

// 代码
func main() {
	const STATE_STORE_NAME = "statestore"
	rand.Seed(time.Now().UnixMicro())
	for i := 0; i < 10; i++ {
		orderId := rand.Intn(1000-1) + 1
		client, err := dapr.NewClient()
		if err != nil {
			panic(err)
		}
		defer client.Close()
		ctx := context.Background()
		err = client.SaveState(ctx, STATE_STORE_NAME, "order_1", []byte(strconv.Itoa(orderId)), nil)
		if err != nil {
			panic(err)
		}
		result, err := client.GetState(ctx, STATE_STORE_NAME, "order_1", nil)
		if err != nil {
			panic(err)
		}

        ops := make([]*dapr.StateOperation, 0)
        data1 := "data1"
        data2 := "data2"

        op1 := &dapr.StateOperation{
            Type: dapr.StateOperationTypeUpsert,
            Item: &dapr.SetStateItem{
                Key:   "key1",
                Value: []byte(data1),
            },
        }
        op2 := &dapr.StateOperation{
            Type: dapr.StateOperationTypeDelete,
            Item: &dapr.SetStateItem{
                Key:   "key2",
                Value: []byte(data2),
            },
        }
        ops = append(ops, op1, op2)
        meta := map[string]string{}
        err = client.ExecuteStateTransaction(ctx, STATE_STORE_NAME, meta, ops)

		log.Println("获取后的结果:", string(result.Value))
		time.Sleep(2 * time.Second)
	}
}

要为上述示例应用程序启动一个Dapr sidecar,运行类似以下的命令:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 go run OrderProcessingService.go
// 依赖项
import { DaprClient, HttpMethod, CommunicationProtocolEnum } from '@dapr/dapr'; 

// 代码
const daprHost = "127.0.0.1"; 
var main = function() {
    for(var i=0;i<10;i++) {
        sleep(5000);
        var orderId = Math.floor(Math.random() * (1000 - 1) + 1);
        start(orderId).catch((e) => {
            console.error(e);
            process.exit(1);
        });
    }
}

async function start(orderId) {
    const client = new DaprClient({
        daprHost,
        daprPort: process.env.DAPR_HTTP_PORT,
        communicationProtocol: CommunicationProtocolEnum.HTTP,
    });

    const STATE_STORE_NAME = "statestore";
    // 使用Dapr SDK保存和检索多个状态
    await client.state.transaction(STATE_STORE_NAME, [
        {
        operation: "upsert",
        request: {
            key: "order_3",
            value: orderId.toString()
        }
        },
        {
        operation: "delete",
        request: {
            key: "order_2"
        }
        }
    ]);
}

function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

main();

要为上述示例应用程序启动一个Dapr sidecar,运行类似以下的命令:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 npm start

使用上面相同的Dapr实例,执行两个状态事务:

curl -X POST -H "Content-Type: application/json" -d '{"operations": [{"operation":"upsert", "request": {"key": "order_1", "value": "250"}}, {"operation":"delete", "request": {"key": "order_2"}}]}' http://localhost:3601/v1.0/state/statestore/transaction

现在查看您的状态事务的结果:

curl -X POST -H "Content-Type: application/json" -d '{"keys":["order_1", "order_2"]}' http://localhost:3601/v1.0/state/statestore/bulk

使用上面相同的Dapr实例,将两个键/值对保存到您的状态存储中:

Invoke-RestMethod -Method Post -ContentType 'application/json' -Body '{"operations": [{"operation":"upsert", "request": {"key": "order_1", "value": "250"}}, {"operation":"delete", "request": {"key": "order_2"}}]}' -Uri 'http://localhost:3601/v1.0/state/statestore/transaction'

现在查看您的状态事务的结果:

Invoke-RestMethod -Method Post -ContentType 'application/json' -Body '{"keys":["order_1", "order_2"]}' -Uri 'http://localhost:3601/v1.0/state/statestore/bulk'

下一步

4.3 - 操作指南:查询状态

使用查询API查询状态存储

通过状态查询API,您可以从状态存储组件中检索、过滤和排序键/值数据。查询API并不是完整查询语言的替代品。

尽管状态存储是键/值存储,value可能是一个包含自身层次结构、键和值的JSON文档。查询API允许您使用这些键/值来检索相应的文档。

查询状态

您可以通过HTTP POST/PUT或gRPC提交查询请求。请求的主体是一个包含以下三个部分的JSON对象:

  • filter
  • sort
  • page

filter

filter用于指定查询条件,结构类似于树形,每个节点表示一个操作,可能是单一或多个操作数。

支持以下操作:

操作符操作数描述
EQkey:valuekey 等于 value
NEQkey:valuekey 不等于 value
GTkey:valuekey 大于 value
GTEkey:valuekey 大于等于 value
LTkey:valuekey 小于 value
LTEkey:valuekey 小于等于 value
INkey:[]valuekey 等于 value[0] 或 value[1] 或 … 或 value[n]
AND[]operationoperation[0] 且 operation[1] 且 … 且 operation[n]
OR[]operationoperation[0] 或 operation[1] 或 … 或 operation[n]

操作数中的key类似于JSONPath表示法。键中的每个点表示嵌套的JSON结构。例如,考虑以下结构:

{
  "shape": {
    "name": "rectangle",
    "dimensions": {
      "height": 24,
      "width": 10
    },
    "color": {
      "name": "red",
      "code": "#FF0000"
    }
  }
}

要比较颜色代码的值,键将是shape.color.code

如果省略filter部分,查询将返回所有条目。

sort

sort是一个有序的key:order对数组,其中:

  • key是状态存储中的一个键
  • order是一个可选字符串,指示排序顺序:
    • "ASC"表示升序
    • "DESC"表示降序
      如果省略order,默认是升序。

page

page包含limittoken参数。

  • limit设置每页返回的记录数。
  • token是组件返回的分页令牌,用于获取后续查询的结果。

在后台,此查询请求被转换为本地查询语言并由状态存储组件执行。

示例数据和查询

让我们来看一些从简单到复杂的真实示例。

作为数据集,考虑一个包含员工ID、组织、州和城市的员工记录集合。注意,这个数据集是一个键/值对数组,其中:

  • key是唯一ID
  • value是包含员工记录的JSON对象。

为了更好地说明功能,组织名称(org)和员工ID(id)是一个嵌套的JSON person对象。

首先创建一个MongoDB实例,作为您的状态存储。

docker run -d --rm -p 27017:27017 --name mongodb mongo:5

接下来,启动一个Dapr应用程序。参考组件配置文件,该文件指示Dapr使用MongoDB作为其状态存储。

dapr run --app-id demo --dapr-http-port 3500 --resources-path query-api-examples/components/mongodb

用员工数据集填充状态存储,以便您可以稍后查询它。

curl -X POST -H "Content-Type: application/json" -d @query-api-examples/dataset.json http://localhost:3500/v1.0/state/statestore

填充后,您可以检查状态存储中的数据。下图中,MongoDB UI的一部分显示了员工记录。

示例数据集

每个条目都有一个_id成员作为连接的对象键,以及一个包含JSON记录的value成员。

查询API允许您从这个JSON结构中选择记录。

现在您可以运行示例查询。

示例1

首先,查找加利福尼亚州的所有员工,并按其员工ID降序排序。

这是查询

{
    "filter": {
        "EQ": { "state": "CA" }
    },
    "sort": [
        {
            "key": "person.id",
            "order": "DESC"
        }
    ]
}

此查询在SQL中的等价形式是:

SELECT * FROM c WHERE
  state = "CA"
ORDER BY
  person.id DESC

使用以下命令执行查询:

curl -s -X POST -H "Content-Type: application/json" -d @query-api-examples/query1.json http://localhost:3500/v1.0-alpha1/state/statestore/query | jq .
Invoke-RestMethod -Method Post -ContentType 'application/json' -InFile query-api-examples/query1.json -Uri 'http://localhost:3500/v1.0-alpha1/state/statestore/query'

查询结果是一个按请求顺序排列的匹配键/值对数组:

{
  "results": [
    {
      "key": "3",
      "data": {
        "person": {
          "org": "Finance",
          "id": 1071
        },
        "city": "Sacramento",
        "state": "CA"
      },
      "etag": "44723d41-deb1-4c23-940e-3e6896c3b6f7"
    },
    {
      "key": "7",
      "data": {
        "city": "San Francisco",
        "state": "CA",
        "person": {
          "id": 1015,
          "org": "Dev Ops"
        }
      },
      "etag": "0e69e69f-3dbc-423a-9db8-26767fcd2220"
    },
    {
      "key": "5",
      "data": {
        "state": "CA",
        "person": {
          "org": "Hardware",
          "id": 1007
        },
        "city": "Los Angeles"
      },
      "etag": "f87478fa-e5c5-4be0-afa5-f9f9d75713d8"
    },
    {
      "key": "9",
      "data": {
        "person": {
          "org": "Finance",
          "id": 1002
        },
        "city": "San Diego",
        "state": "CA"
      },
      "etag": "f5cf05cd-fb43-4154-a2ec-445c66d5f2f8"
    }
  ]
}

示例2

现在,查找来自"Dev Ops"和"Hardware"组织的所有员工。

这是查询

{
    "filter": {
        "IN": { "person.org": [ "Dev Ops", "Hardware" ] }
    }
}

此查询在SQL中的等价形式是:

SELECT * FROM c WHERE
  person.org IN ("Dev Ops", "Hardware")

使用以下命令执行查询:

curl -s -X POST -H "Content-Type: application/json" -d @query-api-examples/query2.json http://localhost:3500/v1.0-alpha1/state/statestore/query | jq .
Invoke-RestMethod -Method Post -ContentType 'application/json' -InFile query-api-examples/query2.json -Uri 'http://localhost:3500/v1.0-alpha1/state/statestore/query'

与前一个示例类似,结果是一个匹配键/值对的数组。

示例3

在此示例中,查找:

  • 来自"Dev Ops"部门的所有员工。
  • 来自"Finance"部门并居住在华盛顿州和加利福尼亚州的员工。

此外,首先按州按字母降序排序,然后按员工ID升序排序。让我们一次处理最多3条记录。

这是查询

{
    "filter": {
        "OR": [
            {
                "EQ": { "person.org": "Dev Ops" }
            },
            {
                "AND": [
                    {
                        "EQ": { "person.org": "Finance" }
                    },
                    {
                        "IN": { "state": [ "CA", "WA" ] }
                    }
                ]
            }
        ]
    },
    "sort": [
        {
            "key": "state",
            "order": "DESC"
        },
        {
            "key": "person.id"
        }
    ],
    "page": {
        "limit": 3
    }
}

此查询在SQL中的等价形式是:

SELECT * FROM c WHERE
  person.org = "Dev Ops" OR
  (person.org = "Finance" AND state IN ("CA", "WA"))
ORDER BY
  state DESC,
  person.id ASC
LIMIT 3

使用以下命令执行查询:

curl -s -X POST -H "Content-Type: application/json" -d @query-api-examples/query3.json http://localhost:3500/v1.0-alpha1/state/statestore/query | jq .
Invoke-RestMethod -Method Post -ContentType 'application/json' -InFile query-api-examples/query3.json -Uri 'http://localhost:3500/v1.0-alpha1/state/statestore/query'

成功执行后,状态存储返回一个包含匹配记录列表和分页令牌的JSON对象:

{
  "results": [
    {
      "key": "1",
      "data": {
        "person": {
          "org": "Dev Ops",
          "id": 1036
        },
        "city": "Seattle",
        "state": "WA"
      },
      "etag": "6f54ad94-dfb9-46f0-a371-e42d550adb7d"
    },
    {
      "key": "4",
      "data": {
        "person": {
          "org": "Dev Ops",
          "id": 1042
        },
        "city": "Spokane",
        "state": "WA"
      },
      "etag": "7415707b-82ce-44d0-bf15-6dc6305af3b1"
    },
    {
      "key": "10",
      "data": {
        "person": {
          "org": "Dev Ops",
          "id": 1054
        },
        "city": "New York",
        "state": "NY"
      },
      "etag": "26bbba88-9461-48d1-8a35-db07c374e5aa"
    }
  ],
  "token": "3"
}

分页令牌在后续查询中“按原样”使用,以获取下一批记录:

{
    "filter": {
        "OR": [
            {
                "EQ": { "person.org": "Dev Ops" }
            },
            {
                "AND": [
                    {
                        "EQ": { "person.org": "Finance" }
                    },
                    {
                        "IN": { "state": [ "CA", "WA" ] }
                    }
                ]
            }
        ]
    },
    "sort": [
        {
            "key": "state",
            "order": "DESC"
        },
        {
            "key": "person.id"
        }
    ],
    "page": {
        "limit": 3,
        "token": "3"
    }
}
curl -s -X POST -H "Content-Type: application/json" -d @query-api-examples/query3-token.json http://localhost:3500/v1.0-alpha1/state/statestore/query | jq .
Invoke-RestMethod -Method Post -ContentType 'application/json' -InFile query-api-examples/query3-token.json -Uri 'http://localhost:3500/v1.0-alpha1/state/statestore/query'

此查询的结果是:

{
  "results": [
    {
      "key": "9",
      "data": {
        "person": {
          "org": "Finance",
          "id": 1002
        },
        "city": "San Diego",
        "state": "CA"
      },
      "etag": "f5cf05cd-fb43-4154-a2ec-445c66d5f2f8"
    },
    {
      "key": "7",
      "data": {
        "city": "San Francisco",
        "state": "CA",
        "person": {
          "id": 1015,
          "org": "Dev Ops"
        }
      },
      "etag": "0e69e69f-3dbc-423a-9db8-26767fcd2220"
    },
    {
      "key": "3",
      "data": {
        "person": {
          "org": "Finance",
          "id": 1071
        },
        "city": "Sacramento",
        "state": "CA"
      },
      "etag": "44723d41-deb1-4c23-940e-3e6896c3b6f7"
    }
  ],
  "token": "6"
}

这样,您可以在查询中更新分页令牌,并迭代结果,直到不再返回记录。

限制

状态查询API有以下限制:

  • 要查询存储在状态存储中的actor状态,您需要使用特定数据库的查询API。请参阅查询actor状态
  • 该API不适用于Dapr加密状态存储功能。由于加密是由Dapr运行时完成并存储为加密数据,因此这实际上阻止了服务器端查询。

您可以在相关链接部分找到更多信息。

相关链接

4.4 - 操作指南:构建有状态服务

通过状态管理构建可扩展、可复制的服务

在本文中,您将学习如何创建一个可以水平扩展的有状态服务,选择性使用并发和一致性模型。状态管理API可以帮助开发者简化状态协调、冲突解决和故障处理的复杂性。

设置状态存储

状态存储组件是Dapr用来与数据库通信的资源。在本指南中,我们将使用默认的Redis状态存储。

使用Dapr CLI

当您在本地模式下运行dapr init时,Dapr会创建一个默认的Redis statestore.yaml并在您的本地机器上运行一个Redis状态存储,位置如下:

  • 在Windows上,位于%UserProfile%\.dapr\components\statestore.yaml
  • 在Linux/MacOS上,位于~/.dapr/components/statestore.yaml

通过statestore.yaml组件,您可以轻松替换底层组件而无需更改应用程序代码。

查看支持的状态存储列表

Kubernetes

查看如何在Kubernetes上设置不同的状态存储

强一致性和最终一致性

在强一致性模式下,Dapr确保底层状态存储:

  • 在数据写入所有副本后才返回响应。
  • 在写入或删除状态之前从法定人数接收确认。

对于读取请求,Dapr确保在副本之间一致地返回最新的数据。默认情况下是最终一致性,除非在请求状态API时另有指定。

以下示例展示了如何使用强一致性保存、获取和删除状态。示例用Python编写,但适用于任何编程语言。

保存状态

import requests
import json

store_name = "redis-store" # 在状态存储组件yaml文件中指定的状态存储名称
dapr_state_url = "http://localhost:3500/v1.0/state/{}".format(store_name)
stateReq = '[{ "key": "k1", "value": "Some Data", "options": { "consistency": "strong" }}]'
response = requests.post(dapr_state_url, json=stateReq)

获取状态

import requests
import json

store_name = "redis-store" # 在状态存储组件yaml文件中指定的状态存储名称
dapr_state_url = "http://localhost:3500/v1.0/state/{}".format(store_name)
response = requests.get(dapr_state_url + "/key1", headers={"consistency":"strong"})
print(response.headers['ETag'])

删除状态

import requests
import json

store_name = "redis-store" # 在状态存储组件yaml文件中指定的状态存储名称
dapr_state_url = "http://localhost:3500/v1.0/state/{}".format(store_name)
response = requests.delete(dapr_state_url + "/key1", headers={"consistency":"strong"})

如果没有指定concurrency选项,默认是后写胜出并发模式。

先写胜出和后写胜出

Dapr允许开发者在使用数据存储时选择两种常见的并发模式:

  • 先写胜出:在应用程序的多个实例同时写入同一个键的情况下很有用。
  • 后写胜出:Dapr的默认模式。

Dapr使用版本号来确定特定键是否已更新。您可以:

  1. 在读取键的数据时保留版本号。
  2. 在更新(如写入和删除)时使用版本号。

如果自从检索版本号以来版本信息已更改,将抛出错误,要求您执行另一次读取以获取最新的版本信息和状态。

Dapr利用ETags来确定状态的版本号。ETags从状态请求中以ETag头返回。使用ETags,您的应用程序知道自上次检查以来资源已更新,因为在ETag不匹配时会出错。

以下示例展示了如何:

  • 获取ETag。
  • 使用ETag保存状态。
  • 删除状态。

以下示例用Python编写,但适用于任何编程语言。

import requests
import json

store_name = "redis-store" # 在状态存储组件yaml文件中指定的状态存储名称
dapr_state_url = "http://localhost:3500/v1.0/state/{}".format(store_name)
response = requests.get(dapr_state_url + "/key1", headers={"concurrency":"first-write"})
etag = response.headers['ETag']
newState = '[{ "key": "k1", "value": "New Data", "etag": {}, "options": { "concurrency": "first-write" }}]'.format(etag)

requests.post(dapr_state_url, json=newState)
response = requests.delete(dapr_state_url + "/key1", headers={"If-Match": "{}".format(etag)})

处理版本不匹配失败

在以下示例中,您将看到如何在版本已更改时重试保存状态操作:

import requests
import json

# 此方法保存状态,如果保存状态失败则返回false
def save_state(data):
    try:
        store_name = "redis-store" # 在状态存储组件yaml文件中指定的状态存储名称
        dapr_state_url = "http://localhost:3500/v1.0/state/{}".format(store_name)
        response = requests.post(dapr_state_url, json=data)
        if response.status_code == 200:
            return True
    except:
        return False
    return False

# 此方法获取状态并返回响应,ETag在头中 -->
def get_state(key):
    response = requests.get("http://localhost:3500/v1.0/state/<state_store_name>/{}".format(key), headers={"concurrency":"first-write"})
    return response

# 当保存状态成功时退出。如果存在ETag不匹配,success将为False -->
success = False
while success != True:
    response = get_state("key1")
    etag = response.headers['ETag']
    newState = '[{ "key": "key1", "value": "New Data", "etag": {}, "options": { "concurrency": "first-write" }}]'.format(etag)

    success = save_state(newState)

4.5 - 操作指南:启用事务性 Outbox 模式

在状态存储和发布/订阅消息代理之间提交单个事务

事务性 Outbox 模式是一种广为人知的设计模式,用于发送应用程序状态变化的通知。它通过一个跨越数据库和消息代理的单一事务来传递通知。

开发人员在尝试自行实现此模式时会遇到许多技术难题,通常需要编写复杂且容易出错的中央协调管理器,这些管理器最多支持一种或两种数据库和消息代理的组合。

例如,您可以使用 Outbox 模式来:

  1. 向账户数据库写入新的用户记录。
  2. 发送账户成功创建的通知消息。

通过 Dapr 的 Outbox 支持,您可以在调用 Dapr 的事务 API时通知订阅者应用程序的状态何时被创建或更新。

下图概述了 Outbox 功能的工作原理:

  1. 服务 A 使用事务将状态保存/更新到状态存储。
  2. 在同一事务下将消息写入消息代理。当消息成功传递到消息代理时,事务完成,确保状态和消息一起被事务化。
  3. 消息代理将消息主题传递给任何订阅者 - 在此情况下为服务 B。
显示 Outbox 模式步骤的图示

要求

Outbox 功能可以与 Dapr 支持的任何事务性状态存储一起使用。所有发布/订阅代理都支持 Outbox 功能。

了解更多关于您可以使用的事务方法。

启用 Outbox 模式

要启用 Outbox 功能,请在状态存储组件上添加以下必需和可选字段:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: mysql-outbox
spec:
  type: state.mysql
  version: v1
  metadata:
  - name: connectionString
    value: "<CONNECTION STRING>"
  - name: outboxPublishPubsub # 必需
    value: "mypubsub"
  - name: outboxPublishTopic # 必需
    value: "newOrder"
  - name: outboxPubsub # 可选
    value: "myOutboxPubsub"
  - name: outboxDiscardWhenMissingState # 可选,默认为 false
    value: false

元数据字段

名称必需默认值描述
outboxPublishPubsubN/A设置发布状态更改时传递通知的发布/订阅组件的名称
outboxPublishTopicN/A设置接收在配置了 outboxPublishPubsub 的发布/订阅上的状态更改的主题。消息体将是 insertupdate 操作的状态事务项
outboxPubsuboutboxPublishPubsub设置 Dapr 用于协调状态和发布/订阅事务的发布/订阅组件。如果未设置,则使用配置了 outboxPublishPubsub 的发布/订阅组件。如果您希望将用于发送通知状态更改的发布/订阅组件与用于协调事务的组件分开,这将很有用
outboxDiscardWhenMissingStatefalse通过将 outboxDiscardWhenMissingState 设置为 true,如果 Dapr 无法在数据库中找到状态且不重试,则 Dapr 将丢弃事务。如果在 Dapr 能够传递消息之前,状态存储数据因任何原因被删除,并且您希望 Dapr 从发布/订阅中删除项目并停止重试获取状态,此设置可能会很有用

其他配置

在同一状态存储上组合 Outbox 和非 Outbox 消息

如果您希望使用相同的状态存储来发送 Outbox 和非 Outbox 消息,只需定义两个连接到相同状态存储的状态存储组件,其中一个具有 Outbox 功能,另一个没有。

没有 Outbox 的 MySQL 状态存储

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: mysql
spec:
  type: state.mysql
  version: v1
  metadata:
  - name: connectionString
    value: "<CONNECTION STRING>"

具有 Outbox 的 MySQL 状态存储

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: mysql-outbox
spec:
  type: state.mysql
  version: v1
  metadata:
  - name: connectionString
    value: "<CONNECTION STRING>"
  - name: outboxPublishPubsub # 必需
    value: "mypubsub"
  - name: outboxPublishTopic # 必需
    value: "newOrder"

形状 Outbox 模式消息

您可以通过设置另一个不保存到数据库并明确提及为投影的事务来覆盖发布到发布/订阅代理的 Outbox 模式消息。此事务添加了一个名为 outbox.projection 的元数据键,值设置为 true。当添加到事务中保存的状态数组时,此负载在写入状态时被忽略,数据用作发送到上游订阅者的负载。

要正确使用,key 值必须在状态存储上的操作和消息投影之间匹配。如果键不匹配,则整个事务失败。

如果您为同一键启用了两个或多个 outbox.projection 状态项,则使用第一个定义的项,其他项将被忽略。

了解更多关于默认和自定义 CloudEvent 消息。

在以下 Python SDK 的状态事务示例中,值 "2" 被保存到数据库,但值 "3" 被发布到最终用户主题。

DAPR_STORE_NAME = "statestore"

async def main():
    client = DaprClient()

    # 定义第一个状态操作以保存值 "2"
    op1 = StateItem(
        key="key1",
        value=b"2"
    )

    # 定义第二个状态操作以带有元数据发布值 "3"
    op2 = StateItem(
        key="key1",
        value=b"3",
        options=StateOptions(
            metadata={
                "outbox.projection": "true"
            }
        )
    )

    # 创建状态操作列表
    ops = [op1, op2]

    # 执行状态事务
    await client.state.transaction(DAPR_STORE_NAME, operations=ops)
    print("状态事务已执行。")

通过将元数据项 "outbox.projection" 设置为 "true" 并确保 key 值匹配(key1):

  • 第一个操作被写入状态存储,消息未写入消息代理。
  • 第二个操作值被发布到配置的发布/订阅主题。

在以下 JavaScript SDK 的状态事务示例中,值 "2" 被保存到数据库,但值 "3" 被发布到最终用户主题。

const { DaprClient, StateOperationType } = require('@dapr/dapr');

const DAPR_STORE_NAME = "statestore";

async function main() {
  const client = new DaprClient();

  // 定义第一个状态操作以保存值 "2"
  const op1 = {
    operation: StateOperationType.UPSERT,
    request: {
      key: "key1",
      value: "2"
    }
  };

  // 定义第二个状态操作以带有元数据发布值 "3"
  const op2 = {
    operation: StateOperationType.UPSERT,
    request: {
      key: "key1",
      value: "3",
      metadata: {
        "outbox.projection": "true"
      }
    }
  };

  // 创建状态操作列表
  const ops = [op1, op2];

  // 执行状态事务
  await client.state.transaction(DAPR_STORE_NAME, ops);
  console.log("状态事务已执行。");
}

main().catch(err => {
  console.error(err);
});

通过将元数据项 "outbox.projection" 设置为 "true" 并确保 key 值匹配(key1):

  • 第一个操作被写入状态存储,消息未写入消息代理。
  • 第二个操作值被发布到配置的发布/订阅主题。

在以下 .NET SDK 的状态事务示例中,值 "2" 被保存到数据库,但值 "3" 被发布到最终用户主题。

public class Program
{
    private const string DAPR_STORE_NAME = "statestore";

    public static async Task Main(string[] args)
    {
        var client = new DaprClientBuilder().Build();

        // 定义第一个状态操作以保存值 "2"
        var op1 = new StateTransactionRequest(
            key: "key1",
            value: Encoding.UTF8.GetBytes("2"),
            operationType: StateOperationType.Upsert
        );

        // 定义第二个状态操作以带有元数据发布值 "3"
        var metadata = new Dictionary<string, string>
        {
            { "outbox.projection", "true" }
        };
        var op2 = new StateTransactionRequest(
            key: "key1",
            value: Encoding.UTF8.GetBytes("3"),
            operationType: StateOperationType.Upsert,
            metadata: metadata
        );

        // 创建状态操作列表
        var ops = new List<StateTransactionRequest> { op1, op2 };

        // 执行状态事务
        await client.ExecuteStateTransactionAsync(DAPR_STORE_NAME, ops);
        Console.WriteLine("状态事务已执行。");
    }
}

通过将元数据项 "outbox.projection" 设置为 "true" 并确保 key 值匹配(key1):

  • 第一个操作被写入状态存储,消息未写入消息代理。
  • 第二个操作值被发布到配置的发布/订阅主题。

在以下 Java SDK 的状态事务示例中,值 "2" 被保存到数据库,但值 "3" 被发布到最终用户主题。

public class Main {
    private static final String DAPR_STORE_NAME = "statestore";

    public static void main(String[] args) {
        try (DaprClient client = new DaprClientBuilder().build()) {
            // 定义第一个状态操作以保存值 "2"
            StateOperation<String> op1 = new StateOperation<>(
                    StateOperationType.UPSERT,
                    "key1",
                    "2"
            );

            // 定义第二个状态操作以带有元数据发布值 "3"
            Map<String, String> metadata = new HashMap<>();
            metadata.put("outbox.projection", "true");

            StateOperation<String> op2 = new StateOperation<>(
                    StateOperationType.UPSERT,
                    "key1",
                    "3",
                    metadata
            );

            // 创建状态操作列表
            List<StateOperation<?>> ops = new ArrayList<>();
            ops.add(op1);
            ops.add(op2);

            // 执行状态事务
            client.executeStateTransaction(DAPR_STORE_NAME, ops).block();
            System.out.println("状态事务已执行。");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

通过将元数据项 "outbox.projection" 设置为 "true" 并确保 key 值匹配(key1):

  • 第一个操作被写入状态存储,消息未写入消息代理。
  • 第二个操作值被发布到配置的发布/订阅主题。

在以下 Go SDK 的状态事务示例中,值 "2" 被保存到数据库,但值 "3" 被发布到最终用户主题。

ops := make([]*dapr.StateOperation, 0)

op1 := &dapr.StateOperation{
    Type: dapr.StateOperationTypeUpsert,
    Item: &dapr.SetStateItem{
        Key:   "key1",
        Value: []byte("2"),
    },
}
op2 := &dapr.StateOperation{
    Type: dapr.StateOperationTypeUpsert,
    Item: &dapr.SetStateItem{
        Key:   "key1",
				Value: []byte("3"),
         // 覆盖保存到数据库的数据负载 
				Metadata: map[string]string{
					"outbox.projection": "true",
        },
    },
}
ops = append(ops, op1, op2)
meta := map[string]string{}
err := testClient.ExecuteStateTransaction(ctx, store, meta, ops)

通过将元数据项 "outbox.projection" 设置为 "true" 并确保 key 值匹配(key1):

  • 第一个操作被写入状态存储,消息未写入消息代理。
  • 第二个操作值被发布到配置的发布/订阅主题。

您可以使用以下 HTTP 请求传递消息覆盖:

curl -X POST http://localhost:3500/v1.0/state/starwars/transaction \
  -H "Content-Type: application/json" \
  -d '{
  "operations": [
    {
      "operation": "upsert",
      "request": {
        "key": "order1",
        "value": {
            "orderId": "7hf8374s",
            "type": "book",
            "name": "The name of the wind"
        }
      }
    },
    {
      "operation": "upsert",
      "request": {
        "key": "order1",
        "value": {
            "orderId": "7hf8374s"
        },
        "metadata": {
           "outbox.projection": "true"
        },
        "contentType": "application/json"
      }
    }
  ]
}'

通过将元数据项 "outbox.projection" 设置为 "true" 并确保 key 值匹配(key1):

  • 第一个操作被写入状态存储,消息未写入消息代理。
  • 第二个操作值被发布到配置的发布/订阅主题。

覆盖 Dapr 生成的 CloudEvent 字段

您可以使用自定义 CloudEvent 元数据覆盖发布的 Outbox 事件上的Dapr 生成的 CloudEvent 字段

async def execute_state_transaction():
    async with DaprClient() as client:
        # 定义状态操作
        ops = []

        op1 = {
            'operation': 'upsert',
            'request': {
                'key': 'key1',
                'value': b'2',  # 将字符串转换为字节数组
                'metadata': {
                    'cloudevent.id': 'unique-business-process-id',
                    'cloudevent.source': 'CustomersApp',
                    'cloudevent.type': 'CustomerCreated',
                    'cloudevent.subject': '123',
                    'my-custom-ce-field': 'abc'
                }
            }
        }

        ops.append(op1)

        # 执行状态事务
        store_name = 'your-state-store-name'
        try:
            await client.execute_state_transaction(store_name, ops)
            print('状态事务已执行。')
        except Exception as e:
            print('执行状态事务时出错:', e)

# 运行异步函数
if __name__ == "__main__":
    asyncio.run(execute_state_transaction())
const { DaprClient } = require('dapr-client');

async function executeStateTransaction() {
    // 初始化 Dapr 客户端
    const daprClient = new DaprClient();

    // 定义状态操作
    const ops = [];

    const op1 = {
        operationType: 'upsert',
        request: {
            key: 'key1',
            value: Buffer.from('2'),
            metadata: {
                'id': 'unique-business-process-id',
                'source': 'CustomersApp',
                'type': 'CustomerCreated',
                'subject': '123',
                'my-custom-ce-field': 'abc'
            }
        }
    };

    ops.push(op1);

    // 执行状态事务
    const storeName = 'your-state-store-name';
    const metadata = {};
}

executeStateTransaction();
public class StateOperationExample
{
    public async Task ExecuteStateTransactionAsync()
    {
        var daprClient = new DaprClientBuilder().Build();

        // 将值 "2" 定义为字符串并序列化为字节数组
        var value = "2";
        var valueBytes = JsonSerializer.SerializeToUtf8Bytes(value);

        // 定义第一个状态操作以保存值 "2" 并带有元数据
       // 覆盖 Cloudevent 元数据
        var metadata = new Dictionary<string, string>
        {
            { "cloudevent.id", "unique-business-process-id" },
            { "cloudevent.source", "CustomersApp" },
            { "cloudevent.type", "CustomerCreated" },
            { "cloudevent.subject", "123" },
            { "my-custom-ce-field", "abc" }
        };

        var op1 = new StateTransactionRequest(
            key: "key1",
            value: valueBytes,
            operationType: StateOperationType.Upsert,
            metadata: metadata
        );

        // 创建状态操作列表
        var ops = new List<StateTransactionRequest> { op1 };

        // 执行状态事务
        var storeName = "your-state-store-name";
        await daprClient.ExecuteStateTransactionAsync(storeName, ops);
        Console.WriteLine("状态事务已执行。");
    }

    public static async Task Main(string[] args)
    {
        var example = new StateOperationExample();
        await example.ExecuteStateTransactionAsync();
    }
}
public class StateOperationExample {

    public static void main(String[] args) {
        executeStateTransaction();
    }

    public static void executeStateTransaction() {
        // 构建 Dapr 客户端
        try (DaprClient daprClient = new DaprClientBuilder().build()) {

            // 定义值 "2"
            String value = "2";

            // 覆盖 CloudEvent 元数据
            Map<String, String> metadata = new HashMap<>();
            metadata.put("cloudevent.id", "unique-business-process-id");
            metadata.put("cloudevent.source", "CustomersApp");
            metadata.put("cloudevent.type", "CustomerCreated");
            metadata.put("cloudevent.subject", "123");
            metadata.put("my-custom-ce-field", "abc");

            // 定义状态操作
            List<StateOperation<?>> ops = new ArrayList<>();
            StateOperation<String> op1 = new StateOperation<>(
                    StateOperationType.UPSERT,
                    "key1",
                    value,
                    metadata
            );
            ops.add(op1);

            // 执行状态事务
            String storeName = "your-state-store-name";
            daprClient.executeStateTransaction(storeName, ops).block();
            System.out.println("状态事务已执行。");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
func main() {
	// 创建 Dapr 客户端
	client, err := dapr.NewClient()
	if err != nil {
		log.Fatalf("创建 Dapr 客户端失败: %v", err)
	}
	defer client.Close()

	ctx := context.Background()
	store := "your-state-store-name"

	// 定义状态操作
	ops := make([]*dapr.StateOperation, 0)
	op1 := &dapr.StateOperation{
		Type: dapr.StateOperationTypeUpsert,
		Item: &dapr.SetStateItem{
			Key:   "key1",
			Value: []byte("2"),
			// 覆盖 Cloudevent 元数据
			Metadata: map[string]string{
				"cloudevent.id":                "unique-business-process-id",
				"cloudevent.source":            "CustomersApp",
				"cloudevent.type":              "CustomerCreated",
				"cloudevent.subject":           "123",
				"my-custom-ce-field":           "abc",
			},
		},
	}
	ops = append(ops, op1)

	// 事务的元数据(如果有)
	meta := map[string]string{}

	// 执行状态事务
	err = client.ExecuteStateTransaction(ctx, store, meta, ops)
	if err != nil {
		log.Fatalf("执行状态事务失败: %v", err)
	}

	log.Println("状态事务已执行。")
}
curl -X POST http://localhost:3500/v1.0/state/starwars/transaction \
  -H "Content-Type: application/json" \
  -d '{
        "operations": [
          {
            "operation": "upsert",
            "request": {
              "key": "key1",
              "value": "2"
            }
          },
        ],
        "metadata": {
          "id": "unique-business-process-id",
          "source": "CustomersApp",
          "type": "CustomerCreated",
          "subject": "123",
          "my-custom-ce-field": "abc",
        }
      }'

演示

观看此视频以了解 Outbox 模式的概述

4.6 - 操作指南:在应用程序之间共享状态

了解在不同应用程序之间共享状态的策略

Dapr 提供了多种在应用程序之间共享状态的方法。

不同的架构在共享状态时可能有不同的需求。在某些情况下,您可能会希望:

  • 在特定应用程序中封装所有状态
  • 让 Dapr 为您管理状态访问

在其他情况下,您可能需要两个应用程序在同一状态上进行操作,以便获取和保存相同的键。

为了实现状态共享,Dapr 支持以下键前缀策略:

键前缀描述
appid默认策略,允许您仅通过指定 appid 的应用程序管理状态。所有状态键将以 appid 为前缀,并限定于该应用程序。
name使用状态存储组件的名称作为前缀。多个应用程序可以共享同一状态存储中的相同状态。
namespace如果设置了命名空间,此策略会将 appid 键前缀替换为配置的命名空间,生成一个限定于该命名空间的键。这允许在不同命名空间中具有相同 appid 的应用程序重用相同的状态存储。如果未配置命名空间,则会回退到 appid 策略。有关 Dapr 中命名空间的更多信息,请参见 操作指南:将组件限定到一个或多个应用程序
none不使用任何前缀。多个应用程序可以在不同的状态存储中共享状态,而不受特定前缀的限制。

指定状态前缀策略

要指定前缀策略,请在状态组件上添加名为 keyPrefix 的元数据键:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: statestore
  namespace: production
spec:
  type: state.redis
  version: v1
  metadata:
  - name: keyPrefix
    value: <key-prefix-strategy>

示例

以下示例演示了使用每种支持的前缀策略进行状态检索的情况。

appid(默认)

在下面的示例中,具有应用程序 ID myApp 的 Dapr 应用程序正在将状态保存到名为 redis 的状态存储中:

curl -X POST http://localhost:3500/v1.0/state/redis \
  -H "Content-Type: application/json"
  -d '[
        {
          "key": "darth",
          "value": "nihilus"
        }
      ]'

键将被保存为 myApp||darth

namespace

在命名空间 production 中运行的具有应用程序 ID myApp 的 Dapr 应用程序正在将状态保存到名为 redis 的状态存储中:

curl -X POST http://localhost:3500/v1.0/state/redis \
  -H "Content-Type: application/json"
  -d '[
        {
          "key": "darth",
          "value": "nihilus"
        }
      ]'

键将被保存为 production.myApp||darth

name

在下面的示例中,具有应用程序 ID myApp 的 Dapr 应用程序正在将状态保存到名为 redis 的状态存储中:

curl -X POST http://localhost:3500/v1.0/state/redis \
  -H "Content-Type: application/json"
  -d '[
        {
          "key": "darth",
          "value": "nihilus"
        }
      ]'

键将被保存为 redis||darth

none

在下面的示例中,具有应用程序 ID myApp 的 Dapr 应用程序正在将状态保存到名为 redis 的状态存储中:

curl -X POST http://localhost:3500/v1.0/state/redis \
  -H "Content-Type: application/json"
  -d '[
        {
          "key": "darth",
          "value": "nihilus"
        }
      ]'

键将被保存为 darth

4.7 - 操作指南:加密应用程序状态

自动加密应用程序状态并管理密钥轮换

对静态应用程序状态进行加密,以在企业工作负载或受监管环境中提供更强的安全性。Dapr 提供基于 AES 的自动客户端加密,采用 Galois/Counter Mode (GCM),支持 128、192 和 256 位的密钥。

除了自动加密,Dapr 还支持主加密密钥和次加密密钥,使开发人员和运维团队更容易启用密钥轮换策略。此功能由所有 Dapr 状态存储支持。

加密密钥始终从 secret 中获取,不能在 metadata 部分中以明文形式提供。

启用自动加密

将以下 metadata 部分添加到任何 Dapr 支持的状态存储中:

metadata:
- name: primaryEncryptionKey
  secretKeyRef:
    name: mysecret
    key: mykey # key 是可选的。

例如,这是一个 Redis 加密状态存储的完整 YAML:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: statestore
spec:
  type: state.redis
  version: v1
  metadata:
  - name: redisHost
    value: localhost:6379
  - name: redisPassword
    value: ""
  - name: primaryEncryptionKey
    secretKeyRef:
      name: mysecret
      key: mykey

现在,您已配置了一个 Dapr 状态存储,以从名为 mysecret 的 secret 中获取加密密钥,其中包含名为 mykey 的实际加密密钥。

实际的加密密钥必须是有效的、十六进制编码的加密密钥。虽然支持 192 位和 256 位密钥,但建议使用 128 位加密密钥。如果加密密钥无效,Dapr 会报错并退出。

例如,您可以使用以下命令生成一个随机的、十六进制编码的 128 位(16 字节)密钥:

openssl rand 16 | hexdump -v -e '/1 "%02x"'
# 结果将类似于 "cb321007ad11a9d23f963bff600d58e0"

注意,secret 存储不必支持密钥。

密钥轮换

为了支持密钥轮换,Dapr 提供了一种指定次加密密钥的方法:

metadata:
- name: primaryEncryptionKey
    secretKeyRef:
      name: mysecret
      key: mykey
- name: secondaryEncryptionKey
    secretKeyRef:
      name: mysecret2
      key: mykey2

当 Dapr 启动时,它会获取 metadata 部分中列出的包含加密密钥的 secrets。Dapr 自动识别哪个状态项是用哪个密钥加密的,因为它会将 secretKeyRef.name 字段附加到实际状态密钥的末尾。

要轮换密钥,

  1. 更改 primaryEncryptionKey 以指向包含新密钥的 secret。
  2. 将旧的主加密密钥移至 secondaryEncryptionKey

新数据将使用新密钥加密,任何检索到的旧数据将使用次密钥解密。

使用旧密钥加密的数据项的任何更新都将使用新密钥重新加密。

相关链接

4.8 - 与后端状态存储交互

指导如何与特定的后端状态存储进行交互

请查看操作部分,了解支持的状态存储列表,并学习如何配置状态存储组件

4.8.1 - Azure Cosmos DB

使用 Azure Cosmos DB 作为状态存储

Dapr 在保存和检索状态时不对状态值进行转换。Dapr 要求所有状态存储实现遵循特定的键格式规范(参见状态管理规范)。您可以直接与底层存储交互以操作状态数据,例如:

  • 查询状态。
  • 创建聚合视图。
  • 进行备份。

连接到 Azure Cosmos DB

要连接到您的 Cosmos DB 实例,您可以:

按应用程序 ID 列出键

要获取与应用程序 “myapp” 关联的所有状态键,请使用查询:

SELECT * FROM states WHERE CONTAINS(states.id, 'myapp||')

上述查询返回所有 id 包含 “myapp||” 的文档,这是状态键的前缀。

获取特定状态数据

要通过键 “balance” 获取应用程序 “myapp” 的状态数据,请使用查询:

SELECT * FROM states WHERE states.id = 'myapp||balance'

读取返回文档的 value 字段。要获取状态版本/ETag,请使用命令:

SELECT states._etag FROM states WHERE states.id = 'myapp||balance'

读取 actor 状态

要获取与实例 ID 为 “leroy” 的 actor 类型 “cat” 关联的所有状态键,该 actor 属于 ID 为 “mypets” 的应用程序,请使用命令:

SELECT * FROM states WHERE CONTAINS(states.id, 'mypets||cat||leroy||')

要获取特定的 actor 状态,例如 “food”,请使用命令:

SELECT * FROM states WHERE states.id = 'mypets||cat||leroy||food'

4.8.2 - Redis

使用 Redis 作为状态存储

Dapr 在保存和检索状态时不对状态值进行转换。Dapr 要求所有状态存储实现遵循特定的键格式规范(参见状态管理规范)。您可以直接与底层存储交互以操作状态数据,例如:

  • 查询状态。
  • 创建聚合视图。
  • 进行备份。

连接到 Redis

您可以使用官方的 redis-cli 或任何其他兼容 Redis 的工具连接到 Redis 状态存储以直接查询 Dapr 状态。如果您在容器中运行 Redis,最简单的使用 redis-cli 的方法是通过容器:

docker run --rm -it --link <Redis 容器的名称> redis redis-cli -h <Redis 容器的名称>

按应用 ID 列出键

要获取与应用程序 “myapp” 关联的所有状态键,请使用命令:

KEYS myapp*

上述命令返回现有键的列表,例如:

1) "myapp||balance"
2) "myapp||amount"

获取特定状态数据

Dapr 将状态值保存为哈希值。每个哈希值包含一个 “data” 字段,其中存储状态数据,以及一个 “version” 字段,作为 ETag,表示不断递增的版本。

例如,要通过键 “balance” 获取应用程序 “myapp” 的状态数据,请使用命令:

HGET myapp||balance data

要获取状态版本/ETag,请使用命令:

HGET myapp||balance version

读取 actor 状态

要获取与应用程序 ID 为 “mypets” 的 actor 类型 “cat” 的实例 ID 为 “leroy” 关联的所有状态键,请使用命令:

KEYS mypets||cat||leroy*

要获取特定的 actor 状态,例如 “food”,请使用命令:

HGET mypets||cat||leroy||food value

4.8.3 - SQL server

使用 SQL server 作为后端状态存储

Dapr 在保存和检索状态时不对状态值进行转换。Dapr 要求所有状态存储实现遵循特定的键格式(参见状态管理规范)。您可以直接与底层存储交互来操作状态数据,例如:

  • 查询状态。
  • 创建聚合视图。
  • 进行备份。

连接到 SQL Server

连接到 SQL Server 实例的最简单方法是使用:

按应用程序 ID 列出键

要获取与应用程序 “myapp” 关联的所有状态键,请使用以下查询:

SELECT * FROM states WHERE [Key] LIKE 'myapp||%'

上述查询返回所有 ID 包含 “myapp||” 的行,这是状态键的前缀。

获取特定状态数据

要通过键 “balance” 获取应用程序 “myapp” 的状态数据,请使用以下查询:

SELECT * FROM states WHERE [Key] = 'myapp||balance'

读取返回行的 Data 字段。要获取状态版本/ETag,请使用以下命令:

SELECT [RowVersion] FROM states WHERE [Key] = 'myapp||balance'

获取过滤的状态数据

要获取 JSON 数据中值 “color” 等于 “blue” 的所有状态数据,请使用以下查询:

SELECT * FROM states WHERE JSON_VALUE([Data], '$.color') = 'blue'

读取 actor 状态

要获取与 actor 类型 “cat” 的实例 ID “leroy” 关联的所有状态键,该 actor 属于 ID 为 “mypets” 的应用程序,请使用以下命令:

SELECT * FROM states WHERE [Key] LIKE 'mypets||cat||leroy||%'

要获取特定的 actor 状态,例如 “food”,请使用以下命令:

SELECT * FROM states WHERE [Key] = 'mypets||cat||leroy||food'

4.9 - 状态生存时间 (TTL)

管理具有 TTL 的状态。

Dapr 允许为每个状态设置生存时间 (TTL)。这意味着应用程序可以为存储的每个状态指定一个生存时间,过期后将无法检索这些状态。

对于支持的状态存储,只需在发布消息时设置 ttlInSeconds 元数据。其他状态存储将忽略此值。对于某些状态存储,您可以为每个表或容器指定默认的过期时间。

原生状态 TTL 支持

当状态存储组件原生支持状态 TTL 时,Dapr 会直接传递 TTL 配置,而不添加额外的逻辑,从而保持行为的一致性。这在组件以不同方式处理过期状态时尤为有用。

如果未指定 TTL,将保留状态存储的默认行为。

显式持久化绕过全局定义的 TTL

对于允许为所有数据指定默认 TTL 的状态存储,持久化状态的方式包括:

  • 通过 Dapr 组件设置全局 TTL 值,或
  • 在 Dapr 之外创建状态存储并设置全局 TTL 值。

如果未指定特定的 TTL,数据将在全局 TTL 时间段后过期,这不是由 Dapr 直接控制的。

此外,所有状态存储还支持显式持久化数据的选项。这意味着您可以忽略默认的数据库策略(可能是在 Dapr 之外或通过 Dapr 组件设置的),以无限期保留特定的数据库记录。您可以通过将 ttlInSeconds 设置为 -1 来实现,这表示忽略任何设置的 TTL 值。

支持的组件

请参阅状态存储组件指南中的 TTL 列。

示例

您可以在状态存储请求的元数据中设置状态 TTL:

# 依赖

from dapr.clients import DaprClient

# 代码

DAPR_STORE_NAME = "statestore"

with DaprClient() as client:
        client.save_state(DAPR_STORE_NAME, "order_1", str(orderId), state_metadata={
            'ttlInSeconds': '120'
        }) 

要启动 Dapr sidecar 并运行上述示例应用程序,您可以运行类似以下的命令:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 -- python3 OrderProcessingService.py
// 依赖

using Dapr.Client;

// 代码

await client.SaveStateAsync(storeName, stateKeyName, state, metadata: new Dictionary<string, string>() { 
    { 
        "ttlInSeconds", "120" 
    } 
});

要启动 Dapr sidecar 并运行上述示例应用程序,您可以运行类似以下的命令:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 dotnet run
// 依赖

import (
	dapr "github.com/dapr/go-sdk/client"
)

// 代码

md := map[string]string{"ttlInSeconds": "120"}
if err := client.SaveState(ctx, store, "key1", []byte("hello world"), md); err != nil {
   panic(err)
}

要启动 Dapr sidecar 并运行上述示例应用程序,您可以运行类似以下的命令:

dapr run --app-id orderprocessing --app-port 6001 --dapr-http-port 3601 --dapr-grpc-port 60001 go run .
curl -X POST -H "Content-Type: application/json" -d '[{ "key": "order_1", "value": "250", "metadata": { "ttlInSeconds": "120" } }]' http://localhost:3601/v1.0/state/statestore
Invoke-RestMethod -Method Post -ContentType 'application/json' -Body '[{"key": "order_1", "value": "250", "metadata": {"ttlInSeconds": "120"}}]' -Uri 'http://localhost:3601/v1.0/state/statestore'

相关链接

5 - Bindings

与外部系统交互或被外部系统触发

5.1 - bindings 概述

bindings API 模块的概述

通过 Dapr 的 bindings API,您可以利用外部系统的事件来触发应用程序,并与外部系统交互。使用 bindings API,您可以:

  • 避免连接到消息系统并进行轮询的复杂性(如队列和消息总线)。
  • 专注于业务逻辑,而不是系统交互的实现细节。
  • 使您的代码不依赖于特定的 SDK 或库。
  • 处理重试和故障恢复。
  • 在运行时可以切换不同的 bindings。
  • 构建具有特定环境 bindings 设置的可移植应用程序,而无需更改代码。

例如,通过 bindings,您的应用程序可以响应传入的 Twilio/SMS 消息,而无需:

  • 添加或配置第三方 Twilio SDK
  • 担心从 Twilio 轮询(或使用 WebSockets 等)
显示 bindings 的图示

在上图中:

  • 输入 binding 触发您应用程序上的一个方法。
  • 在组件上执行输出 binding 操作,例如 "create"

bindings 的开发独立于 Dapr 运行时。您可以查看并贡献 bindings

输入 bindings

通过输入 bindings,您可以在外部资源发生事件时触发您的应用程序。请求中可以发送可选的负载和元数据。

以下概述视频和演示展示了 Dapr 输入 binding 的工作原理。

要接收来自输入 binding 的事件:

  1. 定义描述 binding 类型及其元数据(如连接信息)的组件 YAML。
  2. 使用以下方式监听传入事件:
    • HTTP 端点
    • gRPC proto 库获取传入事件。

阅读使用输入 bindings 创建事件驱动应用程序指南以开始使用输入 bindings。

输出 bindings

通过输出 bindings,您可以调用外部资源。调用请求中可以发送可选的负载和元数据。

以下概述视频和演示展示了 Dapr 输出 binding 的工作原理。

要调用输出 binding:

  1. 定义描述 binding 类型及其元数据(如连接信息)的组件 YAML。
  2. 使用 HTTP 端点或 gRPC 方法调用 binding,并附带可选负载。
  3. 指定输出操作。输出操作取决于您使用的 binding 组件,可以包括:
    • "create"
    • "update"
    • "delete"
    • "exec"

阅读使用输出 bindings 与外部资源交互指南以开始使用输出 bindings。

binding 方向(可选)

您可以提供 direction 元数据字段以指示 binding 组件支持的方向。这可以使 Dapr sidecar 避免“等待应用程序准备就绪”状态,减少 Dapr sidecar 与应用程序之间的生命周期依赖:

  • "input"
  • "output"
  • "input, output"

查看 bindings direction 元数据的完整示例。

试用 bindings

快速入门和教程

想要测试 Dapr bindings API?通过以下快速入门和教程来查看 bindings 的实际应用:

快速入门/教程描述
bindings 快速入门使用输入 bindings 处理外部系统的事件,并使用输出 bindings 调用操作。
bindings 教程演示如何使用 Dapr 创建到其他组件的输入和输出 bindings。使用 bindings 连接到 Kafka。

直接在您的应用程序中开始使用 bindings

想要跳过快速入门?没问题。您可以直接在应用程序中试用 bindings 模块,以调用输出 bindings 和触发输入 bindings。在Dapr 安装完成后,您可以从输入 bindings 如何指南开始使用 bindings API。

下一步

5.2 - 操作指南:使用输入绑定触发应用程序

使用Dapr输入绑定触发事件驱动的应用程序

当外部资源发生事件时,您可以通过输入绑定来触发您的应用程序。外部资源可以是队列、消息管道、云服务、文件系统等。请求中可以发送可选的负载和元数据。

输入绑定非常适合用于事件驱动的处理、数据管道或一般的事件响应和后续处理。Dapr输入绑定允许您:

  • 在不需要特定SDK或库的情况下接收事件
  • 在不更改代码的情况下替换绑定
  • 专注于业务逻辑而不是事件资源的实现
示例服务的绑定图示

本指南使用Kafka绑定作为示例。您可以从绑定组件列表中找到您偏好的绑定规范。在本指南中:

  1. 示例调用/binding端点,使用checkout作为要调用的绑定名称。
  2. 负载需要放在data字段中,可以是任何可序列化为JSON的值。
  3. operation字段指定绑定需要执行的操作。例如,Kafka绑定支持create操作

创建绑定

创建一个binding.yaml文件,并保存到应用程序目录中的components子文件夹中。

创建一个名为checkout的新绑定组件。在metadata部分中,配置以下与Kafka相关的属性:

  • 您将发布消息的主题
  • 代理

在创建绑定组件时,指定绑定的支持direction

使用dapr run命令的--resources-path标志指向您的自定义资源目录。

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: checkout
spec:
  type: bindings.kafka
  version: v1
  metadata:
  # Kafka代理连接设置
  - name: brokers
    value: localhost:9092
  # 消费者配置:主题和消费者组
  - name: topics
    value: sample
  - name: consumerGroup
    value: group1
  # 发布者配置:主题
  - name: publishTopic
    value: sample
  - name: authRequired
    value: false
  - name: direction
    value: input

要部署到Kubernetes集群中,运行kubectl apply -f binding.yaml

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: checkout
spec:
  type: bindings.kafka
  version: v1
  metadata:
  # Kafka代理连接设置
  - name: brokers
    value: localhost:9092
  # 消费者配置:主题和消费者组
  - name: topics
    value: sample
  - name: consumerGroup
    value: group1
  # 发布者配置:主题
  - name: publishTopic
    value: sample
  - name: authRequired
    value: false
  - name: direction
    value: input

监听传入事件(输入绑定)

配置您的应用程序以接收传入事件。如果您使用HTTP,您需要:

  • 监听一个POST端点,其名称与binding.yaml文件中的metadata.name指定的绑定名称相同。
  • 确保您的应用程序允许Dapr对该端点进行OPTIONS请求。

以下是利用Dapr SDK展示输入绑定的代码示例。

//依赖项
using System.Collections.Generic;
using System.Threading.Tasks;
using System;
using Microsoft.AspNetCore.Mvc;

//代码
namespace CheckoutService.controller
{
    [ApiController]
    public class CheckoutServiceController : Controller
    {
        [HttpPost("/checkout")]
        public ActionResult<string> getCheckout([FromBody] int orderId)
        {
            Console.WriteLine("Received Message: " + orderId);
            return "CID" + orderId;
        }
    }
}
//依赖项
import org.springframework.web.bind.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Mono;

//代码
@RestController
@RequestMapping("/")
public class CheckoutServiceController {
    private static final Logger log = LoggerFactory.getLogger(CheckoutServiceController.class);
        @PostMapping(path = "/checkout")
        public Mono<String> getCheckout(@RequestBody(required = false) byte[] body) {
            return Mono.fromRunnable(() ->
                    log.info("Received Message: " + new String(body)));
        }
}
#依赖项
import logging
from dapr.ext.grpc import App, BindingRequest

#代码
app = App()

@app.binding('checkout')
def getCheckout(request: BindingRequest):
    logging.basicConfig(level = logging.INFO)
    logging.info('Received Message : ' + request.text())

app.run(6002)
//依赖项
import (
	"encoding/json"
	"log"
	"net/http"
	"github.com/gorilla/mux"
)

//代码
func getCheckout(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	var orderId int
	err := json.NewDecoder(r.Body).Decode(&orderId)
	log.Println("Received Message: ", orderId)
	if err != nil {
		log.Printf("error parsing checkout input binding payload: %s", err)
		w.WriteHeader(http.StatusOK)
		return
	}
}

func main() {
	r := mux.NewRouter()
	r.HandleFunc("/checkout", getCheckout).Methods("POST", "OPTIONS")
	http.ListenAndServe(":6002", r)
}
//依赖项 
import { DaprServer, CommunicationProtocolEnum } from '@dapr/dapr'; 

//代码
const daprHost = "127.0.0.1"; 
const serverHost = "127.0.0.1";
const serverPort = "6002"; 
const daprPort = "3602"; 

start().catch((e) => {
    console.error(e);
    process.exit(1);
});

async function start() {
    const server = new DaprServer({
        serverHost,
        serverPort,
        communicationProtocol: CommunicationProtocolEnum.HTTP,
        clientOptions: {
            daprHost,
            daprPort, 
        }
    });
    await server.binding.receive('checkout', async (orderId) => console.log(`Received Message: ${JSON.stringify(orderId)}`));
    await server.start();
}

确认事件

通过从HTTP处理程序返回200 OK响应,告知Dapr您已成功处理应用程序中的事件。

拒绝事件

通过返回200 OK以外的任何响应,告知Dapr事件在您的应用程序中未正确处理,并安排重新投递。例如,500 Error

指定自定义路由

默认情况下,传入事件将被发送到与输入绑定名称对应的HTTP端点。您可以通过在binding.yaml中设置以下元数据属性来覆盖此设置:

name: mybinding
spec:
  type: binding.rabbitmq
  metadata:
  - name: route
    value: /onevent

事件投递保证

事件投递保证由绑定实现控制。根据绑定实现,事件投递可以是精确一次或至少一次。

参考资料

5.3 - 操作指南:使用输出绑定与外部资源交互

通过输出绑定调用外部系统

使用输出绑定,您可以与外部资源进行交互。在调用请求中,您可以发送可选的负载和元数据。

示例服务的绑定图示

本指南以Kafka绑定为例。您可以从绑定组件列表中选择您偏好的绑定规范。在本指南中:

  1. 示例中调用了/binding端点,使用checkout作为要调用的绑定名称。
  2. 负载放在必需的data字段中,可以是任何JSON可序列化的值。
  3. operation字段指定绑定需要执行的操作。例如,Kafka绑定支持create操作

创建绑定

创建一个binding.yaml文件,并将其保存到应用程序目录中的components子文件夹中。

创建一个名为checkout的新绑定组件。在metadata部分中,配置以下与Kafka相关的属性:

  • 您将发布消息的主题
  • 代理

在创建绑定组件时,指定绑定的支持direction

使用dapr run--resources-path标志指向您的自定义资源目录。

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: checkout
spec:
  type: bindings.kafka
  version: v1
  metadata:
  # Kafka代理连接设置
  - name: brokers
    value: localhost:9092
  # 消费者配置:主题和消费者组
  - name: topics
    value: sample
  - name: consumerGroup
    value: group1
  # 发布者配置:主题
  - name: publishTopic
    value: sample
  - name: authRequired
    value: false
  - name: direction
    value: output

要将以下binding.yaml文件部署到Kubernetes集群中,运行kubectl apply -f binding.yaml

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: checkout
spec:
  type: bindings.kafka
  version: v1
  metadata:
  # Kafka代理连接设置
  - name: brokers
    value: localhost:9092
  # 消费者配置:主题和消费者组
  - name: topics
    value: sample
  - name: consumerGroup
    value: group1
  # 发布者配置:主题
  - name: publishTopic
    value: sample
  - name: authRequired
    value: false
  - name: direction
    value: output

发送事件(输出绑定)

下面的代码示例利用Dapr SDK在运行的Dapr实例上调用输出绑定端点。

//依赖项
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Dapr.Client;
using Microsoft.AspNetCore.Mvc;
using System.Threading;

//代码
namespace EventService
{
    class Program
    {
        static async Task Main(string[] args)
        {
            string BINDING_NAME = "checkout";
            string BINDING_OPERATION = "create";
            while(true)
            {
                System.Threading.Thread.Sleep(5000);
                Random random = new Random();
                int orderId = random.Next(1,1000);
                using var client = new DaprClientBuilder().Build();
                //使用Dapr SDK调用输出绑定
                await client.InvokeBindingAsync(BINDING_NAME, BINDING_OPERATION, orderId);
                Console.WriteLine("发送消息: " + orderId);
            }
        }
    }
}
//依赖项
import io.dapr.client.DaprClient;
import io.dapr.client.DaprClientBuilder;
import io.dapr.client.domain.HttpExtension;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Random;
import java.util.concurrent.TimeUnit;

//代码
@SpringBootApplication
public class OrderProcessingServiceApplication {

	private static final Logger log = LoggerFactory.getLogger(OrderProcessingServiceApplication.class);

	public static void main(String[] args) throws InterruptedException{
		String BINDING_NAME = "checkout";
		String BINDING_OPERATION = "create";
		while(true) {
			TimeUnit.MILLISECONDS.sleep(5000);
			Random random = new Random();
			int orderId = random.nextInt(1000-1) + 1;
			DaprClient client = new DaprClientBuilder().build();
          //使用Dapr SDK调用输出绑定
			client.invokeBinding(BINDING_NAME, BINDING_OPERATION, orderId).block();
			log.info("发送消息: " + orderId);
		}
	}
}
#依赖项
import random
from time import sleep    
import requests
import logging
import json
from dapr.clients import DaprClient

#代码
logging.basicConfig(level = logging.INFO)
BINDING_NAME = 'checkout'
BINDING_OPERATION = 'create' 
while True:
    sleep(random.randrange(50, 5000) / 1000)
    orderId = random.randint(1, 1000)
    with DaprClient() as client:
        #使用Dapr SDK调用输出绑定
        resp = client.invoke_binding(BINDING_NAME, BINDING_OPERATION, json.dumps(orderId))
    logging.basicConfig(level = logging.INFO)
    logging.info('发送消息: ' + str(orderId))
    
//依赖项
import (
	"context"
	"log"
	"math/rand"
	"time"
	"strconv"
	dapr "github.com/dapr/go-sdk/client"

)

//代码
func main() {
	BINDING_NAME := "checkout";
	BINDING_OPERATION := "create";
	for i := 0; i < 10; i++ {
		time.Sleep(5000)
		orderId := rand.Intn(1000-1) + 1
		client, err := dapr.NewClient()
		if err != nil {
			panic(err)
		}
		defer client.Close()
		ctx := context.Background()
        //使用Dapr SDK调用输出绑定
		in := &dapr.InvokeBindingRequest{ Name: BINDING_NAME, Operation: BINDING_OPERATION , Data: []byte(strconv.Itoa(orderId))}
		err = client.InvokeOutputBinding(ctx, in)
		log.Println("发送消息: " + strconv.Itoa(orderId))
	}
}
    
//依赖项
import { DaprClient, CommunicationProtocolEnum } from "@dapr/dapr";

//代码
const daprHost = "127.0.0.1";

(async function () {
    for (var i = 0; i < 10; i++) {
        await sleep(2000);
        const orderId = Math.floor(Math.random() * (1000 - 1) + 1);
        try {
            await sendOrder(orderId)
        } catch (err) {
            console.error(e);
            process.exit(1);
        }
    }
})();

async function sendOrder(orderId) {
    const BINDING_NAME = "checkout";
    const BINDING_OPERATION = "create";
    const client = new DaprClient({
        daprHost,
        daprPort: process.env.DAPR_HTTP_PORT,
        communicationProtocol: CommunicationProtocolEnum.HTTP,
    });
    //使用Dapr SDK调用输出绑定
    const result = await client.binding.send(BINDING_NAME, BINDING_OPERATION, orderId);
    console.log("发送消息: " + orderId);
}

function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

您还可以使用HTTP调用输出绑定端点:

curl -X POST -H 'Content-Type: application/json' http://localhost:3601/v1.0/bindings/checkout -d '{ "data": 100, "operation": "create" }'

观看此视频以了解如何使用双向输出绑定。

参考资料

6 - Actors

将代码和数据封装在可重用的actor对象中,这是一种常见的微服务设计模式

6.1 - Actors 概述

Actors API 构建模块概述

Actor 模型将 actor 描述为“计算的基本单位”。换句话说,您可以将代码编写在一个自包含的单元中(称为 actor),该单元接收消息并一次处理一条消息,而无需任何形式的并发或线程。

当您的代码处理一条消息时,它可以向其他 actor 发送一条或多条消息,或创建新的 actor。底层运行时管理每个 actor 的运行方式、时间和位置,并在 actor 之间路由消息。

大量的 actor 可以同时执行,并且 actor 彼此独立执行。

Dapr 中的 Actor 模型

Dapr 包含一个专门实现虚拟 Actor 模型的运行时。通过 Dapr 的实现,您可以根据 Actor 模型编写 Dapr actor,Dapr 利用底层平台提供的可扩展性和可靠性保证。

每个 actor 都被定义为 actor 类型的一个实例,与对象是类的一个实例相同。例如,可能有一个实现计算器功能的 actor 类型,并且可能有许多此类型的 actor 分布在集群的各个节点上。每个这样的 actor 都由一个 actor ID 唯一标识。

以下概述视频和演示展示了 Dapr 中的 actor 如何工作。

Dapr Actors 与 Dapr Workflow

Dapr actors 基于状态管理和服务调用 API 创建具有身份的有状态、长时间运行的对象。Dapr Workflow 和 Dapr Actors 是相关的,workflow 基于 actor 提供更高层次的抽象来编排一组 actor,实现常见的 workflow 模式并代表您管理 actor 的生命周期。

Dapr actors 旨在提供一种在分布式系统中封装状态和行为的方法。actor 可以按需由客户端应用程序激活。当 actor 被激活时,它被分配一个唯一的身份,这使得它能够在多次调用中保持其状态。这使得 actor 在构建有状态、可扩展和容错的分布式应用程序时非常有用。

另一方面,Dapr Workflow 提供了一种定义和编排涉及多个服务和组件的复杂 workflow 的方法。workflow 允许您定义需要按特定顺序执行的一系列步骤或任务,并可用于实现业务流程、事件驱动的 workflow 和其他类似场景。

如上所述,Dapr Workflow 基于 Dapr Actors 管理其激活和生命周期。

何时使用 Dapr Actors

与任何其他技术决策一样,您应该根据要解决的问题来决定是否使用 actor。例如,如果您正在构建一个聊天应用程序,您可能会使用 Dapr actors 来实现聊天室和用户之间的单个聊天会话,因为每个聊天会话都需要维护自己的状态并且具有可扩展性和容错性。

一般来说,如果您的问题空间涉及大量(数千个或更多)小型、独立和隔离的状态和逻辑单元,可以考虑使用 actor 模式来建模您的问题或场景。

  • 您的问题空间涉及大量(数千个或更多)小型、独立和隔离的状态和逻辑单元。
  • 您希望使用单线程对象,这些对象不需要外部组件的显著交互,包括跨一组 actor 查询状态。
  • 您的 actor 实例不会通过发出 I/O 操作来阻塞调用者,导致不可预测的延迟。

何时使用 Dapr Workflow

当您需要定义和编排涉及多个服务和组件的复杂 workflow 时,您可以使用 Dapr Workflow。例如,使用前面提到的聊天应用程序示例,您可能会使用 Dapr Workflow 来定义应用程序的整体 workflow,例如如何注册新用户、如何发送和接收消息以及应用程序如何处理错误和异常。

了解有关 Dapr Workflow 的更多信息以及如何在应用程序中使用 workflow。

Actor 类型和 Actor ID

actor 被唯一定义为 actor 类型的一个实例,类似于对象是类的一个实例。例如,您可能有一个实现计算器功能的 actor 类型。可能有许多此类型的 actor 分布在集群的各个节点上。

每个 actor 都由一个 actor ID 唯一标识。actor ID 可以是您选择的任何字符串值。如果您不提供 actor ID,Dapr 会为您生成一个随机字符串作为 ID。

功能

命名空间化的 Actors

Dapr 支持命名空间化的 actor。actor 类型可以部署到不同的命名空间中。您可以在同一命名空间中调用这些 actor 的实例。

了解有关命名空间化的 actor 及其工作原理的更多信息。

Actor 生命周期

由于 Dapr actors 是虚拟的,因此不需要显式创建或销毁。Dapr actor 运行时:

  1. 一旦收到该 actor ID 的初始请求,就会自动激活 actor。
  2. 垃圾收集未使用的 actor 的内存对象。
  3. 维护 actor 的存在信息,以防它稍后被重新激活。

actor 的状态超出了对象的生命周期,因为状态存储在为 Dapr 运行时配置的状态提供者中。

了解有关 actor 生命周期的更多信息。

分布和故障转移

为了提供可扩展性和可靠性,actor 实例在整个集群中分布,Dapr 在整个集群中分布 actor 实例,并自动将它们迁移到健康的节点。

了解有关 Dapr actor 放置的更多信息。

Actor 通信

您可以通过 HTTP 调用 actor 方法,如下面的通用示例所示。

  1. 服务调用 sidecar 上的 actor API。
  2. 使用来自放置服务的缓存分区信息,sidecar 确定哪个 actor 服务实例将托管 actor ID 3。调用被转发到适当的 sidecar。
  3. pod 2 中的 sidecar 实例调用服务实例以调用 actor 并执行 actor 方法。

了解有关调用 actor 方法的更多信息。

并发

Dapr actor 运行时为访问 actor 方法提供了一个简单的轮流访问模型。轮流访问极大地简化了并发系统,因为不需要同步机制来进行数据访问。

状态

事务性状态存储可以用于存储 actor 状态。无论您是否打算在 actor 中存储任何状态,您都必须在状态存储组件的元数据部分中将属性 actorStateStore 的值指定为 true。actor 状态以特定方案存储在事务性状态存储中,允许进行一致的查询。所有 actor 只能使用单个状态存储组件作为状态存储。阅读状态 API 参考actors API 参考以了解有关 actor 状态存储的更多信息。

Actor 定时器和提醒

actor 可以通过注册定时器或提醒来安排定期工作。

定时器和提醒的功能非常相似。主要区别在于 Dapr actor 运行时在停用后不保留有关定时器的任何信息,而是使用 Dapr actor 状态提供者持久化有关提醒的信息。

这种区别允许用户在轻量级但无状态的定时器与更耗资源但有状态的提醒之间进行权衡。

以下概述视频和演示展示了 actor 定时器和提醒如何工作。

下一步

Actors 功能和概念 >>

相关链接

6.2 - Actor 的运行时特性

了解 Dapr 中 Actor 的特性和概念

在您已经从高层次上了解了 Actor 构建块之后,让我们深入探讨 Dapr 中 Actor 的特性和概念。

Actor 的生命周期

Dapr 中的 Actor 是虚拟的,这意味着它们的生命周期与内存中的表示无关。因此,不需要显式地创建或销毁它们。Dapr 的 Actor 运行时会在首次收到某个 Actor ID 的请求时自动激活该 Actor。如果某个 Actor 在一段时间内未被使用,Dapr 的 Actor 运行时会对其进行垃圾回收,但会保留其存在的信息,以便在需要时重新激活。

调用 Actor 方法、定时器和提醒会重置 Actor 的空闲时间。例如,提醒的触发会保持 Actor 的活跃状态。

  • Actor 的提醒会在无论其活跃与否的情况下触发。如果提醒触发了一个不活跃的 Actor,它会先激活该 Actor。
  • Actor 的定时器触发会重置空闲时间;然而,定时器仅在 Actor 活跃时触发。

Dapr 运行时用于判断 Actor 是否可以被垃圾回收的空闲超时和扫描间隔是可配置的。当 Dapr 运行时调用 Actor 服务以获取支持的 Actor 类型时,可以传递此信息。

这种虚拟 Actor 生命周期的抽象带来了一些注意事项,尽管 Dapr 的 Actor 实现有时会偏离这种模型。

Actor 在首次向其 Actor ID 发送消息时会自动激活(即构建 Actor 对象)。经过一段时间后,Actor 对象会被垃圾回收。将来再次使用该 Actor ID 会导致构建新的 Actor 对象。Actor 的状态超越对象的生命周期,因为状态存储在为 Dapr 运行时配置的状态提供者中。

分布和故障转移

为了提供可扩展性和可靠性,Actor 实例分布在整个集群中,Dapr 会根据需要自动将它们从故障节点迁移到健康节点。

Actor 分布在 Actor 服务的实例中,这些实例分布在集群中的节点上。每个服务实例包含给定 Actor 类型的一组 Actor。

Actor 放置服务

Dapr 的 Actor 运行时通过 Actor Placement 服务为您管理分布方案和键范围设置。当创建服务的新实例时:

  1. Sidecar 调用 Actor 服务以检索注册的 Actor 类型和配置设置。
  2. 相应的 Dapr 运行时注册它可以创建的 Actor 类型。
  3. Placement 服务计算给定 Actor 类型的所有实例的分区。

每个 Actor 类型的分区数据表在环境中运行的每个 Dapr 实例中更新和存储,并且可以随着 Actor 服务的新实例的创建和销毁而动态变化。

当客户端调用具有特定 ID 的 Actor(例如,Actor ID 123)时,客户端的 Dapr 实例对 Actor 类型和 ID 进行哈希,并使用信息调用可以为该特定 Actor ID 提供请求的相应 Dapr 实例。因此,对于任何给定的 Actor ID,总是调用相同的分区(或服务实例)。这在下图中显示。

这简化了一些选择,但也带来了一些考虑:

  • 默认情况下,Actor 随机放置到 Pod 中,导致均匀分布。
  • 由于 Actor 是随机放置的,因此应预期 Actor 操作总是需要网络通信,包括方法调用数据的序列化和反序列化,从而产生延迟和开销。

Actor 的通信

您可以通过调用 HTTP 端点与 Dapr 交互以调用 Actor 方法。

POST/GET/PUT/DELETE http://localhost:3500/v1.0/actors/<actorType>/<actorId>/<method/state/timers/reminders>

您可以在请求体中为 Actor 方法提供任何数据,请求的响应将在响应体中,这是来自 Actor 调用的数据。

另一种可能更方便的与 Actor 交互的方式是通过 SDK。Dapr 目前支持 .NETJavaPython 的 Actor SDK。

有关更多详细信息,请参阅 Dapr Actor 特性

并发

Dapr 的 Actor 运行时为访问 Actor 方法提供了简单的轮转访问模型。这意味着在任何时候,Actor 对象的代码中最多只能有一个线程处于活动状态。轮转访问极大地简化了并发系统,因为不需要同步机制来进行数据访问。这也意味着系统必须针对每个 Actor 实例的单线程访问特性进行特殊设计。

单个 Actor 实例不能同时处理多个请求。如果期望 Actor 实例处理并发请求,它可能会导致吞吐量瓶颈。

如果在两个 Actor 之间存在循环请求,同时对其中一个 Actor 发出外部请求,Actor 可能会相互死锁。Dapr 的 Actor 运行时会自动在 Actor 调用上超时,并向调用者抛出异常以中断可能的死锁情况。

重入

要允许 Actor “重入” 并调用自身的方法,请参阅 Actor 重入

轮转访问

轮转包括响应其他 Actor 或客户端请求的 Actor 方法的完整执行,或定时器/提醒回调的完整执行。即使这些方法和回调是异步的,Dapr 的 Actor 运行时也不会交错它们。一个轮转必须完全完成后,才允许新的轮转。换句话说,当前正在执行的 Actor 方法或定时器/提醒回调必须完全完成后,才允许对方法或回调的新调用。方法或回调被认为已完成,如果执行已从方法或回调返回,并且方法或回调返回的任务已完成。值得强调的是,即使在不同的方法、定时器和回调之间,也要尊重轮转并发性。

Dapr 的 Actor 运行时通过在轮转开始时获取每个 Actor 锁,并在轮转结束时释放锁来强制执行轮转并发性。因此,轮转并发性是在每个 Actor 的基础上强制执行的,而不是跨 Actor。Actor 方法和定时器/提醒回调可以代表不同的 Actor 同时执行。

以下示例说明了上述概念。考虑一个实现了两个异步方法(例如,Method1 和 Method2)、一个定时器和一个提醒的 Actor 类型。下图显示了代表属于此 Actor 类型的两个 Actor(ActorId1 和 ActorId2)的方法和回调执行时间线的示例。

下一步

定时器和提醒 >>

相关链接

6.3 - actor 运行时配置参数

修改默认 Dapr actor 运行时配置行为

您可以使用以下配置参数来调整 Dapr actor 的默认运行时行为。

参数描述默认值
entities此主机支持的 actor 类型。N/A
actorIdleTimeout空闲 actor 的停用超时时间。每隔 actorScanInterval 时间间隔检查一次。60 分钟
actorScanInterval指定扫描空闲 actor 的时间间隔。超过 actorIdleTimeout 的 actor 将被停用。30 秒
drainOngoingCallTimeout在重新平衡 actor 时,指定当前活动 actor 方法的完成超时时间。如果没有正在进行的方法调用,则忽略此项。60 秒
drainRebalancedActors如果设置为 true,Dapr 将在 drainOngoingCallTimeout 时间内等待当前 actor 调用完成,然后再尝试停用 actor。true
reentrancy (ActorReentrancyConfig)配置 actor 的重入行为。如果未提供,则重入功能被禁用。禁用,false
remindersStoragePartitions配置 actor 的提醒分区数量。如果未提供,所有提醒将作为 actor 状态存储中的单个记录保存。0
entitiesConfig使用配置数组单独配置每个 actor 类型。任何在单个实体配置中指定的实体也必须在顶级 entities 字段中列出。N/A

示例

// 在 Startup.cs 中
public void ConfigureServices(IServiceCollection services)
{
    // 使用 DI 注册 actor 运行时
    services.AddActors(options =>
    {
        // 注册 actor 类型并配置 actor 设置
        options.Actors.RegisterActor<MyActor>();

        // 配置默认设置
        options.ActorIdleTimeout = TimeSpan.FromMinutes(60);
        options.ActorScanInterval = TimeSpan.FromSeconds(30);
        options.DrainOngoingCallTimeout = TimeSpan.FromSeconds(60);
        options.DrainRebalancedActors = true;
        options.RemindersStoragePartitions = 7;
        options.ReentrancyConfig = new() { Enabled = false };

        // 为特定 actor 类型添加配置。
        // 此 actor 类型必须在基础级别的 'entities' 字段中有匹配值。如果没有,配置将被忽略。
        // 如果有匹配的实体,这里的值将用于覆盖根配置中指定的任何值。
        // 在此示例中,`ReentrantActor` 启用了重入;然而,'MyActor' 将不启用重入。
        options.Actors.RegisterActor<ReentrantActor>(typeOptions: new()
        {
            ReentrancyConfig = new()
            {
                Enabled = true,
            }
        });
    });

    // 注册用于 actor 的其他服务
    services.AddSingleton<BankService>();
}

查看 .NET SDK 文档以注册 actor

import { CommunicationProtocolEnum, DaprClient, DaprServer } from "@dapr/dapr";

// 使用 DaprClientOptions 配置 actor 运行时。
const clientOptions = {
  actor: {
    actorIdleTimeout: "1h",
    actorScanInterval: "30s",
    drainOngoingCallTimeout: "1m",
    drainRebalancedActors: true,
    reentrancy: {
      enabled: true,
      maxStackDepth: 32,
    },
    remindersStoragePartitions: 0,
  },
};

// 在创建 DaprServer 和 DaprClient 时使用这些选项。

// 注意,DaprServer 内部创建了一个 DaprClient,需要使用 clientOptions 进行配置。
const server = new DaprServer(serverHost, serverPort, daprHost, daprPort, clientOptions);

const client = new DaprClient(daprHost, daprPort, CommunicationProtocolEnum.HTTP, clientOptions);

查看使用 JavaScript SDK 编写 actor 的文档

from datetime import timedelta
from dapr.actor.runtime.config import ActorRuntimeConfig, ActorReentrancyConfig

ActorRuntime.set_actor_config(
    ActorRuntimeConfig(
        actor_idle_timeout=timedelta(hours=1),
        actor_scan_interval=timedelta(seconds=30),
        drain_ongoing_call_timeout=timedelta(minutes=1),
        drain_rebalanced_actors=True,
        reentrancy=ActorReentrancyConfig(enabled=False),
        remindersStoragePartitions=7
    )
)

查看使用 Python SDK 运行 actor 的文档

// import io.dapr.actors.runtime.ActorRuntime;
// import java.time.Duration;

ActorRuntime.getInstance().getConfig().setActorIdleTimeout(Duration.ofMinutes(60));
ActorRuntime.getInstance().getConfig().setActorScanInterval(Duration.ofSeconds(30));
ActorRuntime.getInstance().getConfig().setDrainOngoingCallTimeout(Duration.ofSeconds(60));
ActorRuntime.getInstance().getConfig().setDrainBalancedActors(true);
ActorRuntime.getInstance().getConfig().setActorReentrancyConfig(false, null);
ActorRuntime.getInstance().getConfig().setRemindersStoragePartitions(7);

查看使用 Java SDK 编写 actor 的文档

const (
    defaultActorType = "basicType"
    reentrantActorType = "reentrantType"
)

type daprConfig struct {
	Entities                []string                `json:"entities,omitempty"`
	ActorIdleTimeout        string                  `json:"actorIdleTimeout,omitempty"`
	ActorScanInterval       string                  `json:"actorScanInterval,omitempty"`
	DrainOngoingCallTimeout string                  `json:"drainOngoingCallTimeout,omitempty"`
	DrainRebalancedActors   bool                    `json:"drainRebalancedActors,omitempty"`
	Reentrancy              config.ReentrancyConfig `json:"reentrancy,omitempty"`
	EntitiesConfig          []config.EntityConfig   `json:"entitiesConfig,omitempty"`
}

var daprConfigResponse = daprConfig{
	Entities:                []string{defaultActorType, reentrantActorType},
	ActorIdleTimeout:        actorIdleTimeout,
	ActorScanInterval:       actorScanInterval,
	DrainOngoingCallTimeout: drainOngoingCallTimeout,
	DrainRebalancedActors:   drainRebalancedActors,
	Reentrancy:              config.ReentrancyConfig{Enabled: false},
	EntitiesConfig: []config.EntityConfig{
		{
            // 为特定 actor 类型添加配置。
            // 此 actor 类型必须在基础级别的 'entities' 字段中有匹配值。如果没有,配置将被忽略。
            // 如果有匹配的实体,这里的值将用于覆盖根配置中指定的任何值。
            // 在此示例中,`reentrantActorType` 启用了重入;然而,'defaultActorType' 将不启用重入。
			Entities: []string{reentrantActorType},
			Reentrancy: config.ReentrancyConfig{
				Enabled:       true,
				MaxStackDepth: &maxStackDepth,
			},
		},
	},
}

func configHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(daprConfigResponse)
}

查看使用 Go SDK 的 actor 示例

下一步

启用 actor reminder 分区 >>

相关链接

6.4 - 命名空间中的actor

了解命名空间中的actor

在Dapr中,命名空间用于提供隔离,从而支持多租户。通过为actor添加命名空间,相同的actor类型可以部署在不同的命名空间中。您可以在同一命名空间中使用这些actor的实例。

创建和配置命名空间

您可以在自托管模式或Kubernetes上使用命名空间。

在自托管模式下,您可以通过设置NAMESPACE环境变量为Dapr实例指定命名空间。

在Kubernetes上,您可以在部署actor应用程序时创建和配置命名空间。例如,使用以下kubectl命令开始:

kubectl create namespace namespace-actorA
kubectl config set-context --current --namespace=namespace-actorA

然后,将您的actor应用程序部署到此命名空间中(在示例中为namespace-actorA)。

为命名空间配置actor状态存储

每个命名空间中的actor部署必须使用独立的状态存储。虽然您可以为每个actor命名空间使用不同的物理数据库,但某些状态存储组件提供了一种通过表、前缀、集合等逻辑分隔数据的方法。这允许您在多个命名空间中使用相同的物理数据库,只要您在Dapr组件定义中提供逻辑分隔即可。

以下是一些示例。

示例1:通过etcd中的前缀

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: statestore
spec:
  type: state.etcd
  version: v2
  metadata:
  - name: endpoints
    value: localhost:2379
  - name: keyPrefixPath
    value: namespace-actorA
  - name: actorStateStore
    value: "true"

示例2:通过SQLite中的表名

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: statestore
spec:
  type: state.sqlite
  version: v1
  metadata:
  - name: connectionString
    value: "data.db"
  - name: tableName
    value: "namespace-actorA"
  - name: actorStateStore
    value: "true"

示例3:通过Redis中的逻辑数据库编号

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: statestore
spec:
  type: state.redis
  version: v1
  metadata:
  - name: redisHost
    value: localhost:6379
  - name: redisPassword
    value: ""
  - name: actorStateStore
    value: "true"
  - name: redisDB
    value: "1"
  - name: redisPassword
    secretKeyRef:
      name: redis-secret
      key:  redis-password
  - name: actorStateStore
    value: "true"
  - name: redisDB
    value: "1"
auth:
  secretStore: <SECRET_STORE_NAME>

查看您的状态存储组件规格以了解其提供的功能。

下一步

6.5 - actor 定时器和提醒

为您的 actor 设置定时器和提醒并执行错误处理

actor 可以通过注册定时器或提醒来安排周期性工作。

定时器和提醒的功能非常相似。主要区别在于 Dapr actor 运行时在停用后不会保留任何关于定时器的信息,而是使用 Dapr actor 状态提供程序持久化提醒的信息。

这种区别允许用户在轻量但无状态的定时器与更资源密集但有状态的提醒之间进行选择。

定时器和提醒的调度配置是相同的,概述如下:


dueTime 是一个可选参数,用于设置第一次调用回调的时间或时间间隔。如果省略 dueTime,则在定时器/提醒注册后立即调用回调。

支持的格式:

  • RFC3339 日期格式,例如 2020-10-02T15:00:00Z
  • time.Duration 格式,例如 2h30m
  • ISO 8601 持续时间 格式,例如 PT2H30M

period 是一个可选参数,用于设置两次连续回调调用之间的时间间隔。当以 ISO 8601-1 持续时间 格式指定时,您还可以配置重复次数以限制回调调用的总次数。 如果省略 period,则回调只会被调用一次。

支持的格式:


ttl 是一个可选参数,用于设置定时器/提醒将过期和删除的时间或时间间隔。如果省略 ttl,则不应用任何限制。

支持的格式:

  • RFC3339 日期格式,例如 2020-10-02T15:00:00Z
  • time.Duration 格式,例如 2h30m
  • ISO 8601 持续时间 格式。示例:PT2H30M

actor 运行时验证调度配置的正确性,并在输入无效时返回错误。

当您同时指定 period 中的重复次数和 ttl 时,定时器/提醒将在任一条件满足时停止。

actor 定时器

您可以在 actor 上注册一个基于定时器执行的回调。

Dapr actor 运行时确保回调方法遵循基于轮次的并发保证。这意味着在此回调完成执行之前,不会有其他 actor 方法或定时器/提醒回调正在进行。

Dapr actor 运行时在回调完成时保存对 actor 状态所做的更改。如果在保存状态时发生错误,该 actor 对象将被停用,并激活一个新实例。

当 actor 作为垃圾回收的一部分被停用时,所有定时器都会停止。之后不会调用任何定时器回调。此外,Dapr actor 运行时不会保留关于停用前正在运行的定时器的任何信息。actor 需要在将来重新激活时注册所需的任何定时器。

您可以通过调用如下所示的 HTTP/gRPC 请求或通过 Dapr SDK 为 actor 创建定时器。

POST/PUT http://localhost:3500/v1.0/actors/<actorType>/<actorId>/timers/<name>

示例

定时器参数在请求体中指定。

以下请求体配置了一个 dueTime 为 9 秒和 period 为 3 秒的定时器。这意味着它将在 9 秒后首次触发,然后每隔 3 秒触发一次。

{
  "dueTime":"0h0m9s0ms",
  "period":"0h0m3s0ms"
}

以下请求体配置了一个 period 为 3 秒(ISO 8601 持续时间格式)的定时器。它还将调用次数限制为 10 次。这意味着它将触发 10 次:首先在注册后立即触发,然后每隔 3 秒触发一次。

{
  "period":"R10/PT3S",
}

以下请求体配置了一个 period 为 3 秒(ISO 8601 持续时间格式)和 ttl 为 20 秒的定时器。这意味着它在注册后立即触发,然后每隔 3 秒触发一次,持续 20 秒。

{
  "period":"PT3S",
  "ttl":"20s"
}

以下请求体配置了一个 dueTime 为 10 秒、period 为 3 秒和 ttl 为 10 秒的定时器。它还将调用次数限制为 4 次。这意味着它将在 10 秒后首次触发,然后每隔 3 秒触发一次,持续 10 秒,但总共不超过 4 次。

{
  "dueTime":"10s",
  "period":"R4/PT3S",
  "ttl":"10s"
}

您可以通过调用以下命令删除 actor 定时器

DELETE http://localhost:3500/v1.0/actors/<actorType>/<actorId>/timers/<name>

有关更多详细信息,请参阅 api 规范

actor 提醒

提醒是一种在指定时间触发 actor 上持久回调的机制。它们的功能类似于定时器。但与定时器不同,提醒在所有情况下都会被触发,直到 actor 明确取消注册它们或 actor 被明确删除或调用次数耗尽。具体来说,提醒在 actor 停用和故障转移期间被触发,因为 Dapr actor 运行时使用 Dapr actor 状态提供程序持久化关于 actor 提醒的信息。

您可以通过调用如下所示的 HTTP/gRPC 请求或通过 Dapr SDK 为 actor 创建持久提醒。

POST/PUT http://localhost:3500/v1.0/actors/<actorType>/<actorId>/reminders/<name>

提醒的请求结构与 actor 的相同。请参阅 actor 定时器示例

检索 actor 提醒

您可以通过调用以下命令检索 actor 提醒

GET http://localhost:3500/v1.0/actors/<actorType>/<actorId>/reminders/<name>

删除 actor 提醒

您可以通过调用以下命令删除 actor 提醒

DELETE http://localhost:3500/v1.0/actors/<actorType>/<actorId>/reminders/<name>

如果 actor 提醒被触发且应用程序未向运行时返回 2** 代码(例如,由于连接问题),actor 提醒将重试最多三次,每次尝试之间的退避间隔为一秒。可能会根据任何可选应用的 actor 弹性策略进行额外的重试。

有关更多详细信息,请参阅 api 规范

错误处理

当 actor 的方法成功完成时,运行时将继续按照指定的定时器或提醒计划调用该方法。然而,如果方法抛出异常,运行时会捕获它并在 Dapr sidecar 日志中记录错误消息,而不进行重试。

为了允许 actor 从故障中恢复并在崩溃或重启后重试,您可以通过配置状态存储(如 Redis 或 Azure Cosmos DB)来持久化 actor 的状态。

如果方法的调用失败,定时器不会被移除。定时器仅在以下情况下被移除:

  • sidecar 崩溃
  • 执行次数用尽
  • 您明确删除它

提醒数据序列化格式

actor 提醒数据默认序列化为 JSON。从 Dapr v1.13 开始,支持通过 Placement 和 Scheduler 服务为工作流的内部提醒数据使用 protobuf 序列化格式。根据吞吐量和负载大小,这可以显著提高性能,为开发人员提供更高的吞吐量和更低的延迟。

另一个好处是将较小的数据存储在 actor 底层数据库中,这在使用某些云数据库时可以实现成本优化。使用 protobuf 序列化的限制是提醒数据不再可查询。

以 protobuf 格式保存的提醒数据无法在 Dapr 1.12.x 及更早版本中读取。建议在 Dapr v1.13 中测试此功能,并验证它在您的数据库中按预期工作,然后再投入生产。

在 Kubernetes 上启用 protobuf 序列化

要在 Kubernetes 上为 actor 提醒使用 protobuf 序列化,请使用以下 Helm 值:

--set dapr_placement.maxActorApiLevel=20

在自托管环境中启用 protobuf 序列化

要在自托管环境中为 actor 提醒使用 protobuf 序列化,请使用以下 daprd 标志:

--max-api-level=20

下一步

配置 actor 运行时行为 >>

相关链接

6.6 - 如何启用actor提醒分区

为您的应用程序启用actor提醒分区

actor提醒在sidecar重启后仍然持久化并继续触发。注册了多个提醒的应用程序可能会遇到以下问题:

  • 提醒注册和注销的吞吐量低
  • 基于state存储单个记录大小限制的提醒注册数量有限

为了解决这些问题,应用程序可以通过在state存储中将数据分布在多个键中来启用actor提醒分区。

  1. actors\|\|<actor type>\|\|metadata中使用一个元数据记录来存储给定actor类型的持久化配置。
  2. 多个记录存储同一actor类型的提醒子集。
actors||<actor type>||metadata{ "id": <actor metadata identifier>, "actorRemindersMetadata": { "partitionCount": <number of partitions for reminders> } }
actors||<actor type>||<actor metadata identifier>||reminders||1[ <reminder 1-1>, <reminder 1-2>, ... , <reminder 1-n> ]
actors||<actor type>||<actor metadata identifier>||reminders||2[ <reminder 1-1>, <reminder 1-2>, ... , <reminder 1-m> ]

如果您需要更改分区数量,Dapr的sidecar将自动重新分配提醒集。

配置actor运行时以分区actor提醒

与其他actor配置元素类似,actor运行时通过actor的GET /dapr/config端点提供适当的配置来分区actor提醒。选择您偏好的语言以获取actor运行时配置示例。

// 在Startup.cs中
public void ConfigureServices(IServiceCollection services)
{
    // 使用DI注册actor运行时
    services.AddActors(options =>
    {
        // 注册actor类型并配置actor设置
        options.Actors.RegisterActor<MyActor>();

        // 配置默认设置
        options.ActorIdleTimeout = TimeSpan.FromMinutes(60);
        options.ActorScanInterval = TimeSpan.FromSeconds(30);
        options.RemindersStoragePartitions = 7;
    });

    // 注册用于actor的其他服务
    services.AddSingleton<BankService>();
}

查看.NET SDK中注册actor的文档

import { CommunicationProtocolEnum, DaprClient, DaprServer } from "@dapr/dapr";

// 使用DaprClientOptions配置actor运行时。
const clientOptions = {
  actor: {
    remindersStoragePartitions: 0,
  },
};

const actor = builder.build(new ActorId("my-actor"));

// 注册一个提醒,它有一个默认回调:`receiveReminder`
await actor.registerActorReminder(
  "reminder-id", // 提醒的唯一名称。
  Temporal.Duration.from({ seconds: 2 }), // DueTime
  Temporal.Duration.from({ seconds: 1 }), // Period
  Temporal.Duration.from({ seconds: 1 }), // TTL
  100, // 发送到提醒回调的状态。
);

// 删除提醒
await actor.unregisterActorReminder("reminder-id");

查看使用JavaScript SDK编写actor的文档

from datetime import timedelta

ActorRuntime.set_actor_config(
    ActorRuntimeConfig(
        actor_idle_timeout=timedelta(hours=1),
        actor_scan_interval=timedelta(seconds=30),
        remindersStoragePartitions=7
    )
)

查看使用Python SDK运行actor的文档

// import io.dapr.actors.runtime.ActorRuntime;
// import java.time.Duration;

ActorRuntime.getInstance().getConfig().setActorIdleTimeout(Duration.ofMinutes(60));
ActorRuntime.getInstance().getConfig().setActorScanInterval(Duration.ofSeconds(30));
ActorRuntime.getInstance().getConfig().setRemindersStoragePartitions(7);

查看使用Java SDK编写actor的文档

type daprConfig struct {
	Entities                   []string `json:"entities,omitempty"`
	ActorIdleTimeout           string   `json:"actorIdleTimeout,omitempty"`
	ActorScanInterval          string   `json:"actorScanInterval,omitempty"`
	DrainOngoingCallTimeout    string   `json:"drainOngoingCallTimeout,omitempty"`
	DrainRebalancedActors      bool     `json:"drainRebalancedActors,omitempty"`
	RemindersStoragePartitions int      `json:"remindersStoragePartitions,omitempty"`
}

var daprConfigResponse = daprConfig{
	[]string{defaultActorType},
	actorIdleTimeout,
	actorScanInterval,
	drainOngoingCallTimeout,
	drainRebalancedActors,
	7,
}

func configHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(daprConfigResponse)
}

查看使用Go SDK的actor示例

以下是一个有效的提醒分区配置示例:

{
	"entities": [ "MyActorType", "AnotherActorType" ],
	"remindersStoragePartitions": 7
}

处理配置更改

为了配置actor提醒分区,Dapr将actor类型元数据持久化在actor的state存储中。这允许配置更改在全局范围内应用,而不仅仅是在单个sidecar实例中。

此外,您只能增加分区数量,不能减少。这允许Dapr在滚动重启时自动重新分配数据,其中一个或多个分区配置可能处于活动状态。

演示

观看此视频以获取actor提醒分区的演示

6.7 - 操作指南:使用脚本与虚拟actor交互

通过调用actor方法来管理状态

了解如何通过HTTP/gRPC端点来使用虚拟actor。

调用actor方法

您可以通过调用HTTP/gRPC端点与Dapr交互,以调用actor方法。

POST/GET/PUT/DELETE http://localhost:3500/v1.0/actors/<actorType>/<actorId>/method/<method>

在请求体中提供actor方法所需的数据。请求的响应,即actor方法调用返回的数据,将在响应体中。

有关更多详细信息,请参阅Actors API规范

使用actors保存状态

您可以通过HTTP/gRPC端点与Dapr交互,利用Dapr的actor状态管理功能来可靠地保存状态。

要使用actors,您的状态存储必须支持多项事务。这意味着您的状态存储组件需要实现TransactionalStore接口。

查看支持事务/actors的组件列表。所有actors只能使用一个状态存储组件来保存状态。

下一步

actor重入 >>

相关链接

6.8 - 如何:在Dapr中启用和使用actor重入

了解更多关于actor重入的信息

虚拟actor模式的一个核心原则是actor的单线程执行特性。没有重入时,Dapr运行时会锁定所有actor请求。第二个请求必须等到第一个请求完成后才能启动。这意味着actor不能调用自身,也不能被另一个actor调用,即使它们属于同一调用链。

重入通过允许同一链或上下文的请求重新进入已锁定的actor来解决这个问题。这在以下场景中非常有用:

  • 一个actor想要调用自身的方法
  • actor在工作流中用于执行任务,然后回调到协调actor。

重入允许的调用链示例如下:

Actor A -> Actor A
Actor A -> Actor B -> Actor A

通过重入,您可以执行更复杂的actor调用,而不影响虚拟actor的单线程特性。

显示协调工作流actor调用工作actor或actor调用自身方法的重入图示

maxStackDepth参数用于设置一个值,以控制对同一actor可以进行多少次重入调用。默认情况下,这个值为32,通常已经足够。

配置actor运行时以启用重入

要启用actor重入,必须提供适当的配置。这是通过actor的GET /dapr/config端点完成的,类似于其他actor配置元素。

public class Startup
{
	public void ConfigureServices(IServiceCollection services)
	{
		services.AddSingleton<BankService>();
		services.AddActors(options =>
		{
		options.Actors.RegisterActor<DemoActor>();
		options.ReentrancyConfig = new Dapr.Actors.ActorReentrancyConfig()
			{
			Enabled = true,
			MaxStackDepth = 32,
			};
		});
	}
}
import { CommunicationProtocolEnum, DaprClient, DaprServer } from "@dapr/dapr";

// 使用DaprClientOptions配置actor运行时。
const clientOptions = {
  actor: {
    reentrancy: {
      enabled: true,
      maxStackDepth: 32,
    },
  },
};
from fastapi import FastAPI
from dapr.ext.fastapi import DaprActor
from dapr.actor.runtime.config import ActorRuntimeConfig, ActorReentrancyConfig
from dapr.actor.runtime.runtime import ActorRuntime
from demo_actor import DemoActor

reentrancyConfig = ActorReentrancyConfig(enabled=True)
config = ActorRuntimeConfig(reentrancy=reentrancyConfig)
ActorRuntime.set_actor_config(config)
app = FastAPI(title=f'{DemoActor.__name__}Service')
actor = DaprActor(app)

@app.on_event("startup")
async def startup_event():
	# 注册DemoActor
	await actor.register_actor(DemoActor)

@app.get("/MakeExampleReentrantCall")
def do_something_reentrant():
	# 在这里调用另一个actor,重入将自动处理
	return

以下是一个用Golang编写的actor代码片段,通过HTTP API提供重入配置。重入尚未包含在Go SDK中。

type daprConfig struct {
	Entities                []string                `json:"entities,omitempty"`
	ActorIdleTimeout        string                  `json:"actorIdleTimeout,omitempty"`
	ActorScanInterval       string                  `json:"actorScanInterval,omitempty"`
	DrainOngoingCallTimeout string                  `json:"drainOngoingCallTimeout,omitempty"`
	DrainRebalancedActors   bool                    `json:"drainRebalancedActors,omitempty"`
	Reentrancy              config.ReentrancyConfig `json:"reentrancy,omitempty"`
}

var daprConfigResponse = daprConfig{
	[]string{defaultActorType},
	actorIdleTimeout,
	actorScanInterval,
	drainOngoingCallTimeout,
	drainRebalancedActors,
	config.ReentrancyConfig{Enabled: true, MaxStackDepth: &maxStackDepth},
}

func configHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(daprConfigResponse)
}

处理重入请求

处理重入请求的关键在于Dapr-Reentrancy-Id头。此头的值用于将请求与其调用链匹配,并允许它们绕过actor的锁。

此头由Dapr运行时为任何具有重入配置的actor请求生成。一旦生成,它用于锁定actor,并且必须传递给所有后续请求。以下是一个actor处理重入请求的示例:

func reentrantCallHandler(w http.ResponseWriter, r *http.Request) {
    /*
     * 省略。
     */

	req, _ := http.NewRequest("PUT", url, bytes.NewReader(nextBody))

	reentrancyID := r.Header.Get("Dapr-Reentrancy-Id")
	req.Header.Add("Dapr-Reentrancy-Id", reentrancyID)

	client := http.Client{}
	resp, err := client.Do(req)

    /*
     * 省略。
     */
}

演示

观看此视频以了解如何使用actor重入。

下一步

Dapr SDK中的actor

相关链接

7 - Secret 管理

安全地从应用程序访问 Secret

7.1 - Secrets 管理概述

Secrets 管理 API 构建块概述

应用程序通常使用专用的 secret 存储来保存敏感信息。例如,您可以使用存储在 secret 存储中的连接字符串、密钥、令牌和其他应用程序级别的 secret 来对数据库、服务和外部系统进行身份验证,例如 AWS Secrets Manager, Azure Key Vault, Hashicorp Vault 等

为了访问这些 secret 存储,应用程序需要导入 secret 存储的 SDK。在多云场景中,这种情况更具挑战性,因为可能会使用不同供应商特定的 secret 存储。

Secrets 管理 API

Dapr 的专用 secrets 构建块 API 使开发人员更容易从 secret 存储中使用应用程序 secret。要使用 Dapr 的 secret 存储构建块,您需要:

  1. 为特定的 secret 存储解决方案设置一个组件。
  2. 在应用程序代码中使用 Dapr secrets API 检索 secret。
  3. 可选地,在 Dapr 组件文件中引用 secret。

以下概述视频和演示展示了 Dapr secrets 管理的工作原理。

功能

Secrets 管理 API 构建块为您的应用程序带来了多种功能。

在不更改应用程序代码的情况下配置 secret

您可以在应用程序代码中调用 secrets API,从 Dapr 支持的 secret 存储中检索和使用 secret。观看此视频以了解如何在应用程序中使用 secrets 管理 API 的示例。

例如,下图显示了一个应用程序从配置的云 secret 存储中请求名为 “mysecret” 的 secret,该 secret 存储名为 “vault”。

应用程序还可以使用 secrets API 从 Kubernetes secret 存储中访问 secret。默认情况下,Dapr 在 Kubernetes 模式下启用了内置的 Kubernetes secret 存储,可以通过以下方式部署:

  • 使用 Helm 的默认设置,或
  • 运行 dapr init -k

如果您使用其他 secret 存储,可以通过在 deployment.yaml 文件中添加注释 dapr.io/disable-builtin-k8s-secret-store: "true" 来禁用(不配置)Dapr Kubernetes secret 存储。默认值为 false

在下面的示例中,应用程序从 Kubernetes secret 存储中检索相同的 secret “mysecret”。

在 Azure 中,您可以配置 Dapr 使用托管身份通过 Azure Key Vault 检索 secret。在下面的示例中:

  1. 配置了一个 Azure Kubernetes Service (AKS) 集群 以使用托管身份。
  2. Dapr 使用 pod 身份 代表应用程序从 Azure Key Vault 检索 secret。

在上述示例中,应用程序代码无需更改即可获取相同的 secret。Dapr 通过 secrets 管理构建块 API 使用 secret 管理组件。

尝试使用 secrets API 通过我们的快速入门或教程之一。

在 Dapr 组件中引用 secret 存储

在配置 Dapr 组件(如 state 存储)时,通常需要在组件文件中包含凭据。或者,您可以将凭据放在 Dapr 支持的 secret 存储中,并在 Dapr 组件中引用该 secret。这是首选方法和推荐的最佳实践,尤其是在生产环境中。

有关更多信息,请阅读在组件中引用 secret 存储

限制对 secret 的访问

为了对 secret 的访问提供更细粒度的控制,Dapr 提供了定义范围和限制访问权限的能力。了解更多关于使用 secret 范围的信息。

尝试 secrets 管理

快速入门和教程

想要测试 Dapr secrets 管理 API 吗?通过以下快速入门和教程来查看 Dapr secrets 的实际应用:

快速入门/教程描述
Secrets 管理快速入门使用 secrets 管理 API 从配置的 secret 存储中在应用程序代码中检索 secret。
Secret Store 教程演示如何使用 Dapr Secrets API 访问 secret 存储。

直接在您的应用中开始管理 secret

想要跳过快速入门?没问题。您可以直接在应用程序中尝试使用 secret 管理构建块来检索和管理 secret。在安装 Dapr后,您可以从secrets 使用指南开始使用 secrets 管理 API。

下一步

7.2 - 如何检索 Secret

使用 Secret 存储构建块安全地检索 Secret

在了解了Dapr Secret 构建块的功能后,接下来学习如何在服务中使用它。本指南将演示如何调用 Secret API,并从配置的 Secret 存储中将 Secret 检索到应用程序代码中。

示例服务的 Secret 管理示意图。

配置 Secret 存储

在应用程序代码中检索 Secret 之前,您需要先配置一个 Secret 存储组件。此示例配置了一个使用本地 JSON 文件存储 Secret 的 Secret 存储。

在项目目录中,创建一个名为 secrets.json 的文件,内容如下:

{
   "secret": "Order Processing pass key"
}

创建一个名为 components 的新目录。进入该目录并创建一个名为 local-secret-store.yaml 的组件文件,内容如下:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: localsecretstore
spec:
  type: secretstores.local.file
  version: v1
  metadata:
  - name: secretsFile
    value: secrets.json  # Secret 文件的路径
  - name: nestedSeparator
    value: ":"

更多信息:

获取 Secret

通过调用 Dapr sidecar 的 Secret API 来获取 Secret:

curl http://localhost:3601/v1.0/secrets/localsecretstore/secret

查看完整的 API 参考

从代码中调用 Secret API

现在您已经设置了本地 Secret 存储,可以通过 Dapr 从应用程序代码中获取 Secret。以下是利用 Dapr SDK 检索 Secret 的代码示例。

// 依赖项
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Dapr.Client;
using Microsoft.AspNetCore.Mvc;
using System.Threading;
using System.Text.Json;

// 代码
namespace EventService
{
    class Program
    {
        static async Task Main(string[] args)
        {
            string SECRET_STORE_NAME = "localsecretstore";
            using var client = new DaprClientBuilder().Build();
            // 使用 Dapr SDK 获取 Secret
            var secret = await client.GetSecretAsync(SECRET_STORE_NAME, "secret");
            Console.WriteLine($"Result: {string.Join(", ", secret)}");
        }
    }
}
// 依赖项
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.dapr.client.DaprClient;
import io.dapr.client.DaprClientBuilder;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;

// 代码
@SpringBootApplication
public class OrderProcessingServiceApplication {

    private static final Logger log = LoggerFactory.getLogger(OrderProcessingServiceApplication.class);
    private static final ObjectMapper JSON_SERIALIZER = new ObjectMapper();

    private static final String SECRET_STORE_NAME = "localsecretstore";

    public static void main(String[] args) throws InterruptedException, JsonProcessingException {
        DaprClient client = new DaprClientBuilder().build();
        // 使用 Dapr SDK 获取 Secret
        Map<String, String> secret = client.getSecret(SECRET_STORE_NAME, "secret").block();
        log.info("Result: " + JSON_SERIALIZER.writeValueAsString(secret));
    }
}
# 依赖项 
import random
from time import sleep    
import requests
import logging
from dapr.clients import DaprClient
from dapr.clients.grpc._state import StateItem
from dapr.clients.grpc._request import TransactionalStateOperation, TransactionOperationType

# 代码
logging.basicConfig(level = logging.INFO)
DAPR_STORE_NAME = "localsecretstore"
key = 'secret'

with DaprClient() as client:
    # 使用 Dapr SDK 获取 Secret
    secret = client.get_secret(store_name=DAPR_STORE_NAME, key=key)
    logging.info('Result: ')
    logging.info(secret.secret)
    # 使用 Dapr SDK 获取批量 Secret
    secret = client.get_bulk_secret(store_name=DAPR_STORE_NAME)
    logging.info('Result for bulk secret: ')
    logging.info(sorted(secret.secrets.items()))
// 依赖项 
import (
	"context"
	"log"

	dapr "github.com/dapr/go-sdk/client"
)

// 代码
func main() {
	client, err := dapr.NewClient()
	SECRET_STORE_NAME := "localsecretstore"
	if err != nil {
		panic(err)
	}
	defer client.Close()
	ctx := context.Background()
     // 使用 Dapr SDK 获取 Secret
	secret, err := client.GetSecret(ctx, SECRET_STORE_NAME, "secret", nil)
	if secret != nil {
		log.Println("Result : ")
		log.Println(secret)
	}
    // 使用 Dapr SDK 获取批量 Secret
	secretBulk, err := client.GetBulkSecret(ctx, SECRET_STORE_NAME, nil)

	if secret != nil {
		log.Println("Result for bulk: ")
		log.Println(secretBulk)
	}
}
// 依赖项 
import { DaprClient, HttpMethod, CommunicationProtocolEnum } from '@dapr/dapr'; 

// 代码
const daprHost = "127.0.0.1"; 

async function main() {
    const client = new DaprClient({
        daprHost,
        daprPort: process.env.DAPR_HTTP_PORT,
        communicationProtocol: CommunicationProtocolEnum.HTTP,
    });
    const SECRET_STORE_NAME = "localsecretstore";
    // 使用 Dapr SDK 获取 Secret
    var secret = await client.secret.get(SECRET_STORE_NAME, "secret");
    console.log("Result: " + secret);
    // 使用 Dapr SDK 获取批量 Secret
    secret = await client.secret.getBulk(SECRET_STORE_NAME);
    console.log("Result for bulk: " + secret);
}

main();

相关链接

7.3 - 如何使用:配置 secret 访问范围

通过设置访问范围限制应用程序从 secret 存储中读取的 secret

当您为应用程序配置了 secret 存储后,Dapr 应用程序默认可以访问该存储中定义的所有 secret。

您可以通过在应用程序配置中定义 secret 访问范围策略,来限制 Dapr 应用程序对特定 secret 的访问权限。

secret 访问范围策略适用于任何secret 存储,包括:

  • 本地 secret 存储
  • Kubernetes secret 存储
  • 公有云 secret 存储

有关如何设置secret 存储的详细信息,请阅读如何:检索 secret

观看此视频以了解如何在应用程序中使用 secret 访问范围的演示。

场景 1:拒绝访问 secret 存储中的所有 secret

在此示例中,所有 secret 访问都被拒绝给运行在 Kubernetes 集群上的应用程序,该集群配置了名为 mycustomsecretstoreKubernetes secret 存储。除了用户定义的自定义存储外,示例还配置了 Kubernetes 默认存储(名为 kubernetes),以确保所有 secret 都被拒绝访问。了解有关 Kubernetes 默认 secret 存储的更多信息

定义以下 appconfig.yaml 配置,并使用命令 kubectl apply -f appconfig.yaml 将其应用于 Kubernetes 集群。

apiVersion: dapr.io/v1alpha1
kind: Configuration
metadata:
  name: appconfig
spec:
  secrets:
    scopes:
      - storeName: kubernetes
        defaultAccess: deny
      - storeName: mycustomsecreststore
        defaultAccess: deny

对于需要拒绝访问 Kubernetes secret 存储的应用程序,请按照这些说明,并将以下注释添加到应用程序 pod:

dapr.io/config: appconfig

配置完成后,应用程序将无法访问 Kubernetes secret 存储中的任何 secret。

场景 2:仅允许访问 secret 存储中的某些 secret

此示例使用名为 vault 的 secret 存储。这可以是设置在应用程序上的 Hashicorp secret 存储组件。要允许 Dapr 应用程序仅访问 vault secret 存储中的 secret1secret2,请定义以下 appconfig.yaml

apiVersion: dapr.io/v1alpha1
kind: Configuration
metadata:
  name: appconfig
spec:
  secrets:
    scopes:
      - storeName: vault
        defaultAccess: deny
        allowedSecrets: ["secret1", "secret2"]

vault secret 存储的默认访问是 deny,但应用程序可以根据 allowedSecrets 列表访问特定的 secret。了解如何将配置应用于 sidecar

场景 3:拒绝访问 secret 存储中的某些敏感 secret

定义以下 config.yaml

apiVersion: dapr.io/v1alpha1
kind: Configuration
metadata:
  name: appconfig
spec:
  secrets:
    scopes:
      - storeName: vault
        defaultAccess: allow # 这是默认值,可以省略
        deniedSecrets: ["secret1", "secret2"]

此示例配置明确拒绝访问名为 vault 的 secret 存储中的 secret1secret2,同时允许访问所有其他 secret。了解如何将配置应用于 sidecar

权限优先级

allowedSecretsdeniedSecrets 列表的设置优先于 defaultAccess 策略。

场景defaultAccessallowedSecretsdeniedSecrets权限
1 - 仅默认访问deny/allowdeny/allow
2 - 默认拒绝并允许列表deny[“s1”]仅 “s1” 可访问
3 - 默认允许并拒绝列表allow[“s1”]仅 “s1” 不可访问
4 - 默认允许并允许列表allow[“s1”]仅 “s1” 可访问
5 - 默认拒绝并拒绝列表deny[“s1”]deny
6 - 默认拒绝/允许并同时有列表deny/allow[“s1”][“s2”]仅 “s1” 可访问

相关链接

8 - 配置

管理应用程序配置并接收更改通知

8.1 - 配置概述

配置API构建模块的概述

在开发应用程序时,配置是一个常见的任务。通常,我们会使用配置存储来管理这些配置数据。配置项通常具有动态特性,并且与应用程序的需求紧密相关。

例如,应用程序的配置可能包括:

  • 密钥名称
  • 各种标识符
  • 分区或消费者ID
  • 数据库名称等

通常,配置项以键/值对的形式存储在状态存储或数据库中。开发人员或运维人员可以在运行时更改配置存储中的应用程序配置。一旦进行了更改,服务会被通知以加载新的配置。

从应用程序API的角度来看,配置数据是只读的,配置存储的更新通过运维工具进行。使用Dapr的配置API,您可以:

  • 获取以只读键/值对形式返回的配置项
  • 订阅配置项的变更通知

试用配置

快速入门

想要测试Dapr配置API?通过以下快速入门来了解配置API的实际应用:

快速入门描述
配置快速入门使用配置API获取配置项或订阅配置更改。

直接在应用中开始使用配置API

想要跳过快速入门?没问题。您可以直接在应用程序中尝试配置构建模块以读取和管理配置数据。在Dapr安装完成后,您可以从配置操作指南开始使用配置API。

观看演示

观看使用Dapr配置构建模块的演示

下一步

请参阅以下指南:

8.2 - 操作指南:从存储中管理配置

学习如何获取应用程序配置并订阅更改

本示例使用Redis配置存储组件来演示如何检索配置项。

示例服务获取配置的图示

在存储中创建配置项

在支持的配置存储中创建一个配置项。这可以是一个简单的键值项,使用您选择的任何键。本示例使用Redis配置存储组件。

使用Docker运行Redis

docker run --name my-redis -p 6379:6379 -d redis:6

保存一个配置项

使用Redis CLI,连接到Redis实例:

redis-cli -p 6379

保存一个配置项:

MSET orderId1 "101||1" orderId2 "102||1"

配置Dapr配置存储

将以下组件文件保存到您机器上的默认组件文件夹。您可以将其用作Dapr组件YAML:

  • 对于Kubernetes使用kubectl
  • 使用Dapr CLI运行时。
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: configstore
spec:
  type: configuration.redis
  metadata:
  - name: redisHost
    value: localhost:6379
  - name: redisPassword
    value: <PASSWORD>

检索配置项

获取配置项

以下示例展示了如何使用Dapr配置API获取已保存的配置项。

//依赖项
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Dapr.Client;

//代码
namespace ConfigurationApi
{
    public class Program
    {
        private static readonly string CONFIG_STORE_NAME = "configstore";

        public static async Task Main(string[] args)
        {
            using var client = new DaprClientBuilder().Build();
            var configuration = await client.GetConfiguration(CONFIG_STORE_NAME, new List<string>() { "orderId1", "orderId2" });
            Console.WriteLine($"Got key=\n{configuration[0].Key} -> {configuration[0].Value}\n{configuration[1].Key} -> {configuration[1].Value}");
        }
    }
}
//依赖项
import io.dapr.client.DaprClientBuilder;
import io.dapr.client.DaprClient;
import io.dapr.client.domain.ConfigurationItem;
import io.dapr.client.domain.GetConfigurationRequest;
import io.dapr.client.domain.SubscribeConfigurationRequest;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

//代码
private static final String CONFIG_STORE_NAME = "configstore";

public static void main(String[] args) throws Exception {
    try (DaprClient client = (new DaprClientBuilder()).build()) {
      List<String> keys = new ArrayList<>();
      keys.add("orderId1");
      keys.add("orderId2");
      GetConfigurationRequest req = new GetConfigurationRequest(CONFIG_STORE_NAME, keys);
      try {
        Mono<List<ConfigurationItem>> items = client.getConfiguration(req);
        items.block().forEach(ConfigurationClient::print);
      } catch (Exception ex) {
        System.out.println(ex.getMessage());
      }
    }
}
#依赖项
from dapr.clients import DaprClient
#代码
with DaprClient() as d:
        CONFIG_STORE_NAME = 'configstore'
        keys = ['orderId1', 'orderId2']
        #Dapr启动时间
        d.wait(20)
        configuration = d.get_configuration(store_name=CONFIG_STORE_NAME, keys=[keys], config_metadata={})
        print(f"Got key={configuration.items[0].key} value={configuration.items[0].value} version={configuration.items[0].version}")
package main

import (
	"context"
  "fmt"

	dapr "github.com/dapr/go-sdk/client"
)

func main() {
	ctx := context.Background()
	client, err := dapr.NewClient()
	if err != nil {
		panic(err)
	}
	items, err := client.GetConfigurationItems(ctx, "configstore", ["orderId1","orderId2"])
	if err != nil {
		panic(err)
	}
  for key, item := range items {
    fmt.Printf("get config: key = %s value = %s version = %s",key,(*item).Value, (*item).Version)
  }
}
import { CommunicationProtocolEnum, DaprClient } from "@dapr/dapr";

// JS SDK尚不支持通过HTTP协议的配置API
const protocol = CommunicationProtocolEnum.GRPC;
const host = process.env.DAPR_HOST ?? "localhost";
const port = process.env.DAPR_GRPC_PORT ?? 3500;

const DAPR_CONFIGURATION_STORE = "configstore";
const CONFIGURATION_ITEMS = ["orderId1", "orderId2"];

async function main() {
  const client = new DaprClient(host, port, protocol);
  // 从配置存储中获取配置项
  try {
    const config = await client.configuration.get(DAPR_CONFIGURATION_STORE, CONFIGURATION_ITEMS);
    Object.keys(config.items).forEach((key) => {
      console.log("Configuration for " + key + ":", JSON.stringify(config.items[key]));
    });
  } catch (error) {
    console.log("Could not get config item, err:" + error);
    process.exit(1);
  }
}

main().catch((e) => console.error(e));

启动一个Dapr sidecar:

dapr run --app-id orderprocessing --dapr-http-port 3601

在另一个终端中,获取之前保存的配置项:

curl http://localhost:3601/v1.0/configuration/configstore?key=orderId1

启动一个Dapr sidecar:

dapr run --app-id orderprocessing --dapr-http-port 3601

在另一个终端中,获取之前保存的配置项:

Invoke-RestMethod -Uri 'http://localhost:3601/v1.0/configuration/configstore?key=orderId1'

订阅配置项更新

以下是利用SDK订阅使用configstore存储组件的键[orderId1, orderId2]的代码示例。

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Dapr.Client;

const string DAPR_CONFIGURATION_STORE = "configstore";
var CONFIGURATION_KEYS = new List<string> { "orderId1", "orderId2" };
var client = new DaprClientBuilder().Build();

// 订阅配置更改
SubscribeConfigurationResponse subscribe = await client.SubscribeConfiguration(DAPR_CONFIGURATION_STORE, CONFIGURATION_ITEMS);

// 打印配置更改
await foreach (var items in subscribe.Source)
{
  // 应用程序订阅配置更改时的首次调用仅返回订阅ID
  if (items.Keys.Count == 0)
  {
    Console.WriteLine("App subscribed to config changes with subscription id: " + subscribe.Id);
    subscriptionId = subscribe.Id;
    continue;
  }
  var cfg = System.Text.Json.JsonSerializer.Serialize(items);
  Console.WriteLine("Configuration update " + cfg);
}

导航到包含上述代码的目录,然后运行以下命令以启动Dapr sidecar和订阅者应用程序:

dapr run --app-id orderprocessing -- dotnet run
using System;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Dapr.Client;
using Dapr.Extensions.Configuration;
using System.Collections.Generic;
using System.Threading;

namespace ConfigurationApi
{
    public class Program
    {
        public static void Main(string[] args)
        {
            Console.WriteLine("Starting application.");
            CreateHostBuilder(args).Build().Run();
            Console.WriteLine("Closing application.");
        }

        /// <summary>
        /// 创建WebHost Builder。
        /// </summary>
        /// <param name="args">参数。</param>
        /// <returns>返回IHostbuilder。</returns>
        public static IHostBuilder CreateHostBuilder(string[] args)
        {
            var client = new DaprClientBuilder().Build();
            return Host.CreateDefaultBuilder(args)
                .ConfigureAppConfiguration(config =>
                {
                    // 获取初始值并继续监视其更改。
                    config.AddDaprConfigurationStore("configstore", new List<string>() { "orderId1","orderId2" }, client, TimeSpan.FromSeconds(20));
                    config.AddStreamingDaprConfigurationStore("configstore", new List<string>() { "orderId1","orderId2" }, client, TimeSpan.FromSeconds(20));

                })
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.UseStartup<Startup>();
                });
        }
    }
}

导航到包含上述代码的目录,然后运行以下命令以启动Dapr sidecar和订阅者应用程序:

dapr run --app-id orderprocessing -- dotnet run
import io.dapr.client.DaprClientBuilder;
import io.dapr.client.DaprClient;
import io.dapr.client.domain.ConfigurationItem;
import io.dapr.client.domain.GetConfigurationRequest;
import io.dapr.client.domain.SubscribeConfigurationRequest;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

//代码
private static final String CONFIG_STORE_NAME = "configstore";
private static String subscriptionId = null;

public static void main(String[] args) throws Exception {
    try (DaprClient client = (new DaprClientBuilder()).build()) {
      // 订阅配置更改
      List<String> keys = new ArrayList<>();
      keys.add("orderId1");
      keys.add("orderId2");
      Flux<SubscribeConfigurationResponse> subscription = client.subscribeConfiguration(DAPR_CONFIGURATON_STORE,keys);

      // 读取配置更改20秒
      subscription.subscribe((response) -> {
          // 首次响应包含订阅ID
          if (response.getItems() == null || response.getItems().isEmpty()) {
              subscriptionId = response.getSubscriptionId();
              System.out.println("App subscribed to config changes with subscription id: " + subscriptionId);
          } else {
              response.getItems().forEach((k, v) -> {
                  System.out.println("Configuration update for " + k + ": {'value':'" + v.getValue() + "'}");
              });
          }
      });
      Thread.sleep(20000);
    }
}

导航到包含上述代码的目录,然后运行以下命令以启动Dapr sidecar和订阅者应用程序:

dapr run --app-id orderprocessing -- -- mvn spring-boot:run
#依赖项
from dapr.clients import DaprClient
#代码

def handler(id: str, resp: ConfigurationResponse):
    for key in resp.items:
        print(f"Subscribed item received key={key} value={resp.items[key].value} "
              f"version={resp.items[key].version} "
              f"metadata={resp.items[key].metadata}", flush=True)

def executeConfiguration():
    with DaprClient() as d:
        storeName = 'configurationstore'
        keys = ['orderId1', 'orderId2']
        id = d.subscribe_configuration(store_name=storeName, keys=keys,
                          handler=handler, config_metadata={})
        print("Subscription ID is", id, flush=True)
        sleep(20)

executeConfiguration()

导航到包含上述代码的目录,然后运行以下命令以启动Dapr sidecar和订阅者应用程序:

dapr run --app-id orderprocessing -- python3 OrderProcessingService.py
package main

import (
	"context"
  "fmt"
  "time"

	dapr "github.com/dapr/go-sdk/client"
)

func main() {
	ctx := context.Background()
	client, err := dapr.NewClient()
	if err != nil {
		panic(err)
	}
  subscribeID, err := client.SubscribeConfigurationItems(ctx, "configstore", []string{"orderId1", "orderId2"}, func(id string, items map[string]*dapr.ConfigurationItem) {
  for k, v := range items {
    fmt.Printf("get updated config key = %s, value = %s version = %s \n", k, v.Value, v.Version)
  }
  })
	if err != nil {
		panic(err)
	}
	time.Sleep(20*time.Second)
}

导航到包含上述代码的目录,然后运行以下命令以启动Dapr sidecar和订阅者应用程序:

dapr run --app-id orderprocessing -- go run main.go
import { CommunicationProtocolEnum, DaprClient } from "@dapr/dapr";

// JS SDK尚不支持通过HTTP协议的配置API
const protocol = CommunicationProtocolEnum.GRPC;
const host = process.env.DAPR_HOST ?? "localhost";
const port = process.env.DAPR_GRPC_PORT ?? 3500;

const DAPR_CONFIGURATION_STORE = "configstore";
const CONFIGURATION_ITEMS = ["orderId1", "orderId2"];

async function main() {
  const client = new DaprClient(host, port, protocol);
  // 订阅配置更新
  try {
    const stream = await client.configuration.subscribeWithKeys(
      DAPR_CONFIGURATION_STORE,
      CONFIGURATION_ITEMS,
      (config) => {
        console.log("Configuration update", JSON.stringify(config.items));
      }
    );
    // 取消订阅配置更新并在20秒后退出应用程序
    setTimeout(() => {
      stream.stop();
      console.log("App unsubscribed to config changes");
      process.exit(0);
    }, 20000);
  } catch (error) {
    console.log("Error subscribing to config updates, err:" + error);
    process.exit(1);
  }
}
main().catch((e) => console.error(e));

导航到包含上述代码的目录,然后运行以下命令以启动Dapr sidecar和订阅者应用程序:

dapr run --app-id orderprocessing --app-protocol grpc --dapr-grpc-port 3500 -- node index.js

取消订阅配置项更新

在您订阅监视配置项后,您将收到所有订阅键的更新。要停止接收更新,您需要显式调用取消订阅API。

以下是展示如何使用取消订阅API取消订阅配置更新的代码示例。

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Dapr.Client;

const string DAPR_CONFIGURATION_STORE = "configstore";
var client = new DaprClientBuilder().Build();

// 取消订阅配置更新并退出应用程序
async Task unsubscribe(string subscriptionId)
{
  try
  {
    await client.UnsubscribeConfiguration(DAPR_CONFIGURATION_STORE, subscriptionId);
    Console.WriteLine("App unsubscribed from config changes");
    Environment.Exit(0);
  }
  catch (Exception ex)
  {
    Console.WriteLine("Error unsubscribing from config updates: " + ex.Message);
  }
}
import io.dapr.client.DaprClientBuilder;
import io.dapr.client.DaprClient;
import io.dapr.client.domain.ConfigurationItem;
import io.dapr.client.domain.GetConfigurationRequest;
import io.dapr.client.domain.SubscribeConfigurationRequest;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

//代码
private static final String CONFIG_STORE_NAME = "configstore";
private static String subscriptionId = null;

public static void main(String[] args) throws Exception {
    try (DaprClient client = (new DaprClientBuilder()).build()) {
      // 取消订阅配置更改
      UnsubscribeConfigurationResponse unsubscribe = client
              .unsubscribeConfiguration(subscriptionId, DAPR_CONFIGURATON_STORE).block();
      if (unsubscribe.getIsUnsubscribed()) {
          System.out.println("App unsubscribed to config changes");
      } else {
          System.out.println("Error unsubscribing to config updates, err:" + unsubscribe.getMessage());
      }
    } catch (Exception e) {
        System.out.println("Error unsubscribing to config updates," + e.getMessage());
        System.exit(1);
    }
}
import asyncio
import time
import logging
from dapr.clients import DaprClient
subscriptionID = ""

with DaprClient() as d:
  isSuccess = d.unsubscribe_configuration(store_name='configstore', id=subscriptionID)
  print(f"Unsubscribed successfully? {isSuccess}", flush=True)
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"os"
	"time"

	dapr "github.com/dapr/go-sdk/client"
)

var DAPR_CONFIGURATION_STORE = "configstore"
var subscriptionID = ""

func main() {
	client, err := dapr.NewClient()
	if err != nil {
		log.Panic(err)
	}
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
  if err := client.UnsubscribeConfigurationItems(ctx, DAPR_CONFIGURATION_STORE , subscriptionID); err != nil {
    panic(err)
  }
}
import { CommunicationProtocolEnum, DaprClient } from "@dapr/dapr";

// JS SDK尚不支持通过HTTP协议的配置API
const protocol = CommunicationProtocolEnum.GRPC;
const host = process.env.DAPR_HOST ?? "localhost";
const port = process.env.DAPR_GRPC_PORT ?? 3500;

const DAPR_CONFIGURATION_STORE = "configstore";
const CONFIGURATION_ITEMS = ["orderId1", "orderId2"];

async function main() {
  const client = new DaprClient(host, port, protocol);

  try {
    const stream = await client.configuration.subscribeWithKeys(
      DAPR_CONFIGURATION_STORE,
      CONFIGURATION_ITEMS,
      (config) => {
        console.log("Configuration update", JSON.stringify(config.items));
      }
    );
    setTimeout(() => {
      // 取消订阅配置更新
      stream.stop();
      console.log("App unsubscribed to config changes");
      process.exit(0);
    }, 20000);
  } catch (error) {
    console.log("Error subscribing to config updates, err:" + error);
    process.exit(1);
  }
}

main().catch((e) => console.error(e));
curl 'http://localhost:<DAPR_HTTP_PORT>/v1.0/configuration/configstore/<subscription-id>/unsubscribe'
Invoke-RestMethod -Uri 'http://localhost:<DAPR_HTTP_PORT>/v1.0/configuration/configstore/<subscription-id>/unsubscribe'

下一步

9 - 分布式锁

分布式锁为应用程序提供对共享资源的独占访问。

9.1 - 分布式锁概述

分布式锁API构建模块概述

介绍

锁用于确保资源的互斥访问。例如,您可以使用锁来:

  • 独占访问数据库的行、表或整个数据库
  • 顺序锁定从队列中读取消息

任何需要更新的共享资源都可以被锁定。锁通常用于改变状态的操作,而不是读取操作。

每个锁都有一个名称。应用程序决定锁定哪些资源。通常,同一应用程序的多个实例使用这个命名锁来独占访问资源并进行更新。

例如,在竞争消费者模式中,应用程序的多个实例访问一个队列。您可以选择在应用程序执行其业务逻辑时锁定队列。

在下图中,同一应用程序的两个实例,App1,使用Redis锁组件来锁定共享资源。

  • 第一个应用程序实例获取命名锁并获得独占访问权。
  • 第二个应用程序实例无法获取锁,因此在锁被释放之前不允许访问资源,释放方式可以是:
    • 通过应用程序显式调用解锁API,或
    • 由于租约超时而在一段时间后自动释放。

*此API目前处于Alpha状态。

特性

资源的互斥访问

在任何给定时刻,只有一个应用程序实例可以持有命名锁。锁的范围限定在Dapr应用程序ID内。

使用租约防止死锁

Dapr分布式锁使用基于租约的锁定机制。如果应用程序获取锁后遇到异常,无法释放锁,则锁将在一段时间后通过租约自动释放。这防止了在应用程序故障时发生资源死锁。

演示

观看此视频以了解分布式锁API的概述

下一步

请参阅以下指南:

9.2 - 操作指南:使用锁

学习如何使用分布式锁来提供对资源的独占访问

了解了Dapr分布式锁API构建块的功能后,学习如何在服务中使用它。在本指南中,我们将通过一个示例应用程序演示如何使用Redis锁组件获取锁。有关支持的锁存储类型,请参阅此参考页面

下图展示了相同应用程序的两个实例尝试获取锁,其中一个成功,另一个被拒绝。

下图显示了相同应用程序的两个实例获取锁,其中一个实例成功,另一个被拒绝

下图展示了相同应用程序的两个实例,其中一个实例释放锁,另一个实例随后成功获取锁。

图示显示了从相同应用程序的多个实例中释放锁

下图展示了不同应用程序的两个实例在同一资源上获取不同的锁。

下图显示了不同应用程序的两个实例,在同一资源上获取不同的锁

配置锁组件

将以下组件文件保存到您机器上的默认组件文件夹

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: lockstore
spec:
  type: lock.redis
  version: v1
  metadata:
  - name: redisHost
    value: localhost:6379
  - name: redisPassword
    value: <PASSWORD>

获取锁

curl -X POST http://localhost:3500/v1.0-alpha1/lock/lockstore
   -H 'Content-Type: application/json'
   -d '{"resourceId":"my_file_name", "lockOwner":"random_id_abc123", "expiryInSeconds": 60}'
using System;
using Dapr.Client;

namespace LockService
{
    class Program
    {
        [Obsolete("Distributed Lock API is in Alpha, this can be removed once it is stable.")]
        static async Task Main(string[] args)
        {
            string DAPR_LOCK_NAME = "lockstore";
            string fileName = "my_file_name";
            var client = new DaprClientBuilder().Build();
    
            await using (var fileLock = await client.Lock(DAPR_LOCK_NAME, fileName, "random_id_abc123", 60))
            {
                if (fileLock.Success)
                {
                    Console.WriteLine("Success");
                }
                else
                {
                    Console.WriteLine($"Failed to lock {fileName}.");
                }
            }
        }
    }
}
package main

import (
    "fmt"

    dapr "github.com/dapr/go-sdk/client"
)

func main() {
    client, err := dapr.NewClient()
    if err != nil {
        panic(err)
    }
    defer client.Close()
    
    resp, err := client.TryLockAlpha1(ctx, "lockstore", &dapr.LockRequest{
			LockOwner:         "random_id_abc123",
			ResourceID:      "my_file_name",
			ExpiryInSeconds: 60,
		})

    fmt.Println(resp.Success)
}

解锁现有锁

curl -X POST http://localhost:3500/v1.0-alpha1/unlock/lockstore
   -H 'Content-Type: application/json'
   -d '{"resourceId":"my_file_name", "lockOwner":"random_id_abc123"}'
using System;
using Dapr.Client;

namespace LockService
{
    class Program
    {
        static async Task Main(string[] args)
        {
            string DAPR_LOCK_NAME = "lockstore";
            var client = new DaprClientBuilder().Build();

            var response = await client.Unlock(DAPR_LOCK_NAME, "my_file_name", "random_id_abc123"));
            Console.WriteLine(response.status);
        }
    }
}
package main

import (
    "fmt"

    dapr "github.com/dapr/go-sdk/client"
)

func main() {
    client, err := dapr.NewClient()
    if err != nil {
        panic(err)
    }
    defer client.Close()
    
    resp, err := client.UnlockAlpha1(ctx, "lockstore", &UnlockRequest{
			LockOwner:    "random_id_abc123",
			ResourceID: "my_file_name",
		})

    fmt.Println(resp.Status)
}

下一步

阅读分布式锁API概述以了解更多信息。

10 - 任务

管理任务的调度与编排

10.1 - 作业概述

作业API构建模块概述

许多应用程序需要作业调度,或者需要在未来执行某些操作。作业API是一个用于管理和安排这些未来作业的工具,可以在特定时间或间隔执行。

作业API不仅帮助您安排作业,Dapr内部还利用调度服务来安排actor提醒。

在Dapr中,作业包括:

查看示例场景。

显示调度器控制平面服务和作业API的图示

工作原理

作业API是一个作业调度器,而不是作业的执行者。设计上保证作业至少执行一次,注重可靠性和可扩展性,而非精确性。这意味着:

  • 保证: 作业不会在计划时间之前被调用。
  • 不保证: 作业在到期时间之后被调用的具体时间。

所有计划作业的详细信息和用户相关数据都存储在调度器服务的Etcd数据库中。 您可以使用作业来:

  • 延迟您的pubsub消息传递 您可以在未来的特定时间发布消息(例如:一周后,或特定的UTC日期/时间)。
  • 调度应用程序之间的服务调用方法。

场景

作业调度在以下场景中可能会有所帮助:

  • 自动化数据库备份: 确保数据库每天备份以防止数据丢失。安排一个备份脚本在每晚2点运行,创建数据库备份并将其存储在安全位置。

  • 定期数据处理和ETL(提取、转换、加载): 处理和转换来自各种来源的原始数据并将其加载到数据仓库中。安排ETL作业在特定时间运行(例如:每小时、每天)以获取新数据、处理并更新数据仓库中的信息。

  • 电子邮件通知和报告: 通过电子邮件接收每日销售报告和每周性能摘要。安排一个作业生成所需的报告并在每天早上6点通过电子邮件发送每日报告,每周一早上8点发送每周摘要。

  • 维护任务和系统更新: 执行定期维护任务,如清理临时文件、更新软件和检查系统健康状况。安排各种维护脚本在非高峰时段运行,如周末或深夜,以尽量减少对用户的干扰。

  • 金融交易的批处理: 处理需要在每个工作日结束时批处理和结算的大量交易。安排批处理作业在每个工作日下午5点运行,汇总当天的交易并执行必要的结算和对账。

Dapr的作业API确保这些场景中表示的任务在没有人工干预的情况下始终如一地执行,提高效率并减少错误风险。

特性

作业API提供了多种特性,使您可以轻松调度作业。

在多个副本之间调度作业

调度器服务支持在多个副本之间扩展作业调度,同时保证作业仅由一个调度器服务实例触发。

试用作业API

您可以在应用程序中试用作业API。在Dapr安装完成后,您可以开始使用作业API,从如何:调度作业指南开始。

下一步

10.2 - 操作指南:调度和处理触发的作业

学习如何使用作业API来调度和处理触发的作业

现在您已经了解了作业构建块提供的功能,让我们来看一个如何使用API的示例。下面的代码示例描述了一个为数据库备份应用程序调度作业并在触发时处理它们的应用程序,也就是作业因到达其到期时间而被返回到应用程序的时间。

启动调度器服务

当您在本地托管模式或Kubernetes上运行dapr init时,Dapr调度器服务会启动。

设置作业API

在您的代码中,配置并调度应用程序内的作业。

以下.NET SDK代码示例调度名为prod-db-backup的作业。作业数据包含有关您将定期备份的数据库的信息。在本示例中,您将:

  • 定义在示例其余部分中使用的类型
  • 在应用程序启动期间注册一个端点,以处理服务上的所有作业触发调用
  • 向Dapr注册作业

在以下示例中,您将创建记录,序列化并与作业一起注册,以便在将来作业被触发时可以使用这些信息:

  • 备份任务的名称(db-backup
  • 备份任务的Metadata,包括:
    • 数据库名称(DBName
    • 数据库位置(BackupLocation

创建一个ASP.NET Core项目,并从NuGet添加最新版本的Dapr.Jobs

注意: 虽然您的项目不严格需要使用Microsoft.NET.Sdk.Web SDK来创建作业,但在撰写本文档时,只有调度作业的服务会接收到其触发调用。由于这些调用期望有一个可以处理作业触发的端点,并且需要Microsoft.NET.Sdk.Web SDK,因此建议您为此目的使用ASP.NET Core项目。

首先定义类型以持久化我们的备份作业数据,并将我们自己的JSON属性名称属性应用于属性,以便它们与其他语言示例保持一致。

//定义我们将用来表示作业数据的类型
internal sealed record BackupJobData([property: JsonPropertyName("task")] string Task, [property: JsonPropertyName("metadata")] BackupMetadata Metadata);
internal sealed record BackupMetadata([property: JsonPropertyName("DBName")]string DatabaseName, [property: JsonPropertyName("BackupLocation")] string BackupLocation);

接下来,作为应用程序设置的一部分,设置一个处理程序,该处理程序将在作业在您的应用程序上被触发时调用。此处理程序负责根据提供的作业名称识别应如何处理作业。

这通过在ASP.NET Core中注册一个处理程序来实现,路径为/job/<job-name>,其中<job-name>是参数化的,并传递给此处理程序委托,以满足Dapr期望有一个端点可用于处理触发的命名作业。

在您的Program.cs文件中填入以下内容:

using System.Text;
using System.Text.Json;
using Dapr.Jobs;
using Dapr.Jobs.Extensions;
using Dapr.Jobs.Models;
using Dapr.Jobs.Models.Responses;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDaprJobsClient();
var app = builder.Build();

//注册一个端点以接收和处理触发的作业
var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(5));
app.MapDaprScheduledJobHandler((string jobName, DaprJobDetails jobDetails, ILogger logger, CancellationToken cancellationToken) => {
  logger?.LogInformation("Received trigger invocation for job '{jobName}'", jobName);
  switch (jobName)
  {
    case "prod-db-backup":
      // 反序列化作业负载元数据
      var jobData = JsonSerializer.Deserialize<BackupJobData>(jobDetails.Payload);
      
      // 处理备份操作 - 我们假设这在您的代码中已实现
      await BackupDatabaseAsync(jobData, cancellationToken);
      break;
  }
}, cancellationTokenSource.Token);

await app.RunAsync();

最后,作业本身需要在Dapr中注册,以便可以在以后触发。您可以通过将DaprJobsClient注入到类中并作为应用程序的入站操作的一部分执行此操作,但为了本示例的目的,它将放在您上面开始的Program.cs文件的底部。因为您将使用依赖注入注册的DaprJobsClient,所以首先创建一个范围以便可以访问它。

//创建一个范围以便可以访问注册的DaprJobsClient
await using scope = app.Services.CreateAsyncScope();
var daprJobsClient = scope.ServiceProvider.GetRequiredService<DaprJobsClient>();

//创建我们希望与未来作业触发一起呈现的负载
var jobData = new BackupJobData("db-backup", new BackupMetadata("my-prod-db", "/backup-dir")); 

//将我们的负载序列化为UTF-8字节
var serializedJobData = JsonSerializer.SerializeToUtf8Bytes(jobData);

//调度我们的备份作业每分钟运行一次,但只重复10次
await daprJobsClient.ScheduleJobAsync("prod-db-backup", DaprJobSchedule.FromDuration(TimeSpan.FromMinutes(1)),
    serializedJobData, repeats: 10);

以下Go SDK代码示例调度名为prod-db-backup的作业。作业数据存储在备份数据库("my-prod-db")中,并使用ScheduleJobAlpha1进行调度。这提供了jobData,其中包括:

  • 备份Task名称
  • 备份任务的Metadata,包括:
    • 数据库名称(DBName
    • 数据库位置(BackupLocation
package main

import (
    //...

	daprc "github.com/dapr/go-sdk/client"
	"github.com/dapr/go-sdk/examples/dist-scheduler/api"
	"github.com/dapr/go-sdk/service/common"
	daprs "github.com/dapr/go-sdk/service/grpc"
)

func main() {
    // 初始化服务器
	server, err := daprs.NewService(":50070")
    // ...

	if err = server.AddJobEventHandler("prod-db-backup", prodDBBackupHandler); err != nil {
		log.Fatalf("failed to register job event handler: %v", err)
	}

	log.Println("starting server")
	go func() {
		if err = server.Start(); err != nil {
			log.Fatalf("failed to start server: %v", err)
		}
	}()
    // ...

    // 设置备份位置
	jobData, err := json.Marshal(&api.DBBackup{
		Task: "db-backup",
		Metadata: api.Metadata{
			DBName:         "my-prod-db",
			BackupLocation: "/backup-dir",
		},
	},
	)
	// ...
}

作业是通过设置Schedule和所需的Repeats数量来调度的。这些设置决定了作业应被触发并发送回应用程序的最大次数。

在此示例中,在触发时间,即根据Schedule@every 1s,此作业被触发并最多发送回应用程序Repeats10)次。

    // ...
    // 设置作业
	job := daprc.Job{
		Name:     "prod-db-backup",
		Schedule: "@every 1s",
		Repeats:  10,
		Data: &anypb.Any{
			Value: jobData,
		},
	}

在触发时间,调用prodDBBackupHandler函数,在触发时间执行此作业的所需业务逻辑。例如:

HTTP

当您使用Dapr的作业API创建作业时,Dapr会自动假定在/job/<job-name>有一个可用的端点。例如,如果您调度一个名为test的作业,Dapr期望您的应用程序在/job/test监听作业事件。确保您的应用程序为此端点设置了一个处理程序,以便在作业被触发时处理它。例如:

注意:以下示例是用Go编写的,但适用于任何编程语言。


func main() {
    ...
    http.HandleFunc("/job/", handleJob)
	http.HandleFunc("/job/<job-name>", specificJob)
    ...
}

func specificJob(w http.ResponseWriter, r *http.Request) {
    // 处理特定触发的作业
}

func handleJob(w http.ResponseWriter, r *http.Request) {
    // 处理触发的作业
}

gRPC

当作业到达其计划的触发时间时,触发的作业通过以下回调函数发送回应用程序:

注意:以下示例是用Go编写的,但适用于任何支持gRPC的编程语言。

import rtv1 "github.com/dapr/dapr/pkg/proto/runtime/v1"
...
func (s *JobService) OnJobEventAlpha1(ctx context.Context, in *rtv1.JobEventRequest) (*rtv1.JobEventResponse, error) {
    // 处理触发的作业
}

此函数在您的gRPC服务器上下文中处理触发的作业。当您设置服务器时,确保注册回调服务器,当作业被触发时将调用此函数:

...
js := &JobService{}
rtv1.RegisterAppCallbackAlphaServer(server, js)

在此设置中,您可以完全控制如何接收和处理触发的作业,因为它们直接通过此gRPC方法路由。

SDKs

对于SDK用户,处理触发的作业更简单。当作业被触发时,Dapr会自动将作业路由到您在服务器初始化期间设置的事件处理程序。例如,在Go中,您可以这样注册事件处理程序:

...
if err = server.AddJobEventHandler("prod-db-backup", prodDBBackupHandler); err != nil {
    log.Fatalf("failed to register job event handler: %v", err)
}

Dapr负责底层路由。当作业被触发时,您的prodDBBackupHandler函数将被调用,并带有触发的作业数据。以下是处理触发作业的示例:

// ...

// 在作业触发时调用此函数
func prodDBBackupHandler(ctx context.Context, job *common.JobEvent) error {
	var jobData common.Job
	if err := json.Unmarshal(job.Data, &jobData); err != nil {
		// ...
	}

	var jobPayload api.DBBackup
	if err := json.Unmarshal(job.Data, &jobPayload); err != nil {
		// ...
	}
	fmt.Printf("job %d received:\n type: %v \n typeurl: %v\n value: %v\n extracted payload: %v\n", jobCount, job.JobType, jobData.TypeURL, jobData.Value, jobPayload)
	jobCount++
	return nil
}

运行Dapr sidecar

一旦您在应用程序中设置了作业API,在终端窗口中使用以下命令运行Dapr sidecar。

dapr run --app-id=distributed-scheduler \
                --metrics-port=9091 \
                --dapr-grpc-port 50001 \
                --app-port 50070 \
                --app-protocol grpc \
                --log-level debug \
                go run ./main.go

下一步

11 - 互动

通过提示有效使用大型语言模型(LLMs)

11.1 - 会话概述

会话API功能概述

Dapr的会话API简化了与大型语言模型(LLM)进行大规模、安全、可靠交互的复杂性。无论您是缺乏必要本地SDK的开发者,还是只想专注于LLM交互提示的多语言开发团队,会话API都提供了一个统一的API接口来与底层LLM提供商进行对话。

显示用户应用与Dapr的LLM组件通信流程的图示。

除了启用关键的性能和安全功能(如提示缓存个人信息清理),您还可以将会话API与Dapr的其他功能结合使用,例如:

  • 弹性断路器和重试机制,以应对限制和令牌错误,或
  • 中间件,用于验证与LLM之间的请求

Dapr通过为您的LLM交互提供指标,增强了系统的可观测性。

功能

以下功能适用于所有支持的会话组件

提示缓存

提示缓存通过存储和重用在多个API调用中经常重复的提示来优化性能。Dapr将这些频繁使用的提示存储在本地缓存中,从而显著减少延迟和成本,使您的集群、pod或其他组件可以重用,而无需为每个新请求重新处理信息。

个人信息清理

个人信息清理功能能够识别并删除会话响应中的任何形式的敏感用户信息。只需在输入和输出数据上启用此功能,即可保护您的隐私,清除可能用于识别个人的敏感细节。

演示

观看在Diagrid的Dapr v1.15庆祝活动中展示的演示,了解会话API如何使用.NET SDK工作。

试用会话

快速入门和教程

想要测试Dapr会话API?通过以下快速入门和教程来查看其实际应用:

快速入门/教程描述
会话快速入门TODO

直接在您的应用中开始使用会话API

想跳过快速入门?没问题。您可以直接在您的应用中试用会话模块。在Dapr安装完成后,您可以从操作指南开始使用会话API。

下一步

11.2 - 操作指南:使用 conversation API 与 LLM 对话

学习如何简化与大型语言模型交互的复杂性

让我们开始使用 conversation API。在本指南中,您将学习如何:

  • 配置一个可用的 Dapr 组件(echo),以便与 conversation API 搭配使用。
  • 将 conversation 客户端集成到您的应用程序中。
  • 使用 dapr run 启动连接。

配置 conversation 组件

创建一个名为 conversation.yaml 的新配置文件,并将其保存到应用程序目录中的组件或配置子文件夹中。

为您的 conversation.yaml 文件选择 合适的 conversation 组件规范

在这个场景中,我们使用一个简单的 echo 组件。

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: echo
spec:
  type: conversation.echo
  version: v1

集成 conversation 客户端

以下示例使用 HTTP 客户端向 Dapr 的 sidecar HTTP 端点发送 POST 请求。您也可以使用 Dapr SDK 客户端

using Dapr.AI.Conversation;
using Dapr.AI.Conversation.Extensions;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDaprConversationClient();

var app = builder.Build();

var conversationClient = app.Services.GetRequiredService<DaprConversationClient>();
var response = await conversationClient.ConverseAsync("conversation",
    new List<DaprConversationInput>
    {
        new DaprConversationInput(
            "Please write a witty haiku about the Dapr distributed programming framework at dapr.io",
            DaprConversationRole.Generic)
    });

Console.WriteLine("Received the following from the LLM:");
foreach (var resp in response.Outputs)
{
    Console.WriteLine($"\t{resp.Result}");
}
package main

import (
	"context"
	"fmt"
	dapr "github.com/dapr/go-sdk/client"
	"log"
)

func main() {
	client, err := dapr.NewClient()
	if err != nil {
		panic(err)
	}

	input := dapr.ConversationInput{
		Message: "Please write a witty haiku about the Dapr distributed programming framework at dapr.io",
		// Role:     nil, // Optional
		// ScrubPII: nil, // Optional
	}

	fmt.Printf("conversation input: %s\n", input.Message)

	var conversationComponent = "echo"

	request := dapr.NewConversationRequest(conversationComponent, []dapr.ConversationInput{input})

	resp, err := client.ConverseAlpha1(context.Background(), request)
	if err != nil {
		log.Fatalf("err: %v", err)
	}

	fmt.Printf("conversation output: %s\n", resp.Outputs[0].Result)
}
use dapr::client::{ConversationInputBuilder, ConversationRequestBuilder};
use std::thread;
use std::time::Duration;

type DaprClient = dapr::Client<dapr::client::TonicClient>;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Sleep to allow for the server to become available
    thread::sleep(Duration::from_secs(5));

    // Set the Dapr address
    let address = "https://127.0.0.1".to_string();

    let mut client = DaprClient::connect(address).await?;

    let input = ConversationInputBuilder::new("Please write a witty haiku about the Dapr distributed programming framework at dapr.io").build();

    let conversation_component = "echo";

    let request =
        ConversationRequestBuilder::new(conversation_component, vec![input.clone()]).build();

    println!("conversation input: {:?}", input.message);

    let response = client.converse_alpha1(request).await?;

    println!("conversation output: {:?}", response.outputs[0].result);
    Ok(())
}

启动 conversation 连接

使用 dapr run 命令启动连接。例如,在这个场景中,我们在一个应用程序上运行 dapr run,其应用程序 ID 为 conversation,并指向 ./config 目录中的 conversation YAML 文件。

dapr run --app-id conversation --dapr-grpc-port 50001 --log-level debug --resources-path ./config -- dotnet run
dapr run --app-id conversation --dapr-grpc-port 50001 --log-level debug --resources-path ./config -- go run ./main.go

预期输出

  - '== APP == conversation output: Please write a witty haiku about the Dapr distributed programming framework at dapr.io'
dapr run --app-id=conversation --resources-path ./config --dapr-grpc-port 3500 -- cargo run --example conversation

预期输出

  - 'conversation input: hello world'
  - 'conversation output: hello world'

相关链接

尝试使用支持的 SDK 仓库中提供的完整示例来体验 conversation API。

下一步

12 -

type: docs title: “加密技术” linkTitle: “加密技术” weight: 100 description: “在不暴露密钥的情况下执行加密操作,确保应用程序的安全性”


12.1 - 加密概述

Dapr 加密概述

使用加密构建块,您可以安全且一致地利用加密技术。Dapr 提供的 API 允许您在密钥库或 Dapr sidecar 中执行加密和解密操作,而无需将加密密钥暴露给您的应用程序。

为什么需要加密?

加密技术在应用程序中被广泛使用,正确实施可以在数据泄露时提高安全性。在某些情况下,您可能需要使用加密技术以符合行业法规(如金融领域)或法律要求(如 GDPR 等隐私法规)。

然而,正确使用加密技术可能很复杂。您需要:

  • 选择合适的算法和选项
  • 学习正确的密钥管理和保护方法
  • 在希望限制对加密密钥材料的访问时,处理操作复杂性

安全的一个重要要求是限制对加密密钥的访问,这通常被称为“原始密钥材料”。Dapr 可以与密钥库集成,如 Azure Key Vault(未来将支持更多组件),这些密钥库将密钥存储在安全的环境中,并在库中执行加密操作,而不将密钥暴露给您的应用程序或 Dapr。

或者,您可以配置 Dapr 为您管理加密密钥,在 sidecar 中执行操作,同样不将原始密钥材料暴露给您的应用程序。

Dapr 中的加密

使用 Dapr,您可以在不将加密密钥暴露给应用程序的情况下执行加密操作。

显示 Dapr 加密如何与您的应用程序协作的图示

通过使用加密构建块,您可以:

  • 更轻松地以安全的方式执行加密操作。Dapr 提供了防止使用不安全算法或不安全选项的保护措施。
  • 将密钥保存在应用程序之外。应用程序从未看到“原始密钥材料”,但可以请求库使用密钥执行操作。当使用 Dapr 的加密引擎时,操作在 Dapr sidecar 中安全地执行。
  • 实现更好的关注点分离。通过使用外部库或加密组件,只有授权团队可以访问私钥材料。
  • 更轻松地管理和轮换密钥。密钥在库中管理并在应用程序之外,它们可以在不需要开发人员参与(甚至不需要重启应用程序)的情况下轮换。
  • 启用更好的审计日志记录,以监控何时在库中使用密钥执行操作。

功能

加密组件

Dapr 加密构建块包括两种组件:

  • 允许与管理服务或库(“密钥库”)交互的组件。
    类似于 Dapr 在各种 secret 存储或 state 存储之上的“抽象层”,这些组件允许与各种密钥库(如 Azure Key Vault)交互(未来 Dapr 版本中会有更多)。通过这些组件,对私钥的加密操作在库中执行,Dapr 从未看到您的私钥。

  • 基于 Dapr 自身加密引擎的组件。
    当密钥库不可用时,您可以利用基于 Dapr 自身加密引擎的组件。这些组件名称中带有 .dapr.,在 Dapr sidecar 中执行加密操作,密钥存储在文件、Kubernetes secret 或其他来源中。虽然 Dapr 知道私钥,但它们仍然对您的应用程序不可用。

这两种组件,无论是利用密钥库还是使用 Dapr 中的加密引擎,都提供相同的抽象层。这允许您的解决方案根据需要在各种库和/或加密组件之间切换。例如,您可以在开发期间使用本地存储的密钥,而在生产中使用云库。

加密 API

加密 API 允许使用 Dapr Crypto Scheme v1 加密和解密数据。这是一种有见地的加密方案,旨在使用现代、安全的加密标准,并以流的方式高效处理数据(甚至是大文件)。

试用加密

快速入门和教程

想要测试 Dapr 加密 API 吗?通过以下快速入门和教程,看看加密如何实际运作:

快速入门/教程描述
加密快速入门使用加密 API 使用 RSA 和 AES 密钥加密和解密消息和大文件。

直接在您的应用程序中开始使用加密

想要跳过快速入门?没问题。您可以直接在应用程序中试用加密构建块来加密和解密您的应用程序。在 安装 Dapr 后,您可以从 加密操作指南 开始使用加密 API。

演示

观看此 Dapr 社区电话 #83 中的加密 API 演示视频

下一步

使用加密 API >>

相关链接

12.2 - 如何:使用加密API

学习如何加密和解密文件

在您了解了Dapr作为加密构建块之后,让我们通过使用SDK来学习如何使用加密API。

加密

在您的项目中使用Dapr SDK和gRPC API,您可以加密数据流,例如文件或字符串:

# 当传递数据(缓冲区或字符串)时,`encrypt`会返回一个包含加密信息的缓冲区
def encrypt_decrypt_string(dapr: DaprClient):
    message = 'The secret is "passw0rd"'

    # 加密消息
    resp = dapr.encrypt(
        data=message.encode(),
        options=EncryptOptions(
            # 加密组件的名称(必需)
            component_name=CRYPTO_COMPONENT_NAME,
            # 存储在加密组件中的密钥(必需)
            key_name=RSA_KEY_NAME,
            # 用于包装密钥的算法,必须由上述密钥支持。
            # 选项包括:"RSA", "AES"
            key_wrap_algorithm='RSA',
        ),
    )

    # 该方法返回一个可读流,我们将其完整读取到内存中
    encrypt_bytes = resp.read()
    print(f'加密后的消息长度为 {len(encrypt_bytes)} 字节')

在您的项目中使用Dapr SDK和gRPC API,您可以加密缓冲区或字符串中的数据:

// 当传递数据(缓冲区或字符串)时,`encrypt`会返回一个包含加密信息的缓冲区
const ciphertext = await client.crypto.encrypt(plaintext, {
    // Dapr组件的名称(必需)
    componentName: "mycryptocomponent",
    // 存储在组件中的密钥名称(必需)
    keyName: "mykey",
    // 用于包装密钥的算法,必须由上述密钥支持。
    // 选项包括:"RSA", "AES"
    keyWrapAlgorithm: "RSA",
});

API也可以与流一起使用,以更高效地加密来自流的数据。下面的示例使用流加密文件,并写入另一个文件:

// `encrypt`可以用作双工流
await pipeline(
    fs.createReadStream("plaintext.txt"),
    await client.crypto.encrypt({
        // Dapr组件的名称(必需)
        componentName: "mycryptocomponent",
        // 存储在组件中的密钥名称(必需)
        keyName: "mykey",
        // 用于包装密钥的算法,必须由上述密钥支持。
        // 选项包括:"RSA", "AES"
        keyWrapAlgorithm: "RSA",
    }),
    fs.createWriteStream("ciphertext.out"),
);

在您的项目中使用Dapr SDK和gRPC API,您可以加密字符串或字节数组中的数据:

using var client = new DaprClientBuilder().Build();

const string componentName = "azurekeyvault"; //更改此以匹配您的加密组件
const string keyName = "myKey"; //更改此以匹配您加密存储中的密钥名称

const string plainText = "This is the value we're going to encrypt today";

//将字符串编码为UTF-8字节数组并加密
var plainTextBytes = Encoding.UTF8.GetBytes(plainText);
var encryptedBytesResult = await client.EncryptAsync(componentName, plaintextBytes, keyName, new EncryptionOptions(KeyWrapAlgorithm.Rsa));

在您的项目中使用Dapr SDK,您可以加密数据流,例如文件。

out, err := sdkClient.Encrypt(context.Background(), rf, dapr.EncryptOptions{
	// Dapr组件的名称(必需)
	ComponentName: "mycryptocomponent",
	// 存储在组件中的密钥名称(必需)
	KeyName:       "mykey",
	// 用于包装密钥的算法,必须由上述密钥支持。
	// 选项包括:"RSA", "AES"
	Algorithm:     "RSA",
})

以下示例将Encrypt API置于上下文中,代码读取文件,加密它,然后将结果存储在另一个文件中。

// 输入文件,明文
rf, err := os.Open("input")
if err != nil {
	panic(err)
}
defer rf.Close()

// 输出文件,加密
wf, err := os.Create("output.enc")
if err != nil {
	panic(err)
}
defer wf.Close()

// 使用Dapr加密数据
out, err := sdkClient.Encrypt(context.Background(), rf, dapr.EncryptOptions{
	// 这是3个必需参数
	ComponentName: "mycryptocomponent",
	KeyName:       "mykey",
	Algorithm:     "RSA",
})
if err != nil {
	panic(err)
}

// 读取流并将其复制到输出文件
n, err := io.Copy(wf, out)
if err != nil {
	panic(err)
}
fmt.Println("已写入", n, "字节")

以下示例使用Encrypt API加密字符串。

// 输入字符串
rf := strings.NewReader("Amor, ch’a nullo amato amar perdona, mi prese del costui piacer sì forte, che, come vedi, ancor non m’abbandona")

// 使用Dapr加密数据
enc, err := sdkClient.Encrypt(context.Background(), rf, dapr.EncryptOptions{
	ComponentName: "mycryptocomponent",
	KeyName:       "mykey",
	Algorithm:     "RSA",
})
if err != nil {
	panic(err)
}

// 将加密数据读取到字节切片中
enc, err := io.ReadAll(enc)
if err != nil {
	panic(err)
}

解密

要解密数据流,请使用decrypt

def encrypt_decrypt_string(dapr: DaprClient):
    message = 'The secret is "passw0rd"'

    # ...

    # 解密加密数据
    resp = dapr.decrypt(
        data=encrypt_bytes,
        options=DecryptOptions(
            # 加密组件的名称(必需)
            component_name=CRYPTO_COMPONENT_NAME,
            # 存储在加密组件中的密钥(必需)
            key_name=RSA_KEY_NAME,
        ),
    )

    # 该方法返回一个可读流,我们将其完整读取到内存中
    decrypt_bytes = resp.read()
    print(f'解密后的消息长度为 {len(decrypt_bytes)} 字节')

    print(decrypt_bytes.decode())
    assert message == decrypt_bytes.decode()

使用Dapr SDK,您可以解密缓冲区中的数据或使用流。

// 当传递数据作为缓冲区时,`decrypt`会返回一个包含解密信息的缓冲区
const plaintext = await client.crypto.decrypt(ciphertext, {
    // 唯一必需的选项是组件名称
    componentName: "mycryptocomponent",
});

// `decrypt`也可以用作双工流
await pipeline(
    fs.createReadStream("ciphertext.out"),
    await client.crypto.decrypt({
        // 唯一必需的选项是组件名称
        componentName: "mycryptocomponent",
    }),
    fs.createWriteStream("plaintext.out"),
);

要解密字符串,请在您的项目中使用’解密Async’ gRPC API。

在以下示例中,我们将获取一个字节数组(例如上面的示例)并将其解密为UTF-8编码的字符串。

public async Task<string> DecryptBytesAsync(byte[] encryptedBytes)
{
  using var client = new DaprClientBuilder().Build();

  const string componentName = "azurekeyvault"; //更改此以匹配您的加密组件
  const string keyName = "myKey"; //更改此以匹配您加密存储中的密钥名称

  var decryptedBytes = await client.DecryptAsync(componentName, encryptedBytes, keyName);
  var decryptedString = Encoding.UTF8.GetString(decryptedBytes.ToArray());
  return decryptedString;
}

要解密文件,请在您的项目中使用Decrypt gRPC API。

在以下示例中,out是一个可以写入文件或在内存中读取的流,如上面的示例中所示。

out, err := sdkClient.Decrypt(context.Background(), rf, dapr.EncryptOptions{
	// 唯一必需的选项是组件名称
	ComponentName: "mycryptocomponent",
})

下一步

加密组件规范