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

GoalBook - 混合智能客户端

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.86/5 (19投票s)

2009 年 3 月 26 日

CPOL

10分钟阅读

viewsIcon

83184

downloadIcon

855

一个 WPF 混合智能客户端,可将您的目标与 Toodledo 在线待办服务同步。

引言

GoalBook 是一个 混合智能客户端 桌面管理器应用程序,用于 Toodledo 在线待办服务。GoalBook 在您的 PC 上本地运行,利用 Windows 的线程、存储和打印功能,并在连接到互联网时同步 Toodledo 网站托管的数据,即使在离线状态下也能提供丰富的功能。GoalBook 是使用 Windows Presentation Foundation (WPF)、Visual Studio 2008 和 .NET 3.5 (SP1) 构建的,并展示了多项技术,包括 Composite Application Library (CAL)、LINQ、REST、CSLA.NET 和 Infragistics XamDatagrid。如果您未使用过 CAL,可以查看 Composite Application Library 入门。要使用 GoalBook 访问 Toodledo 在线服务,您需要获取一个(免费)Toodledo 账户

GoalBook.png

使用 REST 和 LINQ 从 Toodledo 检索数据

Toodledo API 通过一个简单的 REST 接口公开。REST 避免了其他 网络机制 的复杂性,通常通过简单的 HTTP 请求实现。理解 REST 的一个重要概念是,您需要知道 REST 响应的格式(可能是 XML、JSON 或其他格式)。这与其他 Web 服务协议(例如 SOAP,其响应由 WSDL 定义)不同。REST 需要阅读文档和/或进行一些试错。

以下示例请求从 Toodledo 获取登录令牌

http://api.toodledo.com/api.php?method=getToken;userid=td123f4567b8910

此 HTTP 请求的结果是一个 XML 片段

<token>td12345678901234</token>

所有对 Toodledo API 的请求都遵循这个简单的模式,此外还需要一个身份验证密钥才能请求用户数据。身份验证密钥是通过对令牌、用户 ID 和密码的 MD5 哈希值生成的。(注意:密码首先进行哈希,然后与令牌和用户 ID 再次哈希。)

/// <summary>
/// Get SessionKey.
/// </summary>
protected string GetSessionKey()
{
  ...

  //Create SessionKey.
  return MD5(MD5(ConnectInfo.Password) + _token + ConnectInfo.UserID);
}

请求目标使用以下 HTTP 请求

http://api.toodledo.com/api.php?method=getGoals;key=317a1aae7a6cc76e2510c8ade6e6ed05

此 HTTP 请求的结果是一个 XML 片段

<goals>
  <goal id="123" level="0" contributes="0" archived="1">Get a Raise</goal>
  <goal id="456" level="0" contributes="0" archived="0">Lose Weight</goal>
  <goal id="789" level="1" contributes="456" archived="0">Exercise</goal>
</goals>

GoalBook 使用 C# 中的 HttpWebRequestHttpWebResponse 类发出这些 HTTP 请求。当从 Toodledo 请求数据时,会使用 HTTP GET。请求所需的参数使用 HttpUtility.UrlEncode 进行编码,并发送在 URL 中。对于 LAN 上的用户,可以使用 WebProxy 类指定代理身份验证详细信息。从 Toodledo 收到的结果被加载到 XDocument 中。

/// <summary>
/// Make Server Call using Http Get.
/// </summary>
/// <param name="url">url (parameters separated by ;)</param>
/// <returns>XDocument</returns>
private XDocument HttpGet(string url)
{
    HttpWebRequest request = WebRequest.Create(url) as HttpWebRequest;

    if (HostInfo.Enabled && HostInfo.IsValid)
    {
        //Set manual HTTP Proxy configuration.
        request.Proxy = new WebProxy(HostInfo.Host, Convert.ToInt32(HostInfo.Port));
        request.Proxy.Credentials = CredentialCache.DefaultCredentials;
    }

    XDocument result = null;
    using (HttpWebResponse response = request.GetResponse() as HttpWebResponse)
    using (StreamReader reader = new StreamReader(response.GetResponseStream()))
    using (XmlTextReader textReader = new XmlTextReader(reader))
    {
        // Load the response into an XDocument.
        result = XDocument.Load(textReader);
    }

    return result;
}

当数据上传到 Toodledo 时(例如,添加或编辑记录时),会使用 HTTP POST。参数也使用 HttpUtility.UrlEncode 进行编码,并作为表单数据发送(请注意,POST 数据的参数用&amp; 分隔)。

url: http://api.toodledo.com/api.php
parameters: method=addGoal&key=317a1aae7a6cc76e2510c8ade6e6ed05&
                    title=Get+another+Raise&level=1

从 Toodledo 收到的响应是一个指示 POST 结果的 XML 片段,并被加载到 XDocument 中。

/// <summary>
/// Make Server Call using Http Post.
/// </summary>
/// <param name="url">url parameter</param>
/// <param name="parameters">parameters (separated by &)</param>
/// <returns>XDocument</returns>
private XDocument HttpPost(string url, string parameters)
{            
    byte[] data = UTF8Encoding.UTF8.GetBytes(parameters);

    HttpWebRequest request = WebRequest.Create(new Uri(url)) as HttpWebRequest;
    request.Method = "POST";
    request.ContentType = "application/x-www-form-urlencoded";
    request.ContentLength = data.Length;

    if (HostInfo.Enabled && HostInfo.IsValid)
    {
        //Set manual HTTP Proxy configuration.
        request.Proxy = new WebProxy(HostInfo.Host, Convert.ToInt32(HostInfo.Port));
        request.Proxy.Credentials = CredentialCache.DefaultCredentials;
    }
    
    using (Stream stream = request.GetRequestStream())
    {
        // Write the request.
        stream.Write(data, 0, data.Length);
    }
    
    XDocument result = null;
    using (HttpWebResponse response = request.GetResponse() as HttpWebResponse)
    using (StreamReader reader = new StreamReader(response.GetResponseStream()))
    using (XmlTextReader textReader = new XmlTextReader(reader))
    {
        // Load the response into an XDocument.
        result = XDocument.Load(textReader);
    }

    return result;
}

XDocument 是 .NET 3.5 LINQ to XML 技术的一个关键组件。可以针对 XDocument 执行 LINQ 查询(请注意,下面的示例中已过滤掉已归档的目标,并按级别排序)。LINQ 查询的结果是一个 System.Linq.OrderedEnumerable<XElement>,可以使用 foreach 进行迭代。

//Filter and order the result list.
var orderedServerList = from s in serverList.Descendants(GOAL)
                        where int.Parse(s.Attribute(GOAL_ARCHIVED).Value) == 0
                        orderby s.Attribute(GOAL_LEVEL).Value
                        select s;

//Iterate the result.
foreach (XElement element in orderedServerList){ ... }

在后台线程中同步

与 Toodledo 的数据同步在后台线程中使用 BackgroundWorker 类进行。进度会报告回应用程序并在状态栏中显示。请注意,将克隆的列表传递给后台工作线程,因为列表属于 UI 线程(它们已绑定到 XamDatagrid)。.NET 编程的一个基本规则是,一个线程不允许访问另一个线程拥有的对象,如果您尝试这样做,将会引发异常。BackgroundWorker 实例负责将对象过渡回调用线程,因此直接处理 BackgroundWorker 响应是安全的。当调用同步器回调方法时,响应列表会被合并到其原始列表中以完成同步。

/// <summary>
/// Handle Synchronisor_SyncCompleted event.
/// </summary>
private void Synchronisor_SyncCompleted(SyncCompletedEventArgs e)
{
    //Merge changes.
    lock (_persistenceService.Goals) { _persistenceService.Goals.Merge(e.Data.Goals); }
    lock (_persistenceService.Folders) { _persistenceService.Folders.Merge(e.Data.Folders); }
    lock (_persistenceService.Notes) { _persistenceService.Notes.Merge(e.Data.Notes); }
    lock (_persistenceService.Tasks) { _persistenceService.Tasks.Merge(e.Data.Tasks); }
    ...
}

将 XDocument 保存到隔离存储

GoalBook 数据使用 XDocument 类功能以 XML 格式保存到 隔离存储 中。XDocument 会自动处理文本字符串中的特殊字符(例如,&amp; 等),通过按需进行编码/解码。请注意,GoalBook 可以长时间离线工作而无需同步。GoalBook 启动时会从隔离存储中检索保存的数据。StreamWriter 用于保存 GetGoalsXDocument() 方法的输出。

/// <summary>
/// Save To IsolatedStorage.
/// (e.g. C:\Users\[UserName]\AppData\Local\IsolatedStorage).
/// </summary>
/// <param name="document">XDocument to save</param>
/// <param name="file">Name of file</param>
private void SaveToIsolatedStorage(XDocument document, string file)
{
    // Open the stream from IsolatedStorage.
    IsolatedStorageFileStream stream = new IsolatedStorageFileStream(
        file, FileMode.Create, GetUserStore());
    using (stream)
    {
        using (StreamWriter writer = new StreamWriter(stream))
        {
            document.Save(writer);
        }
    }
}

使用业务对象框架操作数据

CSLA.NET 是 GoalBook 使用的业务对象框架。业务对象框架的作用是支持数据绑定、规则验证、状态管理等。业务对象提供了将应用程序数据绑定到用户界面的机制。CSLA.NET 提供的一个有用功能是多级撤销。您可以从 GoalBook 的编辑菜单(或 Ctrl-Z)访问此功能。所有添加、删除和编辑操作都可以撤销。GoalBook 用户界面中的数据过滤功能是通过 CSLA FilteredBindingList 类实现的。FilteredBindingList 需要将其 FilterProvider 属性设置为一个返回布尔值的函数。源列表中的每个项目都会由该函数进行评估,以确定它是否应显示在过滤后的列表中。由于过滤后的列表只是一个包装了源列表的包装器,因此对数据绑定列表项进行的任何编辑都会直接作用于源列表。FilteredBindingList 从标准的 CSLA BusinessListBase 初始化。例如:

this.Model.Goals = new FilteredBindingList(_persistenceService.Goals);
this.Model.Goals.FilterProvider = this.LevelFilter;
this.Model.Goals.ApplyFilter(Goal.LevelProperty.Name, this.ActionView.Filter);

CSLA 通过内置的规则引擎提供了能力,可以使用 CSLA 预定义的规则(例如,StringRequired)或通过定义在对象验证时执行的自定义方法来验证每个对象(目标、注释、凭证等)。验证机制通过标准的 IDataErrorInfo 接口公开,GoalBook 对话框使用此接口来指示验证错误(将鼠标悬停在错误图像上即可看到验证消息)。例如:

/// <summary>
/// EmailValidator.
/// Called by BusinessBase RulesEngine.
/// </summary>
public static bool EmailValidator<T>(T target, RuleArgs e) where T : Credentials
{
    //Validate Email.
    const string emailRegex = @"^([0-9a-zA-Z]([-.\w]*[0-9a-zA-Z])*
                @([0-9a-zA-Z][-\w]*[0-9a-zA-Z]\.)+[a-zA-Z]{2,9})$";

    if (Regex.IsMatch(target.Email, emailRegex)) { return true; }
    else { e.Description = Resources.ValidationEmailMessage; }

    return false;
}

Account

使用 Composite Application Library 实现模块化

GoalBook 内置了模块化功能,使用了 Microsoft Patterns & Practices 团队的 Composite Application Library (CAL)。模块的加载可以是动态的(例如,可以指定一个文件夹路径)或静态的(指定单个模块)。GoalBook 使用静态加载,因为模块是已知的。这是一个关键的设计决策,GoalBook Shell 对应用程序的用途有相当多的了解,并且只将编辑和显示功能交给模块。定义模块如何加载发生在 Bootstrapper 类中。例如:

/// <summary>
/// Get Module Catalog. Statically loaded modules.
/// </summary>        
protected override IModuleCatalog GetModuleCatalog()
{
    return new ModuleCatalog()
        .AddModule(typeof(TasksModule))
        .AddModule(typeof(NotesModule))
        .AddModule(typeof(GoalsModule));                   
}

Bootstrapper 类继承自 UnityBootstrapperUnityBootstrapper 是一个控制反转 (IOC) 容器,负责类的动态初始化。GoalBook Shell 和模块使用对 IUnityContainer 接口的直接引用。这是 CAL 文档中推荐的方法:“容器具有不同的使用语义,这通常会驱动选择该容器的原因”。Unity Container 使用反射来检查正在初始化的类,并注入所需的参数。这种初始化模式称为依赖注入,或者更具体地说,构造函数注入。这提供了一种比传统接口方法实现的模块化应用程序更解耦的架构。您可以使用 Unity Container 的 Resolve 方法来动态初始化类实例。例如:

Model = _unityContainer.Resolve<MainPresentationModel>();

当实现了 IActiveAware 接口时,CAL 视图就是“活动感知”的。这使得全局命令只被路由到当前活动视图中已注册的事件处理程序。例如,当 Delete 命令执行时,只有当前活动视图有机会处理该事件。实现 IActiveAware 接口的视图会在每次活动视图更改时收到通知,并且必须将此事件引发给关联的演示者类,以便可以设置本地命令引用的活动状态。

/// <summary>
/// Handle View_IsActiveChanged event.
/// </summary>
/// <param name="sender">Source of caller</param>
/// <param name="e">EventArgs parameter</param>
private void View_IsActiveChanged(object sender, EventArgs e)
{            
    this.printCommand.IsActive =
    this.undoCommand.IsActive =
    this.deleteCommand.IsActive = this.View.IsActive;
}

使用 Infragistics XamDatagrid 进行展示

Infragistics XamDatagrid(Express Edition)是 GoalBook 中用于展示的数据网格控件。数据网格是一个普遍存在的控件,以表格形式展示数据,可以轻松地进行排序和滚动。XamDatagrid 直接绑定到模块 DataContext 中的列表(在本例中是 Goals 集合)。绑定是单向的 - 编辑发生在对话框中,并且通过已编辑业务对象引发的属性更改事件自动刷新更改。例如:

<igDP:XamDataGrid DataSource="{Binding Path=Goals, Mode=OneWay}" ... >

XamDatagrid 中显示为字符串的一些字段在业务对象中表示为整数(外键)。在数据绑定过程中,通过在 XAML 中指定要使用的转换器来执行此转换。下面的示例使用自定义的 IntegerForiegnKeyConverter 来显示 Goal Level。请注意,Label 文本是从资源文件中引用的。这允许对用户可见的字符串进行本地化(目前只有英文资源文件)。例如:

<igDP:Field Name="LevelID" Label="{x:Static ModuleProperties:Resources.LabelLevel}" 
    Converter="{StaticResource integerForeignKeyConverter}" 
    ConverterParameter="{StaticResource levelReferenceData}">

使用 WPF RichTextBox 撰写笔记

GoalBook 提供了一个复杂的所见即所得笔记编辑器,它利用了 WPF RichTextBox。在 GoalBook 中撰写的笔记会与 Toodledo 的笔记本同步。GoalBook 笔记编辑器将 Flow Document 作为其数据源,并在本地保存为 XAML。当上传到 Toodledo 时,笔记会被转换为具有 HTML 格式的文本。

Note.png

在构建 GoalBook 的笔记编辑器时遇到了许多技术挑战。项目要求是生成一个符合 Toodledo 笔记格式的笔记(基本的文本和 HTML 标签,例如粗体、斜体、超链接、编号列表和项目符号列表)。Toodledo 笔记格式和 WPF RichTextBox 中渲染的 FlowDocument 之间的转换需要多个步骤,并利用了两个 HTML 解析库。第一个库将 XAML 转换为 HTML(带有样式),而第二个库提供逐字符解析,将 HTML 样式替换为 Toodledo 支持的基本 HTML 标签(<b><i><a><ul><ol><li>)。这两个库的源代码都包含在 GoalBook.Public 项目中。GoalBook 笔记编辑器的最后一个挑战是允许激活超链接。首先,通过将 RichTextBoxIsDocumentEnabled 属性设置为 true 来启用 FlowDocument。然后,可以为每个超链接挂接一个事件处理程序。例如:

/// <summary>
/// Locate And Hook Hyperlinks.
/// </summary>
/// <param name="flowDocument">FlowDocument parameter</param>
private void LocateAndHookHyperlinks(FlowDocument flowDocument)
{            
    FlowDocumentHelper helper = new FlowDocumentHelper(flowDocument);
    foreach (Hyperlink hyperlink in helper.GetHyperlinks())
    {
        this.HookHyperlink(hyperlink);
    }
}

/// <summary>
/// Hook Hyperlink to event handler.
/// </summary>
/// <param name="hyperLink">Hyperlink parameter</param>
private void HookHyperlink(Hyperlink hyperLink)
{
    hyperLink.Click += this.Hyperlink_Click;
    hyperLink.Focusable = false;
    hyperLink.ToolTip = string.Format(
        "Ctrl + click to follow link: {0}", 
        hyperLink.NavigateUri);
}

/// <summary>
/// Handle Hyperlink_Click event.
/// </summary>
/// <param name="sender">sender parameter</param>
/// <param name="e">RoutedEventArgs parameter</param>
private void Hyperlink_Click(object sender, RoutedEventArgs e)
{
    if (sender is Hyperlink)
    {
        Process p = new Process();
        p.StartInfo.FileName = (sender as Hyperlink).NavigateUri.ToString();
        p.StartInfo.UseShellExecute = true;
        p.StartInfo.RedirectStandardOutput = false;
        p.StartInfo.Arguments = string.Empty;

        p.Start();
    }
}

要定位超链接,有必要递归遍历 FlowDocument 中的 Block(感谢 Mole 揭示了 FlowDocument 的内部结构)。这在 FlowDocumentHelper 类中完成。

/// <summary>
/// Get Hyperlinks.
/// </summary> 
/// <returns>Hyperlink enumerable list</returns>
public IEnumerable<Hyperlink> GetHyperlinks()
{
    return this.LocateHyperlinks(this.flowDocument.Blocks);
}

/// <summary>
/// Locate Hyperlinks.
/// </summary>
/// <param name="blockCollection">BlockCollection parameter</param>
/// <returns>Hyperlink enumerable list</returns>
private IEnumerable<Hyperlink> LocateHyperlinks(BlockCollection blockCollection)
{
    foreach (Block block in blockCollection)
    {
        if (block is Paragraph)
        {
            foreach (Inline inline in (block as Paragraph).Inlines)
            {
                if (inline is Hyperlink)
                {
                    yield return inline as Hyperlink;
                }
            }
        }
        else if (block is List)
        {
            foreach (ListItem listItem in (block as List).ListItems)
            {
                // Recurse Blocks in each ListItem.
                foreach (Hyperlink hyperlink in this.LocateHyperlinks(listItem.Blocks))
                {
                    yield return hyperlink;
                }
            }
        }
    }
}

使用 XPS Document Writer 进行打印

GoalBook 的打印服务使用 XPSDocumentWriterFlowDocument 功能来打印目标、笔记和任务。打印是异步进行的,打印完成后会向调用模块引发一个事件。自定义类 FlowDocumentPaginator 负责准备每一页的打印。这包括插入页眉和页脚。

/// <summary>
/// Print the specified FlowDocument.
/// </summary>
/// <param name="title">title parameter</param>
/// <param name="document">FlowDocument to print</param>
public void Print(string title, FlowDocument document)
{            
    // Return if user cancels.
    if (this.printDialog.ShowDialog().Equals(false)) 
    { 
        return; 
    }
                                      
    // Print the document.
    if (this.printDialog.PrintableAreaWidth > 0 && 
        this.printDialog.PrintableAreaHeight > 0)
    {
        ...
                        
        // Initialise the FlowDocumentPaginator.
        FlowDocumentPaginator paginator = new FlowDocumentPaginator(
            pageDefinition, document);

        // Print asynchronously.
        xpsDocumentWriter.WriteAsync(paginator);
    }            
}

/// <summary>
/// Print Completed.
/// </summary>
/// <param name="sender">sender parameter</param>
/// <param name="e">WritingCompletedEventArgs parameter</param>
private void PrintCompleted(object sender, WritingCompletedEventArgs e)
{
    if (e.Cancelled || e.Error != null)
    {
        this.OnPrintException(new PrintDialogException(
            string.Format(ShellResources.PrintExceptionMessage, e.Error.Message), 
            e.Error));
    }
    else
    {
        this.OnPrintEnd(ShellResources.PrintEndMessage);
    }
}

GoalBook 的路线图

这个 GoalBook 应用程序代表了我探索使用 WPF 作为平台来构建一个可扩展的、偶尔连接的混合智能客户端应用程序的成果。技术和架构的选择已基本完成,我的目标现在集中在添加功能和探索构建高质量产品所需的知识。我打算免费支持 Toodledo 公共 API 提供的所有功能。我期待着在这个技术轨道上继续我的探索,重点是增强 Windows 7 的用户体验。

参考文献

历史

  • 2009 年 3 月 25 日:版本 0.3(初始版本)。
  • 2009 年 4 月 21 日:版本 0.4(添加了打印和代理服务器身份验证)。
  • 2009 年 6 月 25 日:版本 0.5(添加了 Notes 模块,增强了打印功能)。
  • 2009 年 7 月 28 日:版本 0.6(添加了更改账户,增强了对话框服务)。
  • 2009 年 8 月 24 日:版本 0.7(添加了 Tasks 模块,增强了同步器)。
  • 2009 年 9 月 26 日:版本 0.8(添加了带过滤和搜索的操作窗格)。
© . All rights reserved.