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

Membership+ 管理系统,第二部分:账户设置

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (17投票s)

2014年2月26日

Apache

34分钟阅读

viewsIcon

49842

downloadIcon

2902

实现 Membership+ 管理系统的用户账户设置部分。

GitHub 托管项目

注意:本系列中的每篇文章可能对数据服务有不同的数据模式。出于历史原因,它们存在于不同的源代码控制分支中。因此,早期文章中的内容可能与后期文章中的内容不同步。在尝试重用其他文章中的数据服务和样本数据时应谨慎。

注意 (2014-03-02):数据模式已更改,请用当前版本替换早期版本的数据服务(如果存在),以便新版本能正常工作。

相关文章

目录

User details

1. 引言 ^

Membership+ 管理系统,第一部分:引导 中描述了 Membership+ 系统的管理 Web 应用程序的启动。本文提供了关于全球化、媒体处理和会员个人信息管理的一些详细信息。

2. 背景 ^

2.1 全球化 ^

互联网是一个全球互联的网络,不同文化的人们可以共享。如果 Web 应用程序具有全球化功能,它就能充分利用互联网的优势。

实现 Web 应用程序的全球化有多种方法。许多网站使用不同的 URL 来区分不同语言的内容。这种全球化方案,在此称为显式全球化。这种方法有优点,例如它允许独立的语言导向团队构建专门针对特定语言的各种网站域,而彼此之间的依赖性很小,等等,这里我们不作详尽枚举。这种方法的缺点之一是维护内容更难、成本更高,因为网站从最高级别就分为语言域,更难进行并排翻译,一旦网站部分内容发生更改,就很难保持同步,需要更高的人力资源投入。

此处采用的方法称为隐式全球化。在 Web 应用程序的隐式全球化方案中,所有支持语言的内容 URL 都保持不变。根据用户浏览上下文中的语言规范变量,将对应于特定语言的内容发送到用户的浏览器。其中一个明显的变量是大多数现代浏览器中的内容语言偏好选项,该选项由系统在客户端自动设置,并且用户以后可以更改。此选择将编码到 Accept-Language HTTP 请求头(参见 此处此处,以及 此处)并发送到 Web 服务器。我们开发了一种基于我们称之为“uni-resource”技术的轻量级全球化方法。该系统的早期版本已发布(参见,例如,GTRM,读者尝试下载和安装时很可能无法正常工作,因为该系统的加载器目前已不再支持)。该系统最新版本的文本资源编辑和翻译部分目前仅在内部使用。但是,该项目包含的程序集可以通过简单的 API(见下文)以极高的速度从使用上述工具构建的自定义 uni-resource 文本数据库中检索各种语言的文本块,这非常适合包含大量小文本块的网页,这些文本块在隐式全球化后否则可能会降低网站性能。有人可能会问,为什么不直接使用 .Net 框架的资源系统,就像用于客户端应用程序一样?有几个原因。

  • 默认的 API 用于选择特定语言的文本块,该 API 基于共享属性(即静态属性或与 Thread 相关的属性),在 Web 服务器的线程池环境中,如果没有独占锁,在请求处理生命周期中这些属性不是确定性的。在 Web 服务器的线程池环境中,如果没有独占锁,这会导致性能大幅下降。对于采用 4.0 版本后 .Net 运行时环境新 async 范式的网站尤其如此,在这种环境中,异步处理程序可以将顺序执行流程分解为在不同线程上运行的部分(参见,例如,此处),在这种情况下无法锁定。

  • 非基于文件的 .net 资源已编译到网站的程序集中。这使得无法在运行时更新文本。对于静态文本块来说这还可以,但动态 Web 应用程序可能包含不断变化的內容。

  • 在 Web 服务器上,文件资源的 .Net 资源管理器在重负载下并不可靠,至少在某些早期版本的 .Net 和我们测试过的用例中是这样。有时会抛出随机异常。此外,这些资源文件在运行时会被锁定,因此在不停止相应的 Web 应用程序的情况下不允许更新它们。

  • 它不具有可移植性。例如,Linux 上的 MONO 不支持 .Net 资源;Windows RT 有不同的资源编码方式,等等。

2.2 内容缓存 ^

适当缓存 Web 内容可以显著提高 Web 应用程序的性能。内容缓存可以在服务器端、客户端或两者结合进行。托管 Web 应用程序的 Web 服务器可用于缓存内容(不至于非常大)或作为整个响应中为每次请求重新生成成本较高的一部分。如果完整的 Web 内容很大且更改缓慢,以下称为静态内容,最好将其缓存在客户端,因为这样可以节省服务器上的内存消耗以及原本用于传输频繁更改内容到客户端的带宽。

大多数 Web 浏览器可以将在服务器端的内容保存在客户端,并根据 Http 协议 指定的规则决定是否使用特定内容的已保存副本,这结合使用了 HTTP 请求/响应中相应的 If-Modified-Since/Last-ModifiedIf-None-Match/Etag 标头字段。

大多数 Web 服务器可以处理基于文件的静态内容的客户端缓存,而无需开发人员过多关注。这部分是因为大多数操作系统的文件系统中包含标准元属性的文件,例如 Web 服务器无需了解应用程序上下文即可用于控制客户端缓存的 mime 类型最后修改时间等。无法很好处理的是从数据库检索到的静态内容,因为这些内容所需的元信息没有标准。事实上,在许多系统中,上述元信息的一部分或全部缺失。不仅 Web 服务器,而且 Asp.Net 框架也无法访问所需信息来处理后一种静态内容的客户端缓存。由于 .Net 基于 ActionFilterAttribute 的类无法接受参数,OutputCacheAttribute 只能很好地处理服务器端缓存。对于从数据库检索到的静态内容的客户端缓存,发现显式在操作级别处理更灵活。

Membership+ 系统的 数据模式(参见此处) 的设计方式是,大多数静态和大型媒体数据都有一个最小集对应的元属性,可用于控制客户端渲染和缓存。

  • UserAppMembers 数据集中的实体的 IconImage 属性有两个配套属性,即 IconMimeIconLastModified,可用于控制数据的客户端渲染和缓存。IconLastModified 的值是从用于更新 IconImage 数据的相应文件中检索的。
  • UserDetails 数据集中的实体的 Photo 属性有两个配套属性,即 PhotoMimePhotoLastModified,可用于控制数据的客户端渲染和缓存。PhotoLastModified 的值是从用于更新 Photo 数据的相应文件中检索的。

数据服务的前端以及负责输入和更新此类数据的 Web 应用程序的构建方式是,元属性会自动从源数据(目前是文件)中检索(见下文的实现部分)。

2.3 网页组合 ^

本节内容面向刚接触 Asp.Net Mvc 框架的读者。其他人可以跳过。

对于刚接触 Asp.Net Mvc 但有桌面或 Web 窗体应用程序开发经验的开发者来说,可能会发现新框架中似乎没有用户控件。这实际上并非如此,“用户控件”由部分视图表示。

可以使用两种方法来利用部分视图:

  1. 直接包含部分视图,通过 @Html.Partial("ViewName", model)@{ Html.RenderPartial("ViewName", model); } 生成,其中前者生成一个字符串,后者返回 void,因此调用方式不同。后者直接将部分视图生成的 HTML 写入响应输出流。

  2. 通过调用 @Html.Action("ActionName", "ControllerName", ... )@{ Html.RenderAction("ActionName", "ControllerName", ... ); } 形式的操作来间接且更灵活地包含部分视图,与上面一样,前者生成一个字符串,后者返回 void,因此调用方式不同。后者直接将部分视图生成的 HTML 写入响应输出流。

    它不接受部分视图的名称,而是接受操作的名称,以及可选的定义操作的控制器名称。由调用的操作负责选择哪个部分视图,如何初始化选定的视图,以及如何缓存各种输出。通常,操作的形式为:

    [ChildActionOnly]
    public ActionResult ActionName()
    {
        ... partial view selection and initialization ...
        
        return PartialView("PartialViewName", viewmodel);
    }

    ChildActionOnly 属性用于阻止操作被直接从请求 URL 调用。

部分视图允许重构 asp.net Mvc 视图,以便将一些公共部分在多个网页中使用和重用,从而提高网页的可维护性和一致性。

3. 实现 ^

3.1 全球化 ^

3.1.1 资源适配器 ^

ArchymetaMembershipPlusStores 项目的 Resources 子目录下的 ResourceUtils.cs 文件中的类型和方法用于 Web 应用程序隐式检索 uni-resource 数据库中包含的文本资源。

HTTP Accept-Language 头字段中包含的语言偏好遵循 ISO-639 语言代码 + ISO-3166 国家/地区代码(参见 此处),这与 .Net 分类语言的方式不同。映射方法

private static CultureInfo MapCultureInfo(string lan, out float weight)
{
    int ipos = lan.IndexOf(';');
    string cn = ipos == -1 ? lan.Trim() : lan.Substring(0, ipos).Trim();
    if (ipos == -1)
        weight = 1.0f;
    else
    {
        if (!float.TryParse(lan.Substring(ipos + 1).Trim(), out weight))
            weight = 0.0f;
    }
    CultureInfo ci = null;
    if (cn == "zh" || cn == "zh-chs" || cn == "zh-hans" || cn == "zh-cn")
        ci = new CultureInfo("zh-Hans");
    else if (cn == "zh-cht" || cn == "zh-hant" || cn == "zh-tw" || cn == "zh-hk" 
                                               || cn == "zh-sg" || cn == "zh-mo")
        ci = new CultureInfo("zh-Hant");
    else if (cn.StartsWith("zh-"))
        ci = new CultureInfo("zh-Hans");
    else
    {
        bool fail = false;
        try
        {
            ci = new CultureInfo(cn);
        }
        catch
        {
            fail = true;
        }
        if (fail && cn.IndexOf('-') != -1)
        {
            cn = cn.Substring(0, cn.IndexOf('-'));
            try
            {
                ci = new CultureInfo(cn);
            }
            catch
            {
            }
        }
    }
    return ci;
}

用于映射两者。

目前该映射不打算完全详尽,因为目前我们对英语(美国)和中文以外的各种语言没有足够的详细了解。项目中的 uni-resource 数据库中的默认语言“en”的内容实际上更接近“en-US”。但是,可以看出,可以稍后轻松扩展映射列表。欢迎其他文化的读者在 github.com 上分叉该项目(在特性分支 codeproject-2 上)以提供额外的映射。

当请求到达时,其加权的语言偏好列表(如果有)将与 Web.config 文件的 <appSettings> 节点下的

<add key="SupportedLanguages" value="en,zh-hans"/>

节点中指定的受支持语言列表进行比较。第一个匹配项将被视为响应中使用的语言。如果找不到匹配项,则使用默认语言“en”。

3.1.2 需要进行的修改 ^

uni-resource 存储中的文本块及其在其他语言中的翻译列表由一个 16 字节的全局唯一标识符(guid)标识。ResourceUtils 类的 GetString 静态方法的各种形式可用于从 uni-resource 数据库中获取特定语言的文本块。最简单的是

string GetString(string resId, string defval = null)

其中文本 guid 的字符串形式,即 resId,是所述 guid 的十六进制编码值。defval 用于指定在文本未在 uni-resource 数据库中找到时返回的文本的默认值。此方法用于从数据库的相应 uni-resource 文件中检索小文本块,即 Web 应用程序 App_Data 子目录下的 ShortResources.didxlShortResources.datal 文件。用于存储大文本块的 BlockResources.didxlBlockResources.datal 文件目前未包含在项目中。

假设一个 Web 页面最初是用英文编写的。全球化 Web 页面的过程非常简单:

  1. 定位需要全球化的任何文本块。

  2. 将相应的文本块添加到 uni-resource 数据库,该数据库将根据文本块的内容生成其十六进制编码的 guid。

  3. 用对 GetString 方法的调用替换原始文本块,使用十六进制编码的 guid 作为其第一个参数,并使用原始文本作为第二个参数。

    例如,在 Razor 网页中,“Home Page”文本变为

    @ResourceUtils.GetString("cfe6e34a4c7f24aad32aa4299562f5b2", "Home Page")

与前一篇文章中描述的英文 Web 应用程序相比,当前系统通过遵循上述规则,在本篇及后续文章中实现了完全全球化。

对于有兴趣分叉该项目的人来说,如果他们没有访问 uni-resource 输入工具,或者只对特定语言感兴趣,那么就没有必要进一步全球化项目。在前者的情况下,可以调用拥有工具的其他人来为您进行全球化。如果兴趣足够,我们可以选择重新发布新版本的工具。

3.2 客户端缓存 ^

Web 应用程序中的所有控制器都派生自 BaseController 类,该类包含两个用于管理客户端缓存控制的方法。

protected bool CheckClientCache(DateTime? lastModified, string Etag, 
                                out int StatusCode, out string StatusDescr)
{
    StatusCode = 200;
    StatusDescr = "OK";
    if (!EnableClientCache)
        return false;
    bool Timed = !string.IsNullOrEmpty(Request.Headers["If-Modified-Since"]);
    bool HasEtag = !string.IsNullOrEmpty(Request.Headers["If-None-Match"]);
    if (!Timed && !HasEtag || lastModified == null && Etag == null)
        return false;
    DateTime? cacheTime = null;
    if (Timed)
        cacheTime = DateTime.Parse(
                       Request.Headers["If-Modified-Since"]).ToUniversalTime();
    string OldEtag = HasEtag ? Request.Headers["If-None-Match"] : null;
    if (Timed && HasEtag)
    {
        if (lastModified != null && 
            !IsTimeGreater(lastModified.Value.ToUniversalTime(), cacheTime.Value) &&
            OldEtag == Etag)
        {
            StatusCode = 304;
            StatusDescr = "Not Modified";
            return true;
        }
    }
    else if (Timed)
    {
        if (lastModified != null && 
            !IsTimeGreater(lastModified.Value.ToUniversalTime(), acheTime.Value))
        {
            StatusCode = 304;
            StatusDescr = "Not Modified";
            return true;
        }
    }
    else if (HasEtag)
    {
        if (OldEtag == Etag)
        {
            StatusCode = 304;
            StatusDescr = "Not Modified";
            return true;
        }
    }
    return false;
}

用于检查是否存在有效的客户端缓存项,以及

protected void SetClientCacheHeader(DateTime? LastModified, string Etag, 
                       HttpCacheability CacheKind, bool ReValidate = true)
{
    if (!EnableClientCache || LastModified == null && Etag == null)
        return;
    HttpCachePolicyBase cp = Response.Cache;
    cp.AppendCacheExtension("max-age=" + 3600 * MaxClientCacheAgeInHours);
    if (ReValidate)
    {
        cp.AppendCacheExtension("must-revalidate");
        cp.AppendCacheExtension("proxy-revalidate");
    }
    cp.SetCacheability(CacheKind);
    cp.SetOmitVaryStar(false);
    if (LastModified != null)
        cp.SetLastModified(LastModified.Value);
    cp.SetExpires(DateTime.UtcNow.AddHours(MaxClientCacheAgeInHours));
    if (Etag != null)
        cp.SetETag(Etag);
}

用于在可能的情况下设置客户端缓存。两个方法中使用的 EnableClientCache 属性与 Web.config 文件中的配置设置有关。

protected bool EnableClientCache
{
    get
    {
        string sval = ConfigurationManager.AppSettings["EnableClientCache"];
        bool bval;
        if (string.IsNullOrEmpty(sval) || !bool.TryParse(sval, out bval))
            return false;
        else
            return bval;
    }
}

可以控制是否启用客户端缓存。客户端缓存可能会使调试和测试变得困难。开发人员可以在开发过程中关闭客户端缓存,并在 Web 应用程序部署时通过相应的配置设置将其打开。

3.3 媒体处理 ^

这里的媒体是指在 Web 页面上渲染的图像、视频和音频数据。基于文件的媒体相对容易处理。有趣的是如何处理从非文件数据源(如本文强调的关系数据源)检索到的媒体数据。

3.3.1 用户的“头像” ^

一旦用户登录,用户就拥有一个经过身份验证的唯一标识,可以以视觉方式表示。最简单的方式就是用户的用户名。这是在上述文章引用的系统初始化阶段为简单起见所做的。

然而,它可以做得更花哨。这是因为 UserAppMembers 数据集中的实体包含一个名为 IconImage 的字段,该字段可以包含用户为当前应用程序选择的图标图片。由于用户的“头像”可以在各种网页中使用,因此最好为其定义一个可重用的部分视图。以下是最简单的包含用户头像图片的视图:

@model MemberAdminMvc5.Models.UserIconModel
@using Microsoft.AspNet.Identity
@using Archymeta.Web.Security.Resources
<div class="UserSmallIcon">
@if (!string.IsNullOrEmpty(Model.IconUrl))
{
    <img src="@Url.Content("~/" + Model.IconUrl)" />
}
else
{
    <span class="ion-android-contact"></span>
}
    <span>@Model.Greetings</span>
</div>

该视图包含在 Web 应用程序 Views\Account 子目录下的 _UserIconPartial.cshtml 文件中。此部分视图由用户自己使用。在 Web 应用程序的 Views\Home 子目录下还有一个同名视图,显示给系统的其他成员。两者之间几乎没有区别,只是后者将 @Model.Greetings 替换为 @Model.UserLabel。该部分视图处理了用户可能未上传头像的可能性,在这种情况下会显示默认头像图片。

User icon

图:用户“头像”部分视图。

视图模型 UserIconModel 定义如下:

public class UserIconModel
{
    public string UserLabel
    {
        get;
        set;
    }
 
    public string Greetings
    {
        get;
        set;
    }
 
    public string IconUrl
    {
        get;
        set;
    }
}

此部分视图通常通过 @Html.Action(...) 包含,因为它不期望 UserIconModel 类型的对象包含并初始化在所有包含用户“头像”的网页的视图模型中。有一个专用的通用操作来动态注入模型更简单。例如,在 Web 应用程序 Views\Shared 子目录下的 _LoginPartial.cshtml 文件中,用户“头像”通过以下方式包含:

@{ Html.RenderAction("GenerateUserIcon", "Account"); }

调用 AccountController 的 GenerateUserIcon 方法来设置部分视图:

[ChildActionOnly]
public ActionResult GenerateUserIcon()
{
    var m = new Models.UserIconModel();
    m.Greetings = User.Identity.GetUserName();
    m.UserLabel = m.Greetings;
    var ci = User.Identity as System.Security.Claims.ClaimsIdentity;
    string strIcon = (from d in ci.Claims 
                             where d.Type == CustomClaims.HasIcon 
                             select d.Value).SingleOrDefault();
    bool hasIcon;
    if (!string.IsNullOrEmpty(strIcon) && 
         bool.TryParse(strIcon, out hasIcon) && hasIcon)
    {
        m.IconUrl = "Account/GetMemberIcon?id=" + 
                                          User.Identity.GetUserId();
    }
    return PartialView("_UserIconPartial", m);
}

它不检索图像,而是提供一个图像 URL,即 m.IconUrl,网页可以稍后下载。此 URL 调用 AccountController 类中的 GetMemberIcon 方法,该方法返回二进制图像数据:

[HttpGet]
public async Task<ActionResult> GetMemberIcon(string id)
{
     if (string.IsNullOrEmpty(id))
     {
         if (!Request.IsAuthenticated)
             return new HttpStatusCodeResult(404, "Not Found");
         id = User.Identity.GetUserId();
     }
     var rec = await MembershipContext.GetMemberIcon(id);
     if (rec == null<span lang="zh-cn"> || string.IsNullOrEmpty(rec.MimeType)</span>)
         return new HttpStatusCodeResult(404, "Not Found");
     int status;
     string statusstr;
     bool bcache = CheckClientCache(rec.LastModified, rec.ETag, 
                                    out status, out statusstr);
     SetClientCacheHeader(rec.LastModified, rec.ETag, 
                                      HttpCacheability.Public);
     if (!bcache)
         return File(rec.Data, rec.MimeType);
     else
     {
         Response.StatusCode = status;
         Response.StatusDescription = statusstr;
         return Content("");
     }
}

该操作主要负责处理客户端缓存。它首先通过调用 CheckClientCache 方法检查图像数据是否已在客户端缓存,然后尝试通过调用上一小节中描述的 SetClientCacheHeader 方法来设置客户端缓存指令。图像数据的 Etag 是数据的 base64 编码 MD5 哈希值。

public class ContentRecord
{
    public byte[] Data
    {
        get;
        set;
    }
 
    public string ETag
    {
        get
        {
            if (Data == null || Data.Length == 0)
                return null;
            if (_etag == null)
            {
                var h = HashAlgorithm.Create("MD5");
                _etag = Convert.ToBase64String(h.ComputeHash(Data));
            }
            return _etag;
        }
    }
    private string _etag = null;
 
    public string MimeType
    {
        get;
        set;
    }
 
    public DateTime LastModified
    {
        get;
        set;
    }
}

图像数据的检索委托给 MembershipPlusAppLayer45 项目中定义的 MembershipContext 类的 GetMemberIcon 方法。

public static async Task<ContentRecord> GetMemberIcon(string id)
{
    UserAppMemberServiceProxy umsvc = new UserAppMemberServiceProxy();
    var um = await umsvc.LoadEntityByKeyAsync(Cntx, ApplicationContext.App.ID, id);
    if (um == null)
        return null;
    ContentRecord rec = new ContentRecord();
    rec.MimeType = um.IconMime;
    rec.LastModified = um.IconLastModified.HasValue ? 
                                       um.IconLastModified.Value : DateTime.MaxValue;
    rec.Data = await umsvc.LoadEntityIconImgAsync(Cntx, ApplicationContext.App.ID, id);
    return rec;
}

由于 IconImage 被声明为延迟加载,图像数据分两步加载:1) 加载类型为 UserAppMember 的实体模型以检索图像数据的元信息,然后加载图像数据。

3.3.2 更新用户的头像图片 ^

用户的头像图片在 Web 应用程序 Views\Account 子目录下的 UpdateMemberIcon.cshtml 网页中设置或更新。

@using Microsoft.AspNet.Identity;
@using Archymeta.Web.Security.Resources;
@{
    ViewBag.Title = ResourceUtils.GetString("a11249b2e553b45f53a9d1f5d0ac89ba", 
                                                "Update User Membership Icon");
}
@section scripts {
    <script>
        $(function () {
            $("#submit").prop("disabled", true);
            if (window.File && window.FileReader && 
                                       window.FileList && window.Blob) {
                $('#IconImg')[0].addEventListener('change', function (evt) {
                    var file = evt.target.files[0];
                    $('#IconLastModified').val(file.lastModifiedDate.toUTCString());
                    var reader = new FileReader();
                    reader.onload = function (e) {
                        $('#imgPreview').attr('src', e.target.result);
                        $("#submit").prop("disabled", false);
                    }
                    reader.readAsDataURL(file);
                }, false);
            } else {
                alert('@ResourceUtils.GetString(
                          "0274e2eeb63505510d4baab9f70dc418", 
                          "The File APIs are not fully supported in this browser.")');
            }
        });
    </script>
}
<div class="row">
    <div class="col-md-12">
         
    </div>
</div>
@using (Html.BeginForm("UpdateMemberIconAsync", "Account", 
                        new { returnUrl = ViewBag.ReturnUrl }, 
                        FormMethod.Post, new { enctype = "multipart/form-data" }))
{
    @Html.AntiForgeryToken()
    <input type="hidden" id="IconLastModified" name="IconLastModified" />
    <div class="row">
        <div class="col-md-offset-2 col-md-10">
            <div style="display:inline-block">
                <label for="IconImg">
       @ResourceUtils.GetString("4673637028866e44d46b7c9760bf3a4c", "Local Icon File:")
                </label>
                <input type="file" id="IconImg" name="IconImg" class="form-control" />
                <div> </div>
                <input type="submit" name="submit" id="submit" class="btn btn-default" 
                    value="@ResourceUtils.GetString("91412465ea9169dfd901dd5e7c96dd9a", 
                                                                         "Upload")" />
            </div>
            <div style="display:inline-block; margin-left:20px;">
                <img id="imgPreview" src="@Url.Content("~/Account/GetMemberIcon?id="+
                         User.Identity.GetUserId())" style="vertical-align:bottom;" />
            </div>
        </div>
    </div>
}
User icon updator

图:用户头像图片加载页面。

初始加载后,页面将 JavaScript File API 监听器添加到“IconImg”输入字段,并在页面右侧显示旧的头像图片(如果存在)。用户选择图像文件后,图像文件的最后修改日期将更新到隐藏的“IconLastModified”表单字段中,并在右侧显示新图像以供预览。当用户单击“上传”按钮时,它将调用 AccountControllerUpdateMemberIconAsync 方法进行更新。

[HttpPost]
[Authorize]
[ValidateAntiForgeryToken]
[OutputCache(NoStore = true, Duration = 0)]
public async Task<ActionResult> UpdateMemberIconAsync(string returnUrl)
{
    if (Request.Files != null && Request.Files.Count > 0)
    {
        if (!Request.Files[0].ContentType.StartsWith("image"))
            throw new Exception("content mismatch!");
        string IconMime = Request.Files[0].ContentType;
        System.Nullable<DateTime> IconLastModified = 
                                       default(System.Nullable<DateTime>);
        if (Request.Form.AllKeys.Contains("IconLastModified"))
            IconLastModified = DateTime.Parse(Request.Form["IconLastModified"]);
        System.IO.Stream strm = Request.Files[0].InputStream;
        int size = Request.Files[0].ContentLength;
        byte[] data = new byte[size];
        strm.Read(data, 0, size);
        if (await MembershipContext.UpdateMemeberIcon(User.Identity.GetUserId(), 
                                             IconMime, IconLastModified.Value, data))
        {
            if (string.IsNullOrEmpty(returnUrl))
                return RedirectToAction("Index", "Home");
            else
                return Redirect(returnUrl);
        }
    }
    return View();
}

其中检索最后修改日期、mime 类型和图像数据,并将其传递给 MembershipContext 类的 UpdateMemeberIcon 方法。

public static async Task<bool> UpdateMemeberIcon(string id, string mineType, 
                                              DateTime lastModified, byte[] imgdata)
{
    UserAppMemberServiceProxy umsvc = new UserAppMemberServiceProxy();
    var um = umsvc.LoadEntityByKey(Cntx, ApplicationContext.App.ID, id);
    if (um == null)
        return false;
    um.IconImg = imgdata;
    um.IsIconImgLoaded = true;
    um.IsIconImgModified = true;
    um.IconMime = mineType;
    um.IconLastModified = lastModified;
    var result = await umsvc.AddOrUpdateEntitiesAsync(
                                               Cntx, 
                                               new UserAppMemberSet(), 
                                               new UserAppMember[] { um });
    return (result.ChangedEntities[0].OpStatus & (int)EntityOpStatus.Updated) > 0;
}

3.3.3 会员的照片 ^

会员的照片数据包含在 UserDetails 数据集中的用户详细信息实体(UserDetails)的 Photo 属性中。处理方式与上面讨论的 UserAppMembers 数据集中包含的用户头像图片数据非常相似,这里不再赘述。有兴趣的读者可以阅读相应的代码以了解更多关于这些方法的信息。

3.4 总结:会员属性 ^

Membership+ 系统的 第一部分 介绍了 Membership+ 系统的 数据模式。这里的介绍提供了关于描述会员的数据集的补充细节。在 **Membership+** 系统中有四种关于会员的信息:

  1. 会员的强制性通用信息。它保存在 Users 数据集中的会员记录中。它包含会员的简要信息,例如强制性的 Username、登录 Password 的哈希值以及会员的可选 FirstNameLastName
  2. 会员的强制性应用程序特定简要信息。它保存在 UserAppMembers 数据集中的会员记录中。用户可以选择在记录中放置不同的(或不放置)会员信息,具体取决于他/她使用的 Web 应用程序。此处,除其他外,会员主电子邮件地址 Email 是必需的属性,但用户头像图片 IconImg 是可选的。
  3. 会员的可选应用程序特定详细信息。它保存在 UserDetails 数据集中的会员记录中。它包含一个用户个人属性的暂定列表以及用户描述和用户照片数据字段。根据应用程序场景,列表可以扩展、修改甚至缩小,而无需太多工作。
  4. 会员的可选应用程序特定“通讯渠道”列表。它保存在 Communications 数据集中的会员记录中。此处包括供他人联系会员的所有可寻址目的地,包括会员的家庭/工作地址、电话号码、电子通讯渠道,包括但不限于电子邮件地址等。数据模式的设计允许用户拥有多个通讯渠道,即使在同一类别中。

3.5 账户信息 ^

用户的账户信息与保存在强制性记录(如上所述)中的属性子集相关,即类型 1 和 2 数据集中。下面的图显示了会员个人信息管理网页的快照。

Account info

图:用户账户信息管理页面。

它有两个选项卡页面:“个人信息”和“管理密码”。Bootstrap CSS 类用于实现选项卡。

<ul class="nav nav-tabs">
    <li class="active">
      <a href="#personal-info" data-toggle="tab">Personal Information</a>
    </li>
    <li>
      <a href="#password-panel" data-toggle="tab">Manage Password</a>
    </li>
</ul>
<div class="tab-content">
    <div id="personal-info" class="tab-pane active">
    
       ... Personal information part ...
       
    </div>
    <div id="password-panel" class="tab-pane">
    
       ... Password management part ...
       
    </div>
</div>

3.5.1 个人信息面板 ^

左侧的“个人信息部分”是一个表单,其中包含用户与会员资格相关的属性,可以将其发布回 AccountControllerChangeAccountInfo 方法,该方法构建一个数据传输对象并调用 MembershipContext 类的 ChangeAccountInfo 方法来更新更改的属性。

public static async Task ChangeAccountInfo(string id, ApplicationUser user)
{
    var cntx = Cntx;
    UserServiceProxy usvc = new UserServiceProxy();
    var u = await usvc.LoadEntityByKeyAsync(cntx, id);
    if (u == null)
        return;
    u.FirstName = user.FirstName;
    u.LastName = user.LastName;
    if (u.IsFirstNameModified || u.IsLastNameModified)
        await usvc.AddOrUpdateEntitiesAsync(cntx, new UserSet(), new User[] { u });
    if (!string.IsNullOrEmpty(user.Email))
    {
        UserAppMemberServiceProxy mbsvc = new UserAppMemberServiceProxy();
        var mb = await mbsvc.LoadEntityByKeyAsync(cntx, ApplicationContext.App.ID, id);
        if (mb != null)
        {
            mb.Email = user.Email;
            if (mb.IsEmailModified)
                await mbsvc.AddOrUpdateEntitiesAsync(cntx, new UserAppMemberSet(), 
                                                           new UserAppMember[] { mb });
        }
    }
}

考虑了两个可能涉及的数据集,即 UsersUserAppMembers,因为会员的 Email 属性是应用程序特定的。正如所见,它不允许用户将现有 Email 地址值重置为空或 null。

此处为了避免在实际没有更新时调用后端服务,首先检查了可编辑属性的“Is<propertyName>Modified”伴随属性,其中“<propertyName>”是相应属性的名称。此伴随属性在先前持久化的实体的相应属性被赋值时自动更新。只有在检测到任何更改时,该方法才执行实际更新。

3.5.2 密码管理面板 ^

这是标准的密码更新面板。包含在相应表单中的密码更新信息被发布到 AccountController 类的 ChangePassword 方法,该方法不在应用程序逻辑层(即 MembershipPlusAppLayer45 项目中定义的层)内部处理密码更新,而是通过 UserManagerEx 类实现的 API 使用 Asp.Net 用户存储(此处讨论)中的密码更新。

IdentityResult result = await UserManager.ChangePasswordAsync(User.Identity.GetUserId(), 
                                                              model.OldPassword, 
                                                              model.Password);

这是因为更改密码是一个与安全相关的操作,最好由更专业的安全模块处理,即我们自定义实现的 Asp.Net 身份管理系统。

3.6 会员详情 ^

用户的详细信息由可选记录(如上所述,即 UserDetails 数据集)中保存的属性和数据子集组成。会员不必建立详细信息简介。

UserDetails.cshtml 产生两种输出,取决于用户是否有详细信息记录:

@if (Model.Details == null)
{
    .... output content when the details record is absent ...
}
else
{
    .... output content when the details record exists ...
}
  1. 当详细信息记录不存在时,页面包含两个选项卡页面:
    • 创建详细信息简介的面板,允许会员创建一个空的详细信息简介。
    • 会员通讯渠道管理面板,将在下一小节中介绍。
  2. 否则
    • 会员属性和照片更新面板,会员可以在其中更新其个人属性。还有一个指向会员照片更新页面的超链接。
    • 会员描述更新面板。在这里,用户可以使用 HTML 编辑器详细描述自己。
    • 会员通讯渠道管理面板,将在下一小节中介绍。

这两个面板的通用面板,即会员通讯渠道管理面板,将在下一小节中单独介绍。这是因为使用了 jQuery 和 KnockoutJS 的客户端技术来处理它,这值得更详细的介绍。

3.6.1 用户详情加载 ^

AccountController 中的 UserDetails 操作非常简单,因为它将加载委托给 MembershipContext 类的 GetUserDetails 方法。

public static async Task<UserDetailsVM> GetUserDetails(string id, bool direct = false)
{
    //
    // load data record from UserDetails data set, if any
    //
    UserDetailServiceProxy udsvc = new UserDetailServiceProxy();
    var cntx = Cntx;
    cntx.DirectDataAccess = direct;
    var details = await udsvc.LoadEntityByKeyAsync(cntx, ApplicationContext.App.ID, id);
    UserDetailsVM m = null;
    if (details != null)
    {
        if (!details.IsDescriptionLoaded)
        {
            details.Description = udsvc.LoadEntityDescription(cntx, 
                                                        ApplicationContext.App.ID, id);
            details.IsDescriptionLoaded = true;
        }
        m = new UserDetailsVM { Details = details };
        m.IsPhotoAvailable = !string.IsNullOrEmpty(details.PhotoMime);
    }
    else
    {
        m = new UserDetailsVM();
        m.IsPhotoAvailable = false;
    }
    UserDetailSet uds = new UserDetailSet();
    m.Genders = uds.GenderValues;
    //
    // Load user communication channel types
    //
    QueryExpresion qexpr = new QueryExpresion();
    qexpr.OrderTks = new List<QToken>(new QToken[] { 
        new QToken { TkName = "ID" },
        new QToken { TkName = "asc" }
    });
    CommunicationTypeServiceProxy ctsvc = new CommunicationTypeServiceProxy();
    var lct = await ctsvc.QueryDatabaseAsync(Cntx, new CommunicationTypeSet(), qexpr);
    m.ChannelTypes = lct.ToArray();
    //
    // Load user communication channels of a member under the current
    // application context.
    //
    CommunicationServiceProxy csvc = new CommunicationServiceProxy();
    qexpr = new QueryExpresion();
    qexpr.OrderTks = new List<QToken>(new QToken[] { 
        new QToken { TkName = "TypeID" },
        new QToken { TkName = "asc" }
    });
    qexpr.FilterTks = new List<QToken>(new QToken[] { 
        new QToken { TkName = "UserID" },
        new QToken { TkName = "==" },
        new QToken { TkName = "\"" + id + "\"" },
        new QToken { TkName = "&&" },
        new QToken { TkName = "ApplicationID" },
        new QToken { TkName = "==" },
        new QToken { TkName = "\"" + ApplicationContext.App.ID + "\"" }
    });
    var lc = await csvc.QueryDatabaseAsync(Cntx, new CommunicationSet(), qexpr);
    foreach (var c in lc)
    {
        c.Comment = await csvc.LoadEntityCommentAsync(Cntx, c.ID);
        c.IsCommentLoaded = true;
        c.CommunicationTypeRef = await csvc.MaterializeCommunicationTypeRefAsync(Cntx, c);
        m.Channels.Add(new { id = c.ID, 
                             label = c.DistinctString, 
                             addr = c.AddressInfo, 
                             comment = c.Comment, 
                             typeId = c.CommunicationTypeRef.TypeName });
    }
    return m;
}

此方法构建一个 UserDetailsVM 类的实例,并使用从三个数据集加载的数据更新它:UserDetailsCommunicationTypesCommunications,这些数据集将由 UserDetails.cshtml 网页使用。

3.6.1.1 检索约束实体集 ^

约束集概念仅适用于依赖其他数据集的数据集。通过约束集,我们指的是 said 数据集的一个子集,其中一些外键值作为固定值提供。

用户的通讯渠道是约束集,其中通讯渠道的外键 ApplicationIDUserID 是固定的,而 TypeID 是任意的。上面的代码使用泛型查询 API 方法来获取这个子集。

    CommunicationServiceProxy csvc = new CommunicationServiceProxy();
    qexpr = new QueryExpresion();
    qexpr.OrderTks = new List<QToken>(new QToken[] { 
        new QToken { TkName = "TypeID" },
        new QToken { TkName = "asc" }
    });
    qexpr.FilterTks = new List<QToken>(new QToken[] { 
        new QToken { TkName = "UserID" },
        new QToken { TkName = "==" },
        new QToken { TkName = "\"" + id + "\"" },
        new QToken { TkName = "&&" },
        new QToken { TkName = "ApplicationID" },
        new QToken { TkName = "==" },
        new QToken { TkName = "\"" + ApplicationContext.App.ID + "\"" }
    });
    var lc = await csvc.QueryDatabaseAsync(Cntx, new CommunicationSet(), qexpr);

有一种等效但更具体的方法用于这种类型的查询:

    var fkeys = new CommunicationSetConstraints
    {
        ApplicationIDWrap = new ForeignKeyData<string> { 
                                       KeyValue = ApplicationContext.App.ID },
        TypeIDWrap = null, // no restriction on types
        UserIDWrap = new ForeignKeyData<string> { KeyValue = id }
    };
    var lc = await csvc.ConstraintQueryAsync(Cntx, new CommunicationSet(), fkeys, null);

这更加简洁且语义上更有意义。

3.6.2 缺少详细信息简介 ^

在会员创建其详细信息之前,UserDetails页面如下所示:

.

图:用户详细信息记录不存在时的用户详细信息页面。

单击“立即创建”按钮后,页面变为:

.

图:用户详细信息记录最初创建后的用户详细信息页面。

创建由 AccountController 类的 CreateUserDetails 方法处理。

[HttpGet]
[Authorize]
public async Task<ActionResult> CreateUserDetails()
{
    await MembershipContext.CreateUserDetails(User.Identity.GetUserId());
    return RedirectToAction("UserDetails", "Account");
}

它调用 MembershipContext 类的 CreateUserDetails 方法。

public static async Task<bool> CreateUserDetails(string id)
{
    UserDetailServiceProxy udsvc = new UserDetailServiceProxy();
    var ud = await udsvc.LoadEntityByKeyAsync(Cntx, ApplicationContext.App.ID, id);
    if (ud == null)
    {
        await udsvc.AddOrUpdateEntitiesAsync(Cntx, new UserDetailSet(), 
            new UserDetail[] { 
            new UserDetail{
                    UserID = id,
                    ApplicationID = ApplicationContext.App.ID,
                    CreateDate = DateTime.UtcNow
            }
        });
    }
    return true;
}

3.6.3 详细信息简介管理器 ^

网页包含两个可以独立提交的表单,以及一个指向会员照片更新页面的链接。

3.6.3.1 会员属性 ^

用户详细信息管理页面的左侧是一个包含会员属性及其当前值的表单。

.

图:用户详细信息页面。

更新由 AccountController 类的 UpdateUserProperties 方法处理,该方法将更新委托给 MembershipContext 类的 UpdateUserProperties 方法。

public static async Task<UserDetailsVM> UpdateUserProperties(string id, 
                                                                   UserDetailsVM model)
{
    UserDetailServiceProxy udsvc = new UserDetailServiceProxy();
    var cntx = Cntx;
    var details = await udsvc.LoadEntityByKeyAsync(cntx, ApplicationContext.App.ID, id);
    int chgcnt = 0;
    if (details.Gender != model.Gender)
    {
        details.Gender = model.Gender;
        chgcnt++;
    }
    if (!details.BirthDate.HasValue && model.BirthDate.HasValue || 
        details.BirthDate.HasValue && !model.BirthDate.HasValue ||
        details.BirthDate.HasValue && model.BirthDate.HasValue && 
        details.BirthDate.Value != model.BirthDate.Value)
    {
        details.BirthDate = model.BirthDate;
        chgcnt++;
    }
    if (chgcnt > 0)
    {
        details.LastModified = DateTime.UtcNow;
        udsvc.AddOrUpdateEntities(Cntx, new UserDetailSet(), 
                                                 new UserDetail[] { details });
    }
    return await GetUserDetails(id, true);
}

这里再次将实体相关属性的原始值与更新后的值进行比较。只有当至少一个相关属性被修改时,才会执行更新。

该方法调用 GetUserDetails,并将第二个参数设置为 true。如上面所示,第二个参数的值传递给 cntx.DirectDataAccess 属性。该属性的值用于控制数据服务端读取操作的缓存行为,如果将其设置为 true,则将检索刚刚更新的值并返回给浏览器,而不是返回旧的缓存副本(如果有),这样浏览器就能在不等待缓存过期的情况下显示更改。

3.6.3.2 会员照片 ^

通过单击照片图像底部的“更新照片”链接,用户将被带到会员照片更新页面。

.

图:照片更新页面。

本方法中图像的更新和显示方式已在 3.3 小节 中讨论过,此处不再重复。

3.6.3.3 会员描述 ^

会员描述面板有两个选项卡页面:

  1. 描述编辑器,该编辑器附加到 ckeditor WYSIWYG HTML 编辑器。
  2. 后置查看器。后置查看器显示已保存的 HTML 格式的会员描述。因此,如果有人更改了描述,他不应该期望在提交之前立即看到更改。但是,编辑器的 WYSIWYG 特性使得预览不必要。
.

图:会员描述编辑器。

描述通过 POST 请求发送到 AccountController 类的 UpdateUserDescription 方法。

[HttpPost]
[Authorize]
[ValidateInput(false)]
[OutputCache(NoStore = true, Duration = 0)]
public async Task<actionresult> UpdateUserDescription(UserDetailsVM m)
{
    // custom validator here ...

    m = await MembershipContext.UpdateUserDescription(
                                              User.Identity.GetUserId(), m);
    return View("UserDetails", m);
}</actionresult>

[ValidateInput(false)] 属性禁用正常的内容验证,因为 POST 的内容是以 HTML 片段的形式出现的,这些片段会被正常的内容验证器阻止。这里未来改进的地方是实现一个自定义内容验证器,可以用来过滤 POST 的内容,使其符合组织的 B 安全标准。

它调用 MembershipContext 类的 UpdateUserDescription 方法。该方法的实现与上面描述的方法非常相似。此处不再重复。

3.7 用户通讯渠道 ^

用户的通讯渠道与上面描述的列表记录(即 Communications 数据集)中保存的通讯相关数据结构的子集有关。会员可以记录零个或多个他/她认为合适的通讯渠道。

3.7.1 概述 ^

会员的通讯渠道列表的处理方式与迄今为止使用过的更传统的服务器端技术不同。这里使用了 jQuery 和 KnockoutJS 支持的基于 JavaScript 的客户端方法。KnockoutJS 支持 MVVM 架构模式,该模式解耦了视图和代码之间的紧密绑定,使得两者在不同的应用程序上下文中都可以单独重用。例如,后端数据服务提供的许多 KnockoutJS 视图模型都可以在应用程序层中使用,只需稍作修改。这可以节省开发人员在创建、维护和与数据模式的更改保持同步方面所需的大量时间。

Asp.Net Mvc 内置支持了将这两种世界连接起来所需的绝大部分内容:强类型 .Net 数据结构和以 JSON 表示的数据结构。

本节也为我们在后续开发中更广泛地使用该技术进行预热。

UserDetails.cshtml 网页有一个脚本部分,它将被注入到浏览器加载的输出 HTML 页面中。

@section scripts {
    ...
    @Scripts.Render("~/bundles/knockout")
    <script src="@Url.Content("~/Scripts/DataService/UserDetailsPage.js")">
    </script>
    ...
    <script>
        appRoot = '@Url.Content("~/")';
        $(function () {
            var vm = new UserDetails();
            @foreach (var m in Model.ChannelTypes)
            {
            <text>vm.channelTypes.push(new ChannelType(@m.ID, '@m.TypeName'));</text>
            }
            @if (Model.Details != null)
            {
                foreach (var c in Model.Channels)
                {
            <a name="member-channel-push statement"><text></a>
                    vm.Channels.push(
                         new Channel(
                            JSON.parse(
                              '@Html.Raw(
                                 Utils.GetDynamicJson(c)
                              )'
                            )
                         )
                    );
            </text>
                }
            }
            ...
            ko.applyBindings(vm, $('#channels')[0]);
        });
    </script>
}

首先,我将提供一个概述,让读者对整个过程有一个整体的了解,而无需定义所有内容,这是接下来几小节的工作。除了 Views\Schared\_Layout.cshtml 中定义的默认脚本外,它还加载了 Web 应用程序 Scripts\DataService 子目录下的 KnockoutJS JavaScript 包和 UserDetailsPage.js。页面在客户端加载后,它会创建一个 KnockoutJS 视图模型对象 var vm = new UserDetails();,然后通过服务器端生成的 JavaScript 语句列表中的相应元素推送到 channelTypesChannels 中,即 @foreach (var m in Model.ChannelTypes) { ... }@foreach (var c in Model.Channels) { ... } 服务器端语句。在构建完完整的 vm 对象后,通过 KnockoutJS 方法 ko.applyBindings(vm, $('#channels')[0]); 将其绑定到 Web 页面中 id 为“channels”的 HTML 元素上。该 HTML 元素定义在 Web 应用程序 Views\Account 子目录下的部分视图 _PersonalChannelsPartial.cshtml 中,该部分视图由 UserDetails.cshtml 包含。

@model Archymeta.Web.MembershipPlus.AppLayer.Models.UserDetailsVM
@using Microsoft.AspNet.Identity;
@using Archymeta.Web.MembershipPlus.AppLayer;
@using Archymeta.Web.Security.Resources;
<table id="channels" class="table personal-channels">
    <thead>
        <tr>
            <th>
        @ResourceUtils.GetString("a1fa27779242b4902f7ae3bdd5c6d509", "Type")
            </th>
            <th>
        @ResourceUtils.GetString("dd7bf230fde8d4836917806aff6a6b28", "Address")
            </th>
            <th>
        @ResourceUtils.GetString("0be8406951cdfda82f00f79328cf4efd", "Comment")
            </th>
            <th colspan="2" style="width:1px; white-space:nowrap;">
        @ResourceUtils.GetString("456d0deba6a86c9585590550c797502e", "Operations")
           </th>
        </tr>
    </thead>
    <a name="knockout_channels_bind"><tbody data-bind="foreach: Channels"></a>
        <tr>
            <td>
                <span data-bind="text: selectedType"></span>
            </td>
            <td><div>
                     <input data-bind="value: addr" class="form-control" />
            </div></td>
            <td><div>
                     <input data-bind="value: comment" class="form-control" />
            </div></td>
            <td style="width:1px;text-align:center;">
                <!-- ko if: isModified -->
                <button class="btn btn-default btn-xs" 
                       data-bind="click: function(data, event) { 
                                                    _updateChannel(data, event) }" 
                       title="...">
                    <span class="ion-arrow-up-a"></span>
                </button>
                <!-- /ko -->
                <!-- ko ifnot: isModified -->
                &nbsp;
                <!-- /ko -->
            </td>
            <td style="width:1px;text-align:center;">
                <button class="btn btn-default btn-xs" 
                       data-bind="click: function(data, event) { 
                                                    _deleteChannel($parent, data, event) }" 
                       title="...">
                    <span class="ion-close" style="color:Red"></span>
                </button>
            </td>
        </tr>
    </tbody>
    <tfoot>
        <tr>
            <td>
                <select class="form-control" 
                        style="min-width:100px;" 
                        data-bind="options: channelTypes, 
                                   optionsValue: 'id', 
                                   optionsText: 'name', 
                                   value: selectedType, 
                                   optionsCaption: '..'">
                </select>
            </td>
            <td><div>
                <input data-bind="value: newChannel.addr" class="form-control" />
            </div></td>
            <td><div>
                <input data-bind="value: newChannel.comment" class="form-control" />
            </div></td>
            <td style="width:1px;text-align:center;">
                <button class="btn btn-default btn-xs" 
                        data-bind="click: function(data, event) { 
                              _addNewChannel(data, event, 
                                             'Please select a type', 
                                             'Please enter an address')}"
                        title="add the new channel ...">
                    <span class="ion-plus"></span>
                </button>
            </td>
            <td style="width:1px;text-align:center;"> </td>
        </tr>
    </tfoot>
</table>

该部分视图负责呈现当前会员的可编辑的当前通讯渠道列表。这里 <tbody> 元素被绑定到 vmChannels observableArray,以创建渠道列表。<tfoot> 元素用于为要添加到列表的新元素提供模板,其中 <select> 选项绑定到 vmchannelTypes observableArray,会员属性绑定到 vmnewChannel JSON 属性中的相应属性。结果如下:

.

图:会员通讯渠道编辑器。

在底部新元素占位符的右侧有一个添加按钮。每个列出的渠道都有一个删除按钮,当列出的渠道被修改时,更新渠道按钮会像上面第一个渠道右侧显示的那样出现。KnockoutJS 使得实现这种动态行为非常简单轻便(即,它不需要涉及与服务器的往返通信)。

3.7.2 KnockoutJS 视图模型 ^

以下是对应于 CommunicationTypesCommunications 数据集的两个 KnockoutJS 视图模型。

function ChannelType(id, name) {
    var self = this;
    self.id = id;
    self.name = name;
}
 
function Channel(data) {
    var self = this;
    self.data = data;
    self.addr = ko.observable(data.addr);
    self.comment = ko.observable(data.comment);
    self.selectedType = ko.observable(data.typeId);
    self.isModified = ko.computed(function () {
        return self.addr() != self.data.addr || 
                              self.comment() != self.data.comment;
    });
}

这些是简化的视图模型,其中只定义了一些当前感兴趣的数据属性。任何数据集的完全映射和同步的 KnockoutJS 视图模型可以在 **Membership+** 数据服务的 Scripts\DbViewModels\MembershipPlus 子目录中找到。

这里只有可编辑的属性与 KnockoutJS observable 相关联。Channel 视图模型的构造函数接受一个 JSON 对象,该对象用于相应地初始化其成员。它有一个计算属性 isModified,可以检测到监视属性(addrcomment observable)的任何更改,并通知绑定到它的视图部分的更改。例如,网页有

    <!-- ko if: isModified -->
    <button class="btn btn-default btn-xs" 
           data-bind="click: function(data, event) { 
                _updateChannel(data, event) }" 
           title="...">
        <span class="ion-arrow-up-a"></span>
    </button>
    <!-- /ko -->
    <!-- ko ifnot: isModified -->
    &nbsp;
    <!-- /ko -->

这将根据是否已更改任何监视的属性(与从服务器加载的相应属性相比)来隐藏/显示更新按钮。

当前情况下的根视图模型是:

function UserDetails() {
    var self = this;
    self.channelTypes = [];
    self.Channels = ko.observableArray([]);
    self.newChannel = { 
                  typeId: 0, 
                  addr: ko.observable(''), 
                  comment: ko.observable('') 
         };
    self.selectedType = ko.observable();
}

它包含 channelTypes 中的可能渠道类型列表,Channels 中的会员通讯渠道的 observable 数组,newChannel 中的新通讯渠道的数据占位符,以及 selectedType 中的当前渠道类型。Channels 绑定到渠道列表 <tbody> 元素(参见 此处),因此对于 observable 数组中的每个渠道,它都会生成 <tbody> 元素下定义的相应 <tr> 子树。当 Channels 中的对象在 JavaScript 代码中更改时,UI 会自动更新。

3.7.3 .Net 类型和 JSON 连接 ^

3.7.3.1 加载

如上所示,会员通讯编辑面板需要在操作更改之前将 ChannelschannelTypes 初始化为当前会员状态。它们至少可以通过两种方式初始化:

  1. 首先让页面在客户端加载,然后使用 Ajax 调用某个 API 方法来检索这两个列表。
  2. 在服务器端生成列表。这两个列表是 JavaScript 列表,在客户端进行解释,服务器端无法直接操作它们。但是,服务器端软件可以充当 JavaScript 代码生成器,生成要由浏览器解释的 JavaScript 语句,从而间接完成任务。

这里采用了第二种方法。

因此,对于 channelTypes 属性,我们有:

    @foreach (var m in Model.ChannelTypes)
    {
        <text>vm.channelTypes.push(new ChannelType(@m.ID, '@m.TypeName'));</text>
    }

这会在客户端生成

    vm.channelTypes.push(new ChannelType(1, 'HomeAddress'));
    vm.channelTypes.push(new ChannelType(2, 'WorkAddress'));
    vm.channelTypes.push(new ChannelType(3, 'DaytimePhone'));
    ...
    ...

一系列 JavaScript 语句。对于 Channels 属性也是如此,但由于 Channel 视图模型更复杂,并且它只接受结构化的 JSON 数据作为其构造函数的输入,因此会稍微复杂一些,也就是说,我们期望得到类似这样的结果:

    vm.Channels.push(new Channel(channel1);
    vm.Channels.push(new Channel(channel2);
    ...
    ...

JSON 对象 channel1、channel1 等是在客户端根据服务器生成的 JSON 对象字符串表示构建的,例如:

    var channel1 = JSON.parse('{
                       "id":"0c58b64e-dd57-4889-a6da-63ac4a4f5308",
                       "label":"NighttimePhone: 1223-3221 for sysadmin",
                       "addr":"1223-3221",
                       "comment":"",
                       "typeId":"NighttimePhone"
                   }'))
    var channel2 = ...
    ....

在这里,JSON 对象的字符串形式是在服务器上生成的。

3.7.3.1 服务器实体 ^

UsersDetails.cshtml 在服务器端加载时调用的 MembershipContextGetUserDetails 方法(参见 此处)使用与上述预期 JSON 数据结构匹配的 dynamic 类型对象填充 Channels 属性(UserDetailsVM 的!)。

    foreach (var c in lc)
    {
        c.Comment = await csvc.LoadEntityCommentAsync(Cntx, c.ID);
        c.IsCommentLoaded = true;
        c.CommunicationTypeRef = await csvc.MaterializeCommunicationTypeRefAsync(Cntx, c);
        m.Channels.Add(new { id = c.ID, 
                             label = c.DistinctString, 
                             addr = c.AddressInfo, 
                             comment = c.Comment, 
                             typeId = c.CommunicationTypeRef.TypeName });
    }

dynamic 类型对象可以使用 System.Web.Script.Serialization 命名空间中的 JavaScriptSerializer 轻松序列化为字符串形式。UserDetails.cshtml 网页的 JavaScript 部分(参见 此处)的 JSON 字符串生成部分。

      '@Html.Raw(Utils.GetDynamicJson(c))'

调用 MembershipPlusAppLayer45 项目中定义的 Utils 类的 GetDynamicJson 方法。

public static string GetDynamicJson(object obj)
{
    JavaScriptSerializer ser = new JavaScriptSerializer();
    return ser.Serialize(obj);
}

用于连接服务器端 .Net 对象和客户端 JSON 对象。

3.7.4 渠道更新 ^

3.7.4.1 添加 ^

在底部新渠道模板右侧出现的添加按钮的单击事件由以下 JavaScript 方法处理:

function _addNewChannel(data, event, typeMsg, addrMsg) {
    var typeId = data.selectedType();
    var addr = data.newChannel.addr();
    if (typeId) {
        if (addr != '') {
            $.ajax({
                url: appRoot + "Account/AddChannel",
                type: "POST",
                dataType: "json",
                contentType: "application/json; charset=utf-8",
                data: JSON.stringify({ typeId: typeId, 
                                       address: addr, comment: 
                                       data.newChannel.comment() }),
                success: function (content) {
                    if (!content.ok) {
                        alert(content.msg);
                    } else {
                        data.selectedType(null);
                        data.newChannel.comment('');
                        data.newChannel.addr('');
                        data.Channels.push(new Channel(content.data));
                    }
                },
                error: function (jqxhr, textStatus) {
                    alert(jqxhr.responseText);
                },
                complete: function () {
                }
 
            });
        } else {
            alert(addrMsg);
        }
    } else {
        alert(typeMsg);
    }
}

它调用 AccountController 类的 AddChannel 方法。

[HttpPost]
[Authorize]
[OutputCache(NoStore = true, Duration = 0)]
public async Task<ActionResult> AddChannel(int typeId, string address, string comment)
{
    var data = await MembershipContext.AddChannel(User.Identity.GetUserId(), 
                                                                typeId, address, comment);
    return JSON(data);
}

它将添加的渠道数据以 JSON 格式返回。调用的 MembershipContext 类的 AddChannel 方法返回一个 dynamic 类型的对象。

public static async Task<dynamic> AddChannel(string id, int typeId, string address, string comment)
{
    CommunicationServiceProxy csvc = new CommunicationServiceProxy();
    Communication c = new Communication();
    c.ID = Guid.NewGuid().ToString();
    c.TypeID = typeId;
    c.UserID = id;
    c.ApplicationID = ApplicationContext.App.ID;
    c.AddressInfo = address;
    c.Comment = comment;
    var result = await csvc.AddOrUpdateEntitiesAsync(Cntx, new CommunicationSet(), 
                                                           new Communication[] { c });
    if ((result.ChangedEntities[0].OpStatus & (int)EntityOpStatus.Added) > 0)
    {
        c = result.ChangedEntities[0].UpdatedItem;
        c.CommunicationTypeRef = await csvc.MaterializeCommunicationTypeRefAsync(Cntx, c);
        var dc = new { 
                         id = c.ID, 
                         label = c.DistinctString, 
                         addr = c.AddressInfo, 
                         comment = c.Comment, 
                         typeId = c.CommunicationTypeRef.TypeName 
                     };
        return new { ok = true, msg = "", data = dc };
    }
    else
        return new { 
               ok = false, 
               msg = ResourceUtils.GetString("954122aa46fdc842a03ed8b89acdd125", "Add failed!") 
        };
}

返回的动态对象有一个标志(ok)属性,指示操作是否成功,一个 msg 属性,在失败时提供更多信息,以及一个 data 属性,在操作成功时包含新添加的通讯渠道的动态(类型)数据。

操作返回时,将执行以下代码:

if (!content.ok) {
    alert(content.msg);
} else {
    data.selectedType(null);
    data.newChannel.comment('');
    data.newChannel.addr('');
    data.Channels.push(new Channel(content.data));
}

也就是说,如果操作不成功,它会显示错误消息,否则,它会首先清除 newChannel 对象,然后添加一个从返回的 JSON 数据构建的新渠道视图模型对象。KnockoutJS 框架将保证新添加的渠道会自动添加到视图(浏览器中的网页)中。

3.7.4.2 修改 ^

现有渠道右侧的更新按钮仅在 said 渠道被修改时出现。其单击事件由以下 JavaScript 方法处理:

function _updateChannel(data, event) {
    $.ajax({
        url: appRoot + "Account/UpdateChannel",
        type: "POST",
        dataType: "json",
        contentType: "application/json; charset=utf-8",
        data: JSON.stringify({ id: data.data.id, 
                               address: data.addr(), 
                               comment: data.comment() }),
        success: function (content) {
            if (!content.ok) {
                alert(content.msg);
            } else {
                data.data.addr = data.addr();
                data.addr('');
                data.addr(data.data.addr);
                data.data.comment = data.comment();
                data.comment('');
                data.comment(data.data.comment);
            }
        },
        error: function (jqxhr, textStatus) {
            alert(jqxhr.responseText);
        },
        complete: function () {
        }
    });
}

它调用 AccountController 类的 UpdateChannel 方法,该方法又调用 MembershipContext 类的相应方法,由于与上述情况非常相似,此处不再详细描述。

3.7.4.3 删除 ^

删除按钮位于现有渠道的右侧。其单击事件由以下 JavaScript 方法处理:

function _deleteChannel(parent, data, event) {
    $.ajax({
        url: appRoot + "Account/DeleteChannel",
        type: "POST",
        dataType: "json",
        contentType: "application/json; charset=utf-8",
        data: JSON.stringify({ id: data.data.id }),
        success: function (content) {
            if (!content.ok) {
                alert(content.msg);
            } else {
                var cnt = parent.Channels().length;
                for (var i = 0; i < cnt; i++) {
                    var c = parent.Channels()[i];
                    if (c.data.id == data.data.id) {
                        parent.Channels.remove(c);
                        break;
                    }
                }
            }
        },
        error: function (jqxhr, textStatus) {
            alert(jqxhr.responseText);
        },
        complete: function () {
        }
    });
}

它调用 AccountController 类的 DeleteChannel 方法,该方法又调用 MembershipContext 类的相应方法,由于与“添加”情况非常相似,此处不再详细描述。

3.8 备注 ^

这完成了本文范围内的实现细节。尽管冗长的描述可能会给读者留下即使是目前非常简单的功能也需要大量工作才能实现的印象,但一旦读者习惯了工作流程和模式,这些模式可以应用于更复杂的情况,实际上并不需要那么多。

尽管基于 JSON 的数据和 JavaScript 编码框架在启动时非常灵活且成本低廉,但它们并不易于维护。简单的类型错误或更改的数据模式(开发人员心中所想或在设计纸上写下的)的失步可能导致潜在的运行时错误,而不是即时的编译时错误,除非设置了广泛的测试计划和文档。因此,这些松散类型系统的维护者或开发人员将从他们的前任者那里继承更高的“技术债务”。因此,强类型后端和基于 JavaScript 的前端的混合使用似乎是保持可维护性和灵活性的一种好方法。

但是,当它们用于在强类型系统的支持下,在瘦代理或适配层中连接异构系统时,它们会非常有用,因为它们允许所谓的 鸭子类型,这可以大大简化映射过程。

4. 连接数据服务 ^

从当前版本开始,默认数据服务根 URL 在 Web 应用程序的 Web.config 文件的 <system.serviceModel> 节点中设置为 https:///membp。读者可以将其更改为指向数据服务的设置位置,或者在本地机器上设置数据服务,并使用 Web 应用程序名称(即 membp)。

5. 历史记录 ^

  • 2014-02-25。文章版本 1.0.0,首次发布。
  • 2014-03-02。文章版本 1.0.5,更改了 UserDetails 数据集的数据模式。改进了数据服务的文档。对源代码进行了修改。

如果读者对 Git 源代码控制系统有足够的了解,他/她可以关注该项目在 github.com 上的 Git 存储库。当前文章的源代码维护在 **codeproject-2** 分支上,即 此处

© . All rights reserved.