这是一个全部用 100% VBScript 编写的 MVC 框架。可以将其视为经典 ASP 的 Spring Boot。
Sane 是一个功能相对齐全的 MVC 框架,它为经典 ASP 带来了理智。它在风格上与 .NET MVC 和 Rails 有一些相似之处,但并不完全类似于其中任何一种。它固执己见,因为它假设控制器将存在于特定的文件夹位置,但该位置在某种程度上是可配置的。
该框架的主要特点包括:
All/Any
布尔测试、 Min/Max/Sum
、用于投影的Map/Select
以及用于过滤器的Where
,支持基本的 lambda 样式表达式Up
和Down
步进迁移的数据库迁移——版本控制数据库更改Scripting.Dictionary
对象KVArray
及其帮助器方法来使构建 HTML 变得容易所有这些都是用 VBScript 编写的。真的。
Sane 根据 GPLv3 条款获得许可。
注意:该框架是从现实世界的内部工作流路由项目中提取的,因此它有一些粗糙的边缘。
这可以快速概述一个控制器的代码流及其使用的模型和视图。它显示了上面列出的功能有多少实际上是一起使用的。
“类似的还有很多,但这一个是我的。”
主要是因为这是一个有趣的项目,突破了经典 ASP 的极限。绝大多数开发人员讨厌 VBScript 和 Classic ASP,其中大部分都有充分的理由。困扰经典 ASP 的许多问题都源于其开发时期(即 20 世纪 90 年代中期)的限制。开发人员无法使用当今被认为是基本实践(广泛使用类等),因为该语言的设计目的不是以我们所谓的“快速”方式执行,并且使用这些实践会导致应用程序陷入困境并崩溃。因此,ASP 社区被迫像使用 PHP 一样使用 ASP —— 作为一个内联的基于页面的模板处理器,而不是一个完整的应用程序框架。另外,说实话,Microsoft 向每个人推销 ASP,无论其技能水平如何,而且网上找到的大多数教程都很糟糕,并且鼓励了可怕的不良实践。
今天我们知道得更清楚了,得益于摩尔定律,计算能力自 90 年代中期以来已经增长了大约 500 倍,因此我们有能力做几年前不可想象的事情。
该框架是从以这种方式构建的真实项目中提取的。它运行得很好,并且没有理由(从功能上来说)它不应该作为一个可行的应用程序框架。也就是说,实际上,如果我们今天需要开发一个应用程序,我们将使用现代框架,例如 .NET MVC 或其竞争对手之一,因此这实际上只是为了对其他人有所帮助。而且建造起来很有趣。 :)
依赖性:该演示是针对 Microsoft Northwind 示例数据库构建的。在此处下载 SQL Server BAK 文件并将其还原到 SQL Server 实例中。 SQL 脚本和 MDF 文件也可用。
File -> Open Web Site...
并选择Demo
目录AppDALlib.DAL.asp
并修改连接字符串以指向您的数据库。/index.asp
文件index.asp
会自动将您重定向到 Home 控制器/App/Controllers/HomeController.asp
并加载默认操作Index
。
下面的一些功能有相应的 ASPUnit 测试。在 Tests 目录中找到它们。
<%
Class OrdersController
Public Model
Public Sub Show
MVC.RequirePost
dim id : id = Request ( " Id " )
set Model = new Show_ViewModel_Class
set Model.Order = OrderRepository.FindById(id)
%> <!-- #include file="../../Views/Orders/Index.asp" --> <%
End Sub
End Class
MVC.Dispatch
%>
控制器只知道域,而不知道数据库。喜悦!
操作是没有参数的函数(与 Rails 类似,但与 .NET MVC 不同)——与传统 ASP 中一样,参数是从Request
对象中提取的。 MVC.RequirePost
允许您将操作限制为仅响应POST
请求,否则会出错。
MVC.Dispatch
是框架的神奇入口点。由于视图被#include
到控制器中,并且应用程序可以有1..n
控制器,每个控制器有1..n
操作,因此除了几个简单的控制器之外,拥有一个中央整体 MVC 调度程序是不可行的。这是因为 ASP 为每个页面视图加载并编译整个#include
d 文件。鉴于此,框架将实例化委托给控制器本身,让它们负责启动框架,而不是让框架负责加载和实例化所有控制器以及加载和编译每个请求的所有视图。该框架确实根据命名约定动态实例化控制器,但每个请求仅解析和加载一个控制器,而不是所有控制器。通过仅加载一个控制器,我们可以使用节省的资金来加载许多有用的库,从而使开发对开发人员更加友好。
由于这种方法,“路由引擎”实际上只是一个知道如何构建控制器文件 URL 的类,因此Routes.UrlTo("Orders", "Show", Array("Id", order.Id))
生成URL /App/Controllers/OrdersController.asp?_A=Show&Id=123
(对于 order.Id = 123)。 URL 指向控制器并通过_A
参数提供要执行的操作的名称。操作参数通过KVArray
数据结构传递,它只是一个在整个框架中广泛使用的键/值对数组。例如,以下是在众多 HTML 帮助器之一中使用的两个KVArray
:
<%= HTML.LinkToExt( " View Orders " , _
" Orders " , _
" List " , _
array ( " param1 " , " value1 " , " param2 " , " value2 " ), _
array ( " class " , " btn btn-primary " , " id " , " orders-button " )) %>
该方法在幕后构建一个锚点,该锚点路由到正确的控制器/操作组合,通过查询字符串传递指定的参数,并具有指定的 HTML class
和id
属性。由于有一些辅助方法(例如KeyVal
和KVUnzip
KVArray
很容易处理。
KVArray
数据结构是大部分框架的基础,并且大大简化了编码。从根本上来说, KVArray
只不过是一个标准的 VBScript 数组,应该始终以两个一组的方式使用。换句话说,要构建一个KVArray
我们只需要构建一个数组,其中元素 0 是第一个键,元素 1 是其值,元素 2 是第二个键,元素 3 是其值,依此类推。
本质上,您可以将KVArray
想象为使用System.Object
样式调用的一种方式,就像 .NET 的Html.ActionLink
中所做的那样。
例如:
dim kvarray : kvarray = Array( 6 )
'Element 1: Name = Bob
kvarray( 0 ) = "Name"
kvarray( 1 ) = "Bob"
'Element 2: Age = 35
kvarray( 2 ) = "Age"
kvarray( 3 ) = 35
'Element 3: FavoriteColor = Blue
kvarray( 4 ) = "FavoriteColor"
kvarray( 5 ) = "Blue"
但实际上你永远不会这样写,而是使用内联Array
构造函数,如下所示:
dim params : params = Array( "Name" , "Bob" , "Age" , 35 , "FavoriteColor" , "Blue" )
或者为了提高可读性:
dim params : params = Array( _
"Name" , "Bob" , _
"Age" , 35 , _
"FavoriteColor" , "Blue" _
)
要以 2 为单位迭代此数组并使用KeyVal
获取当前键和值:
dim idx, the_key, the_val
For idx = 0 to UBound(kvarray) step 2
KeyVal kvarray, idx, the_key, the_val
Next
在每次迭代中, the_key
将包含当前键(例如“Name”、“Age”或“FavoriteColor”),而the_val
将包含该键对应的值。
但为什么不使用字典呢?
字典很棒,但它们是 COM 组件,并且至少在历史上实例化的成本很高,而且因为线程不应该放置在会话中。对于该框架中的用例来说,它们的使用也很麻烦,并且没有简单的方法可以使用动态数量的参数内联实例化它们。
我们真正需要的是一个快速、只进的键值数据结构,它允许我们迭代值并提取每个键和值来构建类似具有任意属性的 HTML 标记或具有任意列的 SQL where
子句,而不是快速查找各个键。因此,我们需要数组和字典的混合体,以满足我们的特定需求并允许内联声明任意数量的参数。 KVArray
允许我们非常自然地编写代码,如上面的LinkToExt
示例,或者使用Routes.UrlTo()
手动构建 URL:
<%
< a href = " <%= Routes.UrlTo( " Users " , " Edit " , array( " Id " , user.Id)) %> " >
< i class = " glyphicon glyphicon-user " >< /a >
< /a >
%>
我们还可以创建通用存储库Find
方法,可以像这样使用:
set expensive_products_starting_with_C = ProductRepository.Find( _
array( "name like ?" , "C%" , _
"price > ?" , expensive_price _
) _
)
set cheap_products_ending_with_Z = ProductRepository.Find( _
array( "name like ?" , "%Z" , _
"price < ?" , cheap_price _
) _
)
演示存储库中有这样的示例,其中KVUnzip
也可以非常有效地使用来帮助轻松构建 sql where
子句。下面的示例来自ProductRepository.Find()
方法,该方法接受包含谓词键值对的KVArray
并将其解压缩为两个用于构建查询的单独数组:
If Not IsEmpty(where_kvarray) then
sql = sql & " WHERE "
dim where_keys, where_values
KVUnzip where_kvarray, where_keys, where_values
dim i
For i = 0 to UBound(where_keys)
If i > 0 then sql = sql & " AND "
sql = sql & " " & where_keys(i) & " "
Next
End If
...
dim rs : set rs = DAL.Query(sql, where_values)
set Find = ProductList(rs)
<%
Class OrderModel_Class
Public Validator
Public OrderNumber, DateOrdered, CustomerName, LineItems
Public Property Get SaleTotal
SaleTotal = Enumerable(LineItems).Sum( " item_.Subtotal " ) ' whaaaa?
End Property
Public Sub Class_Initialize
ValidatePattern Me, OrderNumber, " ^d{9}[d|X]$ " , " Order number format is incorrect. "
ValidateExists Me, DateOrdered, " DateOrdered cannot be blank. "
ValidateExists Me, CustomerName, " Customer name cannot be blank. "
End Sub
End Class
Class OrderLineItemModel_Class
Public ProductName, Price, Quantity, Subtotal
End Class
%>
通过从模型的Class_Initialize
构造函数中调用适当的Validate*
辅助方法来验证模型:
Private Sub Class_Initialize
ValidateExists Me , "Name" , "Name must exist."
ValidateMaxLength Me , "Name" , 10 , "Name cannot be more than 10 characters long."
ValidateMinLength Me , "Name" , 2 , "Name cannot be less than 2 characters long."
ValidateNumeric Me , "Quantity" , "Quantity must be numeric."
ValidatePattern Me , "Email" , "[w-]+@([w-]+.)+[w-]+" , "E-mail format is invalid."
End Sub
目前仅包括ValidateExists
、 ValidateMinLength
、 ValidateMaxLength
、 ValidateNumeric
和ValidatePattern
。这些辅助方法实际上所做的是创建相应验证类的新实例并将其附加到模型的Validator
属性。例如,当模型使用ValidateExists Me, "Name", "Name must exist."
以下是幕后实际发生的事情:
Sub ValidateExists(instance, field_name, message)
if not IsObject(instance.Validator) then set instance.Validator = new Validator_Class
instance.Validator.AddValidation new ExistsValidation_Class.Initialize(instance, field_name, message)
End Sub
这里Me
是域模型实例。然后使用Validator_Class
(通过YourModel.Validator
)验证所有注册的验证规则,如果发现错误则设置Errors
和HasErrors
字段。这与观察者模式类似。我们通过Me
原因是因为这使我们能够为每个具有强语义含义的验证提供一个措辞方便的方法,例如ValidateExists
。这需要一些密码术,但这是值得的。
添加新的验证很容易,只需添加一个新的验证类和辅助Sub
。例如,要添加要求字符串以字母“A”开头的验证,您将创建StartsWithLetterAValidation_Class
和辅助方法Sub ValidateStartsWithA(instance, field_name, message)
,然后通过ValidateStartsWithA Me, "MyField", "Field must start with A."
调用它ValidateStartsWithA Me, "MyField", "Field must start with A."
可以通过 Automapper 样式的转换将 ADO Recordset 转换为域模型的链接列表来构建域模型。说啥?
Class OrderRepository_Class
Public Function GetAll()
dim sql : sql = "select OrderNumber, DateOrdered, CustomerName from Orders"
dim rs : set rs = DAL.Query(sql, empty) 'optional second parameter, can be scalar or array of binds
dim list : set list = new LinkedList_Class
Do until rs.EOF
list.Push Automapper.AutoMap(rs, new OrderModel_Class) ' keanuwhoa.jpg
rs.MoveNext
Loop
set GetAll = list
Destroy rs ' no passing around recordsets, no open connections to deal with
End Function
End Class
' Convenience wrapper lazy-loads the repository
dim OrderRepository__Singleton
Function OrderRepository()
If IsEmpty(OrderRepository__Singleton) then
set OrderRepository__Singleton = new OrderRepository_Class
End If
set OrderRepository = OrderRepository__Singleton
End Function
使用empty
关键字是该框架采用的常见方法。 VBScript 的一个常见抱怨是它不允许可选参数。虽然这在技术上是正确的,但很容易解决,但实际上在线找到的每个示例都涉及传递空字符串、空值或类似的方法。使用内置的 VBScript 关键字empty
是一种在语义上有意义的处理可选参数的方法,清楚地表明我们专门打算忽略可选参数。在本例中, DAL.Query
方法接受两个参数:SQL 查询和包含绑定值的可选第二个参数。第二个参数可以是DAL.Query("select a from b where a = ?", "foo")
中的单个值,也可以是绑定数组,例如DAL.Query("select a from b where a = ? and c = ?", Array("foo", "bar")
。在上面的示例中,它被显式忽略,因为 SQL 中没有绑定变量。
在此示例中, DAL
变量只是lib.Data.asp
中的Database_Class
的一个实例。在原始项目中,DAL 是一个自定义类,充当一组延迟加载的Database_Class
实例的入口点,允许在工作流程期间在数据库之间共享和移动数据。
Automapper
对象是一个 VBScript 类,它尝试将源对象中的每个字段映射到目标对象中的相应字段。源对象可以是记录集或自定义类。该函数可以映射到新的或现有的对象。 Automapper
对象包含三个方法: AutoMap
尝试映射所有属性; FlexMap
允许您选择要映射的属性子集,例如Automapper.FlexMap(rs, new OrderModel_Class, array("DateOrdered", "CustomerName"))
只会将两个指定字段从源记录集复制到新模型实例;和DynMap
,它允许您动态地重新映射值,有关人为的示例,请参见:
Automapper.DynMap(rs, new OrderModel_Class, _
array( "target.CustomerName = UCase(src.CustomerName)" , _
"target.LikedOrder = src.CustomerWasHappy" ))
因为源和目标都可以是具有实例方法的任何对象,所以这是管理 CRUD 方法中的模型绑定的非常有用的方法,例如:
Public Sub CreatePost
dim new_product_model : set new_product_model = Automapper.AutoMap(Request.Form, new ProductModel_Class)
... etc
End Sub
因为它是#include
到控制器操作中的,所以视图可以完全访问控制器的Model
实例。这里它访问视图模型的Order
属性并迭代LineItems
属性(这将是存储库内构建的LinkedList_Class
实例)以构建视图。使用视图模型,您可以创建不依赖于特定记录集结构的丰富视图。请参阅演示中的HomeController
以获取示例视图模型,该视图模型包含四个单独的域对象列表,用于构建仪表板摘要视图。
MVC.RequireModel
方法提供强类型视图的功能,模仿 .NET MVC 中的@model
指令。
<% MVC.RequireModel Model, " Show_ViewModel_Class " %>
< h2 >Order Summary</ h2 >
< div class = " row " >
< div class = " col-md-2 " >
Order # <%= Model.Order.OrderNumber %>
</ div >
< div class = " col-md-10 " >
Ordered on <%= Model.Order.DateOrdered %>
by <%= Model.Order.CustomerName %>
for <%= FormatCurrency (Model.Order.SaleTotal) %>
</ div >
</ div >
< table class = " table " >
< thead >
< tr >
< th >Product</ th >
< th >Price</ th >
< th >Qty</ th >
< th >Subtotal</ th >
</ tr >
<% dim it : set it = Model.Order.LineItems.Iterator %>
<% dim item %>
<% While it.HasNext %>
<% set item = it.GetNext() %>
< tr >
< td > <%= item .ProductName %> </ td >
< td > <%= item .Price %> </ td >
< td > <%= item .Quantity %> </ td >
< td > <%= item .Subtotal %> </ td >
</ tr >
<% Wend %>
</ thead >
</ table >
在列表上提供可链接的 lambda 样式调用。从单元测试来看:
Enumerable(list) _
.Where( "len(item_) > 5" ) _
.Map( "set V_ = new ChainedExample_Class : V_.Data = item_ : V_.Length = len(item_)" ) _
.Max( "item_.Length" )
V_
是Map
方法使用的特殊实例变量,用于表示“lambda”表达式的结果。 item_
是另一个特殊的实例变量,表示当前正在处理的项目。因此在这种情况下, Map
会迭代列表中的每个项目并执行传递的“lambda”表达式。 Map
的结果是EnumerableHelper_Class
的新实例,其中包含由表达式构建的ChainedExample_Class
实例的列表。然后Max
处理该枚举以返回单个值,即最大长度。
包装连接详细信息和对数据库的访问。除了已经显示的示例之外,它还可以处理:
DAL.Execute "delete from Orders where OrderId = ?", id
set rs = DAL.PagedQuery(sql, params, per_page, page_num)
DAL.BeginTransaction
、 DAL.CommitTransaction
和DAL.RollbackTransaction
该类还通过Class_Terminate
方法自动关闭并销毁包装的连接,该方法在类准备好销毁时调用。
Class Migration_01_Create_Orders_Table
Public Migration
Public Sub Up
Migration.Do "create table Orders " & _
"(OrderNumber varchar(10) not null, DateOrdered datetime, CustomerName varchar(50))"
End Sub
Public Sub Down
Migration.Do "drop table Orders"
End Sub
End Class
Migrations.Add "Migration_01_Create_Orders_Table"
可以通过位于migrate.asp
的 Web 界面逐步进行迁移。 Migration.Do
执行 SQL 命令。迁移按照加载的顺序进行处理。建议遵循如上所示的结构化命名方案,以便于订购。有一些特殊命令,例如Migration.Irreversible
,可让您停止向下迁移的进行等。
从中提取框架的实际项目包含大约 3 打迁移,因此它非常适合在开发过程中对数据库进行版本控制。
注意:迁移 Web 界面非常基本且不美观
依赖性:要使用迁移功能,您必须首先使用脚本meta_migrations
! Create Migrations Table.sql
](Sane/Framework/Data/Migrations/!创建迁移表.sql)。
由于在经典 ASP 中并不总是可以使用逐步调试,因此这使得调试和跟踪变得更加容易。
以有意义的方式Dump
输出对象:
dim a : a = GetSomeArray()
Dump a
输出:
[Array:
0 => «elt1»
1 => «elt2»
2 => «elt3»
]
它甚至使用Class_Get_Properties
字段处理自定义类:
Dump Product
输出:
{ProductModel_Class:
Id : Long => «17»,
Name : String => «Alice Mutton»,
CategoryId : Long => «6»,
Category : Empty => «»,
CategoryName : String => «Meat/Poultry»,
SupplierId : Long => «7»,
Supplier : Empty => «»,
SupplierName : String => «Pavlova, Ltd.»,
UnitPrice : Currency => «250»,
UnitsInStock : Integer => «23»,
UnitsOnOrder : Integer => «0»,
ReorderLevel : Integer => «0»,
Discontinued : Boolean => «True»
}
它处理嵌套,正如在演示中的OrdersController.asp
的Show
操作中放置对Dump Model
调用时所看到的那样:
{OrderModel_Class:
Id : Long => « 11074 » ,
CustomerId : String => « SIMOB » ,
OrderDate : Date => « 5 / 6 / 1998 » ,
RequiredDate : Date => « 6 / 3 / 1998 » ,
ShippedDate : Null => «» ,
ShipName : String => « Simons bistro » ,
ShipAddress : String => « Vinbæltet 34 » ,
ShipCity : String => « Kobenhavn » ,
ShipCountry : String => « Denmark » ,
LineItems : LinkedList_Class =>
[ List:
1 =>
{OrderLineItemModel_Class:
ProductId : Long => « 16 » ,
ProductName : String => « Pavlova » ,
UnitPrice : Currency => « 17.45 » ,
Quantity : Integer => « 14 » ,
Discount : Single => « 0.05 » ,
ExtendedPrice : Currency => « 232.09 »
}
] }
quit
立即停止执行。 die "some message"
停止执行并向屏幕输出“some message”。 trace "text"
和comment "text"
都编写包含“文本”的 HTML 注释,这对于在不破坏布局的情况下跟踪幕后很有用。
Flash.Success = "Product updated."
、 Flash.Errors = model.Validator.Errors
等
如果在创建模型时遇到错误,我们应该能够重新显示表单,并且用户的内容仍然填写。为了简化这一点,框架提供了FormCache
对象,该对象通过会话序列化/反序列化表单数据。
例如,在Create
操作中我们可以:
Public Sub Create
dim form_params : set form_params = FormCache.DeserializeForm( "NewProduct" )
If Not form_params Is Nothing then
set Model = Automapper.AutoMap(form_params, new Create_ViewModel_Class)
Else
set Model = new Create_ViewModel_Class
End If
% > <!--#include file= "../../Views/Products/Create.asp" --> < %
End Sub
在CreatePost
中:
Public Sub CreatePost
dim new_product_model : set new_product_model = Automapper.AutoMap(Request.Form, new ProductModel_Class)
new_product_model.Validator.Validate
If new_product_model.Validator.HasErrors then
FormCache.SerializeForm "NewProduct" , Request.Form
Flash.Errors = new_product_model.Validator.Errors
MVC.RedirectToAction "Create"
Else
ProductRepository.AddNew new_product_model
FormCache.ClearForm "NewProduct"
Flash.Success = "Product added."
MVC.RedirectToAction "Index"
End If
End Sub
put
包装Response.Write
并根据传递的类型改变其输出,并为列表和数组提供特殊输出。H(string)
HTML 对字符串进行编码Assign(target, src)
抽象了对对象使用set
的需要Choice(condition, trueval, falseval)
是一个功能更强大的iif
HTML.FormTag(controller_name, action_name, route_attribs, form_attribs)
HTML.TextBox(id, value)
HTML.TextArea(id, value, rows, cols)
HTML.DropDownList(id, selected_value, list, option_value_field, option_text_field)
*Ext
变体Edit
操作中: HTMLSecurity.SetAntiCSRFToken "ProductEditForm"
<%= HTML.Hidden("nonce", HTMLSecurity.GetAntiCSRFToken("ProductEditForm")) %>
EditPost
操作中: HTMLSecurity.OnInvalidAntiCsrfTokenRedirectToActionExt "ProductEditForm", Request.Form("nonce"), "Edit", Array("Id", Request.Form("Id"))
MVC.ControllerName
、 MVC.ActionName
*Ext
变体的MVC.RedirectTo(controller_name, action_name)
或MVC.RedirectToActionPOST(action_name)
该框架提供了工具来帮助缓解 OWASP Top 10 中的以下三项问题:
Database_Class
支持参数化查询。H()
方法来对所有其他输出进行简单编码。HtmlSecurity
帮助程序提供按表单和按站点随机数检查来减轻此威胁。其余七个漏洞大部分或全部是开发人员和/或管理员的责任。
整个框架中使用的一个习惯用法是 VBScript 不允许方法重载的解决方法。一般来说有两种情况,一种是方法功能齐全,有多个参数,另一种是方法签名被简化。这是通过让全功能方法将Ext
附加到末尾以将其表示为简化方法的“扩展”版本来处理的。
例如,这是来自HTML_Helper_Class
:
Public Function LinkTo(link_text, controller_name, action_name)
LinkTo = LinkToExt(link_text, controller_name, action_name, empty, empty)
End Function
Public Function LinkToExt(link_text, controller_name, action_name, params_array, attribs_array)
LinkToExt = "<a href='" & Encode(Routes.UrlTo(controller_name, action_name, params_array)) & "'" & _
HtmlAttribs(attribs_array) & ">" & link_text & "</a>" & vbCR
End Function
这是来自MVC_Dispatcher_Class
:
Public Sub RedirectTo(controller_name, action_name)
RedirectToExt controller_name, action_name, empty
End Sub
' Redirects the browser to the specified action on the specified controller with the specified querystring parameters.
' params is a KVArray of querystring parameters.
Public Sub RedirectToExt(controller_name, action_name, params)
Response.Redirect Routes.UrlTo(controller_name, action_name, params)
End Sub
Proc
和Func
类在 VBScript 中实现了 lambda(旨在直接解决这个人的批评),但从未发表过。布莱恩(Brian)突破了界限。 LinkedList_Class
及其迭代器改编自他的工作,并为其量身定制了极其强大的 lambda 功能,以避免过多地使 ASP 陷入困境。该框架还采用了他的一些编码约定,例如_Class
后缀和延迟加载的全局范围单例函数的使用。