使用 MSAL、Graph SDK 和 Java 创建事件管理机器人





2.00/5 (1投票)
在本文中,我们将了解如何将 Spring 与 Microsoft Teams 集成,以创建一个简单的事件管理机器人。
关键系统出现故障时,沟通至关重要。支持团队需要让客户随时了解情况,产品负责人需要了解中断的影响,工程团队需要协调他们的工作。
Microsoft Teams 等聊天工具提供了自然的协作点。但非结构化的对话迫使利益相关者回顾大量的聊天消息,试图推断任何补救工作的状态。或者,这可能导致不得不打断那些正在解决问题的人,明确要求状态更新。
事件管理机器人通过允许将有限数量的、预先同意的状态消息发布到聊天中来提供解决方案。这使得利益相关者能够立即了解补救工作的状态。
在本系列第一篇文章中,我们使用 Microsoft Graph SDK for Java 创建了一个身份验证提供程序。在第二篇文章中,我们使用 Java 和 Microsoft Graph SDK 将 OneNote 转换为 Markdown。在这第三篇也是本系列的最后一篇文章中,我们将使用 MSAL、Microsoft Teams、Microsoft Graph API 和 Spring Boot 创建一个简单的事件管理机器人。
示例应用程序
此示例应用程序的源代码可以在 GitHub MSALIncidentManagementDemo 仓库中找到。
合并前端和后端逻辑
此示例应用程序在单个应用程序中公开 Web 前端和 OAuth 资源服务器。此方法在 Microsoft 文档中进行了描述。
引导 Spring 项目
我们将使用 Spring Initializr 生成初始应用程序模板,以创建一个 Java Maven 项目,生成一个 JAR 文件,该文件使用最新非快照版本的 Spring 和 Java 11。
该项目 — 一个结合了 Web 应用程序和资源服务器 — 需要以下依赖项
- Spring Web,用于提供托管的 Web 服务器
- Spring Security,它允许我们保护端点的访问
- Azure Active Directory,用于与 Azure AD 集成
- OAuth2 资源服务器,用于将后端 API 与 OAuth2 授权服务器集成
- OAuth2 客户端,用于将 Web 应用程序与 OAuth2 授权服务器集成
- Thymeleaf,它为 HTML 文件提供了模板语言
配置 Spring 和 Azure AD
我们的 *application.yaml* 文件包含 Azure AD 应用程序的详细信息。
azure:
activedirectory:
tenant-id: ${TENANT_ID}
client-id: ${CLIENT_ID}
client-secret: ${CLIENT_SECRET}
app-id-uri: ${APP_ID_URI}
application-type: web_application_and_resource_server
authorization-clients:
api:
authorizationGrantType: authorization_code
scopes:
- api://d21b7691-b12b-4a9a-a35e-542a0a577f78/Teams
构建结合了 Web 应用程序和资源服务器的应用程序时,必须将 `application-type` 属性设置为 `web_application_and_resource_server`。
application-type: web_application_and_resource_server
另外,请注意我们创建了一个授权客户端,它授予对同一应用程序的访问权限。此客户端是 Web 应用程序控制器与资源服务器控制器通信的方式。
authorization-clients:
api:
authorizationGrantType: authorization_code
scopes:
- api://d21b7691-b12b-4a9a-a35e-542a0a577f78/Teams
配置 Spring Security
我们通过 AuthSecurityConfig 类配置 Spring Security,该类位于以下包中。
package com.matthewcasperson.incidentmanagementdemo.configuration;
我们的安全类嵌套了两个静态类。
第一个是 `ApiWebSecurityConfigurationAdapter` 类,它配置 OAuth 资源服务器公开的端点。此类扩展了 `AADResourceServerWebSecurityConfigurerAdapter` 类,覆盖了 `configure` 方法,并调用基类 `configure` 方法,允许 `AADResourceServerWebSecurityConfigurerAdapter` 初始化通用的资源服务器安全规则。
@EnableWebSecurity
public class AuthSecurityConfig {
@Order(1)
@Configuration
public static class ApiWebSecurityConfigurationAdapter extends
AADResourceServerWebSecurityConfigurerAdapter {
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
然后,我们要求所有以 `/api` 为前缀的端点的请求都必须经过身份验证。
// All the paths that match `/api/**`(configurable) work as the resource server.
// Other paths work as the web application.
// @formatter:off
http
.antMatcher("/api/**")
.authorizeRequests().anyRequest().authenticated();
// @formatter:on
}
}
`HtmlWebSecurityConfigurerAdapter` 类配置 Web 服务器公开的端点。此类扩展了 `AADWebSecurityConfigurerAdapter` 类,覆盖了 `configure` 方法,并调用基类 `configure` 方法,允许 `AADWebSecurityConfigurerAdapter` 初始化通用的 Web 应用程序安全规则。
@Configuration
public static class HtmlWebSecurityConfigurerAdapter extends AADWebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
我们配置规则以允许静态资源(如 JavaScript 和 CSS 文件)以及登录页面在未经身份验证的情况下被请求。然后,我们要求对所有其他端点的请求进行身份验证,并禁用跨站点请求伪造 (CSRF) 以简化示例应用程序的开发。
// @formatter:off
http
.authorizeRequests()
.antMatchers("/login", "/*.js", "/*.css").permitAll()
.anyRequest().authenticated()
.and()
.csrf()
.disable();
// @formatter:on
}
}
}
创建 Graph API 客户端
我们将通过官方 Graph API 客户端访问 Microsoft Graph API。首先,我们在 GraphClientConfiguration 类中将客户端的一个实例公开为一个 Bean。
package com.matthewcasperson.incidentmanagementdemo.providers;
我们注入 `AADAuthenticationProperties` 类的一个实例,从而可以轻松访问 *application.yml* 文件中的配置值。
@Configuration
public class GraphClientConfiguration {
@Autowired
AADAuthenticationProperties azureAd;
客户端在 `getClient` 方法中创建。我们利用前一篇文章中介绍的 `OboAuthenticationProvider` 类来生成适合访问 Graph API 的“代表”(OBO) 令牌。作用域列表包含列出和创建频道、将用户分配到频道以及在频道中创建消息所需的权限。
@Bean
public GraphServiceClient<Request> getClient() {
return GraphServiceClient.builder()
.authenticationProvider(new OboAuthenticationProvider(
Set.of("https://graph.microsoft.com/Channel.Create",
"https://graph.microsoft.com/ChannelSettings.Read.All",
"https://graph.microsoft.com/ChannelMember.ReadWrite.All",
"https://graph.microsoft.com/ChannelMessage.Send",
"https://graph.microsoft.com/Team.ReadBasic.All",
"https://graph.microsoft.com/TeamMember.ReadWrite.All",
"https://graph.microsoft.com/User.ReadBasic.All"),
azureAd.getTenantId(),
azureAd.getClientId(),
azureAd.getClientSecret()))
.buildClient();
}
}
OBO 令牌提供程序
`OboAuthenticationProvider` 类在前一篇文章中进行了详细介绍。我们这里使用的代码将与之前相同,只是包声明不同。
package com.matthewcasperson.incidentmanagementdemo.providers;
总的来说,此类从传递给 OAuth 资源服务器端点的 JWT 中提取信息,并将其交换为具有给定作用域的 OBO 令牌。
创建 WebClient
尽管我们使用一个 Spring Boot 应用程序公开了 Web 应用程序和 OAuth 资源服务器,但 Web 服务器像任何其他外部客户端一样访问资源服务器端点,通过包含适当的 OAuth 身份验证头的 HTTP 请求。
我们需要一个 `WebClient` 供前端应用程序与资源服务器交互。`WebClient` 是用于进行 HTTP 调用的新的非阻塞解决方案,并且是比旧的 `RestTemplate` 更推荐的选项。
`WebClientConfig` 类代码配置了一个 `WebClient` 实例,以包含来自 `OAuth2AuthorizedClient` 的令牌,从而调用资源服务器端点。
package com.matthewcasperson.incidentmanagementdemo.providers;
...
// imports
...
@Configuration
public class WebClientConfiguration {
@Bean
public static WebClient webClient(final OAuth2AuthorizedClientManager oAuth2AuthorizedClientManager) {
final ServletOAuth2AuthorizedClientExchangeFilterFunction function =
new ServletOAuth2AuthorizedClientExchangeFilterFunction(oAuth2AuthorizedClientManager);
return WebClient.builder()
.apply(function.oauth2Configuration())
.build();
}
}
创建 Web 应用程序控制器
Web 应用程序端点由 IncidentWebController 类公开,该类在以下包中详细介绍。
package com.matthewcasperson.incidentmanagementdemo.controller;
前端使用我们在 `WebClientConfiguration` 类中创建的 `WebClient` 与资源服务器端点交互。我们将 `WebClient` 的一个实例注入到我们的控制器中。
@Controller
public class IncidentWebController {
@Autowired
WebClient webClient;
根目录由 `getCreateChannel` 方法公开。此方法注入一个名为 `api` 的客户端,该客户端配置为访问资源服务器。
@GetMapping("/")
public ModelAndView getCreateChannel(
@RegisteredOAuth2AuthorizedClient("api") final OAuth2AuthorizedClient client) {
final ModelAndView mav = new ModelAndView("create");
该页面需要一个团队和用户列表,这些列表是从资源服务器端点检索的。
final List teams = webClient
.get()
.uri("https://:8080/api/teams/")
.attributes(oauth2AuthorizedClient(client))
.retrieve()
.bodyToMono(List.class)
.block();
final List users = webClient
.get()
.uri("https://:8080/api/users/")
.attributes(oauth2AuthorizedClient(client))
.retrieve()
.bodyToMono(List.class)
.block();
用户和团队被添加为模型属性,然后返回 `ModelAndView` 对象。
mav.addObject("teams", teams);
mav.addObject("users", users);
return mav;
}
Thymeleaf 模板在列表中渲染团队和用户,并提供一个文本框,我们可以在其中定义新事件管理频道的名称。
上面的表单将一个 POST 请求提交到频道端点。这由 `postCreateChannel` 方法处理。
@PostMapping("/channel")
public ModelAndView postCreateChannel(
@RegisteredOAuth2AuthorizedClient("api") final OAuth2AuthorizedClient client,
@RequestParam final String channelName,
@RequestParam final String team,
@RequestParam final List<String> users) {
当此方法返回时,浏览器将被重定向到 `message` 页面。
final ModelAndView mav = new ModelAndView("redirect:/message");
通过调用资源服务器,创建新频道并将其分配给选定的用户。
final Channel newChannel = webClient
.post()
.uri("https://:8080/api/teams/" + team + "/channel")
.bodyValue(new IncidentRestController.NewChannelBody(channelName, users))
.attributes(oauth2AuthorizedClient(client))
.retrieve()
.bodyToMono(Channel.class)
.block();
下一个页面需要新频道、团队 ID 的名称和 ID。这些被定义为模型属性。然后返回 ModelAndView。
mav.addObject("channelId", newChannel.id);
mav.addObject("channelName", newChannel.displayName);
mav.addObject("team", team);
return mav;
}
在将浏览器重定向到新页面时,Spring 会将模型属性添加为查询参数。`getCreateMessage` 方法处理到消息页面的 GET 请求,提取查询参数,并在新的 `ModelAndView` 中重新定义它们,以便 Thymeleaf 模板可以使用它们。
@GetMapping("/message")
public ModelAndView getCreateMessage(
@QueryParam("team") final String team,
@QueryParam("channel") final String channelId,
@QueryParam("channel") final String channelName) {
final ModelAndView mav = new ModelAndView("message");
mav.addObject("team", team);
mav.addObject("channelId", channelId);
mav.addObject("channelName", channelName);
return mav;
}
`message` 页面显示一个表单,允许用户输入并提交自定义消息到频道,或使用预定义的 status 消息。这些 status 消息将根据业务需求进行配置,并代表事件的标准生命周期。
通过将这些标准消息发布到事件管理频道,工程和支持团队拥有了一个便捷的通信平台。更新只需按一次按钮,所有各方都能对事件的状态达成共识。
表单执行一个 POST 请求到 `message` 端点,该请求由 `postCreateMessage` 方法处理。
@PostMapping("/message")
public ModelAndView postCreateMessage(
@RegisteredOAuth2AuthorizedClient("api") final OAuth2AuthorizedClient client,
@RequestParam final String channelName,
@RequestParam final String channelId,
@RequestParam final String team,
@RequestParam final String customMessage,
@RequestParam final String status) {
final ModelAndView mav = new ModelAndView("message");
资源服务器负责在 Teams 中创建新消息。
webClient
.post()
.uri("https://:8080/api/teams/" + team + "/channel/" + channelId + "/message")
.bodyValue(status + "\nMessage: " + customMessage)
.attributes(oauth2AuthorizedClient(client))
.retrieve()
.toBodilessEntity()
.block();
然后重新显示相同的表单,以允许发布下一条 status 消息。
mav.addObject("channelName", channelName);
mav.addObject("channelId", channelId);
mav.addObject("team", team);
return mav;
}
}
最后,自定义消息和 status 被发布到新频道。
创建资源服务器控制器
资源服务器由 IncidentRestController 类公开,该类位于以下包中。
package com.matthewcasperson.incidentmanagementdemo.controller;
这些是 REST API 端点,因此我们的控制器用 `@RestController` 注释。
@RestController
public class IncidentRestController {
创建新频道和添加用户所需的信息由 `NewChannelBody` 类定义。
public static class NewChannelBody {
public final String channelName;
public final List<String> members;
public NewChannelBody(
final String channelName,
final List<String> members) {
this.channelName = channelName;
this.members = members;
}
}
对 Graph API 的访问通过 `GraphClientConfiguration` 类创建的客户端进行。此客户端的实例在此注入。
@Autowired
GraphServiceClient<Request> client;
`getTeams` 方法返回一个团队列表。
与 Graph API 客户端交互时的一个常见模式是使用 `Optional` 类来处理可能返回的 `null` 值。我们的应用程序假设 `null` 值表示空列表,但生产代码将对此类情况进行更健壮的错误处理。
@GetMapping("/api/teams")
public List<Team> getTeams() {
return Optional.ofNullable(client
.me()
.joinedTeams()
.buildRequest()
.get())
.map(BaseCollectionPage::getCurrentPage)
.orElse(List.of());
}
`getUsers` 方法返回一个用户列表。
@GetMapping("/api/users")
public List<User> getUsers() {
return Optional.ofNullable(client
.users()
.buildRequest()
.get())
.map(BaseCollectionPage::getCurrentPage)
.orElse(List.of());
}
我们在 `createChannel` 方法中创建一个新频道。
@PostMapping("/api/teams/{team}/channel")
public Channel createChannel(
@PathVariable("team") final String team,
@RequestBody final NewChannelBody newChannelBody) {
final Channel channel = new Channel();
channel.displayName = newChannelBody.channelName;
channel.membershipType = ChannelMembershipType.PRIVATE;
创建新频道涉及多个操作。我们首先查询 Graph API 是否有任何现有频道名称与新频道相同。
final List<Channel> existingChannel = Optional.ofNullable(client
.teams(team)
.channels()
.buildRequest()
.filter("displayName eq '" + newChannelBody.channelName + "'")
.get())
.map(BaseCollectionPage::getCurrentPage)
.orElse(List.of());
然后,我们要么重用现有频道,要么创建一个新频道。
final Channel newChannel = existingChannel.isEmpty()
? client
.teams(team)
.channels()
.buildRequest()
.post(channel)
: existingChannel.get(0);
然后,将每个选定的用户添加到团队,再添加到频道。
for (final String memberId : newChannelBody.members) {
final ConversationMember member = new ConversationMember();
member.oDataType = "#microsoft.graph.aadUserConversationMember";
member.additionalDataManager().put(
"user@odata.bind",
new JsonPrimitive("https://graph.microsoft.com/v1.0/users('" +
URLEncoder.encode(memberId, StandardCharsets.UTF_8) + "')"));
try {
// add the user to the team
client
.teams(team)
.members()
.buildRequest()
.post(member);
// add the user to the channel
client
.teams(team)
.channels(newChannel.id)
.members()
.buildRequest()
.post(member);
} catch (final Exception ex) {
System.out.println(ex);
ex.printStackTrace();
}
}
然后返回频道。
return newChannel;
}
创建新消息由 `createMessage` 方法处理。
@PostMapping("/api/teams/{team}/channel/{channel}/message")
public void createMessage(
@PathVariable("team") final String team,
@PathVariable("channel") final String channel,
@RequestBody final String message) {
我们假设传入的消息是纯文本,因为我们的表单不提供任何富文本编辑。由于消息是使用 HTML 发布,因此换行符被替换为 HTML 的换行符元素。
final ChatMessage chatMessage = new ChatMessage();
chatMessage.body = new ItemBody();
chatMessage.body.content = message.replaceAll("\n", "<br/>");
chatMessage.body.contentType = BodyType.HTML;
然后将消息发布到频道。
client
.teams(team)
.channels(channel)
.messages()
.buildRequest()
.post(chatMessage);
}
}
结论
虽然 Teams 等聊天平台对于一般协作很方便,但自由形式的对话并不是传达正在进行的事件状态的好方法。任何试图快速掌握事件解决工作当前状态的人都被迫阅读和解释长对话和嵌套的线程。通过创建一个有限的聊天界面,其中包含几个预先确定的 status 消息,团队可以与利益相关者保持沟通,并提供一个高信号与低噪声的事件管理频道。
在本文中,我们创建了一个示例 Spring Boot 应用程序,它使用 MSAL 库和 Graph API 客户端快速创建 Teams 中的新频道,添加所需用户,然后发送预定义的 status 消息。
在本三部分系列中,我们探讨了如何使用 Microsoft Identity 平台和 Microsoft Authentication Library (MSAL) 在 Spring Cloud Applications 中构建 Microsoft Graph 应用。Microsoft Graph 平台和 MSAL 对我们 Java 开发人员来说易于使用,是满足我们所有身份验证和授权需求的有力选择。
让 Microsoft 处理身份验证和授权的繁琐部分,这样您就可以专注于应用程序中为业务带来最大价值的部分。既然我们已经探讨了如何使用 Microsoft Identity 库、MSAL 和 Microsoft Graph,那么就开始在您自己的应用程序中使用它们吧。