2018 年 1 月
第 33 卷,第 1 期
Office - 使用 Microsoft Graph 和 Azure Functions 生成组织相关 API
作者 Mike Ammerlaan | 2018 年 1 月
如果将组织比作是 API,将会是什么样子?
首先是人员(组织的核心)及其担任的各种角色和职能。此类人员通常归入负责完成任务和项目的已定义虚拟团队。在此基础之上,再附加各种资源,包括工作地点和完成工作所使用的工具。然后,添加流程和工作活动。这些可能就是 API 中的方法吧?虽然“widgetMarketingTeam.runCampaign()”可能再简单不过,但通过组织相关 API,不仅可以有效地深入了解组织的运营状况,还能生成更高效的流程和工具,从而推动工作效率变革。
关键在于,要确保所有资源始终可用,且在逻辑上相互关联,这样便可以生成适应个人和团队所需工作方式的综合流程。汇集和连接的 API 越多,到目前为止生成的净产品集就越有用,即实现一加一大于二的效果。
为此,我们提供了 Microsoft Graph 这一 API。它不仅覆盖组织内的关键数据集,还支持整合推动工作效率变革所需的一切。而且,Microsoft Graph 还可以与 OneDrive 和 Mail (Outlook.com) 等使用者服务搭配使用,这同样也有助于推动个人工作效率变革。
解决 API 无计划扩张的问题
纵观整个组织,使用中的软件系统集可能会大相径庭。对于开发人员,每个系统都表示一种独特结构,其中通常包括一组独特的 API、身份验证要求和交互样式。软件项目的主要挑战通常就在于,如何桥接不同的系统才能大致了解运行状况,这可能包括将不同的 API 抽象出来,以及掌握各种身份验证方案。
过去,(以我为例,Microsoft 的)不同产品团队的各个 API 不仅运行方式不同,而且还要求执行跨产品集成。甚至五年前,在获取用户完整配置文件和照片的过程中,仍必须同时调用 Exchange API(以便获取人员信息)和 SharePoint API(以便从用户的托管配置文件中获取照片)。每个系统都有自己的身份验证、API 方案和独特要求。若要获取某人的经理信息,该怎么办?这就涉及查询第三个系统,以便获取组织的层次结构。虽然全部这些操作可以一起执行,但复杂程度超出了应有的范围。
为了解决此问题,Microsoft Graph 应运而生。通过统一数据和身份验证并确保系统的一致性,净 API 集的易用性和实用性得到了很大提升。Microsoft Graph 将整个组织内表示公司重要方面和职能的不同系统整合到一起。自两年前发布以来,为了能够真正成为组织的基础 API,Microsoft Graph 一直在不断提升功能和性能。
用户集在 Microsoft Graph 中占据核心地位,这些用户通常是组织中有帐户的所有员工。简化后的集中式组是 Microsoft Graph 中新出现的概念,通常始于用户及其他安全组的列表。各组可拥有一组相关的资源,如 Microsoft Teams 中以聊天为基础的工作区、Planner 任务板以及包含文档库和文件的 SharePoint 网站。其中表示了可供用户和组使用的各种工作工具,这些资源包括通过 Drive API 的文件、通过 Planner API 的任务、用户和组的接收邮件、联系人、日历等(如图 1 所示)。
图 1:Microsoft Graph 的生产力 API
随着时间的推移,Microsoft Graph API 已新增了许多功能。借助在 Microsoft Graph 中暂留自定义元数据及项这一新功能,可以深度自定义这些项。现在,组不再只是一个组。借助描述主题、教师、时间安排的附加元数据,组现在可以表示学校内的班级。可使用此元数据执行查询。例如,查找所有表示理科班的组。也可以将自己系统中的标识符添加到 Microsoft Graph 内的相关实体,从而将系统连接到 Microsoft Graph。
此外,Microsoft Graph 还超越了为核心对象提供创建、读取、更新和删除 (CRUD) API 的范畴。一项主要功能是,可以在用户工作时在后台生成见解层。例如,尽管 Graph 包含完整的组织层次结构和组集合,但不一定就能最准确地呈现团队的工作情况。通过分析工作,可以列出最密切相关的人员(虚拟团队),以及用户可能与之关联的文件。此外,用于确定一组用户的可会面时间等常见实用工具也已成为各种方法。
Azure Functions
Microsoft Graph 旨在量身定制用于更广泛的系统和流程。作为与各种 SDK 相结合的简单 REST API,Microsoft Graph 旨在成为简便易用的搭配产品。在 Microsoft Graph 中生成流程和集成时,自然而然会选择 Azure Functions (functions.azure.com),这样就可以所需的位置上添加已准确定位的代码块,同时仅在使用代码时才需要支付累加费用。Azure Functions 支持跨语言开发,包括 C# 和 Node.js。
最近,一组与 Azure Functions 的新集成让它可以更轻松地连接到 Microsoft Graph。Azure Functions 绑定扩展现与 Azure Functions 2.0 运行时一起处于预览阶段,可自动执行与 Microsoft Graph 搭配使用所需完成的一些常见任务,包括身份验证以及处理 Webhook 的运作方式。
接下来将举例说明如何开始使用 Microsoft Graph。
通过 Azure Functions 创建任务
假设希望经理评审和审批团队成员执行的操作。用户任务是一种要求用户执行操作的方式,可便于转换和跟踪人员操作。在此示例中,我要实现简单的 Web 服务,用于创建分配给用户的经理的任务。
任何 Microsoft Graph 项目的第一站通常都是 Graph 浏览器。Graph 浏览器是应用 Web 网站,可便于快速塑造 Microsoft Graph 调用、查看调用结果,以及执行可以想到的全部操作。Graph 浏览器可以从 developer.microsoft.com/graph 下载,可方便用户使用只读演示租赁或登录自己的租赁。可使用组织帐户进行登录,并直接访问自己的数据。建议使用 Office 开发人员计划 (dev.office.com/devprogram) 提供的开发人员租赁。这样一来,就可以拥有单独的租赁,能够随意进行开发试验。
在此示例中,可以输入两个简单 URL,了解将在此示例中执行的调用的种类。首先,不妨获取用户的经理,具体方法是在 Graph Explorer 中选择“获取我的经理”示例,如图 2 所示。支持此操作的 URL 显示在“运行查询”字段中。
图 2:选择“获取我的经理”的结果
此操作的第二部分是要创建 Planner 任务。在 Graph 浏览器中,可以扩展一组示例,从而添加 Planner 任务示例。这组示例中包含创建 Planner 任务的操作(发布到 https://graph.microsoft.com/v1.0/planner/tasks 的查询)。
至此,已了解到涉及 Web 服务请求,可以使用 Azure Functions 生成函数了。
首先,新建 Azure Functions 应用。通常建议遵循 aka.ms/azfnmsgraph 上的说明完成此操作。简而言之,由于新功能 Azure Functions 绑定扩展处于预览阶段,因此需要将 Azure Functions 应用迁移到 2.0 预览版(beta 版本)运行时。还需要安装 Microsoft Graph 扩展,并配置应用服务身份验证。
在此示例中,配置 Microsoft Graph 应用注册时,需要进一步添加一些权限,以支持读取经理的信息和任务。这些权限包括:
- CRUD 用户任务和项目 (Tasks.ReadWrite)
- 查看用户的基本配置文件 (profile)
- 读写所有组 (Group.ReadWrite.All)
- 读取所有用户的基本配置文件 (User.ReadBasic.All)
不妨使用适用于 Microsoft Graph 的 Azure Functions 绑定扩展,以处理身份验证,并确保拥有已验证访问令牌,可用来访问 Microsoft Graph API。为此,请创建标准的 HTTP C# 触发器。在“集成”下,选择“高级编辑器”,并使用图 3 中的绑定。这就要求用户先登录、进行身份验证并批准应用,然后才能使用应用。
图 3:创建处理身份验证的 HTTP 触发器
{
"bindings": [
{
"name": "req",
"type": "httpTrigger",
"direction": "in"
},
{
"type": "token",
"direction": "in",
"name": "accessToken",
"resource": "https://graph.microsoft.com",
"identity": "userFromRequest"
},
{
"name": "$return",
"type": "http",
"direction": "out"
}
],
"disabled": false
}
图 4**** 展示了此函数的代码。请注意,需要为函数应用配置名为“PlanId”的环境变量,其中包含要用于任务的 Planner 计划的标识符。可通过函数应用的“应用设置”完成此操作。
图 4:将分配的任务发布到用户的 经理 Azure Functions 源
#r "Newtonsoft.Json"
using System.Net;
using System.Threading.Tasks;
using System.Configuration;
using System.Net.Mail;
using System.IO;
using System.Web;
using System.Text;
using Newtonsoft.Json.Linq;
public static HttpResponseMessage Run(HttpRequestMessage req, string accessToken, TraceWriter log)
{
log.Info("Processing incoming task creation requests.");
// Retrieve data from query string
// Expected format is taskTitle=task text&taskBucket=bucket
// title&taskPriority=alert
var values = HttpUtility.ParseQueryString(req.RequestUri.Query);
string taskTitle = values["taskTitle"];
string taskBucket = values["taskBucket"];
string taskPriority = values["taskPriority"];
if (String.IsNullOrEmpty(taskTitle))
{
log.Info("Incomplete request received - no title.");
return new HttpResponseMessage(HttpStatusCode.BadRequest);
}
string planId = System.Environment.GetEnvironmentVariable("PlanId");
// Retrieve the incoming users' managers ID
string managerJson = GetJson(
"https://graph.microsoft.com/v1.0/me/manager/", accessToken, log);
dynamic manager = JObject.Parse(managerJson);
string managerId = manager.id;
string appliedCategories = "{}";
if (taskPriority == "alert" || taskPriority == "1")
{
appliedCategories = "{ \"category1\": true }";
}
else
{
appliedCategories = "{ \"category2\": true }";
}
string now = DateTime.UtcNow.ToString("yyyy-MM-ddTHH\\:mm\\:ss.fffffffzzz");
string due = DateTime.UtcNow.AddDays(5).ToString(
"yyyy-MM-ddTHH\\:mm\\:ss.fffffffzzz");
string bucketId = "";
// If the incoming request wants to place a task in a bucket,
// find the bucket ID to add it to
if (!String.IsNullOrEmpty(taskBucket))
{
// Retrieve a list of planner buckets so that you can match
// the task to a bucket, where possible
string bucketsJson = GetJson(
"https://graph.microsoft.com/v1.0/planner/plans/" + planId +
"/buckets", accessToken, log);
if (!String.IsNullOrEmpty(bucketsJson))
{
dynamic existingBuckets = JObject.Parse(bucketsJson);
taskBucket = taskBucket.ToLower();
foreach (var bucket in existingBuckets.value)
{
var existingBucketTitle = bucket.name.ToString().ToLower();
if (taskBucket.IndexOf(existingBucketTitle) >= 0)
{
bucketId = ", \"bucketId\": \"" + bucket.id.ToString() + "\"";
}
}
}
}
string jsonOutput = String.Format(" {{ \"planId\": \"{0}\", \"title\": \"{1}\", \"orderHint\": \" !\", \"startDateTime\": \"{2}\", \"dueDateTime\": \"{6}\", \"appliedCategories\": {3}, \"assignments\": {{ \"{4}\": {{ \"@odata.type\": \"#microsoft.graph.plannerAssignment\", \"orderHint\": \" !\" }} }}{5} }}",
planId, taskTitle, now, appliedCategories, managerId, bucketId, due);
log.Info("Creating new task: " + jsonOutput);
PostJson("https://graph.microsoft.com/v1.0/planner/tasks",
jsonOutput, accessToken, log);
return new HttpResponseMessage(HttpStatusCode.OK);
}
private static string GetJson(string url, string token, TraceWriter log)
{
HttpWebRequest hwr = (HttpWebRequest)WebRequest.CreateHttp(url);
log.Info("Getting Json from endpoint '" + url + "'");
hwr.Headers.Add("Authorization", "Bearer " + token);
hwr.ContentType = "application/json";
WebResponse response = null;
try
{
response = hwr.GetResponse();
using (Stream stream = response.GetResponseStream())
{
using (StreamReader sr = new StreamReader(stream))
{
return sr.ReadToEnd();
}
}
}
catch (Exception e)
{
log.Info("Error: " + e.Message);
}
return null;
}
private static string PostJson(string url, string body, string token, TraceWriter log)
{
HttpWebRequest hwr = (HttpWebRequest)WebRequest.CreateHttp(url);
log.Info("Posting to endpoint " + url);
hwr.Method = "POST";
hwr.Headers.Add("Authorization", "Bearer " + token);
hwr.ContentType = "application/json";
var postData = Encoding.UTF8.GetBytes(body.ToString());
using (var stream = hwr.GetRequestStream())
{
stream.Write(postData, 0, postData.Length);
}
WebResponse response = null;
try
{
response = hwr.GetResponse();
using (Stream stream = response.GetResponseStream())
{
using (StreamReader sr = new StreamReader(stream))
{
return sr.ReadToEnd();
}
}
}
catch (Exception e)
{
log.Info("Error: " + e.Message);
}
return null;
}
此示例展示了如何通过一个身份验证令牌,将迥然不同的数据集(在此示例中,为用户的经理和 Planner 任务)汇集到一段代码中。由于创建和分配任务是一种推动跨团队活动的常见方法,因此能够便捷地创建任务和利用现有 Planner 经验是相当有用的。虽然它不完全是“widgetMarketingTeam.launchCampaign()”,但至少展示了如何创建一组入门任务,让团队能够有一个可专注工作的结构化开端。
在 OneDrive 中处理文件
另一个可执行的任务是,处理用户 OneDrive 中的文件。在此示例中,将利用适用于 Microsoft Graph 的 Azure Functions 绑定扩展,准备供使用的文件。然后,将它传递到认知服务 API,以执行语音识别。此为数据处理示例,可用于从 OneDrive 和 SharePoint 上的文件中挖掘出更多价值。
首先,按照上一示例中的一些相同步骤操作,包括创建函数应用和 Azure Active Directory 注册。请注意,对此示例配置 Azure Active Directory 应用注册时,需要添加“读取用户可访问的所有文件”(Files.Read.All) 权限。还需要有认知服务语音 API 密钥(可从 aka.ms/tryspeechapi 获取)。
依旧是从 Azure Functions 绑定扩展入手,新建 HTTP C# 触发器。在函数的“集成”选项卡下,使用图 5 中的绑定标记,将函数连接到绑定扩展。在此示例中,绑定扩展将 Azure 函数中的 myOneDriveFile 参数与 OneDrive 绑定扩展相关联。
图 5:新建获取 OneDrive 文件的触发器
{
"bindings": [
{
"name": "req",
"type": "httpTrigger",
"direction": "in"
},
{
"name": "myOneDriveFile",
"type": "onedrive",
"direction": "in",
"path": "{query.filename}",
"identity": "userFromRequest",
},
{
"name": "$return",
"type": "http",
"direction": "out"
}
],
"disabled": false
}
现在,是时候运行图 6**** 中的代码了。
图 6:转录 One Drive 中的音频文件
#r "Newtonsoft.Json"
using System.Net;
using System.Text;
using System.Configuration;
using Newtonsoft.Json.Linq;
public static async Task<HttpResponseMessage> Run(HttpRequestMessage req,
Stream myOneDriveFile, TraceWriter log)
{
// Download the contents of the audio file
log.Info("Downloading audio file contents...");
byte[] audioBytes;
audioBytes = StreamToBytes(myOneDriveFile);
// Transcribe the file using cognitive services APIs
log.Info($"Retrieving the cognitive services access token...");
var accessToken =
System.Environment.GetEnvironmentVariable("SpeechApiKey");
var bingAuthToken = await FetchCognitiveAccessTokenAsync(accessToken);
log.Info($"Transcribing the file...");
var transcriptionValue = await RequestTranscriptionAsync(
audioBytes, "en-us", bingAuthToken, log);
HttpResponseMessage hrm = new HttpResponseMessage(HttpStatusCode.OK);
if (null != transcriptionValue)
{
hrm.Content = new StringContent(transcriptionValue, Encoding.UTF8, "text/html");
}
else
{
hrm.Content = new StringContent("Content could not be transcribed.");
}
return hrm;
}
private static async Task<string> RequestTranscriptionAsync(byte[] audioBytes,
string languageCode, string authToken, TraceWriter log)
{
string conversation_url = $"https://speech.platform.bing.com/speech/recognition/conversation/cognitiveservices/v1?language={languageCode}";
string dictation_url = $"https://speech.platform.bing.com/speech/recognition/dictation/cognitiveservices/v1?language={languageCode}";
HttpResponseMessage response = null;
string responseJson = "default";
try
{
response = await PostAudioRequestAsync(conversation_url, audioBytes, authToken);
responseJson = await response.Content.ReadAsStringAsync();
JObject data = JObject.Parse(responseJson);
return data["DisplayText"].ToString();
}
catch (Exception ex)
{
log.Error($"Unexpected response from transcription service A: {ex.Message} |" +
responseJson + "|" + response.StatusCode + "|" +
response.Headers.ToString() +"|");
return null;
}
}
private static async Task<HttpResponseMessage> PostAudioRequestAsync(
string url, byte[] bodyContents, string authToken)
{
var payload = new ByteArrayContent(bodyContents);
HttpResponseMessage response;
using (var client = new HttpClient())
{
client.DefaultRequestHeaders.Add("Authorization", "Bearer " + authToken);
payload.Headers.TryAddWithoutValidation("content-type", "audio/wav");
response = await client.PostAsync(url, payload);
}
return response;
}
private static byte[] StreamToBytes(Stream stream)
{
using (MemoryStream ms = new MemoryStream())
{
stream.CopyTo(ms);
return ms.ToArray();
}
}
private static async Task<string> FetchCognitiveAccessTokenAsync(
string subscriptionKey)
{
string fetchUri = "https://api.cognitive.microsoft.com/sts/v1.0";
using (var client = new HttpClient())
{
client.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Key", subscriptionKey);
UriBuilder uriBuilder = new UriBuilder(fetchUri);
uriBuilder.Path += "/issueToken";
var response = await client.PostAsync(uriBuilder.Uri.AbsoluteUri, null);
return await response.Content.ReadAsStringAsync();
}
}
设置完此函数,用户就可以在登录 Azure 函数后指定文件名参数了。如果文件的扩展名为 .WAV 且包含英语内容,就会转录为英语文本。由于这是通过 Azure Functions 实现,因此通常只会在函数得到调用时才产生成本。这样一来,就可以灵活扩展 Microsoft Graph 中的数据了。
Azure Functions + Microsoft Graph
我在本文中介绍的两个示例展示了如何在 Microsoft Graph 数据基础之上生成人员和技术流程。加上 Microsoft Graph 的广泛适用范围和跨工作负载能力(如组织层次结构和任务,同本文中的任务示例情况一样),可跨整个组织创造价值和实现增值。结合使用 Microsoft Graph 和 Azure Functions,可以生成完整的组织相关 API,并推动所有方面的工作效率变革。请访问 developer.microsoft.com/graph,并使用 Azure Functions (functions.azure.com),开始为组织生成解决方案。
Mike Ammerlaan 担任 Microsoft Office 生态系统团队的产品营销总监一职,负责帮助客户使用 Office 365 生成有吸引力的解决方案。**而在此之前,他在 Microsoft 担任项目经理一职长达 18 年之久,参与了 SharePoint、Excel、Yammer、必应地图和 Combat Flight Simulator 等产品的开发工作。
衷心感谢以下 Microsoft 技术专家对本文的审阅: Ryan Gregg、Matthew Henderson 和 Dan Silver