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 所示)。

Microsoft Graph 的生产力 API
图 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


在 MSDN 杂志论坛讨论这篇文章