MyStream: 使用 ASP.NET 4.0 进行社交生活流






4.91/5 (63投票s)
使用 ASP.NET 4.0、C# 4.0、PLINQ、任务并行库、依赖注入和插件架构,将您当前的静态网站或博客变成一个包含所有社交活动的社交生活流门户。
- 下载源代码 - 840 KB
- 在 Google Code 上下载最新源代码: http://mystream.googlecode.com/
引言
现如今社交网络站点太多了,难以一一跟上,而这正是生活流服务发挥作用的地方。生活流是一系列按时间顺序排列的活动,它充当您电子“社交”生活的日记。在 Web 2.0(甚至更)时代,您可以利用 Twitter、Facebook、Delicious、Flickr、撰写博客文章,让您的朋友及时了解情况,同时您也好奇您的朋友在做什么。有许多社交网站可以帮助您将所有社交活动聚合到一个地方。FriendFeed 就是这样一个服务,也是最受欢迎的生活流服务之一。这些网站仍然托管在外部,无法取代您的博客或当前网站,即使它们可能提供比您博客或网站更酷的功能。许多人可能会争辩,但我坚信托管您所有在线(包括社交)活动的门户将是下一代个人网站,并将取代您的博客和当前静态页面。
MyStream 是一个完全可定制、可主题化、易于配置且开源的门户框架,它构建在 ASP.NET 4 之上,您可以在自己的服务器上托管。您可以完全自由地升级到尚未发布的新主题,并通过自定义每个细微之处来维护您现有的个人品牌。此插件式架构的当前代码库可以通过 RSS/Atom、Delicious 书签、Flickr 照片流和 Tweets 来流式传输您的博客文章。您可以使用此框架在几分钟内编写自己的社交插件。本文肯定会分享我在 ASP.NET 4 和新的 C# 4.0 功能的实际开发过程中获得的一些经验,以供那些仍在犹豫不决的人参考,因为这得益于 Visual Studio 2010 Beta 1。让我们来看看我迄今为止的截图。
右侧列出了您当前订阅的社交帐户。根据您选择的主题,此列表可能会显示在其他位置。左侧列中显示的每个活动都带有一个或多个链接可供访问。如果您有博客文章通过 Delicious 进行了书签标记,您将看到纯文本链接条目。如果您订阅了 Flickr,您可以看到嵌入的照片。当然,对于推文,您将看到所有可能的链接,例如推文发送者的个人资料(在本例中是网站所有者)、使用的标签、截图中的提及用户(在本例中是 Microsoft)以及其他自然 URL。同时请注意显示活动的顺序。流按降序排序。
Administration
可以通过密码保护的 Web 管理界面修改社交帐户的订阅和全局站点设置。您可以更改站点标题、标语、缓存间隔、主题,以及添加/删除订阅。顺便说一句,默认密码是:password。一个很棒的功能是您可以根据需要添加任意数量的社交帐户订阅。MyStream 上截至目前的所有社交插件都不需要您输入密码。您只需输入用户名即可。MyStream 将自动发现所需的流,如 Twitter、Flickr 和 Delicious feeds,并将其添加到您的页面。这使得您可以添加公共 feed、其他人的 Twitter、Flickr 等服务到您自己的页面!管理 URL 将是:https://:9001/Admin/。
尽管插件是写在与 Web 项目分开的程序集中,以方便维护、更轻松地解耦开发,但 Web UI 完全了解需要呈现的内容。例如,如果您选择一种订阅类型,您将被重定向到一个页面来配置该订阅。下面的截图是 Flickr 的一个示例配置空间。这个 Flickr 特定 UI 完全由插件模型生成,并且验证结果使得插件成为独立的组件。
添加自定义页面和控件
您可以为当前选定的站点主题添加自定义页面。您需要将内容页面添加到 DefaultPage.master
并继承自 ThemedPageBase
,以确保页面元素与所选主题保持一致。如果您创建了这样的内容页面,您将得到一个空白页面,没有内容,但有样式。看看下面的截图,它显示了主页面的一部分,供您添加新控件。社交帐户下面的一个用户控件,您可以删除它,或者在它之前/之后添加新控件。
如果您想添加一个新的菜单项,您可能需要查看下面的 MVC 风格的菜单创建,它还可以确保根据浏览器上正在呈现的 URL 自动选择当前菜单项。
<ul>
<%= Html.MenuItem("Home", "Default.aspx") %>
<%= Html.MenuItem("About me", "About.aspx") %>
<%= Html.MenuItem("Contact", "Contact.aspx") %>
</ul>
主题
您可以将自己的主题添加到 App_Themes 文件夹。目前只有一个主题。一个主题由一个样式表文件和必要的图像组成。您从管理界面选择的任何主题,都会在页面刷新后通过 ThemedPageBase
立即反映出来。下图详细说明了 ThemedPageBase
的使用方法。
插件架构和依赖注入
插件或附加组件是与宿主应用程序交互以按需提供特定功能的扩展。宿主应用程序可以是完整的软件,如 Firefox,也可以是程序。应用程序支持插件的原因有很多,例如允许第三方开发人员创建扩展应用程序的功能,以及支持尚未预料到的功能。宿主应用程序通常独立于其插件组件。插件不完全是扩展,因为宿主应用程序可以与插件一起运行,而扩展则扩展了宿主应用程序的当前行为。
依赖注入是一个向应用程序提供外部依赖的过程。依赖项是需要依赖项帮助来为应用程序服务特定需求的と言う(对象)。容器是 DI 的一个组件,它能够组合依赖项并解析其依赖项,将它们准备好供使用,并将它们注入到需要的地方。它还管理对象的生命周期、实例化和处置。我在网上读到过,“依赖注入”是一个 25 美元的术语,但概念却值 5 美分。所以如果你对这个领域是新手,请不要害怕。这是来自维基百科的 DI 的一个流行类比:我们可以将汽车视为依赖项,引擎视为依赖关系,汽车工厂视为提供者。汽车不知道如何安装引擎,但它需要引擎才能运行。将引擎安装到汽车上是汽车工厂的责任。
由于本文的目的不是探讨 DI,而是展示我们如何使用它来支持我们的插件,有关依赖注入的更多详细信息,您可以阅读 DI 之父 Martin Fowler 的经典文章之一:https://martinfowler.com.cn/articles/injection.html。
为什么会用到依赖注入?
依赖注入非常适合插件架构,因为我们可以定义插件必须提供的服务以及宿主应用程序所必需的服务合同。一个额外的优点是,由于它与宿主应用程序松散耦合,因此具有更好的可测试性。DI 通过公共接口提供了一个抽象层,并消除了对插件的依赖。这意味着插件是由架构绑定的,而不是它们之间相互绑定。创建和链接它们的职责已从它们本身转移到 DI。宿主应用程序独立于插件运行,这使得第三方可以更动态地添加它们,而无需修改、重新编译和重新部署宿主项目。我们将所有插件保留在 MyStream.Plugins 项目中。因此,每当我们想要添加新插件或修改插件时,我们需要重新编译 MyStream.Plugins 项目,然后将 MyStream.Plugins.dll 复制到 Web 项目的 bin 文件夹。就这么简单!
探索 MyStream.Plugins
DI 有许多变体,并且有各种框架、设计模式来促进这种模式,例如抽象工厂(Abstract Factory)和服务定位器(Service Locator)。现在也有几种 .NET 的 DI 框架,如 Castle MicroKernel/Windsor、Autofac 和 Unity。我为这个项目选择了 Unity,只是因为它是我更熟悉的 Microsoft Enterprise Library 的一部分。就像上面的解释一样,我们有一个合同,我们项目中的所有插件都应该遵循:IPlugin
。它包含插件应具有的基本方法的签名,例如 Execute
,它将返回该插件的社交更新给依赖项,在本例中是 MyStream.Business
,它包含所有业务代码。还有 Subscribe
、GetFriendlyName
、GetIconPath
、GetShortName
和 GetTypeName
方法。Subscribe
用于首次订阅社交帐户。此方法获取有关该社交帐户的详细信息,并最终将订阅保存到数据库,以便稍后可以根据这些信息获取社交更新。看看现在的插件层次结构。
它们都实现了 IPlugin
并暴露了公共属性。例如,FlickrPlugin
中的 FlickrUserName
。从类图中,您会找到 PluginParameterAttribute
,其中包含插件属性的元数据。
[PluginParameter("flickr_username", parameterType:
PluginParameterType.Required, friendlyName: "Flickr Username")]
public string FlickrUserName { get; set; }
像图 3 这样的用户界面是通过使用此属性实现的。flickr_username
是表单中 HTML 文本元素的唯一 ID,PluginParameterType.Required
决定该参数不是可选的,而 friendlyName
提供了文本字段的标签。这些信息用于呈现 UI,并指导 JavaScript 验证逻辑,哪些 Text
字段不是可选的。下面的代码块深入分析了插件内部使用的参数。
public static List<PluginParameterAttribute> GetParameters(IPlugin plugin)
{
var list = new List<PluginParameterAttribute>();
var type = plugin.GetType();
var properties = type.GetProperties();
foreach (var property in properties)
{
object[] attrs = property.GetCustomAttributes(
typeof(PluginParameterAttribute), false);
if (attrs.Count() > 0)
{
list.Add(((PluginParameterAttribute[])attrs).Single());
}
}
return list;
}
Unity 容器
现在依赖项和插件都已准备就绪,合同也已到位。它们只需等待依赖项来消耗。在这里,容器(在我们的例子中是 Unity)就发挥了作用。首先,我们需要告诉 Unity 注册当我们每次需要它注入的类型时。要执行此类任务,Global.asax.cs 中的 Application_Start
是一个不错的位置。
private static IUnityContainer _Container;
public static IUnityContainer Container
{
get { return _Container; }
}
protected void Application_Start(object sender, EventArgs e)
{
if (Container == null)
_Container = new UnityContainer();
MyStream.Business.BootstrapTasks.Bootstrapper.Run(_Container);
}
在其他引导程序任务中,有一个任务可以像这样为我们初始化 Unity 容器。
public static void Run(IUnityContainer container)
{
_Container = container;
// Repository Registration
_Container.RegisterType<ISiteInfoRepository, SiteInfoRepository>()
.RegisterType<IStreamDataRepository, StreamDataRepository>()
.RegisterType<ISubscriptionsRepository, SubscriptionsRepository>()
// Register plugins - this list will determine the order when shown as list
.RegisterType<IPlugin, RssPlugin>(RssPlugin.TYPE_NAME)
.RegisterType<IPlugin, TwitterPlugin>(TwitterPlugin.TYPE_NAME)
.RegisterType<IPlugin, DeliciousPlugin>(DeliciousPlugin.TYPE_NAME)
.RegisterType<IPlugin, FlickrPlugin>(FlickrPlugin.TYPE_NAME);
}
这设置了我们正在使用的数据存储库以及插件。除了从代码中显式注册类型外,还有两种注册类型的方法。一种是通过 XML 配置,另一种是通过容器配置 API 为容器提供自定义配置。上面的代码的意思是,“如果我问容器,给我一个 ISubscriptionRepository
的实现,容器应该创建一个并给我一个 SubscriptionRepository
的实例。”对于插件,它的含义略有不同,因为我们通过传递 TYPE_NAME
常量来标识每种类型。对于插件,我们说“如果我们通过名称 Type.TYPE_NAME
请求 IPlugin
的实现,给我们该 Type
的一个实例。”为了简单起见,我们在 Facade
的 Plugins
字段中解析我们在 Run
中注册的所有 IPlugin
实例。
Plugins = IoC.ResolveAll<iplugin>();
当我们按名称需要其中一个时,我们会像这样使用 LINQ 查询来选择一个。
public IPlugin LoadPlugin(string type)
{
return Plugins.AsParallel().SingleOrDefault<IPlugin>(p => p.GetTypeName() == type);
}
如果您对我们如何使用 Unity 的存储库感到好奇,这里有一个示例 Facade
构造函数供您参考。
public Facade() :
this(IoC.Resolve<ISiteInfoRepository>(),
IoC.Resolve<ISubscriptionsRepository>(),
IoC.Resolve<IStreamDataRepository>())
{
if (CurrentSiteInfo == null)
{
ReloadCurrentSiteInfo();
}
Context = System.Web.HttpContext.Current;
}
并行编程
最近 Microsoft 在 .NET 4.0 中投入了大量精力,使开发人员能够轻松安全地将并行编程概念集成到应用程序中。原因无疑是近年来处理器速度的大幅提升和核心数量的增加。摩尔定律告诉我们 CPU 处理器速度每两年翻一番。直到多核处理器进入普通用户手中,这都是事实。我们需要更高的速度,但晶体管密度的增长率已经开始下降。这促使处理器制造商向消费者推出多核处理器。然而,并非所有应用程序和编译器都能在多核系统中自动扩展。多核方法仅在软件能够同时利用不同核心时才有效。
与多线程不同,并行编程的目标是确保应用程序利用多个核心来提高计算性能,并且随着核心数量的增加,这种性能会持续提升。多线程在 .NET 中并不是什么新鲜事,也不难掌握。然而,同步对共享资源的访问通常很困难,而且有时很难找到随机崩溃和冻结问题。.NET 中的并行编程提供了相同的多线程功能和性能扩展,而无需特定于硬件配置的代码。在大多数情况下,它会自动处理分叉、合并、工作分区管理,因此最大限度地减少了需要编写的用户代码。
阿姆达尔定律
在并行化的情况下,阿姆达尔定律指出,如果 P 是一个程序可以并行化的比例(即,受益于并行化),而 (1 − P) 是不能并行化的比例(保持串行),那么使用 N 个处理器所能达到的最大加速比是:
性能增益 = 1 / ((1-P) + (P/N))
这意味着,如果 P 是 90%(1-P)= 10%,并且有 2 个核心,那么您的程序将比其串行实现快 1.81 倍。讨论阿姆达尔定律的原因是想让您对并行化可以实现多少性能或多大程度的性能有一个合理的了解。
并行 LINQ
并行 LINQ 是标准查询运算符的一种实现,它在普通的 LINQ 编程模型下使用并行执行算法和技术。PLINQ 可以自动选择合适的算法,并确定可能的并行度。PLINQ 支持您应用程序中已有的所有 LINQ 用法。您可以通过连接 AsParallel()
扩展方法来查询 XML 文档、数组、List<T>
或任何 IEnumerable<T>
。LINQ to Anything 仍将由各自的查询提供程序执行,但 PLINQ 用于支持从它们获取的内存中查询结果 - 排序、选择、连接都可以并行进行。让我们看一个它有多简单的例子。
list = (from item in feed.Element("channel").Elements("item").AsParallel()
select new StreamItem
{
Title = XmlHelper.StripTags(
item.Element("title").Value, 160).ToTweet("embedded-anchor"),
Url = item.Element("link").Value,
Icon = _Subscription.Icon,
Timestamp = Utilities.Rfc822DateTime.FromString(
(item.Element("pubDate").Value))
}).ToList();
如果您问,为我账户检索到的 10 条推文并行运行操作是否可行,并为此打开 10 个线程?我们是否为每个项目生成一个线程?这是否会比顺序处理它更昂贵?让我们尝试找出答案。嗯,数据分区当然是一件非常棘手的事情。通常,我们可以考虑两种相当简单的分区概念。一种方法是锁定数据源并将其划分为包含恒定数量项的块。另一种方法是锁定数据源并将其划分为系统中可用的核心/处理器数量。如果我们有 200 万条记录,对于 2 核 CPU,我们可以将其分成 100 万条。后者对于简单查询来说是一种非常高效的算法,称为范围分区。
PLINQ 在运行时自动确定分区类型。范围分区是 PLINQ 中一种非常常见的方案,主要用于数组或列表接口实现,其中已知确切的项目数。目前无法确定 IEnumerable<T>
是否没有未探索的项。为了处理这些类型的集合,这些集合是非索引数据源,PLINQ 使用块分区。该算法是您拥有的核心的负载均衡器。它会随着集合中项目数量的增长而动态扩展。该算法确保所有核心都以相同的水平工作。PLINQ 还使用其他算法,这里不一一介绍,但我想说的重点是,PLINQ 决定了查询的适当并行度和最佳算法。
Task Parallel Library
Visual Studio 2010 通过任务并行库为您提供了任务并行化。TPL 使编写可以自动将工作分配给多个处理器/核心的代码变得非常容易。而 PLINQ 只专注于数据,TPL 则允许您同时关注数据和任务并行。有许多方法可以将顺序代码块拆分为代码并行。最常见的用法是与数组或对象集合一起使用。我在这里将展示这样一个例子。我们正在从订阅列表中获取每个项目,并执行相应的插件以接收其社交更新。
public List<StreamItem> GetStreamItems()
{
var subscriptions = GetAllSubscriptions();
var list = new List<StreamItem>();
subscriptions.ForEach(s =>
{
var plugin = GetPluginFromSubscription(s);
var items = new List<StreamItem>();
var cachedItems = Context.Cache[s.ID.ToString()];
items = cachedItems != null ? (List<StreamItem>)cachedItems : plugin.Execute(s);
if (items != null && items.Count > 0)
{
Context.Cache.Add(s.ID.ToString(), items.ToList(), null,
DateTime.Now.AddSeconds(CurrentSiteInfo.CacheDuration),
System.Web.Caching.Cache.NoSlidingExpiration,
System.Web.Caching.CacheItemPriority.Normal, null);
lock (_lock)
{
items.ForEach(item => list.Add(item));
}
}
});
return list.Where(i => i != null && i.Timestamp != null)
.OrderByDescending(i => i.Timestamp).ToList();
}
简单地说,用图表示。
可以使用 Parallel.ForEach
将此顺序代码块转换为代码并行。
public List<StreamItem> GetStreamItems()
{
var list = new List<StreamItem>();
var subscriptions = GetAllSubscriptions();
Parallel.ForEach(subscriptions, s =>
{
var plugin = GetPluginFromSubscription(s);
var items = new List<StreamItem>();
var cachedItems = Context.Cache[s.ID.ToString()];
items = cachedItems != null ? (List<StreamItem>)cachedItems : plugin.Execute(s);
if (items != null && items.Count > 0)
{
Context.Cache.Add(s.ID.ToString(), items.ToList(), null,
DateTime.Now.AddSeconds(CurrentSiteInfo.CacheDuration),
System.Web.Caching.Cache.NoSlidingExpiration,
System.Web.Caching.CacheItemPriority.Normal, null);
list.AddRange(items);
}
});
return list.Where(i => i != null && i.Timestamp != null)
.OrderByDescending(i => i.Timestamp).ToList();
}
除了 Parallel.ForEach
之外,代码是相同的。现在发生的是,TPL 会根据列表的大小、可用的核心数量以及对每个项目执行的操作类型,来确定适当的并行度和合适的算法,这样分叉、执行和合并的成本就不会超过其顺序对应项。然而,上面的代码中一个不可避免的错误是,即使 TPL 为我们处理了很多事情,在运行时,它也会抱怨在其他线程正在访问列表集合时修改它。为了防止该错误发生,我们可以锁定一个虚拟对象,以确保它在当前线程修改其内容时不会被其他方修改。
lock (_lock)
{
list.AddRange(items);
}
为了有效地解决此共享资源访问问题,在 System.Collections.Concurrent
命名空间中有一个名为 ConcurrentBag
的新类。这是一个线程安全集合,允许您向其中添加对象,而无需担心竞态条件。不过缺少一个必要的方法,那就是 AddRange
,这样我们就不必逐个迭代各项并将其添加到集合中。在 System.Collections.Concurrent
命名空间中还有其他类型的线程安全集合,例如 ConcurrentDictionary
。当然,TPL 中还有许多其他类和功能,我们这里没有涵盖。
public List<StreamItem> GetStreamItems()
{
var list = new System.Collections.Concurrent.ConcurrentBag<StreamItem>();
var subscriptions = GetAllSubscriptions();
Parallel.ForEach(subscriptions, s =>
{
var plugin = GetPluginFromSubscription(s);
var items = new List<StreamItem>();
var cachedItems = Context.Cache[s.ID.ToString()];
items = cachedItems != null ? (List<StreamItem>)cachedItems : plugin.Execute(s);
if (items != null && items.Count > 0)
{
Context.Cache.Add(s.ID.ToString(), items.ToList(), null,
DateTime.Now.AddSeconds(CurrentSiteInfo.CacheDuration),
System.Web.Caching.Cache.NoSlidingExpiration,
System.Web.Caching.CacheItemPriority.Normal, null);
items.ForEach(item => list.Add(item));
}
});
return list.Where(i => i != null && i.Timestamp != null)
.OrderByDescending(i => i.Timestamp).ToList();
}
并行化魔法之后
何时不并行
尽管任务并行库为我们省去了很多麻烦,但最好还是测试、分析和衡量您的代码,以确保其有效性。过度使用并行可能导致程序的效果不如其顺序对应项。毕竟,并非所有问题都一样。在某些情况下,您可能根本不想考虑并行。请看以下代码。
public static void Run(IUnityContainer container)
{
var list = new List<IBootstrapTasks>()
{
new SetupIoC(container),
new EnsureInitialData()
};
list.AsParallel().ForAll(h => h.Run());
}
在此代码块中,如果调度程序首先拾取 EnsureInitialData
,它将尝试访问尚未初始化的对象。在这种情况下,我们的 IoC 容器尚未设置。因此它会崩溃。
命名参数和可选参数
这些是 C# 4.0 中长期要求的特性。它们共同使您能够根据需要省略参数,并在调用方法或委托时忽略参数的位置。可选参数要求您将它们放在列表的最后,并指定默认值,如果调用时省略,则使用该默认值。但是,默认值必须是常量。请记住,在编译时,string.Empty
不是常量。带有 ref
或 out
的参数不能是可选的。有人可能会问,那 params
呢?嗯,这当然可以出现在可选参数之后,但不能被视为可选参数,也不能指定任何默认值。这意味着如果调用者省略它,它将收到一个空数组。让我们通过示例来看。下面列出的字符串扩展方法可以将任何推文转换为格式良好的 HTML,该 HTML 会保留推文本身的现有超链接,并为 Tweeter、Mentions 和 Hashtags 呈现超链接。请注意可选参数 anchorCssClass
,它允许渲染特定的 CSS 类用于超链接,如果指定的话。您可以在调用扩展方法时简单地省略此参数。
public static string ToTweet(this string s, string anchorCssClass = "")
{
var status = HttpUtility.HtmlEncode(s);
status = Regex.Replace(status, "[A-Za-z]+://[A-Za-z0-9-_]+." +
"[A-Za-z0-9-_:%&?/.=]+", delegate(Match m)
{
return string.Format("<a class=\"{1}\" href=\"{0}\">{0}</a>",
m.Value, anchorCssClass);
}, RegexOptions.Compiled);
status = Regex.Replace(status, "[@]+[A-Za-z0-9-_]+", delegate(Match m)
{
var user = m.Value.Replace("@", "");
return string.Format("@<a class=\"{1}\" href=\"http://twitter.com/{0}\">{0}</a>",
user, anchorCssClass);
}, RegexOptions.Compiled);
status = Regex.Replace(status, "[#]+[A-Za-z0-9-_]+", delegate(Match m)
{
var tag = m.Value.Replace("#", "");
return string.Format("<a class=\"{1}\" href=\"http://search." +
"twitter.com/search?q=%23{0}\">#{0}</a>",
tag, anchorCssClass);
}, RegexOptions.Compiled);
var name = status.Substring(0, status.IndexOf(": "));
return string.Format("<a class=\"{1}\" href=\"http://twitter.com/{0}\">{0}</a>{2}",
name, anchorCssClass, status.Substring(name.Length));
}
您可以像 JSON 风格一样,将命名参数放在任何位置。您也可以同时使用位置参数和命名参数。在这种情况下,您必须维护位置参数的顺序,然后您可以拥有命名参数。所有必需的参数都应在调用时按位置或名称指定。让我们看一个例子。
public PluginParameterAttribute(string name = "", string friendlyName = "",
string description = "",
PluginParameterType parameterType = PluginParameterType.Optional)
{
Name = name;
FriendlyName = friendlyName;
Description = description;
ParameterType = parameterType;
}
上面定义的属性由 FlickrPlugin 中的 FlickrUserName
属性使用。请注意,Name
属性正在被设置而没有名称,所以我们将其放在第一个位置,然后通过名称(使用冒号:)指定其他属性。
[PluginParameter("flickr_username",
parameterType: PluginParameterType.Required,
friendlyName: "Flickr Username")]
public string FlickrUserName { get; set; }
结论
可以理解的是,由于框架本身在撰写本文时仍处于 Beta 1 版本,托管公司将需要更长的时间来准备其服务器支持 .NET 4.0。我甚至找不到一个托管或价格更低的 VPS 来托管这个项目,也许将来会有。我希望这篇关于开发一个完整的社交生活流门户的文章能成为爱好者的有用资源。您非常欢迎加入 MyStream 开发团队,添加新插件,改进和扩展架构,修复错误,并设计吸引人的主题。我期待您的反馈、对项目的贡献,以及成为下一代个人网站开发的一部分。