65.9K
CodeProject 正在变化。 阅读更多。
Home

创建通用餐饮机器人

starIconstarIconstarIconstarIconstarIcon

5.00/5 (2投票s)

2022年2月11日

CPOL

9分钟阅读

viewsIcon

3545

如何创建一个 Java Spring Boot Web 应用程序,该应用程序复制 GitHub 上“Universal Bots”示例应用程序的功能

设计应用程序时面临的挑战之一是适应人们使用的各种不同设备。每个平台,无论是桌面、Web还是移动设备,都需要其自身的工具和设计注意事项。

然而,得益于Adaptive Cards UI框架,可以轻松创建通过Microsoft Teams等聊天工具公开的平台无关界面。Adaptive Cards使开发人员无需为多个设备构建自定义界面,并且由于Teams已在桌面、Web和移动设备上可用,开发人员不再需要维护多个应用程序。

在本文中,我们将基于上一篇文章中的入门应用程序,创建一个包含在Teams中显示的丰富UI的午餐订购机器人。

必备组件

要跟上本文的进度,您需要Java 11 JDKApache MavenNode.js和npm、用于部署最终应用程序的Azure订阅,以及Azure CLI

我还使用了一个脚手架工具Yeoman来简化设置。您可以使用以下命令安装它

npm install -g yo

聊天机器人模板由generator-botbuilder-java包提供,您需要使用此命令安装它

npm install -g generator-botbuilder-java

现在,您已经拥有了创建示例聊天机器人应用程序所需的一切。

示例源代码

您可以通过查看该项目在GitHub页面上的源代码来跟上进度。

构建基础应用程序

有关构建和更新基础应用程序的过程,请参阅本系列的上一篇文章。按照“创建示例应用程序”和“更新示例应用程序”标题下的说明,使用Yeoman创建一个Spring Boot项目。

添加新依赖项

除了上一篇文章中提到的更新的依赖项之外,您还需要添加额外的Maven依赖项来支持您的餐饮机器人。

将以下依赖项添加到pom.xml文件中

<dependencies>
    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <version>1.18.22</version>
      <scope>provided</scope>
    </dependency>
    <dependency>
      <groupId>io.pebbletemplates</groupId>
      <artifactId>pebble</artifactId>
      <version>3.1.5</version>
    </dependency>
    <dependency>
      <groupId>com.microsoft.sqlserver</groupId>
      <artifactId>mssql-jdbc</artifactId>
      <scope>runtime</scope>
    </dependency>
    <dependency>
      <groupId>com.google.guava</groupId>
      <artifactId>guava</artifactId>
      <version>31.0.1-jre</version>
    </dependency>
    …
</dependencies>

构建模型

您的机器人将在处理、保存和检索午餐订单时与两个框架进行交互。这些框架所需的数据在模型类中定义,这些模型类是Plain Old Java Objects (POJOs),并带有某些专用注释。

首先,构建一个用于Adaptive Card框架的模型。Teams中显示的UI的每个响应都包含当前卡片的ID、要显示的下一张卡片以及由最终用户提供的选项的详细信息。

模型类具有Lombok Data(@Data)注释,以减少您需要编写的样板代码量。@Data注释指示Lombok从类属性创建getter和setter,因此您无需手动编写它们。

CardOptions类中定义卡片响应模型

package com.matthewcasperson.models;

import lombok.Data;

@Data
public class CardOptions {
  private Integer nextCardToSend;
  private Integer currentCard;
  private String option;
  private String custom;
}

接下来,您需要为存储在数据库中的数据定义一个模型。每个午餐订单都持久化在SQL Server数据库中。它捕获下单用户的ID、他们的食物和饮料选择以及订单创建时间。

CardOptions类一样,使用Lombok @Data注释创建getter和setter方法。我还使用了Java Persistence API (JPA) @Entity注释来指示该类已映射到数据库表中的一行。

ID属性上的@Id@GeneratedValue注释将其定义为主键,并具有自动生成的值。

LunchOrder类中定义您的数据库模型

package com.matthewcasperson.models;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import lombok.Data;

@Data
@Entity
public class LunchOrder {

  @Id
  @GeneratedValue(strategy= GenerationType.IDENTITY)
  private Long id;
  private String activityId;
  private java.sql.Timestamp orderCreated;
  private String entre;
  private String drink;
}

连接到数据库

数据库连接详细信息保存在application.properties文件中。将spring.datasource.urlspring.datasource.usernamespring.datasource.password属性替换为您自己的数据库服务器的详细信息。下面的示例显示了与本地SQL数据库服务器的连接,但如果需要,您也可以连接到Azure中托管的数据库。

MicrosoftAppId=<app id goes here>
MicrosoftAppPassword=<app secret goes here>
server.port=3978

# Replace the url, username, and password with the details of your own SQL Server database
spring.datasource.url=jdbc:sqlserver://;databaseName=catering
spring.datasource.username=catering
spring.datasource.password=Password01!
spring.datasource.driver-class-name=com.microsoft.sqlserver.jdbc.SQLServerDriver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.SQLServer2008Dialect

构建卡片列表

机器人发送的每张卡片都定义为enum。卡片由一个数字标识(与CardOptions类中的currentCardnextCardToSend属性中存储的值匹配),并具有一个与之关联的JSON文件,该文件定义了Adaptive Card UI布局。

您的机器人显示五张卡片:主菜选择、饮料选择、订单审查、确认提示和近期订单列表。这些卡片中的每一张都在Cards enum中定义为enum记录。

package com.matthewcasperson;

import java.util.Arrays;

public enum Cards {
  Entre(0, "cards/EntreOptions.json"),
  Drink(1, "cards/DrinkOptions.json"),
  Review(2, "cards/ReviewOrder.json"),
  ReviewAll(3, "cards/RecentOrders.json"),
  Confirmation(4, "cards/Confirmation.json");

  public final String file;
  public final int number;

  Cards(int number, String file) {
    this.file = file;
    this.number = number;
  }

  public static Cards findValueByTypeNumber(int number) throws Exception {
    return Arrays.stream(Cards.values()).filter(v ->
        v.number == number).findFirst().orElseThrow(() ->
        new Exception("Unknown Cards.number: " + number));
  }
}

创建Spring存储库

您的代码将通过一个称为数据存储库的接口与数据库进行交互。该接口公开了具有特殊命名方法的函数,Spring框架可以识别这些函数并将其转换为数据库操作。

因此,名为findByActivityId的方法被Spring识别为按activityId列过滤的查询,而名为findAll的方法是返回所有记录的查询。

您无需提供此接口的具体实现,因为Spring会为您处理。您所要做的就是在代码中引用该接口,您的请求会自动转换为数据库查询。

您的数据库接口称为LunchOrderRepository

package com.matthewcasperson.repository;

import com.matthewcasperson.models.LunchOrder;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.CrudRepository;

public interface LunchOrderRepository extends CrudRepository<LunchOrder, Long> {
  List<LunchOrder> findByActivityId(String lastName);
  Page<LunchOrder> findAll(Pageable pageable);
}

将状态注入机器人

为了让您的UI能够处理订购食物和饮料、审查订单以及列出先前订单所涉及的多个响应,您必须拥有某种持久状态,该状态可以在一个响应传递到下一个响应的过程中保持。

Bot Framework SDK提供了两个类来维护状态:ConversationStateUserState。这些类的实例已提供给Spring,并可以注入到创建机器人类实例的方法中。然后通过构造函数将它们传递给机器人。

为了让机器人访问状态类,请在Application类中用以下代码覆盖getBot方法

    @Bean
    public Bot getBot(final ConversationState conversationState, final UserState userState) {
        return new CateringBot(conversationState, userState);
    }

构建餐饮机器人

有了模型、枚举和存储库,您就可以开始构建实际的机器人了。

首先创建一个新类来扩展Bot Framework SDK提供的ActivityHandler类。

但是,请注意,您已扩展了一个名为FixedActivityHandler的类 — 这是由于ActivityHandler类中的一个bug,该bug已在StackOverflow上记录。我预计此bug将在Bot Framework SDK的未来版本中得到解决,但目前,您需要依赖StackOverflow帖子中描述的解决方法。

public class CateringBot extends FixedActivityHandler {

您的类具有许多静态和实例属性,用于定义Adaptive Card MIME类型、日志记录类、JSON解析器以及与维护状态相关的两个类的常量。

  private static final String CONTENT_TYPE = "application/vnd.microsoft.card.adaptive";

  private static final Logger LOGGER = LoggerFactory.getLogger(CateringBot.class);

  private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()

      .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

  private final ConversationState conversationState;

  private final UserState userState;

通过LunchOrderRepository接口可以访问数据库。通过注入您之前创建的接口实例,Spring为您提供了一个处理所有数据库查询的对象。

  @Autowired

  LunchOrderRepository lunchOrderRepository;

构造函数接受两个状态对象并将它们分配给实例属性

  public CateringBot(final ConversationState conversationState, final UserState userState) {
    this.userState = userState;
    this.conversationState = conversationState;
  }

onMembersAdded方法在新人加入与机器人的对话时被调用。此方法由基础模板提供,但我对其进行了一些修改,以响应一条消息,告知用户输入任何消息都可以开始下新的午餐订单。

  @Override
  protected CompletableFuture<Void> onMembersAdded(
      final List<ChannelAccount> membersAdded,
      final TurnContext turnContext
  ) {
    LOGGER.info("CateringBot.onMembersAdded(List<ChannelAccount>, TurnContext)");

    return membersAdded.stream()
        .filter(
            member -> !StringUtils
                .equals(member.getId(), turnContext.getActivity().getRecipient().getId())
        ).map(channel -> turnContext.sendActivity(MessageFactory.text(
            "Hello and welcome! Type any message to begin placing a lunch order.")))
        .collect(CompletableFutures.toFutureList()).thenApply(resourceResponses -> null);
  }

当发布新消息时,将调用onMessageActivity方法。您的机器人使用此作为启动午餐订购过程的信号。

  @Override
  protected CompletableFuture<Void> onMessageActivity(final TurnContext turnContext) {
    LOGGER.info("CateringBot.onMessageActivity(TurnContext)");

首先,您从用户的状态中获取LunchOrder类的实例。

    final StatePropertyAccessor<LunchOrder> profileAccessor = userState.createProperty("lunch");
    final CompletableFuture<LunchOrder> lunchOrderFuture =
        profileAccessor.get(turnContext, LunchOrder::new);

    try {
      final LunchOrder lunchOrder = lunchOrderFuture.get();

捕获发起对话的用户的ID和订单时间。

      lunchOrder.setActivityId(turnContext.getActivity().getId());
      lunchOrder.setOrderCreated(Timestamp.from(Instant.now()));

然后,您将响应一个Adaptive Card — 一个描述用户界面的JSON文档。这些JSON文件已作为资源保存在您的Java应用程序中,位于src/main/resources/cards目录下。

接下来,您将createCardAttachment的返回值作为消息附件返回。您已传递与UI序列中的第一张卡片相关联的文件:主菜提示,其编号为0

      return turnContext.sendActivity(
          MessageFactory.attachment(createCardAttachment(Cards.findValueByTypeNumber(0).file))
      ).thenApply(sendResult -> null);
    } catch (final Exception ex) {
      return turnContext.sendActivity(
          MessageFactory.text("An exception was thrown: " + ex)
      ).thenApply(sendResult -> null);
    }
  }

这就是聊天中的响应外观

通过卡片UI进行的响应会触发onAdaptiveCardInvoke方法。

在此,您保存用户的选择并发送下一张卡片作为响应。

  @Override
  protected CompletableFuture<AdaptiveCardInvokeResponse> onAdaptiveCardInvoke(
      final TurnContext turnContext, final AdaptiveCardInvokeValue invokeValue) {
    LOGGER.info("CateringBot.onAdaptiveCardInvoke(TurnContext, AdaptiveCardInvokeValue)");

与之前一样,您将获取用户状态的访问权限。

    StatePropertyAccessor<LunchOrder> profileAccessor = userState.createProperty("lunch");
    CompletableFuture<LunchOrder> lunchOrderFuture =
        profileAccessor.get(turnContext, LunchOrder::new);

    try {
      final LunchOrder lunchOrder = lunchOrderFuture.get();

每个响应都有一个与之关联的动词。动词是您在构建卡片UI时选择的。在此示例中,所有卡片都以动词order响应。

      if ("order".equals(invokeValue.getAction().getVerb())) {

响应返回的数据被转换为CardOptions实例。

        final CardOptions cardOptions = convertObject(invokeValue.getAction().getData(),
            CardOptions.class);      

使用currentCard属性识别生成响应的卡片。根据是主菜卡片还是饮品卡片生成了响应,在CardOptions实例中设置适当的属性。

        if (cardOptions.getCurrentCard() == Cards.Entre.number) {
          lunchOrder.setEntre(
              StringUtils.isAllEmpty(cardOptions.getCustom()) ? cardOptions.getOption()
                  : cardOptions.getCustom());
        } else if (cardOptions.getCurrentCard() == Cards.Drink.number) {
          lunchOrder.setDrink(
              StringUtils.isAllEmpty(cardOptions.getCustom()) ? cardOptions.getOption()
                  : cardOptions.getCustom());

如果响应是由订单审查卡片生成的,您可以将订单保存到数据库。

        } else if (cardOptions.getCurrentCard() == Cards.Review.number) {
          lunchOrderRepository.save(lunchOrder);
        }

最后一步是返回序列中的下一张卡片。

Microsoft提供了Adaptive Card Templating库,用于将动态数据嵌入表示Adaptive Card的JSON中。该库预处理JSON文件并生成可以发送到Teams等客户端的响应。

不幸的是,没有这个库的Java版本。相反,您将使用另一个名为Pebble Templates的模板库。尽管这两个模板库使用不同的语法,但只要最终结果是有效的JSON,客户端就不知道卡片是如何生成的。

在这里,您找到下一张卡片的JSON文件,并将其与用于模板库替换JSON的值的Map一起传递给createObjectFromJsonResource方法。

        final AdaptiveCardInvokeResponse response = new AdaptiveCardInvokeResponse();
        response.setStatusCode(200);
        response.setType(CONTENT_TYPE);
        response.setValue(createObjectFromJsonResource(
            Cards.findValueByTypeNumber(cardOptions.getNextCardToSend()).file,
            new HashMap<>() {{
              put("drink", lunchOrder.getDrink());
              put("entre", lunchOrder.getEntre());
              putAll(getRecentOrdersMap());
            }}));

        return CompletableFuture.completedFuture(response);
      }

如果遇到意外的动词或其他异常,则会以异常详细信息进行响应。

      throw new Exception("Invalid verb " + invokeValue.getAction().getVerb());

    } catch (final Exception ex) {
      LOGGER.error("Exception thrown in onAdaptiveCardInvoke", ex);
      return CompletableFuture.failedFuture(ex);
    }
  }

onTurn方法中保存用户的状态。

  @Override
  public CompletableFuture<Void> onTurn(final TurnContext turnContext) {
    return super.onTurn(turnContext)
        .thenCompose(turnResult -> conversationState.saveChanges(turnContext))
        .thenCompose(saveResult -> userState.saveChanges(turnContext));
  }

createCardAttachment方法接受JSON文件的文件名(以及可选的模板处理时的上下文),并返回一个具有正确MIME类型(标识Adaptive Card)的Attachment

  private Attachment createCardAttachment(final String fileName) throws IOException {
    return createCardAttachment(fileName, null);
  }

  private Attachment createCardAttachment
          (final String fileName, final Map<String, Object> context)
      throws IOException {
    final Attachment attachment = new Attachment();
    attachment.setContentType(CONTENT_TYPE);
    attachment.setContent(createObjectFromJsonResource(fileName, context));
    return attachment;
  }

createObjectFromJsonResource方法接受JSON文件名和模板上下文,读取文件内容,可选地进行转换,将生成的JSON转换为通用的Map,并返回结果。

有趣的是,您的代码不会直接将JSON发送到客户端。所有附件都是对象,Bot Framework SDK会将这些对象序列化为JSON。这就是为什么您首先加载JSON,然后将其转换回通用的结构(如Map)的原因。

  private Object createObjectFromJsonResource(final String fileName,
      final Map<String, Object> context) throws IOException {
    final String resource = readResource(fileName);
    final String processedResource = context == null
        ? resource
        : processTemplate(resource, context);
    final Map objectMap = OBJECT_MAPPER.readValue(processedResource, Map.class);
    return objectMap;
  }

processTemplate方法使用Pebble模板库将自定义值注入加载的JSON文件中。JSON文件包含模板标记,如{{entre}}{{drink}},这些标记会被上下文映射中的关联值替换。您可以在ReviewOrder.json模板中看到这一点。

  private String processTemplate(final String template, final Map<String, Object> context)
      throws IOException {
    final PebbleEngine engine = new PebbleEngine.Builder().autoEscaping(false).build();
    final PebbleTemplate compiledTemplate = engine.getLiteralTemplate(template);
    final Writer writer = new StringWriter();
    compiledTemplate.evaluate(writer, context);
    return writer.toString();
  }

readResource方法使用Gson加载嵌入到Java应用程序中的文件内容。

  private String readResource(final String fileName) throws IOException {
    return Resources.toString(Resources.getResource(fileName), Charsets.UTF_8);
  }

convertObject方法用于将Map等通用数据结构转换为常规类。

  private <T> T convertObject(final Object object, final Class<T> convertTo) {
    return OBJECT_MAPPER.convertValue(object, convertTo);
  }

getRecentOrdersMap方法加载最近的三个订单,并将它们的详细信息放入一个Map中,该Map可供模板库使用。

  private Map<String, String> getRecentOrdersMap() {
    final List<LunchOrder> recentOrders = lunchOrderRepository.findAll(
        PageRequest.of(0, 3, Sort.by(Sort.Order.desc("orderCreated")))).getContent();

    final Map<String, String> map = new HashMap<>();

    for (int i = 0; i < 3; ++i) {
      map.put("drink" + (i + 1),
          recentOrders.stream().skip(i).findFirst().map(LunchOrder::getDrink).orElse(""));
      map.put("entre" + (i + 1),
          recentOrders.stream().skip(i).findFirst().map(LunchOrder::getEntre).orElse(""));
      map.put("orderCreated" + (i + 1),
          recentOrders.stream().skip(i).findFirst().map(l -> l.getOrderCreated().toString())
              .orElse(""));
    }

    return map;
  }

测试机器人

有关部署餐饮机器人和将其集成到Teams的说明,请参阅本系列第一篇文章中的“部署机器人”和“链接到Teams”部分。

连接后,输入任何消息即可查看第一张卡片,在那里您可以选择主菜。

然后,选择一种饮料。

确认订单。

此时,订单已保存在数据库中。

然后,您可以查看先前订单的列表。

结论

使用Adaptive Cards框架为开发人员提供了构建可在各种平台中使用的引人入胜的用户界面的机会。在本文中,您了解了如何将Adaptive Cards集成到Java Spring Boot聊天机器人中,并利用Spring Data JPA等框架轻松地将数据持久化到SQL Server数据库并从中检索。

要了解有关为Microsoft Teams构建自己的机器人的更多信息,请查看使用Azure Bot Framework Composer为Microsoft Teams构建出色的机器人

© . All rights reserved.