使用 ASP.Net MVC 和 Web API 构建 Yammer 应用






4.89/5 (6投票s)
在本篇文章中,我们将构建一个 ASP.Net MVC 5 网页应用,该应用将搜索您的 Yammer Feed 中的一个标签,并在 Bing 地图上显示发帖人的家乡。
引言
在本篇文章中,我们将构建一个 ASP.Net MVC 5 网页应用程序,该应用程序将搜索您的 Yammer Feed 中的特定标签,然后使用 Yammer 的图谱搜索获取有关发帖人故乡的信息。我们将创建一个 ASP.Net WebAPI 端点来提供我们的信息,并使用 JQuery 和 Bing Maps 向最终用户显示该信息。
背景
在公司内部,我们使用 Yammer 作为我们的社交网络。去年夏天,当我参加公司赞助的技术会议时,他们建议我们使用一个特定的标签来发布所有帖子。由于我们有来自世界各地的人们前来参加会议,我认为构建一个应用程序来显示包含建议标签的帖子,并突出显示发帖人的来源地,这会很酷。
这篇文章相当长,所以我已经将其分成了两部分。
必备组件
在深入研究代码之前,我们需要完成以下先决条件。
托管
我们需要做的第一件事是设置一些提供 HTTPS 的托管服务。Microsoft Azure 网站目前在其 *.azurewebsites.net 域上提供 10 个站点和 SSL 免费,所以我将选择它。您不必使用 Azure,只需要您的主机支持 HTTPS。本文中此后的一切 都将无法工作,如果您的 SSL/HTTPS 未运行。
如果您使用 Azure,请访问 https://manage.windowsazure.com,使用您的 Microsoft 帐户登录,然后导航到添加网站向导。您可以在此处使用“快速创建”选项,他们使其非常容易。重要的是要预留一个我们可以稍后引用的 URL。
数据库
在用户身份验证后,我们希望有一条记录表明他们曾经访问过——拥有参与度指标有助于确保当我们将来添加功能和进一步开发此应用程序时,我们正朝着正确的方向前进。由于我已经在 Azure 网站上设置了托管,我将使用 SQL Azure 作为我的数据库。这样做可以轻松地与 Entity Framework Code First(我们稍后将使用它)集成。由于我们只使用数据库来记录谁使用了该应用程序,因此此步骤是可选的。在 Azure 中这样做很容易。只需为该步骤使用快速创建。
注意:在使用 SQL Azure 作为数据库主机时,您需要确保防火墙规则已设置,以便您可以从当前位置连接到数据库。向导初始化完成后,花点时间点击页面底部的“管理”按钮以启动底部的 Silverlight 管理器——这将打开一个提示,用于设置防火墙规则。
映射
接下来,我们需要获取 Bing Maps 密钥。如果您查看 Bing Maps AJAX 控件的文档,您会发现为了使用站点地图小部件,您需要一个 API 密钥。
首先,访问 https://www.bingmapsportal.com 并使用您的 Microsoft 帐户登录。登录后,单击创建密钥的链接,然后选择“Basic”作为密钥类型,“Public Website”作为应用程序类型。如果您选择“Trial”作为密钥类型,则生成的密钥仅在 90 天内有效。
注意:在应用程序 URL 的框中,我们使用的是我们为托管预留的 URL 的 HTTPS 版本。
创建密钥后,单击“Create or view keys”链接,然后向下滚动到底部。
在我的示例中,您可以看到我最初创建了一个试用密钥,然后创建了一个没有过期日期的基本密钥。
记下/保存 Bing Maps 生成的密钥。我们稍后将需要此信息。
Yammer
前往 客户端应用程序管理区域,然后单击“Register New App”。查看 Yammer 的 API 简介,它相当不错。
填写所需信息,我们可以更改除应用程序名称之外的所有内容。现在,为网站提供预留的 HTTPS URL (https://hashmaps.azurewebsites.net/),为重定向 URI 提供 https://hashmaps.azurewebsites.net/Home/Display。
- 网站值是用户在 Yammer 中查看您的应用程序的公司资料时将被重定向到的位置。
- 重定向 URI 是 Yammer 在用户成功通过 Yammer 身份验证后将用户重定向到的端点。
填写信息并完成注册后,转到“My Apps”区域,然后记下/保存 Yammer 生成的客户端 ID 和客户端密钥。我们稍后将需要此信息。
代码
代码包含以下部分:
设置解决方案
首先,在 Visual Studio 中创建一个新的空 Web 项目。然后,添加以下 NuGet 包:
- Bootstrap
- EntityFramework
- Microsoft ASP.NET MVC
- Microsoft ASP.Net Web API 2.1
- Microsoft ASP.Net Web Optimization Framework
然后,添加以下项目:
- HashMaps.Data (此处将添加我们的数据库对象)
- 引用 EntityFramework
- HashMaps.Model (此处将存放我们的 DTO 对象)
- Modules (此处将托管我们的 OAuth 逻辑)
- 引用 EntityFramework 和 Json.Net
最后,
- 在您的 HashMaps 项目中,添加 HashMaps.Data、HashMaps.Model 和 Modules 作为项目引用。
- HashMaps.Data 项目引用 HashMaps.Model。
- Modules 项目引用 HashMaps.Data 和 HashMaps.Model。
完成后,您的项目结构应如下所示:
身份验证
在 HashMaps.Model 项目中,让我们首先声明核心的 IPrincipal
和 IIdentity
对象。这些对象将作为我们的“应用程序”级别的标识,我们将在内部使用它们来定义用户“是谁”。我们将把 Yammer 身份验证附加到这些对象上。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Principal;
using System.Text;
using System.Threading.Tasks;
namespace HashMaps.Model
{
public class UserPrincipal : IPrincipal
{
public UserPrincipal(UserIdentity identity)
{
this.Identity = identity;
}
public UserIdentity Identity { get; set; }
IIdentity IPrincipal.Identity { get { return Identity; } }
public bool IsInRole(string role)
{
return true;
}
}
public class UserIdentity : IIdentity
{
public String AuthToken { get; set; }
public int ID { get; set; }
public string AuthenticationType
{
get { return "OAuth"; }
}
public bool IsAuthenticated
{
get { return true; }
}
public string Name { get; set; }
}
}
请注意,我们无条件地为 IsInRole
方法返回 true,这是因为就本应用程序而言,我们不引入分级(普通/管理员)帐户的概念。每个人都可以访问所有内容。
此外,我们在 UserIdentity
类中的 IsAuthenticated
属性上无条件返回 true,因为(再次)就本应用程序而言,如果我们未能对用户进行身份验证,我们将不会创建 UserIdentity
的实例。
请注意,我们还向 UserIdentity
添加了一个 AuthToken
属性。由于在身份验证后我们将进行后续的 Yammer API 调用(并且它们的 API 需要此值),因此我们将此值保存在内存中,这样我们就无需承担不必要的性能损失来在后续时间检索此值。
OAuth 模块
这里的想法是创建一个自定义 HTTP 模块,该模块构建我们的 IPrinciple
和 IIdentity
对象,然后将该用户附加到 Context.User。当 HTTP 请求管道开始执行身份验证和授权代码时,它将使用我们构建的对象作为确定是否继续的基础。如果您想更深入地了解其工作原理,4Guys from Rolla 有一篇 出色的文章 详细解释了其理论。
让我们首先看一下 context_AuthenticateRequest
方法的第一部分。
var app = sender as HttpApplication;
if (app.Context.User != null)
{ return; } //user has alerady been authenticated.
if (app.Request.Cookies.Keys.Cast<String>().Contains(System.Web.Security.FormsAuthentication.FormsCookieName))
{
//the user is re-visiting within the same 'session'.
//attempt to extract the information from the encrypted ticket
try
{
var authCookie = app.Request.Cookies[System.Web.Security.FormsAuthentication.FormsCookieName];
var ticket = System.Web.Security.FormsAuthentication.Decrypt(authCookie.Value);
if(ticket.Expired == false)
{
var userID = Convert.ToInt32(ticket.Name);
var authtoken = ticket.UserData;
app.Context.User = new UserPrincipal(new UserIdentity() { AuthToken = authtoken, ID = userID });
return;
}
}
catch (Exception ex)
{ }
}
在这里,我们只是确保我们已经对用户进行了身份验证。也就是说,如果上下文已经有一个用户实例与之关联,则跳过其余部分,如果请求传入有效的表单身份验证票证在其 cookie 集合中,则将其转换为用户,将其与上下文关联,然后返回。
第二部分是我们做精彩工作的地方。
//the user has authorized the third party to utilized the app.
//now attempt to authenticate them.
if (String.IsNullOrWhiteSpace(app.Request.Params.Get("code")) == false)
{
using (var cli = new WebClient())
{
var authorizeCode = app.Request.Params.Get("code");
var yammerClientID = ConfigurationManager.AppSettings["YammerClientID"];
var yammerClientSecret = ConfigurationManager.AppSettings["YammerClientSecret"];
var authenticateUrl = String.Format("https://www.yammer.com/oauth2/access_token.json?client_id={0}&client_secret={1}&code={2}", yammerClientID, yammerClientSecret, authorizeCode);
var authenticationResponse = cli.DownloadString(authenticateUrl);
var authenticationObject = JsonConvert.DeserializeObject<AuthenticationResult>(authenticationResponse);
var authenticationToken = authenticationObject.access_token.token.ToString();
using (var db = new HashMapContext())
{
var user = db.Users.Where(u => u.ID == authenticationObject.user.id).FirstOrDefault();
if(user == null)
{ //this is a brand new user. create them.
user = db.Users.Create();
user.ID = authenticationObject.user.id;
user.Location = authenticationObject.user.location;
user.MugshotUrl = authenticationObject.user.mugshot_url;
user.Name = authenticationObject.user.full_name;
user.AuthorizationCode = authorizeCode;
user.AuthenticationToken = authenticationToken;
db.Users.Add(user);
}
else
{ //we've seen this user before, update their authentication token.
user.AuthenticationToken = authenticationToken;
}
db.SaveChanges();
}
var expirationDate = DateTime.UtcNow.AddHours(1);
var ticket = new System.Web.Security.FormsAuthenticationTicket(1, authenticationObject.user.id.ToString(), DateTime.UtcNow, expirationDate, true, authenticationToken);
var cookieString = System.Web.Security.FormsAuthentication.Encrypt(ticket);
var authCookie = new HttpCookie(System.Web.Security.FormsAuthentication.FormsCookieName, cookieString);
authCookie.Expires = expirationDate;
authCookie.Path = System.Web.Security.FormsAuthentication.FormsCookiePath;
app.Response.Cookies.Set(authCookie);
app.Context.User = new UserPrincipal(new UserIdentity() { ID = authenticationObject.user.id, AuthToken = authenticationToken });
}
return;
}
当 Yammer 成功验证您的用户并用户授权您的应用程序后,Yammer 会带有一个 URL 中的 code
参数重定向回您的应用程序。使用用户授权码、您的 Yammer 客户端 ID 和您的 Yammer 客户端密钥,然后您可以向 Yammer 发出 API 调用以获取有关您新验证用户的信息。
在我们提取了有关用户的一些信息并将其保存下来后,我们将该信息转换为 UserPrincipal
和 UserIdentity
,然后将其分配给 app.Context.User
。
确保此项目在您的 HashMaps.Web 项目中被引用,然后转到根级别的 web.config。在 System.WebServer
节点下,确保以下内容存在:
<modules>
<add name="OAuthModule" type="Modules.OAuthModule, Modules" />
</modules>
我们自己的 Yammer API
现在让我们转到 Web 项目并添加一个 Web API 端点。在 Get
方法(受 [Authorize]
属性保护)中,我们将首先从上下文的当前用户中提取认证令牌,并发出 API 调用以在 Yammer 中搜索“AvaTS14”。
var user = this.User as UserPrincipal;
String searchResults = null;
var authToken = user.Identity.AuthToken;
using (var cli = new WebClient())
{
cli.Headers.Add("Authorization", "Bearer " + authToken);
cli.QueryString.Add("search", "AvaTS14");
try
{
searchResults = cli.DownloadString("https://www.yammer.com/api/v1/search.json");
}catch(Exception ex)
{
return new List<HashMaps.Model.Dto.DtoMessage>();
}//TODO: add logging. we might have gotten rate limited by the api
}
var feed = JsonConvert.DeserializeObject<RootObject>(searchResults);
注意我们如何能够将 this.User
转换为 UserPrincipal
?这仅仅是因为我们将 [Authorize]
属性显式地放在了方法上(拒绝匿名请求),并将我们的自定义身份验证模块注入到了 HTTP 管道中。
现在,从 Yammer 返回的原始结果包含了很多我们在此应用程序中不需要的东西。然后,我们将这转化为 DTO(数据传输对象)的集合。
var dtos = new List<HashMaps.Model.Dto.DtoMessage>();
foreach (var msg in feed.messages.messages)
{
try
{
var dto = new HashMaps.Model.Dto.DtoMessage();
dto.ID = msg.id;
dto.PlainBody = msg.body.plain;
dto.WebUrl = msg.web_url;
dto.Composer = new HashMaps.Model.Dto.DtoPerson() { ID = msg.sender_id };
dtos.Add(dto);
}
catch (Exception ex)
{ } //add logging later
}
现在我们有了要显示的邮件,我们需要找出邮件的作者来自哪里。
var distinctSenderIDs = feed.messages.messages.Select(m => m.sender_id).Distinct();
var distinctSenders = new List<Model.Dto.DtoPerson>();
using (var db = new HashMapContext())
{
foreach (var senderID in distinctSenderIDs)
{
//first look in the database to see if we know about this person
var dbSender = db.Users.Where(u => u.ID == senderID).FirstOrDefault();
if (dbSender != null)
{
//jackpot, now copy it over
var sender = new Model.Dto.DtoPerson();
sender.FullName = dbSender.Name;
sender.ID = dbSender.ID;
sender.Location = dbSender.Location;
distinctSenders.Add(sender);
continue;
}
using (var cli = new WebClient())
{
cli.Headers.Add("Authorization", "Bearer " + authToken);
try
{
var userJson = cli.DownloadString("https://www.yammer.com/api/v1/users/" + senderID.ToString() + ".json");
var parsedUser = JsonConvert.DeserializeObject<Person>(userJson);
var sender = new Model.Dto.DtoPerson();
sender.FullName = parsedUser.full_name;
sender.ID = parsedUser.id;
sender.Location = parsedUser.location;
distinctSenders.Add(sender);
}
catch (Exception ex)
{ continue; } //add logging later
}
}
}
首先,我们会在自己的数据库中查找此人,然后再联系 Yammer 查找此人。此处所需的字段是 location
。如果我们将其扩展到更大规模的使用,我们可能会修改它,以便我们看到上次拉取位置的时间,因为人们倾向于从一个城市迁移到另一个城市——此应用程序仅设计用于几天,因此命中数据库的想法是我们会在联系 Yammer 请求今天早些时候拉取过的人的位置之前,先对我们的数据库造成性能影响。
现在我们有了要返回给调用者的所有数据,让我们将其合并到一个可用的 DTO 中。
foreach (var dto in dtos)
{
//find the composer
var composer = distinctSenders.Where(s => s.ID == dto.Composer.ID).FirstOrDefault();
if (composer != null)
{
dto.Composer = composer;
}
}
//only return yams that have location. We don't want to push out stuff we can't see.
return dtos.Where(dto => String.IsNullOrWhiteSpace(dto.Composer.Location) == false);
我们为 API 消费者要做的最后一件事是清理 Feed,以省略用户未指定位置的帖子。这整个点的目的是在地图上渲染帖子,如果没有位置数据,那么帖子对我们来说基本上是无用的。
JavaScript 常量
我不太确定这是否被认为是“良好”的实践,但我想做的一件事是在客户端创建一个常量对象,该对象跟踪我们将需要在页面之间使用的所有配置项。
在 Layout.cshtml 页面中,我添加了以下内容:
@Scripts.Render("~/bundles/Frameworks")
<script type="text/javascript">
var constants = {
yammerClientID: "@System.Configuration.ConfigurationManager.AppSettings["YammerClientID"]",
bingMapsKey: "@System.Configuration.ConfigurationManager.AppSettings["BingMapsKey"]",
host: "@String.Format("{0}://{1}", Request.Url.Scheme, Request.Url.Authority)"
};
</script>
@RenderSection("scripts", required: false)
这会从我们的 web.config 中提取 Yammer 客户端 ID 和 Bing Maps 密钥,并使它们作为 JavaScript 变量可用。我还向前推进了主机,因为 Yammer 重定向需要此值与我们在 API 密钥配置文件中设置的值匹配。
登录页面
在登录页面上,您可以使用 Yammer 提供的按钮设计,也可以自己设计。关键在于以下代码:
(function () {
$("#logIn").click(function () {
var redirect = encodeURIComponent(constants.host + "/Home/Display");
window.location = "https://www.yammer.com/dialog/oauth?client_id=" + constants.yammerClientID + "&redirect_uri=" + redirect;
});
});
这会告诉您的应用程序重定向到 Yammer 进行身份验证,并在成功身份验证后,返回到 Home/Display 控制器。
地图页面
最后,在实际渲染地图的页面上,让我们开始设置与 div 关联的 Bing 地图。
var map;
var searchManager;
var options = {
credentials: constants.bingMapsKey,
center: new Microsoft.Maps.Location(35.333333, 25.133333),
zoom: 2
};
$("#mapDiv").text("");
var map = new Microsoft.Maps.Map(document.getElementById("mapDiv"), options);
严格来说,不必居中地图——通过反复试验,我在南美洲和非洲之间找到了一块区域,它将地图默认设置为使南北美洲位于左侧,亚洲、欧洲和非洲位于右侧,南极洲可见并居中在底部。
接下来,我们需要定义一个回调函数,以便在地图准备好进行搜索和地理编码操作时调用。
Microsoft.Maps.loadModule('Microsoft.Maps.Search', { callback: searchModuleLoaded });
function searchModuleLoaded() {
searchManager = new Microsoft.Maps.Search.SearchManager(map);
}
循环帖子
基本思想是,我们将声明一个帖子数组,逐个循环,然后定期从我们创建的 API 中用新值刷新数组。
首先,让我们编写代码来刷新我们正在循环的数组。
var yams = [];
var currentYamIndex = -1;
var currentPin = null;
var infobox = null;
refreshYams();
window.setInterval(function () {
refreshYams();
}, 60 * 2 * 1000);
function refreshYams()
{
$("#loading-placeholder").show();
$.ajax({
url: "/Api/YammerUpdates",
success: function (data, textStatus, jqXHR) {
$("#loading-placeholder").hide();
if (data.length > 0)
{ yams = data; } //only refresh the yams if there's new ones.
},
error: function (jqXHR, textStatus, errorThrown) {
alert("Error");
}
});
}
虽然不怎么酷,但每 2 分钟,我们只会显示加载占位符,向 API 发出 HTTP GET 请求以拉取新数据,然后在检索到数据后隐藏占位符。
现在让我们循环遍历它们。
window.setInterval(function () {
cycleYams();
}, 5 * 1000);
function cycleYams() {
if (searchManager == undefined || searchManager == null)
{ return; }
if (yams.length == 0)
{ return; }
if (currentYamIndex + 1 > yams.length - 1)
{ currentYamIndex = 0; }
else
{ currentYamIndex++; }
PlotData(yams[currentYamIndex]);
}
同样,没什么特别的,只是每 5 秒前进一次当前迭代器,当到达末尾时再次循环回到开头。
真正的魔法发生在绘制帖子时。
function PlotData(item)
{
var geocodeRequest = { where: item.Composer.Location, count: 1, callback: geocodeCallback, errorCallback: errCallback, userData: item };
searchManager.geocode(geocodeRequest);
}
function geocodeCallback(geocodeResult, userData) {
if (currentPin != null)
{
map.entities.remove(currentPin); //clear the existing pin, if necessary
}
if (geocodeResult.results.length > 0)
{
var location = geocodeResult.results[0].location;
currentPin = new Microsoft.Maps.Infobox(location, {
visible: true,
title: userData.Composer.FullName,
description: userData.PlainBody.substr(0,140)
});
map.entities.push(currentPin);
}
}
function errCallback(geocodeRequest) {
// alert("An error occurred.");
}
我们在这里做的第一件事是移除现有的图钉——我们可以同时显示两个帖子,但这会导致用户界面看起来很糟糕。
当我们在地图上放置图钉时,它必须通过纬度和经度。在我们的 Yammer 资料中,我们没有得到这个——我们得到的是“San Antonio, Texas”。所以我们发出一个地理编码请求,当它成功返回时,它会给出纬度和经度,然后我们可以将其传递给一个 infobox,该 infobox 被放置在地图上。
关注点
我在这款应用程序的构建过程中非常开心。我的下一步是将其迁移到 OWIN 应用程序结构,允许用户按自定义标签搜索,并搜索他们的 Facebook、Twitter 和 LinkedIn Feed 以及 Yammer。
历史
2014-11-19:修复了一些拼写错误。
2014-11-18:原始帖子。