봇에 사용자 지정 스토리지 구현Implement custom storage for your bot

적용 대상: SDK v4APPLIES TO: SDK v4

봇의 상호 작용은 세 가지 영역으로 나뉩니다. 첫째, Azure Bot Service와 활동을 교환하는 것, 둘째, Store(저장소)를 통해 대화 상태를 로드하고 저장하는 것, 그리고 마지막으로 봇에서 작업을 수행하는 데 필요한 다른 백 엔드 서비스입니다.A bot's interactions fall into three areas: firstly, the exchange of Activities with the Azure Bot Service, secondly, the loading and saving of dialog state with a Store, and finally any other back-end services the bot needs to work with to get its job done.

확장 상호 작용 다이어그램

필수 구성 요소Prerequisites

  • 이 문서에 사용되는 전체 샘플 코드는 여기에서 찾을 수 있습니다. C# 샘플.The full sample code used in this article can be found here: C# sample.

이 문서에서는 Azure Bot Service 및 Store와 봇의 상호 작용에 관한 의미 체계를 살펴봅니다.In this article, we will be exploring the semantics around the bot's interactions with the Azure Bot Service and the Store.

Bot Framework에는 기본 구현이 포함되어 있습니다. 이 구현은 많은 애플리케이션의 요구 사항에 매우 적합하며, 이를 사용하기 위해 수행해야 하는 모든 작업은 몇 줄의 초기화 코드를 통해 해당 요소를 모두 연결하는 것입니다.The Bot Framework includes a default implementation; this implementation will most likely fit the needs of many applications, and all that is needed to be done to make use of it is to plug the pieces together with a few lines of initialization code. 많은 샘플에서 이를 잘 보여 줍니다.Many of the samples illustrate just that.

그러나 여기서의 목표는 기본 구현의 의미 체계가 애플리케이션에서 원하는 대로 제대로 작동하지 않을 때 수행할 수 있는 작업을 설명하는 것입니다.The goal here, however, is to describe what you can do when the semantics of the default implementation doesn't quite work as you might like in your application. 기본 요점은 이 의미 체계가 프레임워크이며, 확정된 동작으로 미리 준비된 애플리케이션이 아니라는 것입니다. 즉 프레임워크의 많은 메커니즘을 구현하는 것이 기본 구현일 뿐이며 유일한 구현은 아니다.The basic point is that this is a framework and not a canned application with fixed behavior, in other words, the implementation of many of the mechanisms in the framework is just the default implementation and not the only implementation.

특히 프레임워크는 Azure Bot Service를 통한 활동 교환과 모든 Bot 상태의 로드 및 저장 사이의 관계를 지시하지 않습니다, 단순히 기본값을 제공합니다.Specifically, the framework does not dictate the relationship between the exchange of Activities with the Azure Bot Service and the loading and saving of any Bot state; it simply provides a default. 이 점을 더 자세히 설명하기 위해 다른 의미 체계가 있는 대체 구현을 개발할 것입니다.To illustrate this point further, we will be developing an alternative implementation that has different semantics. 이 대체 솔루션은 프레임워크에서 똑같이 적합하며, 개발 중인 애플리케이션에 더 적합할 수도 있습니다.This alternative solution sits equally well in the framework and may even be more appropriate for the application being developed. 이는 모두 시나리오에 따라 달라집니다.It all depends on the scenario.

기본 BotFrameworkAdapter 및 스토리지 공급자의 동작Behavior of the default BotFrameworkAdapter and Storage providers

먼저, 다음 시퀀스 다이어그램과 같이 프레임워크 패키지의 일부로 제공되는 기본 구현을 살펴보겠습니다.Firstly, let's review the default implementation that ships as part of the framework packages as shown by the following sequence diagram:

확장 기본 다이어그램

활동을 받으면 봇에서 이 대화에 해당하는 상태를 로드합니다.On receiving an Activity, the bot loads the state corresponding to this conversation. 그런 다음, 이 상태와 방금 받은 활동을 사용하여 대화 논리를 실행합니다.It then runs the dialog logic with this state and the Activity that has just arrived. 대화를 실행하는 과정에서 하나 이상의 아웃바운드 활동이 만들어 즉시 보냅니다.In the process of executing the dialog, one or more outbound activities are created and immediately sent. 대화 처리가 완료되면 봇에서 업데이트된 상태를 저장하여 이전 상태를 새 상태로 덮어씁니다.When the processing of the dialog is complete, the bot saves the updated state, overwriting the old state with new.

이 동작에 문제가 될 수 있는 몇 가지 사항을 고려해 볼 가치가 있습니다.It is worth considering a couple of things that can go wrong with this behavior.

첫째, 어떤 이유로 Save(저장) 작업이 실패하는 경우 응답을 본 사용자가 상태가 앞으로 이동했다고 느끼므로 상태는 채널에서 보이는 것과 암시적으로 동기화되지 않습니다.Firstly, if the Save operation were to fail for some reason the state has implicitly slipped out of sync with what is seen on the channel because the user having seen the responses is under the impression that the state has moved forward, but it hasn't. 이는 일반적으로 상태와 응답 메시지가 성공적인 경우보다 더 나쁩니다.This is generally worse than if the state was successful and the response messaging were successful. 이로 인해 대화 설계에 영향을 미칠 수 있습니다. 예를 들어 대화에는 추가적이거나 그렇지 않으면 중복된 확인 교환이 포함될 수 있습니다.This can have implications for the conversation design: for example, the dialog might include additional, otherwise redundant confirmation exchanges with the user.

둘째, 구현이 여러 노드에 걸쳐 확장되어 배포되는 경우 실수로 상태를 덮어쓸 수 있습니다. 이는 대화에서 확인 메시지를 전송하는 채널에 활동을 보냈을 가능성이 있으므로 특히 혼란스러울 수 있습니다.Secondly, if the implementation is deployed scaled out across multiple nodes, the state can accidentally get overwritten - this can be particularly confusing because the dialog will likely have sent activities to the channel carrying confirmation messages. 토핑에 대해 물어볼 때 사용자가 버섯을 추가하고 지체 없이 치즈를 추가하는 경우 후속 작업을 실행하는 여러 인스턴스가 있는 확장된 시나리오에서 봇을 실행하는 여러 머신에 동시에 보낼 수 있는 피자 주문 봇의 예를 고려해 보세요.Consider the example of a pizza order bot, if the user, on being asked for a topping, adds mushroom and without delay adds cheese, in a scaled-out scenario with multiple instances running subsequent activities can be sent concurrently to different machines running the bot. 이 경우 한 머신이 다른 머신에서 기록한 상태를 덮어쓸 수 있는 "경합 상태"라고 하는 것이 있습니다.When this happens, there is what is referred to as a "race condition" where one machine might overwrite the state written by another. 하지만 시나리오에서는 응답이 이미 전송되었으므로 사용자는 버섯과 치즈가 모두 추가되었다는 확인 메시지를 받았습니다.However, in our scenario, because the responses were already sent, the user has received confirmation that both mushroom and cheese were added. 아쉽게도 피자가 도착하면 버섯 또는 치즈만 포함되고 둘 모두는 포함되지 않았을 것입니다.Unfortunately, when the pizza arrives, it will only contain mushroom or cheese, not both.

낙관적 잠금Optimistic locking

해결 방법은 상태 주위에 일부 잠금을 도입하는 것입니다.The solution is to introduce some locking around the state. 여기서 사용할 특정 잠금 방식은 '낙관적 잠금'이라고 합니다. 왜냐하면 모든 것이 마치 각각 실행되는 것처럼 실행되게 하고 처리가 완료되면 동시성 위반을 감지하게 되기 때문입니다.The particular style of locking we will be using here is called optimistic locking because we will let everything run as if they were each the only thing running and then we will detect any concurrency violations after the processing has been done. 이는 복잡하게 들릴 수 있지만, 봇 프레임워크에서 클라우드 스토리지 기술과 올바른 확장 지점을 사용하여 구축하기가 매우 쉽습니다.This may sound complicated but is very easy to build using cloud storage technologies and the right extension points in the bot framework.

여기서는 ETag(엔터티 태그) 헤더에 기반한 표준 HTTP 메커니즘을 사용합니다.We will use a standard HTTP mechanism based on the entity tag header, (ETag). 이 메커니즘을 이해하는 것은 다음 코드를 이해하는 데 중요합니다.Understanding this mechanism is crucial to understanding the code that follows. 다음 다이어그램에서는 시퀀스를 보여 줍니다.The following diagram illustrates the sequence.

확장 전제 조건 실패 다이어그램

이 다이어그램에서는 일부 리소스에 대한 업데이트를 수행하는 두 클라이언트의 경우를 보여 줍니다.The diagram illustrates the case of two clients that are performing an update to some resource. 클라이언트에서 GET 요청을 발급하고 서버에서 리소스가 반환되는 경우 ETag 헤더가 수반됩니다.When a client issues a GET request and a resource is returned from the server, it is accompanied by an ETag header. ETag 헤더는 리소스의 상태를 나타내는 불투명 값입니다.The ETag header is an opaque value that represents the state of the resource. 리소스가 변경되면 ETag가 업데이트됩니다.If a resource is changed, the ETag will be updated. 클라이언트에서 상태를 업데이트하면 서버에 이를 다시 게시하므로 이 요청을 통해 클라이언트에서 이전에 받은 ETag 값을 사전 조건 If-Match 헤더에 연결합니다.When the client has done its update to the state, it POSTs it back to the server, making this request the client attaches the ETag value it had previously received in a precondition If-Match header. 이 ETag가 값과 일치하지 않으면 412 사전 조건 실패로 인해 서버에서 마지막으로 반환(모든 클라이언트에 대한 응답에서)한 사전 조건 확인이 실패합니다.If this ETag does not match the value, the server last returned (on any response, to any client) the precondition check fails with a 412 Precondition Failure. 이 오류는 리소스가 업데이트되었다는 POST 요청을 수행하는 클라이언트에 대한 표시기입니다.This failure is an indicator to the client making the POST request that the resource has been updated. 이 오류가 표시되면 클라이언트에 대한 일반적인 동작은 리소스를 다시 가져오고(GET), 원하는 업데이트를 적용하고, 리소스를 다시 게시(POST)하는 것입니다.On seeing this failure, the typical behavior for a client will be to GET the resource again, apply the update it wanted, and POST the resource back. 물론, 이 두 번째 POST는 다른 클라이언트에서 리소스를 제공하고 업데이트하지 않았다고 가정하고 클라이언트에서 다시 시도하는 경우에만 성공합니다.This second POST will be successful, assuming of course, that no other client has come and updated the resource, and if it has the client will just have to try again.

이 프로세스는 리소스를 보유한 클라이언트에서 계속 처리하지만 리소스 자체가 다른 클라이언트에서 제한 없이 액세스 할 수 있다는 의미에서 "잠겨" 있지 않으므로 "낙관적"이라고 합니다.This process is called "optimistic" because the client, having got hold of a resource proceeds to do its processing, the resource itself is not "locked" in the sense that other clients can access it without any restriction. 처리가 완료될 때까지 리소스 상태에 대한 클라이언트 간의 경합은 확인되지 않습니다.Any contention between clients over what the state of the resource should be is not determined until the processing has been done. 일반적으로 이 전략은 분산형 시스템에서 반대되는 "비관적" 접근 방식보다 더 적합합니다.As a rule, in a distributed system this strategy is more optimal than the opposite "pessimistic" approach.

이제까지 살펴본 낙관적 잠금 메커니즘은 프로그램 논리를 안전하게 다시 시도할 수 있다고 가정하고 있습니다. 물론 여기서 고려해야 할 중요 사항은 외부 서비스 호출에 발생하는 상황입니다.The optimistic locking mechanism we've covered assumes program logic can be safely retried, needless, to say the important thing to consider here is what happens to external service calls. 가장 좋은 해결 방법은 이러한 서비스가 멱등원(idempotent)이 될 수 있는 경우입니다.The ideal solution here is if these services can be made idempotent. 컴퓨터 과학에서, idempotent 작업은 동일한 입력 매개 변수를 사용하여 두 번 이상 호출되는 경우 추가적인 효과가 없습니다.In computer science, an idempotent operation is one that has no additional effect if it is called more than once with the same input parameters. GET, PUT 및 DELETE를 구현하는 순수 HTTP REST 서비스가 이 설명에 적합합니다.Pure HTTP REST services that implement GET, PUT and DELETE fit this description. 여기서 추론은 직관적입니다. 즉 처리를 다시 시도할 수 있으므로 다시 시도의 일환으로 다시 실행될 때 추가 효과가 없는 호출을 수행하는 것이 좋습니다.The reasoning here is intuitive: we might be retrying the processing and so making any calls it needs to make have no additional effect as they are re-executed as part of that retry is a good thing. 이 토론을 위해 여기서는 이상적인 세계에 살고 있고, 이 문서의 시작 부분에 있는 시스템 그림의 오른쪽에 표시된 백 엔드 서비스가 모든 idempotent HTTP REST 서비스라고 가정하여 활동 교환만 집중적으로 살펴보겠습니다.For the sake of this discussion, we will assume we are living in an ideal world and the backend services shown to the right of the system picture at the beginning of this article are all idempotent HTTP REST services, from here on we will focus only on the exchange of activities.

아웃바운드 활동 버퍼링Buffering outbound activities

활동을 보내는 것은 idempotent 작업이 아니며, 엔드투엔드 시나리오에서 많은 의미가 있는 것도 명확하지 않습니다.The sending of an Activity is not an idempotent operation, nor is it clear that would make much sense in the end-to-end scenario. 모든 활동이 완료되면 보기에 추가되거나 텍스트 음성 변환 에이전트에 말하는 메시지를 전달하는 경우가 많습니다.After all the Activity is often just carrying a message that is appended to a view or perhaps spoken by a text to speech agent.

이러한 활동을 보낼 때 방지해야 할 주요 사항은 여러 번 보내는 것입니다.The key thing we want to avoid with sending the activities is sending them multiple times. 당면한 문제는 낙관적 잠금 메커니즘에서는 논리를 여러 번 다시 실행해야 한다는 것입니다.The problem we have is that the optimistic locking mechanism requires that we with rerun our logic possibly multiple times. 해결 방법은 간단합니다. 즉 논리를 다시 실행하지 않는 것을 확신할 수 있을 때까지 대화의 아웃바운드 활동을 버퍼링해야 합니다.The solution is simple: we must buffer the outbound activities from the dialog until we are sure we are not going to rerun the logic. 이는 저장 작업이 성공적으로 완료될 때까지입니다.That is until after we have a successful Save operation. 다음과 같은 흐름을 살펴보겠습니다.We are looking for a flow that looks something like the following:

확장 buffer 다이어그램

대화 실행을 중심으로 다시 시도 루프를 작성할 수 있다고 가정할 때 Save 작업에서 사전 조건 실패가 발생하면 다음 동작이 발생합니다.Assuming we can build a retry loop around the dialog execution we get the following behavior when there is a precondition failure on the Save operation:

다이어그램 저장 확장

이 메커니즘을 적용하고 이전의 예를 다시 살펴보면 주문에 피자 토핑이 추가된다는 잘못된 긍정 승인은 결코 표시되지 않습니다.Applying this mechanism and revisiting our example from earlier we should never see an erroneous positive acknowledgment of a pizza topping being added to an order. 실제로 여러 머신에 걸쳐 배포를 확장했을 수 있지만 낙관적 잠금 체계를 사용하여 상태 업데이트를 효과적으로 직렬화했습니다.In fact, although we might have scaled out our deployment across multiple machines, we have effectively serialized our state updates with the optimistic locking scheme. 그러나 피자 주문에서 항목 추가에 따른 승인은 이제 전체 상태를 정확히 반영하도록 작성할 수도 있습니다.In our pizza ordering but the acknowledgement from adding an item can now even be written to reflect the full state accurately. 예를 들어 사용자가 "치즈"를 바로 입력하면 봇에서 "버섯"을 응답하기 전에 나오는 두 개의 응답은 "치즈로 토핑된 피자"와 "치즈와 버섯으로 토핑된 피자"가 될 수 있습니다.For example, if the user immediately types "cheese" and then before the bot has had a chance to reply "mushroom" the two replies can now be "pizza with cheese" and then "pizza with cheese and mushroom."

시퀀스 다이어그램을 살펴보면 성공적인 Save 작업 후에 응답이 손실될 수 있다는 것을 알 수 있지만, 종단 간 통신에서는 어디서나 응답이 손실될 수 있습니다.Looking at the sequence diagram we can see that the replies could be lost after a successful Save operation, however, they could be lost anywhere in the end to end communication. 요점은 상태 관리 인프라에서 해결할 수 있는 문제가 아니라는 것입니다.The point is this is not a problem the state management infrastructure can fix. 여기에는 더 높은 수준의 프로토콜과 아마도 채널 사용자와 관련된 프로토콜이 필요합니다.It will require a higher-level protocol and possibly one involving the user of the channel. 예를 들어 봇에서 응답하지 않았다고 사용자에게 표시되는 경우 사용자가 최종적으로 다시 시도하거나 이러한 동작을 시도할 것으로 예상하는 것이 합리적입니다.For example, if the bot appears to the user not to have replied it is reasonable to expect the user to ultimately try again or some such behavior. 따라서 시나리오에서 이와 같이 일시적으로 중단되는 경우도 합리적이지만, 사용자가 잘못된 긍정 승인 또는 기타 의도하지 않은 메시지를 필터링할 수 있도록 예상하는 것은 매우 합리적이지 않습니다.So while it is reasonable for a scenario to have occasional transient outages such as this it is far less reasonable to expect a user to be able to filter out erroneous positive acknowledgements or other unintended messages.

새 사용자 지정 스토리지 솔루션에 이 모든 것을 통합하여 프레임워크의 기본 구현에서 수행하지 않는 세 가지 작업을 수행할 것입니다.Pulling this all together, in our new custom storage solution, we are going to do three things the default implementation in the framework doesn't do. 첫째, ETag를 사용하여 경합을 검색하고, 둘째, ETag 오류가 검색되면 처리를 다시 시도하며, 마지막으로 저장이 성공할 때까지 아웃바운드 활동을 버퍼링합니다.Firstly, we are going to use ETags to detect contention, secondly we are going to retry the processing when the ETag failure is detected and thirdly we are going to buffer any outbound Activities until we have a successful save. 이 문서의 나머지 부분에서는 이러한 세 부분의 구현에 대해 설명합니다.The remainder of this article describes the implementation of these three parts.

ETag 지원 구현Implementing ETag Support

ETag를 지 원하는 새 저장소에 대 한 인터페이스를 정의 하 여 시작 합니다.We start out by defining an interface for our new store with ETag support. 이 인터페이스를 사용하면 ASP.NET에서 제공하는 종속성 주입 메커니즘을 매우 쉽게 활용할 수 있습니다.The interface will make it very easy to leverage the dependency injection mechanisms we have in ASP.NET. 인터페이스를 사용 하면 프로덕션 버전을 구현할 수 있습니다.Having the interface means we can implement a version for production. 네트워크에 연결할 필요 없이 메모리에서 실행 되는 단위 테스트에 대 한 버전을 구현할 수도 있습니다.We could also implement a version for unit tests that runs in memory without the need of hitting the network.

인터페이스는 Load(로드) 및 Save(저장) 메서드로 구성됩니다.The interface consists of Load and Save methods. 이 두 가지에는 모두 상태에 사용할 키가 있습니다.Both these take the key we will use for the state. Load 작업은 데이터 및 연결된 ETag를 반환합니다.The Load will return the data and the associated ETag. 그리고 Save 작업에서 이러한 항목을 가져옵니다.And the Save will take these in. 또한 Save 작업은 부울을 반환합니다.Additionally, the Save will return bool. 이 부울은 ETag가 일치하고 Save 작업이 성공했는지 여부를 나타냅니다.This bool will indicate whether the ETag has matched and the Save was successful. 이는 일반적인 오류 표시기가 아니라 사전 조건 실패의 특정 표시기를 위한 것이며, 다시 시도 루프의 형태로 표시기 주위에 제어 흐름 논리를 작성하게 되므로 이를 예외가 아닌 반환 코드로 모델링합니다.This is not intended as a general error indicator but rather a specific indicator of precondition failure, we model this as a return code rather than an exception because we will be writing control flow logic around this in the shape of our retry loop.

이 최하위 수준의 스토리지 부분을 연결할 수 있도록 하려면 직렬화 요구 사항을 배치하지 않아야 하지만, 콘텐츠 저장이 JSON 형식이 되도록 지정하는 것이 좋습니다. 저장소 구현에서는 이러한 방식으로 content(콘텐츠) 형식을 설정할 수 있습니다.As we would like this lowest level storage piece to be pluggable, we will make sure to avoid placing any serialization requirements on it, however we would like to specify that the content save should be JSON, that way a store implementation can set the content-type. .NET에서 이 작업을 수행하는 가장 쉽고 자연스러운 방법은 인수 유형을 사용하는 것입니다. 특히 content 인수를 JObject로 입력합니다.The easiest and most natural way to do this in .NET is through the argument types, specifically we will type the content argument as JObject. JavaScript 또는 TypeScript에서 이는 일반 네이티브 개체일 뿐입니다.In JavaScript or TypeScript this will just be a regular native object.

결과 인터페이스는 다음과 같습니다.This is the resulting interface:

IStore.csIStore.cs

public interface IStore
{
    Task<(JObject content, string etag)> LoadAsync(string key);

    Task<bool> SaveAsync(string key, JObject content, string etag);
}

Azure Blob Storage에 대해 이를 구현하는 것은 간단합니다.Implementing this against Azure Blob Storage is straight forward.

BlobStore.csBlobStore.cs

public class BlobStore : IStore
{
    private readonly CloudBlobContainer _container;

    public BlobStore(string accountName, string accountKey, string containerName)
    {
        if (string.IsNullOrWhiteSpace(accountName))
        {
            throw new ArgumentException(nameof(accountName));
        }

        if (string.IsNullOrWhiteSpace(accountKey))
        {
            throw new ArgumentException(nameof(accountKey));
        }

        if (string.IsNullOrWhiteSpace(containerName))
        {
            throw new ArgumentException(nameof(containerName));
        }

        var storageCredentials = new StorageCredentials(accountName, accountKey);
        var cloudStorageAccount = new CloudStorageAccount(storageCredentials, useHttps: true);
        var client = cloudStorageAccount.CreateCloudBlobClient();
        _container = client.GetContainerReference(containerName);
    }

    public async Task<(JObject content, string etag)> LoadAsync(string key)
    {
        if (string.IsNullOrWhiteSpace(key))
        {
            throw new ArgumentException(nameof(key));
        }

        var blob = _container.GetBlockBlobReference(key);
        try
        {
            var content = await blob.DownloadTextAsync();
            var obj = JObject.Parse(content);
            var etag = blob.Properties.ETag;
            return (obj, etag);
        }
        catch (StorageException e)
            when (e.RequestInformation.HttpStatusCode == (int)HttpStatusCode.NotFound)
        {
            return (new JObject(), null);
        }
    }

    public async Task<bool> SaveAsync(string key, JObject obj, string etag)
    {
        if (string.IsNullOrWhiteSpace(key))
        {
            throw new ArgumentException(nameof(key));
        }

        if (obj == null)
        {
            throw new ArgumentNullException(nameof(obj));
        }

        var blob = _container.GetBlockBlobReference(key);
        blob.Properties.ContentType = "application/json";
        var content = obj.ToString();
        if (etag != null)
        {
            try
            {
                await blob.UploadTextAsync(content, Encoding.UTF8, new AccessCondition {IfMatchETag = etag}, new BlobRequestOptions(), new OperationContext());
            }
            catch (StorageException e)
                when (e.RequestInformation.HttpStatusCode == (int)HttpStatusCode.PreconditionFailed)
            {
                return false;
            }
        }
        else
        {
            await blob.UploadTextAsync(content);
        }

        return true;
    }
}

알 수 있듯이 Azure Blob Storage는 여기서 실제 작업을 수행합니다.As you can see Azure Blob Storage is doing the real work here. 특정 예외를 catch하고 호출 코드의 기대치를 충족하기 위해 해당 예외가 변환되는 방법에 주의합니다.Note the catch of specific exceptions and how that is translated across to meet what will be the expectations of the calling code. 즉, Load의 '찾을 수 없음' 예외에서 null을 반환하고, Save의 '사전 조건 실패' 예외에서 부울을 반환해야 합니다.That is, on the load we want a Not Found exception to return null and the Precondition Failed exception on the Save to return bool.

이 소스 코드는 모두 해당 샘플에서 사용할 수 있으며, 이 샘플에는 메모리 저장소 구현이 포함되어 있습니다.All this source code will be available in a corresponding sample and that sample will include a memory store implementation.

다시 시도 루프 구현Implementing the Retry Loop

루프의 기본 모양은 시퀀스 다이어그램에 표시된 동작에서 직접 파생됩니다.The basic shape of the loop is derived directly from the behavior shown in the sequence diagrams.

Activity(활동)를 받으면 해당 대화에 해당하는 상태에 대한 키를 만듭니다.On receiving an Activity we create a key for the corresponding state for that conversation. Activity 및 대화 상태 간의 관계를 변경하지 않으므로 기본 상태 구현과 동일한 방식으로 키를 만듭니다.We are not changing the relationship between Activity and conversation state, so we will be creating the key in exactly the same way as in the default state implementation.

적절한 키가 만들어지면 해당 상태를 로드하려고 합니다(Load).After having created the appropriate key we will attempt to Load the corresponding state. 그런 다음, 봇의 대화를 실행한 후, 저장 작업을 시도합니다.Then run the bot's dialogs and then attempt to Save. Save가 성공하면 대화 실행에 따른 아웃바운드 활동을 보내고 이 작업이 완료됩니다.If that Save is successful, we will send the outbound Activities that resulted from running the dialog and be done. 그렇지 않으면 다시 돌아가서 Load 이전의 전체 프로세스를 반복합니다.Otherwise we will go back and repeat the whole process from before the Load. Load를 다시 수행하면 새 ETag가 제공되므로 다음에 Save 작업을 수행하면 성공하게 됩니다.Redoing the Load will give us a new ETag and so next time the Save will hopefully be successful.

결과적으로 OnTurn 구현은 다음과 같습니다.The resulting OnTurn implementation looks like this:

ScaleoutBot.csScaleoutBot.cs

protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
{
    // Create the storage key for this conversation.
    var key = $"{turnContext.Activity.ChannelId}/conversations/{turnContext.Activity.Conversation?.Id}";

    // The execution sits in a loop because there might be a retry if the save operation fails.
    while (true)
    {
        // Load any existing state associated with this key
        var (oldState, etag) = await _store.LoadAsync(key);

        // Run the dialog system with the old state and inbound activity, the result is a new state and outbound activities.
        var (activities, newState) = await DialogHost.RunAsync(_dialog, turnContext.Activity, oldState, cancellationToken);

        // Save the updated state associated with this key.
        var success = await _store.SaveAsync(key, newState, etag);

        // Following a successful save, send any outbound Activities, otherwise retry everything.
        if (success)
        {
            if (activities.Any())
            {
                // This is an actual send on the TurnContext we were given and so will actual do a send this time.
                await turnContext.SendActivitiesAsync(activities, cancellationToken);
            }

            break;
        }
    }
}

대화 실행을 함수 호출로 모델링했습니다.Note that we have modeled the dialog execution as a function call. 아마도 더 정교한 구현은 인터페이스를 정의하고 이 종속성을 주입할 수 있게 만들었을 것입니다. 그러나 여기서의 목적을 위해 대화를 모두 정적 함수 뒤에 배치하면 이 접근 방식의 기능적 특성을 강조합니다.Perhaps a more sophisticated implementation would have defined an interface and made this dependency injectable but for our purposes having the dialog all sit behind a static function emphasize the functional nature of our approach. 일반적으로 중요한 부분이 기능하게 되도록 구현을 구성하면 네트워크에서 성공적으로 작동하는 데 매우 적합합니다.As a general statement, organizing our implementation such that the crucial parts become functional puts us in a very good place when it comes to having it work successfully on networks.

아웃바운드 활동 버퍼링 구현Implementing outbound Activity buffering

다음 요구 사항은 Save 작업이 성공적으로 수행될 때까지 아웃바운드 활동을 버퍼링한다는 것입니다.The next requirement is that we buffer outbound Activities until a successful Save has been performed. 이렇게 하려면 사용자 지정 BotAdapter 구현이 필요합니다.This will require a custom BotAdapter implementation. 다음 코드에서는 Activity를 보내는 대신 목록에 Activity를 추가하는 추상 SendActivity 함수를 구현합니다.In this code, we will implement the abstract SendActivity function to add the Activity to a list rather than sending it. 호스팅할 대화는 더 현명하지 않습니다.The dialog we will be hosting will be non-the-wiser. 이 특정 시나리오에서는 UpdateActivity 및 DeleteActivity 작업이 지원되지 않으므로 이러한 메서드에서 '구현되지 않음'만 throw합니다.In this particular scenario UpdateActivity and DeleteActivity operations are not supported and so will just throw Not Implemented from those methods. 또한 SendActivity에서 반환되는 값에는 관심이 없습니다.We also don't care about the return value from the SendActivity. 이는 활동에 대한 업데이트를 보내야 하는 시나리오(예: 채널에 표시된 카드의 단추를 사용하지 않도록 설정하는 경우)의 일부 채널에서 사용합니다.This is used by some channels in scenarios where updates to Activities need to be sent, for example, to disable buttons on cards displayed in the channel. 이러한 메시지 교환은 특히 상태가 필요할 때 복잡해질 수 있으며, 이는 이 문서의 범위를 벗어납니다.These message exchanges can get complicated particularly when state is required, that is outside the scope of this article. 사용자 지정 BotAdapter의 전체 구현은 다음과 같습니다.The full implementation of the custom BotAdapter looks like this:

DialogHostAdapter.csDialogHostAdapter.cs

public class DialogHostAdapter : BotAdapter
{
    private List<Activity> _response = new List<Activity>();

    public IEnumerable<Activity> Activities => _response;

    public override Task<ResourceResponse[]> SendActivitiesAsync(ITurnContext turnContext, Activity[] activities, CancellationToken cancellationToken)
    {
        foreach (var activity in activities)
        {
            _response.Add(activity);
        }

        return Task.FromResult(new ResourceResponse[0]);
    }

    #region Not Implemented
    public override Task DeleteActivityAsync(ITurnContext turnContext, ConversationReference reference, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }

    public override Task<ResourceResponse> UpdateActivityAsync(ITurnContext turnContext, Activity activity, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }
    #endregion
}

통합Integration

수행해야 할 나머지 작업은 이러한 다양한 새 조각을 결합하여 기존 프레임워크에 연결하기만 하면 됩니다.All that is left to do is glue these various new pieces together and plug them into the existing framework pieces. 기본 다시 시도 루프는 IBot OnTurn 기능에만 있습니다.The main retry loop just sits in the IBot OnTurn function. 여기에는 테스트를 위해 종속성을 주입할 수 있는 사용자 지정 IStore 구현이 포함되어 있습니다.It holds our custom IStore implementation which for testing purposes we have made dependency injectable. 모든 대화 호스팅 코드를 하나의 public static 함수를 공개하는 DialogHost라는 클래스에 배치했습니다.We have put all the dialog hosting code into a class called DialogHost that exposes a single public static function. 이 함수는 인바운드 활동과 이전 상태를 가져온 다음, 결과 활동과 새 상태를 반환하도록 정의되어 있습니다.This function is defined to take the inbound Activity and the old state and then return the resulting Activities and new state.

이 함수에서 가장 먼저 수행해야 하는 작업은 앞에서 소개한 사용자 지정 BotAdapter를 만드는 것입니다.The first thing to do in this function is to create the custom BotAdapter we introduced earlier. 그런 다음, DialogSet 및 DialogContext를 만들고, 일반적인 Continue 또는 Begin 흐름을 수행하는 것과 똑같은 방식으로 대화를 실행합니다.Then we will just run the dialog in exactly the same was as we usually do by creating a DialogSet and DialogContext and doing the usual Continue or Begin flow. 다루지 않은 유일한 부분은 사용자 지정 접근자가 필요하다는 것입니다.The only piece we haven't covered is the need for a custom Accessor. 이는 대화 상태를 대화 시스템에 쉽게 전달할 수 있게 하는 매우 단순한 쐐기입니다.This turns out to be a very simple shim that facilitates passing the dialog state into the dialog system. 접근자는 대화 시스템에서 작업할 때 참조 의미 체계를 사용하므로 핸들을 전달하기만 하면 됩니다.The Accessor uses ref semantics when working with the dialog system and so all that is needed is to pass the handle across. 더 명확히 하기 위해 참조 의미 체계에 사용하는 클래스 템플릿을 제한했습니다.To make things even clearer we have constrained the class template we are using to ref semantics.

계층화에 매우 주의하고, 다양한 구현에서 서로 다르게 직렬화할 수 있는 경우 플러그형 스토리지 계층 내에 JsonSerialization이 필요하지 않기 때문에 호스팅 코드에 JsonSerialization 인라인 방식으로 배치합니다.We are being very cautious in the layering, we are putting the JsonSerialization inline in our hosting code because we didn't want it inside the pluggable storage layer when different implementations might serialize differently.

드라이버 코드는 다음과 같습니다.Here is the driver code:

DialogHost.csDialogHost.cs

public static class DialogHost
{
    // The serializer to use. Moving the serialization to this layer will make the storage layer more pluggable.
    private static readonly JsonSerializer StateJsonSerializer = new JsonSerializer() { TypeNameHandling = TypeNameHandling.All };

    /// <summary>
    /// A function to run a dialog while buffering the outbound Activities.
    /// </summary>
    /// <param name="dialog">THe dialog to run.</param>
    /// <param name="activity">The inbound Activity to run it with.</param>
    /// <param name="oldState">Th eexisting or old state.</param>
    /// <returns>An array of Activities 'sent' from the dialog as it executed. And the updated or new state.</returns>
    public static async Task<(Activity[], JObject)> RunAsync(Dialog dialog, IMessageActivity activity, JObject oldState, CancellationToken cancellationToken)
    {
        // A custom adapter and corresponding TurnContext that buffers any messages sent.
        var adapter = new DialogHostAdapter();
        var turnContext = new TurnContext(adapter, (Activity)activity);

        // Run the dialog using this TurnContext with the existing state.
        var newState = await RunTurnAsync(dialog, turnContext, oldState, cancellationToken);

        // The result is a set of activities to send and a replacement state.
        return (adapter.Activities.ToArray(), newState);
    }

    /// <summary>
    /// Execute the turn of the bot. The functionality here closely resembles that which is found in the
    /// IBot.OnTurnAsync method in an implementation that is using the regular BotFrameworkAdapter.
    /// Also here in this example the focus is explicitly on Dialogs but the pattern could be adapted
    /// to other conversation modeling abstractions.
    /// </summary>
    /// <param name="dialog">The dialog to be run.</param>
    /// <param name="turnContext">The ITurnContext instance to use. Note this is not the one passed into the IBot OnTurnAsync.</param>
    /// <param name="state">The existing or old state of the dialog.</param>
    /// <returns>The updated or new state of the dialog.</returns>
    private static async Task<JObject> RunTurnAsync(Dialog dialog, ITurnContext turnContext, JObject state, CancellationToken cancellationToken)
    {
        // If we have some state, deserialize it. (This mimics the shape produced by BotState.cs.)
        var dialogStateProperty = state?[nameof(DialogState)];
        var dialogState = dialogStateProperty?.ToObject<DialogState>(StateJsonSerializer);

        // A custom accessor is used to pass a handle on the state to the dialog system.
        var accessor = new RefAccessor<DialogState>(dialogState);

        // Run the dialog.
        await dialog.RunAsync(turnContext, accessor, cancellationToken);

        // Serialize the result (available as Value on the accessor), and put its value back into a new JObject.
        return new JObject { { nameof(DialogState), JObject.FromObject(accessor.Value, StateJsonSerializer) } };
    }
}

마지막으로, 상태가 참조로 사용되므로 사용자 지정 접근자인 Get을 구현하기만 하면 됩니다.And finally, the custom Accessor, we only need to implement Get because the state is by ref:

RefAccessor.csRefAccessor.cs

public class RefAccessor<T> : IStatePropertyAccessor<T>
    where T : class
{
    public RefAccessor(T value)
    {
        Value = value;
    }

    public T Value { get; private set; }

    public string Name => nameof(T);

    public Task<T> GetAsync(ITurnContext turnContext, Func<T> defaultValueFactory = null, CancellationToken cancellationToken = default(CancellationToken))
    {
        if (Value == null)
        {
            if (defaultValueFactory == null)
            {
                throw new KeyNotFoundException();
            }

            Value = defaultValueFactory();
        }

        return Task.FromResult(Value);
    }

    #region Not Implemented
    public Task DeleteAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
    {
        throw new NotImplementedException();
    }

    public Task SetAsync(ITurnContext turnContext, T value, CancellationToken cancellationToken = default(CancellationToken))
    {
        throw new NotImplementedException();
    }
    #endregion
}

추가 정보Additional information

이 문서에서 사용된 C# 샘플 코드는 GitHub에서 사용할 수 있습니다.The C# sample code used in this article is available on GitHub.