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

使用 C# 插件扩展 WordPress

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (21投票s)

2012 年 5 月 29 日

Apache

16分钟阅读

viewsIcon

179117

downloadIcon

1542

本文介绍了如何使用 C# 编写的插件扩展 WordPress,并展示了该系统的第一个 C# 插件。

引言

插件可以以多种方式扩展 WordPress,而无需修改 WordPress 源代码。其面向插件的设计可能是 WordPress 如此受欢迎的原因之一。截至撰写本文时,wordpress.org 插件数据库中已有超过 17,000 个插件,可能还有更多未列出的插件。由于 WordPress 是用 PHP 实现的,因此所有这些插件也都是用 PHP 编写的。在本文中,您将看到第一个用 C# 编写的 WordPress 插件。

由于存在一个名为 Phalanger 的 .NET PHP 编译器,因此可以使用 C# 扩展 WordPress。其 3.0 版本使用 DLR 来实现 PHP 和 .NET 代码之间的互操作。我们可以利用 C# 中的 dynamic 关键字,这使得从 C# 调用 PHP 功能变得轻而易举。

在本文的第一部分,我将演示如何从 C# 调用 WordPress API。第二部分解释了具体的插件及其实现细节。第三部分将介绍如何使用 MVC3 的新视图引擎 Razor 在 WordPress 部分中创建一个配置页面。最后,我将完成文章并进行性能评估。

动机

有许多理由选择用 C# 编写 WordPress 插件。以下列表并非详尽无遗,您可以在评论中分享您自己的想法!

  • 重用 C# 库 – .NET 中有大量优秀的库。能够重用最流行的博客系统中的代码,无疑会带来许多新的可能性。
  • 性能 – 静态类型语言比动态类型语言更高效。PHP 应用程序中性能关键的部分通常以 C 语言编写的 PHP 扩展的形式出现,这需要管理员访问服务器。因此,您无法在共享主机上部署该应用程序。使用 Phalanger,您可以使用任何 .NET 语言编写功能,而无需了解 Zend API,也无需管理员访问权限。
  • 连接 .NET 基础设施 – 公司基础设施可能基于 .NET,但他们仍然希望使用 WordPress。编写插件可以有效地将 WordPress 与公司其余系统连接起来。
  • 便利性 – 有些人对 C# 比 PHP 更熟悉,因此在他们选择的语言中编写插件会更有效率。此外,还可以使用任何 .NET 语言,包括 Visual Basic 或 F#。
  • 出色的 IDE 和 .NET 工具 – .NET 提供了极佳的工具来提高开发人员的生产力,例如 Visual Studio 2010、性能分析工具等。对于更复杂的插件,这些工具可以极大地简化开发。
  • 无源代码分发 – 插件可以以动态链接库 (DLL) 的形式分发,无需共享您的源代码。

为了使用 C# 编写 WordPress 插件,您需要运行 Phalanger 3.0 的 WordPress,使用 IIS 7.5 和 Microsoft .NET 或 Apache 和 Mono 2.10.8。有关更详细的信息,请参阅文章末尾。现在,让我们开始探索 C# 中的 WordPress API。

从 C# 调用 WordPress API

插件可以通过 WordPress API 访问 WordPress 的功能。互联网上有很多关于如何编写插件的资源。最好的起点是 WordPress 网站上的“编写插件”页面

[1]

使用钩子扩展 WordPress

借助 WordPress API,插件可以访问全局变量和全局函数。可以通过挂钩到 WordPress 在特定时刻调用的事件来改变 WordPress 的行为。例如,每当保存帖子或页面时,都会调用 save_post 事件。插件 API 页面 [2] 提供了此类可扩展点的完整列表。

在 WordPress 术语中,这些事件称为钩子,它们分为

  • 过滤器,返回某个值
  • 操作,不返回任何值

以下 PHP 代码片段来自 WordPress API 文档

add_action ( 'hook_name', 'your_function_name', [priority], [accepted_args] );

通过从您的插件调用 add_action 函数,您可以将您的函数与特定的钩子关联起来。当 WordPress 触发钩子时,您的函数将被调用。

在 C# 中注册钩子

WordPress API 是作为过程代码编写的,因此无法将其公开为 C# 类。使用 Phalanger,有多种方法可以从 C# 调用 PHP 函数。在 3.0 版本中,我们设计了一个对象,允许开发人员轻松调用全局函数和访问全局变量。该对象名为 PHP.Core.Utilities.GlobalScope,在 Phalanger 3.0 的 .NET 互操作性概述 [3] 中有更详细的介绍。您可以通过调用以下代码片段来获取 GlobalScope 对象

dynamic wp = PHP.Core.ScriptContext.CurrentContext.Globals;

此对象允许执行各种操作,但对我们来说,最重要的操作是

  • 全局变量访问 (wp.x) – 此构造用于赋值或读取全局变量。如果变量不存在,则会创建它。例如,wp.wp_version 返回 WordPress 的版本。
  • 全局函数调用 (wp.foo(arg1,arg2,...argn)) – 使用给定的参数调用全局 PHP 函数 foo,并返回结果(如果 PHP 函数提供了结果)。所有必要的转换都会自动执行。例如,如果 WordPress 安装是多站点的,wp.is_multisite() 将返回 true

GlobalScope 对象的另一个重要特性是能够将委托(delegate)作为参数传递给全局 PHP 函数。这使得挂钩到 WordPress 事件成为可能

wp.add_action("save_post", new Action<int, dynamic>(CheckPost), 10, 2);

CheckPost 是一个有两个参数的 C# 方法;第一个参数是 int 类型的 postId,表示 WordPress 文档的 ID。第二个参数是 dynamic 类型,表示正在保存的帖子。要挂钩到需要 C# 代码返回值的过滤器,可以使用 Func 委托而不是 Action

值得注意的另一件事是,来自 C# 的输出必须通过 Phalanger 暴露为 ScriptContext.CurrentContext.Output 的流。这相当于调用 PHP 的 echo 函数。这一点很重要,因为 PHP 在使用此流时可以使用输出控制函数 [4]

内容监控插件

对于本文,我选择实现一个监控帖子和页面内容的插件。每次有人提交或更新帖子或页面时,插件都会检查其内容是否包含管理员可以选择的脏词。一个方便的选项是允许匹配整个单词。在捷克语等语言中,我会关闭此功能,因为有很多派生脏词包含一个会被过滤掉的单个脏词。在英语中,我会选择仅匹配整个单词,因为非脏词中也可能包含脏词。如果帖子或页面包含脏词,插件会向管理员或管理员指定的其他人发送通知邮件。它还会通知用户其帖子包含不允许的词。

该插件支持 WordPress 3.3 及更早版本。它也支持多站点安装。最后,该插件必须非常高效,以便能够用于成千上万博主使用的真正大规模的多站点安装。

如何使用该插件

当您在 Phalanger 3.0 上正确安装了 WordPress 3.3.2(或更低版本,但我只测试过 3.2.1)后。您必须将 contentwatchdog.php 放入 wp-content\plugins\ContentWatchdog,并将 ContentWatchdog.dll 放入 WordPress 根目录的 Bin 目录。

然后,有必要更新 web.config 文件,以便 Phalanger 知道它必须加载 DLL

<phpNet>
  <classLibrary>
    <add assembly="ContentWatchdog" />
    <!-- the rest of the file is omitted -->
  </classLibrary>
</phpNet>

就这样。现在您应该可以在管理部分的“插件”中看到该插件了。在下一节中,我们将讨论该插件是如何实现的。

实现内容监控插件

内容监控插件实现为一个 C# 类,该类在创建时向 WordPress 注册必要的钩子。除了这个 C# 类之外,插件还需要一个简单的 PHP 脚本,该脚本被复制到 WordPress 插件目录并引导 C# 实现。但是,这个文件只有几行代码,对于任何其他 C# 插件来说,它都是相同的。

引导 C# 插件

WordPress 必须能够找到插件并在管理部分的插件列表中显示它。在那里,用户可以查看插件信息并激活或停用它。关于插件的元数据以 PHP 注释的形式写在文件开头。这被称为“插件信息头” [5]。在标准的 PHP 中,无法预编译插件,因此典型的 WordPress 插件包含包含元数据的文件中的一些实现。

当使用 C# 编写插件时,我们将基本上所有的实现都包含在 C# 源代码中,并将其编译并作为 DLL 程序集部署。但是,我们仍然需要提供插件信息。对于内容监控插件,文件 contentwatchdog.php 如下所示

<?php
/*
Plugin Name: Content Watchdog
Description: Allows you to check the content of your site and to be notified when any of defined words occurs in posts or pages.
Version: 1.0.0
Author: Miloslav Beno (Devsense)
Author URI: http://devsense.com
Network: true
*/

if (!defined("PHALANGER"))
    die('Content Watchdog is only compatible with Wordpress running on <a target="_blank" href="http://php-compiler.net">Phalanger</a>.');

if (!class_exists("Devsense\WordPress\Plugins\ContentWatchdog\ContentWatchdog"))
    die('It is necessary to add ContentWatchdog assembly in phpNet/ClassLibrary section of the web.config file.');

$contentMonitor = new Devsense\WordPress\Plugins\ContentWatchdog\ContentWatchdog();

?>

如前所述,文件第一部分是插件信息头。第二部分是检查 WordPress 是否运行在 Phalanger 之上,并且其 DLLweb.config 中正确指定。如果不是这种情况,插件将无法加载,管理员可以看到原因。最后一行初始化 ContentWatchdog 类的实例,该类是实际用 C# 实现的插件。

未来,可以编写一个插件来识别 C# 插件,并仅从程序集元数据加载它们的信息,但这只是一个细节。

C# 中的插件初始化

contentwatchdog.php 中的 PHP 代码创建了一个 ContentWatchdog 类的实例。与 WordPress 中的通常情况一样,每个请求都会创建一个实例。在构造函数中,该类向 WordPress 注册

/// <summary>
/// Initialize new instance of the ContentWatchdog plugin
/// </summary>
public ContentWatchdog()
{
    wp = PHP.Core.ScriptContext.CurrentContext.Globals;
   
    if (wp.is_multisite())
        wp.add_action("network_admin_menu", new Action(AddAdminPage), 10, 0);
    else
        wp.add_action("admin_menu", new Action(AddAdminPage), 10, 0);

    if (WatchContent)
    {
        wp.add_action("save_post", new Action<int, dynamic>(CheckPost), 10, 2);
        wp.add_action("all_admin_notices", new Action(ShowNotification), 10, 0);
    }

}

构造函数获取 GlobalScope 对象并将其保存到类型为 dynamic 的字段 wp 中。这使得可以使用此值调用任意 PHP 函数。然后,如果站点是多站点的,插件会挂钩到 network_admin_menu 操作,如果 WP 只是单站点的,则挂钩到 admin_menu。当任一钩子被触发时,我们用 AddAdminPage 方法处理它们。构造函数的最后一部分检查 WatchContent 属性是否设置为 true。如果是这种情况,我们将 save_post 挂钩到我们的 CheckPost 方法(该方法负责内容检查的所有繁重工作),还将 all_admin_notices 挂钩到以向用户显示通知。

在查看实现主要功能的 CheckPost 方法之前,让我们先看看 AddAdminPage 函数,该函数向 WordPress 注册插件配置页面

/// <summary>
/// Adds admin page into admin section
/// </summary>
private void AddAdminPage() 
{
    if (wp.is_multisite())
        wp.add_submenu_page("settings.php", Strings.PluginName, Strings.PluginName, 10, "content-watch-dog", new Action(AdminPageOutput));
    else
        wp.add_options_page(Strings.PluginName, Strings.PluginName, 10, "content-watch-dog", new Action(AdminPageOutput));
}

插件的配置页面被添加到多站点的 network\settings 部分,或者只是单站点的 settings 部分。配置页面的实际输出由 AdminPageOutput 方法处理,该方法在“使用 Razor 添加管理页面”部分中有详细说明。

插件配置

ContentWatchDog 类包含许多属性,用于配置插件并指定检查方式。以下代码显示了 Email 属性。

/// <summary>
/// Email address where notification email will be sent in case of post/page was
/// submitted with bad word.
/// </summary>
/// <remarks>Default email is email of administrator</remarks>
public string Email
{
    get
    {
        string recipient = wp.get_site_option(emailOption) as string;

        //Check if email is not set
        if (String.IsNullOrEmpty(recipient))
        {
            //return administrator email
            return wp.get_site_option("admin_email") as string;
        }

        return recipient;
    }
    set { wp.update_site_option(emailOption, value); }
}

属性实现确保值在多个请求之间保持持久性。它们在内部使用 wp.set_site_optionwp.get_site_option 方法来设置和获取值。实际的存储加载逻辑由 WordPress 处理,因此我们不必担心它。

检查帖子

在前面的代码片段中,我们将 CheckPost 方法注册到了 save_post 钩子。当 WordPress 尝试保存帖子时,我们可以对其进行处理。以下代码片段实现了检查帖子中的脏词、通知用户以及向管理员发送电子邮件的 WordPress 操作

/// <summary>
/// Checks post/page for occurrence of bad word
/// </summary>
/// <param name="postId">Identificator of post/page</param>
/// <param name="post">Object representing the post/page</param>
private void CheckPost(int postId, dynamic post) 
{
    // Don't check it if it's not a post or page
    if (post.post_type != "post" && post.post_type != "page")
        return;

    //Don't check it if it's not published or it's password protected
    if (post.post_status != "publish" || !String.IsNullOrEmpty(post.post_password))
        return;

    if (BadWordsSearch.ContainsAny(post.post_title) || BadWordsSearch.ContainsAny(post.post_content))
    {
        //bad word was find
	 string post_permalink = wp.get_permalink(postId);
	
 NotifyUser();
	 MailNotify(post_permalink, post.post_type);
    }
}

我们首先检查 post_type 是否为 postpage。WordPress 中的其他选项可以是自定义帖子,我们不想检查它们。我们还确保帖子是否真的被发布了。我们忽略只是草稿或受密码保护的帖子。

然后,我们调用 BadWordsSearchContainsAny 方法来检查帖子标题和帖子内容。如果找到匹配项,该方法将返回 true。当找到匹配项时,我们调用 NotifyUser 来通知用户其帖子包含不允许的词,并调用 MailNotify 方法,该方法使用 wp.wp_mailEmail 属性中指定的电子邮件地址发送电子邮件。

用于从文本中查找一组单词的字符串匹配算法在 StringSearch 类中实现。该类的实例保存在以下字段中

private static StringSearch badWordsSearch;

请注意,这是一个静态字段。它允许我们在所有请求之间拥有一个实例。在纯 PHP 中,没有一些缓存扩展是不可能做到这一点的。在 C# 中,静态字段可以正常使用,Phalanger 通过 AppStatic 属性 [6] 为 PHP 提供了类似的功能。

此字段之所以是 static,是因为 StringSearch 类实现了一个非常快的字符串搜索算法。初始化成本更高,但由于使用了 static 字段,因此只需要在应用程序启动时或关键字集更改时初始化一次。这在 InitStringSearch 方法中完成

/// <summary>
/// Initialize StringSearch tree structure
/// </summary>
private void InitStringSearch()
{
    var badWordsCol = BadWordsString.Split(',').Select(p => p.Trim()).ToArray();
    badWordsSearch = new StringSearch(badWordsCol, MatchWholeWord);
}

该方法获取 BadWordsString 属性,使用逗号将其拆分,修剪空格,并将结果数组保存在局部变量 badWordsCol 中,然后将其作为参数与 MatchWholeWord 属性一起传递给 StringSearch 构造函数。然后,我们将 StringSearch 的一个实例分配给 badWordsSearch 字段。我们不必担心同步,最坏的情况下,StringSearch 实例会被分配两次,但分配是一个原子操作。

字符串搜索算法是我从我的朋友兼同事 Tomáš Petříček 那里获取的 C# Aho-corasick 实现 [7]。这是一个较旧的实现,我对其进行了一些优化,并实现了整个单词匹配选项(这基本上是字典树实现)。该算法非常高效,其复杂度为 O(n),其中 n 是输入文本的长度。因此,关键字数量对速度的影响微乎其微。

使用 Razor 添加管理页面

当插件逻辑工作正常后,我想创建一个出现在 WordPress 管理部分的配置页面。这可以通过多种方式完成,但我发现(至少对我而言)在 C# 中创建 HTML 表单最便捷的方法是使用 ASP.NET MVC3 附带的新 Razor 视图引擎 [8]。幸运的是,也可以直接使用它,而无需包含整个 MVC3 框架。

对于不熟悉 Razor 的人来说,它是一个围绕 HTML 生成优化的引擎,采用以代码为中心的模板方法。Razor 具有非常轻量级的语法,不需要将一种语言(C#)中的代码放在另一种语言(HTML)的代码周围的标签中。在 PHP 中,这些标签是 <? ?>,在 ASP 中是 <% %>。使用 Razor,您只需在每段代码前面加上 @ 符号。

对于配置页面,我创建了一个名为 AdminPage.cshtml(razor 视图引擎文件)的配置表单

@* Generator : Template TypeVisibility : Internal *@
@inherits PhpRazorTemplateBase<ContentWatchdog>
@using System.Web
@using Devsense.WordPress.Plugins.ContentWatchdog

<div class="wrap">
    <h2>@Strings.PluginName</h2>
    <form action="@HttpContext.Current.Request.RawUrl" method="post">

        <table class="form-table">
            <tr valign="top">
                <th scope="row">
                    <label for="Email">@Strings.EmailAddress</label>
                </th>
                <td>
                    <input id="Email" name="Email" type="text" value="@Model.Email" />
                    <br />
                    @Strings.EmailDescription
                </td>
            </tr>
            <tr valign="top">
                <th scope="row">
                    <label for="WatchContent">@Strings.WatchContent</label>
                </th>
                <td>
                    <input @(Model.WatchContent ? "checked=\"checked\"" : String.Empty) id="WatchContent" name="WatchContent" type="checkbox" value="true" />
                </td>
            </tr>
            <tr valign="top">
                <th scope="row">
                    <label for="MatchWholeWord">@Strings.MatchWholeWord</label>
                </th>
                <td>
                    <input @(Model.MatchWholeWord ? "checked=\"checked\"" : String.Empty) id="MatchWholeWord" name="MatchWholeWord" type="checkbox" value="true" />
                </td>
            </tr>
            <tr valign="top">
                <th scope="row">
                    <label for="BadWordsString">@Strings.BadWords</label>
                </th>
                <td>
                    <textarea cols="45" id="BadWordsString" name="BadWordsString" rows="5">@Model.BadWordsString</textarea>
                    <br />
                    @Strings.BadWordsDescription
                </td>
            </tr>
        </table>
        <p class="submit">
            <input type="submit" name="Submit" value="Save Changes" />
        </p>

    </form>
</div>

此文件使用名为 Razor generator 的自定义工具 [9] 转换为 C# 源文件,因此 cshtml 文件可以自动翻译成 C#,并作为插件程序集的一部分进行预编译。

让 Razor generator 工作起来很简单。所有必要的代码都在 PhpHtmlTemplateBaseOfT.cs 中,我不会在本文中详细讨论。如果您有兴趣,可以自己研究源代码,或者简单地将其重用于您的插件实现。值得一提的是,默认情况下,Razor generator 工具的基本模板会将输出保存到缓冲区。我已经对其进行了更改,使其直接写入 ScriptContext.CurrentContext.Output 流 – 即 PHP 输出流。

插件中的表单渲染操作由 AdminPageOutput 方法处理

/// <summary>
/// Outputs admin page
/// </summary>
/// <param name="s"></param>
private void AdminPageOutput()
{
    InitPropertiesFromForm();

    var template = new AdminPage{Model = this};
    template.Execute(); //Sends AdminPage to output stream
}

/// <summary>
/// Initialize properties from submitted user form
/// </summary>
private void InitPropertiesFromForm()
{
    var request = System.Web.HttpContext.Current.Request;
    if (request.Form.Count > 0)
    {
        //Update properties
        Email = wp.is_email(request.Form["Email"]) as string;
        WatchContent = request.Form["WatchContent"] != null ;
        MatchWholeWord = request.Form["MatchWholeWord"] != null;
        BadWordsString = wp.esc_html(request.Form["BadWordsString"]) as string;
    }
}

AdminPageOutput 方法调用 InitPropertiesFromForm,该方法处理用户的输入并设置插件的属性。然后,我们创建 AdminPage 类的实例,该类是由 Razor Generator 生成的类,将 ContentWatchDog 实例作为 Model 传递。Model 将用于获取属性并在我们在 AdminPage.cshtml 文件中定义的表单中显示它们。然后,我们调用模板上的 Execute 方法,该方法将其输出渲染到 PHP 输出流。

国际化

WordPress 在世界各地都有使用,因此其结构中内置了国际化和本地化功能,包括插件的本地化。由于我们的插件在 .NET 平台上运行,我更倾向于使用标准的 .NET 实践来本地化应用程序。该插件使用资源文件 Strings.resx,Visual Studio 会将其转换为强类型 Strings 类,该类在整个插件中使用。然后,将插件翻译成其他语言只需提供该资源文件的翻译版本即可。

评估性能

为了评估插件的性能,我找到了另一个基本上做同样事情的插件。不幸的是,这是一个商业插件,因此无法包含在本文中。但是,我很好奇想知道与本文的 C# 插件相比,其性能如何。

我取了 95KB 的文本并将其保存为 WordPress 帖子。然后,我测量了关键字数量(X 轴)对插件算法显示速度(Y 轴,以毫秒为单位)的影响。文本从不包含搜索词,以显示最坏情况的场景——算法必须遍历整个文本。

Content Watchdog 使用 Phalanger 3.0 运行,而另一个插件使用 PHP 5.3.8 运行。

结果清楚地显示了两个插件之间存在巨大的性能差异。我研究了另一个插件的代码,发现它们使用的是基于正则表达式的算法,导致其复杂度呈指数级增长。性能之间巨大的差异有两个原因

  • 低效的字符串匹配实现 – 另一个 PHP 插件仅使用了 PHP 中易于获得的算法。通过使用 C#,我的插件可以使用更高效的算法(Aho-Corasick 匹配),该算法可作为 C# 库免费获得。
  • 对于许多算法问题,编译的 C# 代码与动态类型的 PHP 代码之间的差异会给插件性能带来额外的开销,尽管使用我执行的简单测试无法精确测量这一点。

出于显而易见的原因,我不想透露其他插件的名称。

结论

本文表明,使用 Phalanger、C# 和 Visual Studio 2010 编写 WordPress 插件可以很简单且富有成效。我重用了 C# 中现有的字符串匹配算法实现。对于插件的配置页面,我选择使用 MVC3 中我喜欢的新的 Razor 模板引擎,因为它语法清晰。因此,我能够轻松实现一个过滤单词的插件,其性能优于 WordPress 上类似的商业插件。

要求

为了使用本文介绍的插件,您需要进行以下安装

  • IIS 7.5(使用 Microsoft .NET 时)或 Apache(使用 Mono 时)
  • MySQL 来托管 WordPress 数据库
  • .NET 4.0 或 Mono(至少 2.10.8 版本)上的 WordPress。最简单的方法是下载 wpdotnet - 捆绑了 Phalanger 的 WordPress,网址为 http://www.wpdotnet.com。它基本上是一个传统的 ASP.NET 应用程序,因此在部署时您应该不会遇到任何问题。或者,如果您想自己安装 Phalanger 并配置所有内容,可以按照教程 [10] 进行操作。

参考文献和链接

© . All rights reserved.