精益高效的博客引擎






3.96/5 (19投票s)
2007年2月27日
15分钟阅读

99984

580
我用了 3 天时间尝试编写一个博客引擎。
这是系列文章的第一部分。
引言
首先,您可以在此处实时查看该博客。
以下是一篇“态度鲜明”的文章,讲述了如何使用 .NET 2.0 构建一个基本的博客引擎。
我为什么要写一个博客引擎?因为我从来没有找到过我喜欢的。它们要么
- 需要我不愿学习或支持的平台或提供商(我运行的是带有 W2003 Web Edition 和 IIS 的专用服务器)
- 它们有 bug
- 它们过于复杂
- 它们对演示文稿做出了假设
- 几乎不可能进行自定义
- 需要一个我不想添加到服务器的数据库
而且因为我是一个特立独行的人,我喜欢做自己的事情。我尝试了 DotText,它很笨拙而且过时。我尝试了 SubText,但我无法弄清楚如何更改诸如边距宽度之类的简单内容。其他博客引擎呢?算了。我甚至无法浏览它们的网站来弄清楚需求是什么或如何安装它们。
令人惊讶的是,CodeProject 上没有一篇关于博客引擎的文章。所以这是第一篇(假设在接下来的几天里没有其他文章发布)。
买家请注意
我可能在这篇文章中会说几次,但我认为这是第一次:我对 Web 应用程序相当不熟悉。所以我这样做是为了学习如何不编写 Web 应用程序,因为我肯定会从中更多地学到“如何不写”,而不是“如何写”。所以这也是对精彩的 Web 应用程序世界的一次探索,以及我在编写本应是一个简单应用程序的过程中发现的各种事物。如果您使用此代码(我无法想象任何人真的想使用它),您应该知道这很可能是错误的做事方式。
要求
使用轻量级数据库
博客引擎不应需要 SQL Server 或 MySQL 或其他任何类型的重型数据库。它几乎应该可以用 XML 完成!但是,数据库确实有一些优点,而 SQLite 是一个不错且易于使用的软件包。所以数据库引擎将是 SQLite。
只做博客,不做其他事
除了 RSS。我基本上想要一个可以发布帖子的引擎。仅此而已。能有多复杂?我实在无法理解为什么有人愿意使用笨拙的基于浏览器的文本/HTML 编辑器,而不是在舒适的家中从一个漂亮的 HTML 编辑器来完成,该编辑器能够进行拼写检查、正确设置格式等。使用瘦客户端是一个好主意,但除非你是谷歌,否则我宁愿使用我的桌面工具来舒适地编辑我的博客,谢谢。
RSS
是的,我们需要 RSS,这样人们就可以使用他们的聚合器并收到更新通知。
博客、博文和分类
博客本身
包含
- 页眉,包括博客标题、副标题和菜单
- 主体,包含博文
- 页脚,包含任何内容,例如版权信息
- 可选的左右边距,用于显示存档和分类
- RSS 链接
存档应按月列出条目,排除未发布任何条目的月份。
分类应按类别名称列出。
坦白说,我对菜单不太满意。我需要菜单有什么用,除非将博客置于管理模式,这样我就可以删除或更新条目、修改 UI 或添加新条目。哦,好吧。如果我需要菜单,它最好在一定程度上是可自定义的。稍后会详细介绍。
博文
博文应包含
- 标题
- 副标题/摘要
- 可选的分类
- 发布日期/时间
- 博客文本本身
分类应包含
- 类别名称
- 描述
保持简单
代码应该简单。它应该优雅。它应该经过设计。它应该有注释。我看了其他博客引擎,觉得“WTF?”。SubText 的源代码有 18.5 MB!
我不是 CSS 专家,也不是 HTML 专家,我猜很多人也不是。所以我的博客引擎需要体现“简单的可配置性”的理念。好吧,那些懂 CSS 和 HTML 的人会抱怨非标准的这个或那个。我真的不在乎。它需要为我工作,而不是为他们工作。
很容易陷入对自定义的追求。字体、颜色、对齐方式、定位方式,等等。自定义可以等等,重点是“精简高效”。
我不想要什么
注释
不希望人们发表评论。如果您想说什么,请给我发电子邮件。我还没有见过任何一个博客引擎能够抵御垃圾邮件发送者,除非完全禁用评论。而且,我不想读你们的评论,我想让你们读我的博客。
皮肤
啊!我不知道 CSS,我也不想知道 CSS。我希望它自己看起来还不错。好吧,我承认,人们希望他们的博客能表达他们的个性。这可以等。鉴于我对网页几乎一无所知,学习如何制作一个“可 CSS 化”的东西目前不在计划中。
VB
我不要任何 VB 代码。无需多言。
UI 设计
以下是 UI 元素的图示
UI 实现
初始设计,第一版
因此,在确保 IIS 已安装并且我重新安装了 .NET 2.0 以与 IIS 配合使用后,我启动了 VS2005 并开始了一个新的网站项目。我在设计器中打开了 default.aspx 页面,并自言自语该做什么。好吧,让我们从简单开始。一个用于页眉和页脚的面板,一个用于主体的表格。添加几个标签,然后发现标签不能被对齐。哦,是的,我喜欢 HTML。所以,将标签包装在一个表格里!这可能更有意义,因为表格可以有多行。这意味着我们也可以摆脱面板。
这是基本概念的屏幕截图
侧边距为 20% 宽度,主体为 60% 宽度,页眉和页脚为 100% 宽度。
至于博文,它也需要是一个表格。当然,我不能在设计器中将一个表格作为单元格!哦不!这些表格不是 HTML 表格,它们是 ASP.NET 表格!!!
<asp:TableCell...>Left Gutter</asp:TableCell> <asp:TableCell ...>Blog Entry</asp:TableCell> <asp:TableCell ...>Right Gutter</asp:TableCell>
啊!这并不是我想要的!好吧,TableCell 是一个 WebControl,如果我创建一个具有 Table 作为子控件的特殊 TableCell 呢?这不起作用(至少我没能让它工作)。相反,让我们在页面加载时以编程方式添加表格。而且当你思考的时候,我们实际上拥有的是
- 博客是由博文行组成的集合
- 博文行由另一个表格组成,其中包含标题、副标题、分类、日期和博客文本的行。
初始设计,第二版
所以,我们需要在 Page_Load 方法中添加一些代码。但首先,我需要将 ID(我称之为“cellBogEntry”)分配给博文表格的中间单元格,这样我们就可以将表格添加到其 Controls 集合中。
protected void Page_Load(object sender, EventArgs e) { Table blogTable = CreateTable(new string[] { "Blog Entry" }); Table blogEntry = CreateTable(new string[] {"Title", "Subtitle", "Category", "Date", "Blog Text"}); blogTable.Rows[0].Cells[0].Controls.Add(blogEntry); cellBlogEntry.Controls.Add(blogTable); }
并定义 CreateTable 方法
protected Table CreateTable(string[] rowNames) { Table table = new Table(); table.Width = Unit.Percentage(100); table.BorderStyle = BorderStyle.Solid; table.BorderWidth = 1; foreach (string rowName in rowNames) { TableRow tr = new TableRow(); TableCell tc = new TableCell(); tc.Width = Unit.Percentage(100); tr.Cells.Add(tc); tc.Text = rowName; table.Rows.Add(tr); } return table; }
结果是:
很好!这看起来更符合我的想法。
现在我们从 UI 暂时休息一下,开始处理数据库。
数据库设计
我们需要一些表来开始
BlogInfo
包含字段
- 标题
- Subtitle
- 电子邮件
- Copyright
BlogCategory
- ID
- 名称
- 描述
BlogEntry
- 标题
- Subtitle
- CategoryID
- 日期
- BlogText
数据库实现
由于我计划使用 SQLite,我希望考虑在处理无状态运行时环境时需要注意的事项。首先,我不想频繁地打开和关闭数据库连接。我最好有一个连接并保持打开状态。
应用程序状态
数据库实例将存储在应用程序状态中,以便每个应用程序实例都可以使用相同的数据库实例。坦率地说,在阅读文档时,我对应用程序状态和会话状态感到困惑。我原本以为应用程序状态是永久的,但似乎发生的是每个客户端都创建了一个应用程序实例。但那么会话是什么呢?无论如何,这是我正在寻找的关键信息
Application_Start 和 Application_End 方法是特殊方法,不代表 HttpApplication 事件。ASP.NET 会在应用程序域的整个生命周期中调用它们一次,而不是为每个 HttpApplication 实例调用。
这就是我想创建数据库实例并根据需要进行初始化的地方。我了解到这可以在 global.asax 文件中完成。嗯。没有。啊,但有一个“全局应用程序类”模板可以添加到项目中。添加文件后,我还添加了对 System.Data.SQLite 的引用。我发现引用的程序集会被添加到 web.config 文件中(我对 Web 开发一无所知,唉)。处理 global.asax 文件对我来说非常奇怪,因为类需要完全限定,因为我无法弄清楚在哪里放置“using”语句。
网站路径
问题是,它把 blog.db 文件放在哪里了!?!?好吧,如果我添加一个 System.IO.Path.GetFullPath("blog.db") 的监视,我发现文件被放在了 c:\Program Files\Microsoft Visual Studio 8\Common7\IDE\blog.db!哦天哪,这真不是我想要的。应该有一个更合理的地方,一个与网站本身更相关的地方。为什么文件没有创建在“App_Data”文件夹中?阅读有关网站路径的信息,MSDN 说
您可以在服务器控件的任何路径相关属性中使用 ~ 运算符。
但是 bool dbExists = System.IO.File.Exists("~/App_Data/blog.db");
的不起作用。但存在一个替代方法(可能是一个错误的方法),如代码所示
void Application_Start(object sender, EventArgs e) { string dbPath = Server.MapPath("~") + "\\App_Data\\blog.db"; bool dbExists = System.IO.File.Exists(dbPath); System.Data.SQLite.SQLiteConnection conn = new System.Data.SQLite.SQLiteConnection(); conn.ConnectionString = "Data Source="+dbPath; // conn.SetPassword("my_password"); conn.Open(); if (!dbExists) { CreateDatabase(conn); } Application.Add("db", conn); } void CreateDatabase(System.Data.SQLite.SQLiteConnection conn) { using (System.Data.SQLite.SQLiteCommand cmd = conn.CreateCommand()) { cmd.CommandText = "create table BlogInfo (Title text, Subtitle text, Email text, Copyright text)"; cmd.ExecuteNonQuery(); cmd.CommandText = "create table BlogCategory (ID integer primary key autoincrement, Name text, Description text)"; cmd.ExecuteNonQuery(); cmd.CommandText = "create table BlogEntry (ID integer primary key autoincrement, Title text, Subtitle text, CategoryID integer, Date datetime, BlogText text)"; cmd.ExecuteNonQuery(); cmd.CommandText = "insert into BlogInfo (Title, Subtitle, Email, Copyright) values ('Title', 'Subtitle', 'your email', 'your copyright')"; cmd.ExecuteNonQuery(); } } void Application_End(object sender, EventArgs e) { System.Data.SQLite.SQLiteConnection conn = (System.Data.SQLite.SQLiteConnection)Application["db"]; conn.Close(); }
加载带数据的页面
页眉和页脚
那么,让我们回到 UI,让一些数据显示在博客上。您会注意到我为 BlogInfo 表创建了一些初始数据,因为这是一个单行表。所以,回到设计器,我在页眉表格的单元格中添加了 cellTitle、cellSubtitle 和 cellCopyright ID。我还使用了有用的 SQLite 查询分析器来修改 BlogInfo 数据。
所以我的下一个问题是我想要添加一些辅助类,我认为我不需要将它们添加到 App_Code 文件夹(Visual Studio 会询问)。嗯,我错了。它们是必需的。我完全不知道为什么。
初始化页眉的代码非常直接。我获取连接对象并填充 TableCell 的文本。BlogInfo 只是具有 getter 和 setter 的属性集合。
protected void InitializeHeader() { SQLiteConnection conn = (SQLiteConnection)Application["db"]; BlogInfo blogInfo = Blog.LoadInfo(conn); cellTitle.Text = blogInfo.Title; cellSubtitle.Text = blogInfo.Subtitle; cellCopyright.Text = blogInfo.Copyright; }
LoadInfo 读取唯一的一行
public static BlogInfo LoadInfo(SQLiteConnection conn) { SQLiteCommand cmd = conn.CreateCommand(); cmd.CommandText = "select * from BlogInfo"; SQLiteDataAdapter da = new SQLiteDataAdapter(cmd); DataTable dt = new DataTable(); da.Fill(dt); BlogInfo blogInfo = new BlogInfo(); LoadProperties(blogInfo, dt.Rows[0]); return blogInfo; }
由于我将使用 BlogInfo 之类的辅助类来处理其他表格,我将使用反射来实现一个简陋的 ORM,以在辅助类和 DataRow 之间移动数据
public static void LoadProperties(object target, DataRow row) { foreach (PropertyInfo pi in target.GetType().GetProperties()) { if (row.Table.Columns.Contains(pi.Name)) { pi.SetValue(target, row[pi.Name], null); } } }
结果正在缓慢地变得生动起来
一些示例博文
现在让我们添加几个示例博文,看看它们如何显示。我将使用之前创建的 blogEntry 表(但需要将其移动,以便在本请求中可供类访问)。我遇到的下一个问题与 ASP.NET 无关。SQLite 中的 DateTime 没有转换为 .NET 可理解的内容。经过大约 20 分钟的研究,我发现可能是因为我在插入时没有正确输入日期/时间信息,如此处所示。如果我使用“datetime('now')”插入测试数据,我不会收到字符串转换异常。不幸的是,我得到一个完全错误的日期 1/1/0001 12:00:00 AM!而且,除其他问题外,ID 需要是 long 而不是 int,因为它们在 SQLite 中是 int64,并且 LoadProperties 方法需要处理 DBNull.Value 值,因为 .NET 太愚蠢而无法将其转换为可空值类型的 null。啊!
public static void LoadProperties(object target, DataRow row) { foreach (PropertyInfo pi in target.GetType().GetProperties()) { if (row.Table.Columns.Contains(pi.Name)) { object value = row[pi.Name]; if (value == DBNull.Value) { value = null; } pi.SetValue(target, value, null); } } }
事实证明,错误的日期是我的错——BlogEntry 辅助类中的属性名称与表列名不匹配。
这就是我们现在的情况
请注意,条目按相反顺序排序。代码有点丑陋。首先,填充 ASP.NET 行和单元格(更多时髦的反射)
protected void LoadBlogEntries() { SQLiteConnection conn = (SQLiteConnection)Application["db"]; DataTable dt = Blog.LoadBlogEntries(conn); blogTable.Rows.Clear(); // Create a BlogEntry helper instance. BlogEntry blogEntry=new BlogEntry(); foreach (DataRow row in dt.Rows) { // Create a table for the blog entry. Table blogEntryTable=new Table(); // Add it to the blog collection. AddEntryToTable(blogEntryTable); // Load the helper class with the blog entry row. Blog.LoadProperties(blogEntry, row); // For each property in the following list... foreach (string propName in new String[] { "Title", "Subtitle", "Date", "BlogText" }) { // Add a row to the table. TableRow tr = new TableRow(); blogEntryTable.Rows.Add(tr); // Add a cell to the row. TableCell tc = new TableCell(); tr.Cells.Add(tc); // Use reflection to get the property. PropertyInfo pi = blogEntry.GetType().GetProperty(propName); // Set the cell text. tc.Text = pi.GetValue(blogEntry, null).ToString(); } } } protected void AddEntryToTable(Table blogEntryTable) { // Create a new row in the blog table. TableRow tr = new TableRow(); // Add to the blog table collection. blogTable.Rows.Add(tr); // Create a cell. TableCell tc = new TableCell(); // Add to the row collection. tr.Cells.Add(tc); tc.Width = Unit.Percentage(100); // Add the blog entry table as a child control to the cell. tc.Controls.Add(blogEntryTable); }
还有,从数据库获取数据
public static DataTable LoadBlogEntries(SQLiteConnection conn) { SQLiteCommand cmd = conn.CreateCommand(); cmd.CommandText = "select * from BlogEntry order by Date desc"; SQLiteDataAdapter da = new SQLiteDataAdapter(cmd); DataTable dt = new DataTable(); da.Fill(dt); return dt; }
使其更美观
好的,让我们暂时忽略菜单、分类和存档以及管理,让现有的东西看起来更好一点。让我们也忘掉 CSS 和样式表什么的。我真的不应该这样做,因为这是“正确”的做法。我甚至不会尝试弄清楚主题是如何工作的!然后继续前进...
我想能够设置
- 字体名称
- 字体样式
- size
- 对齐方式
- 背景颜色
- 前景颜色
针对每种类型的单元格。那么,到目前为止我们有哪些类型的单元格
- 页眉表格
- 页眉标题
- 页眉副标题
- 博文表格
- 博文标题
- 博文副标题
- 博文日期
- 博文
- 版权
所以,让我们使用数据库来存储我们想要管理的样式信息,以及一些使用反射处理这些属性的信息,因为我讨厌硬编码这些东西。我将添加 Style 表,包含以下字段
- CellType
- PropertyName
- FontPropertyName
- 值
这些字段(除其他外)足够通用,可以指定边框样式。所以,首先,这里有一些 SQL 来设置一些样式
insert into style (CellType, PropertyName, FontPropertyName, Value) values ('HeaderTitle', null, 'Bold', 'true'); insert into style (CellType, PropertyName, FontPropertyName, Value) values ('HeaderTitle', 'BackColor', null, 'LightBlue'); insert into style (CellType, PropertyName, FontPropertyName, Value) values ('HeaderTitle', null, 'Size', '20'); insert into style (CellType, PropertyName, FontPropertyName, Value) values ('HeaderTitle', null, 'Name', 'verdana');
现在,我很快意识到“CellType”并不是真正的正确字段名。“ObjectType”更准确,因为我们可以更改表格、行和单元格的属性。所以这需要重构。但代码非常简单。任何对象都可以传递给 SetStyle 方法,该方法会调整对象属性或对象 Font 属性的属性(假设它有)。
protected void SetStyle(object obj, string key) { SQLiteConnection conn = (SQLiteConnection)Application["db"]; DataTable styles=Blog.GetStyles(conn, key); foreach (DataRow style in styles.Rows) { if (style["PropertyName"] != DBNull.Value) { PropertyInfo pi = obj.GetType(). GetProperty(style["PropertyName"].ToString()); object val = style["Value"]; // Helper to convert strings to their correct types (booleans, enums, // etc). object newVal = Converter.Convert(val, pi.PropertyType); pi.SetValue(obj, newVal, null); } else if (style["FontPropertyName"] != DBNull.Value) { PropertyInfo piFont = obj.GetType().GetProperty("Font"); FontInfo fontInfo = (FontInfo)piFont.GetValue(obj, null); PropertyInfo pi = fontInfo.GetType().GetProperty( style["FontPropertyName"].ToString()); object val = style["Value"]; // Helper to convert strings to their correct types // (booleans, enums, etc). object newVal = Converter.Convert(val, pi.PropertyType); pi.SetValue(fontInfo, newVal, null); } } }
并且数据库查询是
public static DataTable GetStyles(SQLiteConnection conn, string key) { SQLiteCommand cmd = conn.CreateCommand(); cmd.CommandText = "select * from Style where CellType=@key"; cmd.Parameters.Add(new SQLiteParameter("key", key)); SQLiteDataAdapter da = new SQLiteDataAdapter(cmd); DataTable dt = new DataTable(); da.Fill(dt); return dt; }
所以,重新审视一下初始化页眉之类的东西,注意增加了 SetStyle 调用
protected void InitializeHeader() { SQLiteConnection conn = (SQLiteConnection)Application["db"]; BlogInfo blogInfo = Blog.LoadInfo(conn); cellTitle.Text = blogInfo.Title; SetStyle(cellTitle, "HeaderTitle"); cellSubtitle.Text = blogInfo.Subtitle; SetStyle(cellSubtitle, "HeaderSubtitle"); cellCopyright.Text = blogInfo.Copyright; SetStyle(cellCopyright, "Copyright"); }
在数据库中进行大量样式条目后,我们得到
所以,我认为它进展得相当顺利!(边框可以用我的样式功能去除,但我暂时将它们保留。)
博客链接
你能看出我不擅长写需求吗?博客应该允许您单击一个条目并仅查看该条目,并且 URL 应该可以用作引用,这样您就可以从其他地方直接链接到该条目。让我们弄清楚它是如何工作的。首先,博文标题需要是一个链接,所以我会在文本周围加上“a href”标签,看看效果如何。在我的测试用例中,这奏效了,但存在两个问题
- 如何获取网站的 URL,而不是使用硬编码了它的条目?
- 如何响应“?ID=1”(例如)查询字符串?
啊哈!这两个问题都可以通过 Page_Load 方法中的两行简单代码来解决。一个注意事项是,生成的 URL 必须去除任何查询字符串!
protected void Page_Load(object sender, EventArgs e) { id=Request.QueryString["ID"]; url = StringHelpers.LeftOf(Request.Url.ToString(), '?'); ...
我修改了 Blog.LoadBlogEntries,如果存在 ID,则使用 ID 作为限定符。
public static DataTable LoadBlogEntries(SQLiteConnection conn, string id) { SQLiteCommand cmd = conn.CreateCommand(); if (id == null) { cmd.CommandText = "select * from BlogEntry order by Date desc"; } else { cmd.CommandText = "select * from BlogEntry where ID=@id"; cmd.Parameters.Add(new SQLiteParameter("id", id)); } SQLiteDataAdapter da = new SQLiteDataAdapter(cmd); DataTable dt = new DataTable(); da.Fill(dt); return dt; }
成功!
但现在我们需要博客标题能够点击返回主页。啊!功能蔓延!啊!糟糕的初始需求!
cellTitle.Text = "<a href=\"" + url+"\">" + blogInfo.Title + "</a>";
那底纹是怎么回事?如果标题不带下划线就好了。要做到这一点,你不会相信,但我们需要 CSS(谢谢 Mark Harris)!在每个 Page_Load 的开始时
protected void Page_Load(object sender, EventArgs e) { Response.Write("<style>A {text-decoration: none}</style>"); ...
现在下划线消失了!Mark Harris 说:“你不应该从代码中输出 HTML……那简直太邪恶了!” 呵呵。我想我应该直接将样式信息嵌入到页面的 HTML 中。
下一步
- 好的,这篇文章已经够长了。告诉我我哪里做得不对,以及你们喜欢和不喜欢什么,我可能会对你们不喜欢的东西做些什么,但很可能不会。我会告诉你们我不喜欢什么——各种硬编码的常量、功能,以及无法定义页面各个部分的“流程”。
- 在下一部分中,我将处理菜单、分类和存档。不过,此时,您会注意到我实际上不再处理 ASP.NET,而是处理从数据库中提取信息并将其呈现在页面上的繁琐工作,以及不存在的错误检查。
- 我将 CSS 的复杂性与编辑数据库表的复杂性进行了交换!这不好,所以我不得不添加一些不错的管理功能,因为坦率地说,我不想让用户(或我自己)触摸数据库。
- 最终的管理功能包括添加、更新和删除帖子。
- 博文需要通过某种限制来限定。
- 哦,别忘了 RSS!
啊!我感觉功能蔓延正在发生!
当所有工作完成后,我希望我能有一个精简高效的博客引擎,源代码不超过 100K。当然,然后我必须开始使用它!
我学到的东西
- 编写基本的 ASP.NET 应用程序相当容易
- 大部分工作在于演示、数据收集和管理
- 定制化和可用性的细节才是真正的挑战
- 要删除数据库,我必须终止“WebDev.WebServer.EXE”进程,该进程对 blog.db 文件有文件锁定。
安装
将文件复制到您的 Web 服务器。您还需要安装 SQLite(请参阅下面的链接)。相应地调整 web.config 文件。
由于这是一个 ASP.NET 2.0 应用程序,我学习了如何处理同时运行 ASP.NET 1.1 和 2.0 的 IIS。请在此处阅读有关应用程序池的更多信息,因为如果您同时运行 1.1 和 2.0,您需要为 ASP.NET 2.0 应用程序创建一个单独的应用程序池。
数据库所在的目录也必须设置为可写。我必须更改 IIS_WPG 帐户对 App_Data 文件夹和根文件夹的写权限,以分别启用数据库和 rss.xml 文件的写入。这可能完全是错误的。
如果您删除数据库,应用程序将创建一个带有初始值的数据库。您需要为样式列表创建记录才能使其美观,否则它看起来是这样的