Sane

2025-12-07 0 426

Sane, the devfriendly Classic ASP MVC framework

This is an MVC framework all written in 100% VBScript. Think of it as sort of like Spring Boot for Classic ASP. ?

Controller Example

Repository Method Example

Enumerables (with Lambdas!)

Trivial Viewmodel Iteration

Automapping (and Unit Tests!)

Database Migrations

Irreversible Migrations Too

Overview

Sane is a relatively full-featured MVC framework that brings sanity to Classic ASP. It has some similarities in style to both .NET
MVC and Rails, but doesn\’t exactly resemble either one. It is opinionated in that it assumes controllers will exist in a specific
folder location, but that location is somewhat configurable.

Major features distinguishing this framework include:

  • Extensive use of domain repositories with domain model classes, and separate view model classes
  • Automapper functionality, allowing mapping recordsets to domain objects (and objects to objects etc)
  • Extensive use of linked lists and iterators
  • Enumerable methods like All/Any boolean tests, Min/Max/Sum, Map/Select for projections, and Where for filters, with basic lambda-style expressions supported
  • Database migrations with Up and Down steppable migrations — version controlling database changes
  • OWASP Top 10 mitigations
  • Use of a key-value array data structure to easily pass key-value pairs between methods without creating a Scripting.Dictionary object each time
  • Tons of HTML helpers, many of which use the KVArray and its helper methods to make building HTML easy
  • Form state serialization baked in — easily serialize/deserialize an entire form to/from the session in one line, for preserving state when returning forms to the user to fix validation errors
  • Chocolate gravy

All of this was written in VBScript. Really.

Sane is licensed under the terms of GPLv3.

Note: This framework was extracted from a real-world internal workflow routing project, so it has a few rough edges.

Code Examples: Products

This gives a quick overview of the code flow for one controller and the models and views it uses. It shows how much of the functionality listed above is actually used together.

  • ProductsController
  • ProductsRepository with domain model
  • Products view models
  • Products Index view
  • Product Edit view

Aren\’t there other MVC-style frameworks?

\”There are many like it, but this one is mine.\”

But… why??

Mostly because it was an interesting project that pushes the limits of Classic ASP. The vast majority of developers hate on VBScript and Classic ASP, mostly with good reason.
Many of the issues that plague Classic ASP stem from the constraints of the time in which it was developed, the mid-1990s.
Developers were unable to use what today are considered fundamental practices (widely using classes, etc) because the
language was not designed to execute in a manner we would call \”fast\” and using these practices would cause the application
to bog down and crash. Because of this the ASP community was forced to use ASP the same way PHP was used — as an inline
page-based template processor, not a full application framework in its own right. Plus, let\’s be honest, Microsoft
marketed ASP at everyone regardless of skill level, and most of the tutorials found online were horrible and encouraged
horribly bad practices.

Today we know better, and thanks to Moore\’s Law computing power has risen roughly 500-fold since the mid-90s, so we can
afford to do things that were unthinkable a few years back.

This framework was extracted from a real project that was built in this fashion. It worked quite well, and there should be
no reason (functionally speaking) that it shouldn\’t work as a viable application framework. That said, realistically if we
need to develop an application today we would use a modern framework such as .NET MVC or one of its competitors, so this is
really just here in case it is helpful to someone else. Plus it was fun to build. 🙂

Installation

Dependency: The demo was built against the Microsoft Northwind sample database. Download the SQL Server BAK file here and restore it into a SQL Server instance. SQL Scripts and MDF file are also available.

  1. Download the release to your directory of choice.
  2. In Visual Studio, File -> Open Web Site... and select the Demo directory
  3. Open file App\\DAL\\lib.DAL.asp and modify the connection string to point to your database.
  4. Start the website (F5 or CTRL-F5) at /index.asp

The file index.asp will automatically redirect you to the Home controller, /App/Controllers/HomeController.asp and will load the
default action, Index.

Features

A few of the features below have corresponding ASPUnit tests. Find them in the Tests directory.

Controllers

<%
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
%>

The controller only knows about the domain, not the database. Joy!

Actions are functions without parameters (like Rails and unlike .NET MVC) — params are pulled from the Request object as in traditional ASP. MVC.RequirePost allows you to restrict the action to only respond to POST requests — errors out otherwise.

MVC.Dispatch is the magic sauce entry point to the framework. Since views are #included into the controller, and since an
app can have 1..n controllers each with 1..n actions, having a central monolithic MVC dispatcher is not feasible beyond
a few simple controllers. This is because ASP loads and compiles the entire #included file for every page view. Given that,
the framework instead delegates instantiation out to the controllers themselves, making them responsible for kicking off the
framework instead of making the framework responsible for loading and instantiating all controllers and loading and compiling all
views for every request. The framework does instantiate the controller dynamically based on naming conventions, but only one
controller is parsed and loaded per request instead of all controllers. By only loading one controller we can use the savings to
instead load up a lot of helpful libraries that make development much more developer friendly.

Because of this approach the \”routing engine\” is really just a class that knows how to build URLs to controller files, so
Routes.UrlTo(\"Orders\", \"Show\", Array(\"Id\", order.Id)) generates the URL /App/Controllers/OrdersController.asp?_A=Show&Id=123
(for order.Id = 123). URLs point to controllers and provide the name of the action to be executed via the _A parameter. Action
parameters are passed via a KVArray data structure, which is simply an array of key/value pairs that is used extensively
throughout the framework. For example, here are two KVArrays used in one of the many HTML helpers:

<%= HTML.LinkToExt(\"View Orders\", _
                   \"Orders\", _
                   \"List\", _
                   array(\"param1\", \"value1\", \"param2\", \"value2\"), _
                   array(\"class\", \"btn btn-primary\", \"id\", \"orders-button\")) %>

Behind the scenes this method builds an anchor that routes to the correct controller/action combo, passes the specified parameters
via the querystring, and has the specified HTML class and id attributes. KVArrays are easily handled thanks to a few helper
methods such as KeyVal and KVUnzip.

More about KVArrays

The KVArray data structure is fundamental to much of the framework and greatly simplifies coding. Fundamentally a KVArray is nothing more than a standard VBScript array that should always be consumed in groups of two. In other words, to build a KVArray we just need to build an array with element 0 being the first key and element 1 its value, element 2 the second key and element 3 its value, etc.

In essence you can imagine a KVArray as a way to use System.Object style calls as is done in .NET\’s Html.ActionLink.

For example:

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\"

But in reality you would never write it like that, instead you would use the inline Array constructor like this:

dim params : params = Array(\"Name\", \"Bob\", \"Age\", 35, \"FavoriteColor\", \"Blue\")

Or for more readability:

dim params : params = Array( _
    \"Name\", \"Bob\",           _
    \"Age\", 35,               _
    \"FavoriteColor\", \"Blue\"  _
)

To iterate over this array step by 2 and use KeyVal to get the current key and value:

dim idx, the_key, the_val
For idx = 0 to UBound(kvarray) step 2
    KeyVal kvarray, idx, the_key, the_val
Next

On each iteration, the_key will contain the current key (e.g. \”Name\”, \”Age\”, or \”FavoriteColor\”) and the_val will contain the key\’s corresponding value.

But why not use a Dictionary?

Dictionaries are great, but they are COM components and were at least historically expensive to instantiate and because of threading should not be placed in the session. They are also cumbersome to work with for the use cases in this framework and there is no easy way to instantiate them inline with a dynamic number of parameters.

What we actually need is a fast, forward-only key-value data structure that allows us to iterate over the values and pluck out each key and value to build something like an HTML tag with arbitrary attributes or SQL where clause with arbitrary columns, not fast lookup of individual keys. So we need a hybrid of the array and dictionary that meets our specific needs and allows inline declaration of an arbitrary number of parameters. The KVArray allows us to very naturally write code like the LinkToExt example above, or manually building URLs using Routes.UrlTo():

<%
<a href=\"<%= Routes.UrlTo(\"Users\", \"Edit\", array(\"Id\", user.Id)) %>\">
    <i class=\"glyphicon glyphicon-user\"></a>
</a>
%>

We can also create generic repository Find methods that can be used like this:

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 _
    ) _
)

There are examples of this in the demo repositories, where KVUnzip is also used very effectively to help easily build the sql where clause. The below example is from the ProductRepository.Find() method which accepts a KVArray containing predicate key-value pairs and unzips it into two separate arrays that are used to build the query:

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) 

Domain Models

<%
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
%>

Model Validations

Validate models by calling the appropriate Validate* helper method from within the model\’s Class_Initialize constructor:

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

Currently only ValidateExists, ValidateMinLength, ValidateMaxLength, ValidateNumeric, and ValidatePattern are included.
What these helper methods actually do is create a new instance of the corresponding validation class and attach it to the model\’s
Validator property. For example, when a model declares a validation using ValidateExists Me, \"Name\", \"Name must exist.\" the
following is what actually happens behind the scenes:

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

Here Me is the domain model instance. The Validator_Class is then used (via YourModel.Validator) to validate all registered
validation rules, setting the Errors and HasErrors fields if errors are found. This is similar to the Observer pattern. The
reason we pass Me is because this allows us to have a conveniently-worded method for each validation that has strong semantic
meaning, e.g. ValidateExists. It takes a bit of code-jutsu but its worth it.

Adding new validations is easy, just add a new validation class and helper Sub. For example, to add a validation that requires
that a string start with the letter \”A\” you would create a StartsWithLetterAValidation_Class and helper method
Sub ValidateStartsWithA(instance, field_name, message), then call it via ValidateStartsWithA Me, \"MyField\", \"Field must start with A.\"

Domain Repositories

Domain models can be built by converting an ADO Recordset into a linked list of domain models via Automapper-style transforms. Say Whaaat?

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

The use of the empty keyword is a common approach taken by this framework. A common complaint of VBScript is that it does not
allow optional parameters. While this is technically true it is easy to work around, yet virtually every example found online
involves passing empty strings, or null values, or a similar approach. Using the built-in VBScript keyword empty is a semantically-meaningful way to handle optional parameters, making it clear that we specifically intended to ignore the optional parameter. In this case the DAL.Query method accepts two parameters, the SQL query and an optional second parameter containing bind values. The second parameter can be either a single value as in DAL.Query(\"select a from b where a = ?\", \"foo\") or an array of binds e.g.
DAL.Query(\"select a from b where a = ? and c = ?\", Array(\"foo\", \"bar\"). In the above example it is explicitly ignored since there are no bind variables in the SQL.

In this example the DAL variable is simply an instance
of the Database_Class from lib.Data.asp. In the original project the DAL was a custom class that acted as an entry point for
a set of lazy-loaded Database_Class instances, allowing data to be shared and moved between databases during the workflow.

The Automapper object is a VBScript class that attempts to map each field in the source object to a corresponding field in the
target object. The source object can be a recordset or a custom class. The function can map to a new or existing object. The
Automapper object contains three methods: AutoMap which attempts to map all properties; FlexMap which allows you to choose
a subset of properties to map, e.g. Automapper.FlexMap(rs, new OrderModel_Class, array(\"DateOrdered\", \"CustomerName\"))
will only copy the two specified fields from the source recordset to the new model instance; and DynMap which allows you to
dynamically remap values, for a contrived example see:

Automapper.DynMap(rs, new OrderModel_Class, _
                  array(\"target.CustomerName = UCase(src.CustomerName)\", _
                        \"target.LikedOrder = src.CustomerWasHappy\"))

Because both source and target can be any object with instance methods this is a very useful way to manage model binding in CRUD
methods, for example:

Public Sub CreatePost
    dim new_product_model : set new_product_model = Automapper.AutoMap(Request.Form, new ProductModel_Class)
    ... etc
End Sub

Views

Because it is #included into the controller action, the view has full access to the controller\’s Model instance. Here it accesses
the Order property of the view model and iterates over the LineItems property (which would be a LinkedList_Class instance built
inside the repository) to build the view. Using view models you can create rich views that are not tied to a specific recordset
structure. See the HomeController in the demo for an example view model that contains four separate lists of domain objects to
build a dashboard summary view.

The MVC.RequireModel method provides the ability to strongly-type the view, mimicking the @model directive in .NET MVC.

<% 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>

Enumerable Builder

Provides chainable lambda-style calls on a list. From the unit tests:

Enumerable(list) _
    .Where(\"len(item_) > 5\") _
    .Map(\"set V_ = new ChainedExample_Class : V_.Data = item_ : V_.Length = len(item_)\") _
    .Max(\"item_.Length\")

V_ is a special instance variable used by the Map method to represent the result of the \”lambda\” expression. item_ is
another special instance variable that represents the current item being processed. So in this case, Map iterates over each
item in the list and executes the passed \”lambda\” expression. The result of Map is a new instance of EnumerableHelper_Class
containing a list of ChainedExample_Class instances built by the expression. This enumerable is then processed by Max to
return a single value, the maximum length.

Database Class

Wraps connection details and access to the database. In addition to the examples already shown it can also handle:

  • Execution of SQL without return values: DAL.Execute \"delete from Orders where OrderId = ?\", id
  • Paged queries: set rs = DAL.PagedQuery(sql, params, per_page, page_num)
    • See the demo ProductsController.asp for a usage example
    • Note: This uses recordset paging, you must implement your own server-side paging if needed.
  • Transactions: DAL.BeginTransaction, DAL.CommitTransaction, and DAL.RollbackTransaction

The class also automatically closes and destroys the wrapped connection via the Class_Terminate method which is called when the
class is ready for destruction.

Database Migrations

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\"

Migrations can be stepped up and down via web interface located at migrate.asp. Migration.Do executes SQL commands. Migrations are processed in the order loaded. Recommend following a structured naming scheme as shown above for easy ordering. There are a few special commands, such as
Migration.Irreversible that let you stop a down migration from proceeding, etc.

The real-world project from which the framework was extracted contained approximately 3 dozen migrations, so it worked very well
for versioning the DB during development.

Note: the migrations web interface is very basic and non-pretty

Dependency: To use the migrations feature you must first create the table meta_migrations using the script [! Create Migrations Table.sql](Sane/Framework/Data/Migrations/! Create Migrations Table.sql).

Debugging Helpers

Since the use of step-through debugging is not always possible in Classic ASP, these make debugging and tracing much easier.

Dump outputs objects in a meaningful way:

dim a : a = GetSomeArray()
Dump a

Output:

[Array:
        0 => «elt1»
        1 => «elt2»
        2 => «elt3»
]

It even handles custom classes, using the Class_Get_Properties field:

Dump Product

Output:

{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»
}

And it handles nesting, as seen here when a call to Dump Model was placed in the Show action of OrdersController.asp in the Demo:

{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 immediately halts execution. die \"some message\" halts execution and outputs an \”some message\” to the screen. trace \"text\"
and comment \"text\" both write HTML comments containing \”text\”, useful for tracing behind the scenes without disrupting layout.

Rails-style \”Flash\” Messages

Flash.Success = \"Product updated.\", Flash.Errors = model.Validator.Errors, etc.

Form Serialization

If errors are encountered when creating a model we should be able to re-display the form with the user\’s content still filled in. To
simplify this the framework provides the FormCache object which serializes/deserializes form data via the session.

For example, in a Create action we can have:

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 <span

下载源码

通过命令行克隆项目:

git clone https://github.com/davecan/Sane.git

收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

申明:本文由第三方发布,内容仅代表作者观点,与本网站无关。对本文以及其中全部或者部分内容的真实性、完整性、及时性本站不作任何保证或承诺,请读者仅作参考,并请自行核实相关内容。本网发布或转载文章出于传递更多信息之目的,并不意味着赞同其观点或证实其描述,也不代表本网对其真实性负责。

左子网 开发教程 Sane https://www.zuozi.net/31394.html

RazorSvelte
上一篇: RazorSvelte
active4j oa
下一篇: active4j oa
常见问题
  • 1、自动:拍下后,点击(下载)链接即可下载;2、手动:拍下后,联系卖家发放即可或者联系官方找开发者发货。
查看详情
  • 1、源码默认交易周期:手动发货商品为1-3天,并且用户付款金额将会进入平台担保直到交易完成或者3-7天即可发放,如遇纠纷无限期延长收款金额直至纠纷解决或者退款!;
查看详情
  • 1、描述:源码描述(含标题)与实际源码不一致的(例:货不对板); 2、演示:有演示站时,与实际源码小于95%一致的(但描述中有”不保证完全一样、有变化的可能性”类似显著声明的除外); 3、发货:不发货可无理由退款; 4、安装:免费提供安装服务的源码但卖家不履行的; 5、收费:价格虚标,额外收取其他费用的(但描述中有显著声明或双方交易前有商定的除外); 6、其他:如质量方面的硬性常规问题BUG等。 注:经核实符合上述任一,均支持退款,但卖家予以积极解决问题则除外。
查看详情
  • 1、左子会对双方交易的过程及交易商品的快照进行永久存档,以确保交易的真实、有效、安全! 2、左子无法对如“永久包更新”、“永久技术支持”等类似交易之后的商家承诺做担保,请买家自行鉴别; 3、在源码同时有网站演示与图片演示,且站演与图演不一致时,默认按图演作为纠纷评判依据(特别声明或有商定除外); 4、在没有”无任何正当退款依据”的前提下,商品写有”一旦售出,概不支持退款”等类似的声明,视为无效声明; 5、在未拍下前,双方在QQ上所商定的交易内容,亦可成为纠纷评判依据(商定与描述冲突时,商定为准); 6、因聊天记录可作为纠纷评判依据,故双方联系时,只与对方在左子上所留的QQ、手机号沟通,以防对方不承认自我承诺。 7、虽然交易产生纠纷的几率很小,但一定要保留如聊天记录、手机短信等这样的重要信息,以防产生纠纷时便于左子介入快速处理。
查看详情

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务