ABP示例程序-使用AngularJs,ASP.NET MVC,Web API和EntityFramework创建N层的单页面Web应用

  本片文章翻译自ABP在CodeProject上的一个简单示例程序,网站上的程序是用ABP之前的版本创建的,模板创建界面及工程文档有所改变,本文基于最新的模板创建。通过这个简单的示例可以对ABP有个更深入的了解,每个工程里应该写什么样的代码,代码如何组织以及ABP是如何在工程中发挥作用的。

源文档地址:https://www.codeproject.com/Articles/791740/Using-AngularJs-ASP-NET-MVC-Web-API-and-EntityFram

源码可以下载文档中的示例代码,也可以下载我使用最新模板创建的示例工程,github地址:https://github.com/YSmileX/SimpleTaskSystem0726

 

使用AngularJs,ASP.NET MVC,Web API和EntityFramework创建N层的单页面Web应用

介绍

   在本文中,将展示给你如何使用下面的工具从头到尾发布一个单页面Web应用(SPA):

  • ASP.NET MVCASP.NET Web API作为Web框架。
  • Angularjs作为SPA框架
  • EntityFramework作为ORM(Object-Relational Mapping)框架。
  • Castle Windsor作为依赖注入框架。
  • Twitter Bootstrap作为HTML/CSS框架。
  • 日志使用Log4Net,对象到对象映射使用AutoMapper
  • ASP.NET Boilerplate作为启动模板和应用框架。

  ABP是一个开源的应用框架,它结合了这些所有的框架和类库可以很容易的发布你的应用。它使用最佳实践提供给我们一个基础设施来发布应用。它天生支持依赖注入领域驱动分层架构。示例应用还实现了校验异常处理本地化响应式设计

从模板创建应用

   ABP提供了模板来节省我们创建一个新应用的时间,模板中包含并配置了最好的工具来构建企业级别的Web引用。

  让我们到aspnetboilerplate.com/Templates来从模板构建我们的应用:

  这里我们选择ASP.NET MVC 5.X标签页,然后选择SPA(Sigle Page Application) with AngularJs,ORM选择EntityFramework。工程名称中输入SimpleTaskSystem。点击“Create my project!”按钮就会创建并下载我们的解决方案。

  在解决方案中包含5个工程。Core工程为领域(业务)层,Application工程为应用层,WebApi工程实现Web Api控制器,Web工程为展示层,EntityFramework工程实现Entityframework。

  注意:如果你从本文中下载示例解决方案,解决方案中会有7个工程。我实现了NHibernate和Durandal的支持。如果你对NHibernate或Durandal不感兴趣,可以忽略这两个工程。

创建实体

   我将创建一个简单的应用,这个应用可以创建tasks并把这些tasks分配给people。所以我需要TaskPerson实体。

  Task实体简单定义了Description,CreationTime和State。它还有一个对Person(AssignedPerson)的可选引用:

public class Task : Entity<long>
{
    [ForeignKey("AssignedPersonId")]
    public virtual Person AssignedPerson { get; set; }

    public virtual int? AssignedPersonId { get; set; }

    public virtual string Description { get; set; }

    public virtual DateTime CreationTime { get; set; }

    public virtual TaskState State { get; set; }

    public Task()
    {
        CreationTime = DateTime.Now;
        State = TaskState.Active;
    }
}

  Person实体更简单,仅定义了person的Name:

public class Person : Entity
{
    public virtual string Name { get; set; }
}

  ABP提供了Entity类,它定义了Id属性。我从这个实体类派生实体。因为我从Entity<long>派生,所以Task类有一个long类型的Id。Person类有一个int类型的Id。因为int为默认的主键类型,我没有指定它。

  我在Core工程中定义实体,因为实体为领域/业务层的一部分。

创建DbContext

   如你所知,EntityFramework需要DbContext类。我们首先定义它。ABP模板为我们创建了一个DbContext。我仅仅需要为Task和Person添加IDbSets。这是我的DbContext类:

public class SimpleTaskSystemDbContext : AbpDbContext
{
    public virtual IDbSet<Task> Tasks { get; set; }

    public virtual IDbSet<Person> People { get; set; }

    public SimpleTaskSystemDbContext()
        : base("Default")
    {

    }

    public SimpleTaskSystemDbContext(string nameOrConnectionString)
        : base(nameOrConnectionString)
    {
            
    }
}

  它使用web.config中的Default连接字符串。定义如下所示:

<add name="Default" connectionString="Server=localhost; Database=SimpleTaskSystem; Trusted_Connection=True;" providerName="System.Data.SqlClient" />

创建数据库迁移

   我们使用EntityFramework的Code First迁移来创建和维护数据库模式。ABP模板默认启用迁移并添加了一个Configuration类,如下所示:

internalinternal sealed class Configuration : DbMigrationsConfiguration<SimpleTaskSystem.EntityFramework.SimpleTaskSystemDbContext>
{
    public Configuration()
    {
        AutomaticMigrationsEnabled = false;
    }

    protected override void Seed(SimpleTaskSystem.EntityFramework.SimpleTaskSystemDbContext context)
    {
        context.People.AddOrUpdate(
            p => p.Name,
            new Person {Name = "Isaac Asimov"},
            new Person {Name = "Thomas More"},
            new Person {Name = "George Orwell"},
            new Person {Name = "Douglas Adams"}
            );
    }
}

  在Seed方法中,我添加了4个people作为初始化数据。现在,将创建初始迁移。打开包管理控制台并键入下面的命令:我创建的工程名称为SimpleTaskSystem0726,2017.7.26从ABP官网模板创建

   

  Add-Migration "InitalCreate"命令创建了一个名为InitialCreate的类,如下所示:

    public partial class InitialCreate : DbMigration
    {
        public override void Up()
        {
            CreateTable(
                "dbo.People",
                c => new
                    {
                        Id = c.Int(nullable: false, identity: true),
                        Name = c.String(),
                    })
                .PrimaryKey(t => t.Id);
            
            CreateTable(
                "dbo.Tasks",
                c => new
                    {
                        Id = c.Long(nullable: false, identity: true),
                        AssignedPersonId = c.Int(),
                        Description = c.String(),
                        CreationTime = c.DateTime(nullable: false),
                        State = c.Byte(nullable: false),
                    })
                .PrimaryKey(t => t.Id)
                .ForeignKey("dbo.People", t => t.AssignedPersonId)
                .Index(t => t.AssignedPersonId);
            
        }
        
        public override void Down()
        {
            DropForeignKey("dbo.Tasks", "AssignedPersonId", "dbo.People");
            DropIndex("dbo.Tasks", new[] { "AssignedPersonId" });
            DropTable("dbo.Tasks");
            DropTable("dbo.People");
        }
    }

  我们创建了创建数据所需要的类,但是还没有创建数据库。运行下面的指令创建数据库:

PM> Update-Database

  这个命令会运行迁移,创建数据库并创建初始数据:

  当我们改变实体类时,可以使用Add-Migration命令创建新的迁移类,Update-Database命令更新数据库。要学习更多源于数据库迁移的知识,参见framework的文档。

定义仓储

   在领域驱动设计中,仓储用于实现特定数据库的代码。ABP使用泛型IRepository接口自动为每一个实体创建了一个仓储。IRepository为select,insert,update,delete还有其他一些定义了共同的方法:

  我们可以基于需求扩展这些仓储。我将扩展它来创建一个Task仓储。我想接口与实现分离开,首先创建仓储接口。这里是Task仓储接口:

public interface ITaskRepository : IRepository<Task, long>
{
    List<Task> GetAllWithPeople(int? assignedPersonId, TaskState? state);
}

  它扩展了ABP的泛型IRepository接口。所以,ITaskRepository默认继承了所有这些方法的定义。它也可以添加自己的方法,如我定义了GetAllWithPeople(...)

  没有必要为Person创建一个仓储,因为默认的方法已经足够使用。ABP提供了不用创建仓储类注入泛型仓储的方式。我们将在“构建应用服务”部分的TaskAppService类中见到它。

  我在Core工程中定义了仓储接口,因为它们是领域/业务层的一部分。

实现仓储

   我们应该实现上面定义的ITaskRepository接口。我在EntityFramework工程中实现仓储。这样,领域层完全独立于EntityFramework。

  当我们创建工程模板时,ABP在我们的工程中为仓储定义了一个泛型基类:SimpleTaskSystemRepositoryBase。创建这样一个基类是好的实践,这样我们可以以后为我们的仓储添加共同的方法。你可以在代码中看见这个类的定义。我派生它来实现TaskRepository

public class TaskRepository : SimpleTaskSystemRepositoryBase<Task, long>, ITaskRepository
{
    public List<Task> GetAllWithPeople(int? assignedPersonId, TaskState? state)
    {
        //在仓储方法中,我们不需要处理创建/释放DBConnections,DbContext和transactions,ABP会处理。
            
        var query = GetAll(); //GetAll() returns IQueryable<T>, 所以我们基于它查询.
        //var query = Context.Tasks.AsQueryable(); //我们也可以直接使用 EF's DbContext对象.
        //var query = Table.AsQueryable(); //另一个选择: 我们可以直接使用‘Table’属性取代‘Context.Tasks’,他们是一致的
            
        //添加 Where 条件...

        if (assignedPersonId.HasValue)
        {
            query = query.Where(task => task.AssignedPerson.Id == assignedPersonId.Value);
        }

        if (state.HasValue)
        {
            query = query.Where(task => task.State == state);
        }

        return query
            .OrderByDescending(task => task.CreationTime)
            .Include(task => task.AssignedPerson) //在同一个查询里包含assiged person
            .ToList();
    }
}

   TaskRepository派生自SimpleTaskSystemRepositoryBase并实现了我们定义的ITaskRepository

  GetAllWithPeople是我们特定的方法来获取tasks,方法中包含AssignedPerson(预获取)并可以根据一些条件选择性的过滤。我们可以在仓储中自由使用Context(EF`s DBContext)对象和数据库。ABP为我们管理数据库连接,事务,创建并释放DbContext(参见文档了解更多信息)。

构建应用服务

   应用服务通过提供外观方法来隔离展示层和领域层。我在工程的Application程序集中定义应用服务。首先,为task应用服务定义接口:

public interface ITaskAppService : IApplicationService
{
    GetTasksOutput GetTasks(GetTasksInput input);
    void UpdateTask(UpdateTaskInput input);
    void CreateTask(CreateTaskInput input);
}

  ITaskAppService扩展了IApplicationService。这样,ABP自动为这个类提供一些特征(如依赖注入和校验)。现在,让我们实现ITaskAppService:

 public class TaskAppService : ApplicationService, ITaskAppService
    {
        //在构造函数中使用构造函数注入设置这些成员
        private readonly ITaskRepository _taskRepository;
        private readonly IRepository<Person> _personRepository;

        /// <summary>
        /// 在构造函数中,我们可以获取需要的类/接口。他们自动被依赖注入系统初始化。
        /// </summary>
        /// <param name="taskRepository"></param>
        /// <param name="personRepository"></param>
        public TaskAppService(ITaskRepository taskRepository, IRepository<Person> personRepository)
        {
            _taskRepository = taskRepository;
            _personRepository = personRepository;
        }

        public GetTasksOutput GetTasks(GetTasksInput input)
        {
            //调用task仓储的GetAllWithPeople方法
            var tasks = _taskRepository.GetAllWithPeople(input.AssignedPersonId, input.State);

            //使用AutoMapper自动将List<Task>转换为List<TaskDto>
            return new GetTasksOutput { Tasks = Mapper.Map<List<TaskDto>>(tasks) };
        }

        public void UpdateTask(UpdateTaskInput input)
        {
            //我们可以使用Logger,它在应用服务基类中定义
            Logger.Info("Updating a task for input:" + input);

            //使用仓储的标准方法Get通过给定的id重新获取task实体
            var task = _taskRepository.Get(input.TaskId);

            //更新重新获取的task实体的属性
            if (input.State.HasValue)
            {
                task.State = input.State.Value;
            }

            if (input.AssignedPersonId.HasValue)
            {
                task.AssignedPerson = _personRepository.Load(input.AssignedPersonId.Value);
            }

            //我们不需要调用仓储的Update方法。因为应用服务方法默认为一个工作单元。
            //当工作单元结束时(没有任何异常),ABP自动保存所有更改。
        }

        public void CreateTask(CreateTaskInput input)
        {
            //我们可以使用Logger,它在应用服务基类中定义
            Logger.Info("Creating a task for input:" + input);

            //使用给定的input的属性创建一个新的Task
            var task = new Tasks.Task {Description = input.Description};

            if (input.AssignedPersonId.HasValue)
            {
                task.AssignedPersonId = input.AssignedPersonId.Value;
            }

            //使用仓储标准Insert方法保存实体
            _taskRepository.Insert(task);
        }
    }

  TaskAppService使用仓储来操作数据库。它在构造函数中通过构造函数注入模式获取引用。ABP天生实现了依赖注入,所以我们可以自由使用构造函数注入或属性注入(参见ABP文档中的依赖注入)。

  注意我们通过注入IRepository<Person>来使用PersonRepository。ABP自动为实体创建仓储。如果IRepository默认的方法对我们已足够,我们不需要再创建仓储类。

  服务方法使用数据传输对象(DTOs)工作。这是一个最佳实践,我建议使用这种模式。但是,如果你可以处理在展示层暴露实体的问题就可以不使用它。

  在GetTasks方法中,我使用了之前实现的GetAllWithPeople方法。它返回List<Task>但是我需要返回List<TaskDto>给展示层。AutoMapper帮助我们自动转换Task对象为TaskDto对象。GetTasksInput和GetTasksOut是为GetTasks方法定义的特定DTOs。

  在UpdateTask方法中,我从数据库重新获取Task(使用IRepository的Get方法)并更新Task的属性。注意,我没有调用仓储的Update方法。ABP实现了工作单元模式。所以,应用服务方法中的所有更改为一个工作单元(原子的),在方法结束的时候自动应用到数据库。

  在CreateTask方法中,我简单创建了一个新的Task,使用IRepository的Insert方法插入到数据库。

  ABP的ApplicationService类有一些属性可以简化发布应用服务。例如,它定义了Logger属性从来记录日志。所以,我们从ApplicationService派生TaskAppService并使用它的Logger属性。可以选择性的使用这个类但是必须实现IApplicationService(注意ITaskAppService扩展了IApplicationService)。

校验

  ABP自动校验应用服务方法的输入。CreateTask方法使用CreateTaskInput作为参数:

public class CreateTaskInput
{
    public int? AssignedPersonId { get; set; }

    [Required]
    public string Description { get; set; }
}

  这里,Description标记为Required。你可以使用任何的数据标记特性。如果你想创建自定义校验,可以实现ICustomValidate接口:

public class UpdateTaskInput : ICustomValidate
{
    [Range(1, long.MaxValue)]
    public long TaskId { get; set; }

    public int? AssignedPersonId { get; set; }

    public TaskState? State { get; set; }

    public void AddValidationErrors(List<ValidationResult> results)
    {
        if (AssignedPersonId == null && State == null)
        {
            results.Add(new ValidationResult("Both of AssignedPersonId and State can not be null in order to update a Task!", new[] { "AssignedPersonId", "State" }));
        }
    }

    public override string ToString()
    {
        return string.Format("[UpdateTask > TaskId = {0}, AssignedPersonId = {1}, State = {2}]", TaskId, AssignedPersonId, State);
    }
}

  可以在AddValidationErrors方法里编写自定义校验代码。

处理异常

  注意我们不需要处理任何异常。ABP自动处理异常,记录并返回一个恰当的错误信息到客户端。在客户端处理这些错误信息并显示给用户。实际上,这也适用于ASP.NET MVC和Web API控制器actions。因为我们将使用Web API暴露TaskAppService,我们不需要处理异常。参见异常处理文档了解更多详情。

构建Web API服务

   我想将我的应用服务暴露给远程客户端。这样,我的AngularJs应用可以很容易的使用AJAX调用这些服务方法。

  ABP提供了一种自动暴露应用服务作为ASP.NET Web API的方法。我仅仅使用DynamiApicControllerBuilder,如下所示:

DynamicApiControllerBuilder
    .ForAll<IApplicationService>(Assembly.GetAssembly(typeof (SimpleTaskSystemApplicationModule)), "tasksystem")
    .Build();

  对于这个示例,ABP在应用层程序集中查找所有集成IApplicationService的接口并为每一个应用服务方法创建一个Web api控制器。还有可选的语法可以实现更好的控制。我们将见到如何通过AJAX调用这些服务。

发布SPA

  我将实现一个单页面应用,作为工程的用户接口。AngularJs(Google出品)是使用最广泛的SPA框架之一。

  ABP提供了一个轻松使用AngularJs的模板。这个模板有两个pages(Home和About),可以平滑的在这两个页面之间切换。使用Twitter BootStrap作为HTML/CSS框架(因此,它是响应式的)。它还使用ABP的本地化系统(你可以简单的添加其他语言或移除其中一个)本地化为English和Turkish。

  我们首先更改模板的路由。ABP模板使用AngularUI-Router,AngularJs的de-facto标准路由。它基于路由模式提供状态。我们将有两个视图:task list和new task。所以,我们将在app.js中改变路由定义,如下所示:

app.config([
    '$stateProvider', '$urlRouterProvider',
    function ($stateProvider, $urlRouterProvider) {
        $urlRouterProvider.otherwise('/');
        $stateProvider
            .state('tasklist', {
                url: '/',
                templateUrl: '/App/Main/views/task/list.cshtml',
                menu: 'TaskList' //Matches to name of 'TaskList' menu in SimpleTaskSystemNavigationProvider
            })
            .state('newtask', {
                url: '/new',
                templateUrl: '/App/Main/views/task/new.cshtml',
                menu: 'NewTask' //Matches to name of 'NewTask' menu in SimpleTaskSystemNavigationProvider
            });
    }
]);

   app.js是主要的javascript文件用来配置和启动我们的SPA。注意我们可以使用cshtml文件作为视图!通常,在AngularJs中使用html文件作为视图。ABP使的可以使用cshtml文件。因此我们可以使用razor引擎生成HTML。

  ABP提供了一个基础设施来创建和显示菜单。它允许在C#中定义菜单,可以同时在C#和javascript中使用。创建菜单参见SimpleTaskSystemNavigationProvider类,使用angular方式显示菜单参见header.js/header.cshtml

  首先,为task list视图创建一个Angular控制器:

(function() {
    var app = angular.module('app');

    var controllerId = 'sts.views.task.list';
    app.controller(controllerId, [
        '$scope', 'abp.services.tasksystem.task',
        function($scope, taskService) {
            var vm = this;

            vm.localize = abp.localization.getSource('SimpleTaskSystem');

            vm.tasks = [];

            $scope.selectedTaskState = 0;

            $scope.$watch('selectedTaskState', function(value) {
                vm.refreshTasks();
            });

            vm.refreshTasks = function() {
                abp.ui.setBusy( //直到getTasks完成之前设置整个页忙碌
                    null,
                    taskService.getTasks({ //从javascript中直接调用服务方法
                        state: $scope.selectedTaskState > 0 ? $scope.selectedTaskState : null
                    }).success(function(data) {//注意,如果angular版本高于1.4,success需改为then
                        vm.tasks = data.tasks;
                    })
                );
            };

            vm.changeTaskState = function(task) {
                var newState;
                if (task.state == 1) {
                    newState = 2; //完成
                } else {
                    newState = 1; //活动的
                }

                taskService.updateTask({
                    taskId: task.id,
                    state: newState
                }).success(function() {//注意,如果angular版本高于1.4,success需改为then
                    task.state = newState;
                    abp.notify.info(vm.localize('TaskUpdatedMessage'));
                });
            };

            vm.getTaskCountText = function() {
                return abp.utils.formatString(vm.localize('Xtasks'), vm.tasks.length);
            };
        }
    ]);
})();

  控制器的名称定义为'sts.views.taks.list'。这是我的习惯(为了代码可扩展)但是你可以简化命名为'ListController'。AngularJs也可以使用依赖注入。这里我们注入了'$scope'和'abp.services.tasksystem.task'。第一个为Angular的scope变量,第二个为自动为ITaskAppService(我们在'构建Web API'部分创建的它)创建的javascript服务代理。

  ABP提供了基础设施来在服务端和客户端使用相同的本地化文本(参见文档了解更多详情)。

  vm.tasks为tasks的列表,将在视图中显示。vm.refreshTasks方法通过使用taskService获取任务填充这个数组。当selectedTaskState改变时调用它(使用$scope.$watch监视)。

  如你所见,调用一个应用服务方法非常简单直接。这是ABP的一个特征。它生成Web API层和Javascript代理。因此,我们调用应用服务方法如同调用一个简单的javascript方法。它与AngularJs(使用Angular的$http服务)完全集成。

  让我们看看task list视图编码:

<div class="panel panel-default" ng-controller="sts.views.task.list as vm">

    <div class="panel-heading" style="position: relative;">
        <div class="row">
            
            <!-- Title -->
            <h3 class="panel-title col-xs-6">
                @L("TaskList") - <span>{{vm.getTaskCountText()}}</span>
            </h3>
            
            <!-- Task state combobox -->
            <div class="col-xs-6 text-right">
                <select ng-model="selectedTaskState">
                    <option value="0">@L("AllTasks")</option>
                    <option value="1">@L("ActiveTasks")</option>
                    <option value="2">@L("CompletedTasks")</option>
                </select>
            </div>
        </div>
    </div>

    <!-- Task list -->
    <ul class="list-group" ng-repeat="task in vm.tasks">
        <div class="list-group-item">
            <span class="task-state-icon glyphicon" ng-click="vm.changeTaskState(task)" ng-class="{'glyphicon-minus': task.state == 1, 'glyphicon-ok': task.state == 2}"></span>
            <span ng-class="{'task-description-active': task.state == 1, 'task-description-completed': task.state == 2 }">{{task.description}}</span>
            <br />
            <span ng-show="task.assignedPersonId > 0">
                <span class="task-assignedto">{{task.assignedPersonName}}</span>
            </span>
            <span class="task-creationtime">{{task.creationTime}}</span>
        </div>
    </ul>

</div>

  ng-controller 特性(在第一行)绑定控制器和视图。@L("TaskList")获取“tasklist”的本地化文本(在服务端渲染HTML时获取)。因为这是cshtml文件,所以可以这么做。

  ng-model绑定combobox和javascript变量。当变量改变时,combobox会随之更新。当combobox改变时,变量也会被更新。这是AngularJs的双向绑定。

  ng-repeat是Angular的另一个指令,用来为一个数列中的每个值渲染相同的HTML。当数组改变时(例如添加一个项),它会自动体现到视图。这是AngularJs另一个强力特征。

  注意:当你添加一个Javascript文件时(例如,"task list"控制器),需要把它添加到页中。可以把它添加到Home\Index.cshtml。

本地化

   ABP提供了一个灵活、强大的本地化系统。你可以使用XML文件或资源文件作为本地化源。你也可以自定义本地化源。参见文档了解更多。在这个示例应用中,我使用XML文件(它在web application的Localization文件夹中)

<?xml version="1.0" encoding="utf-8" ?>
<localizationDictionary culture="en">
  <texts>
    <text name="TaskSystem" value="Task System" />
    <text name="TaskList" value="Task List" />
    <text name="NewTask" value="New Task" />
    <text name="Xtasks" value="{0} tasks" />
    <text name="AllTasks" value="All tasks" />
    <text name="ActiveTasks" value="Active tasks" />
    <text name="CompletedTasks" value="Completed tasks" />
    <text name="TaskDescription" value="Task description" />
    <text name="EnterDescriptionHere" value="Task description" />
    <text name="AssignTo" value="Assign to" />
    <text name="SelectPerson" value="Select person" />
    <text name="CreateTheTask" value="Create the task" />
    <text name="TaskUpdatedMessage" value="Task has been successfully updated." />
    <text name="TaskCreatedMessage" value="Task {0} has been created successfully." />
  </texts>
</localizationDictionary>

单元测试

   ABP设计为可测试的。我写了一篇文章来展示ABP的单元集成测试。参见文章:Unit testing in C# using xUnit, Entity Framework, Effort and ASP.NET Boilerplate

总结

   在本文中,我展示了如何使用SPA和响应式用户界面发布一个N层的ASP.NET MVC web应用。我使用了ABP,因为它使用最佳实践简化了发布这样的一个应用并且节省了我们的时间。使用下面的连接获取更多信息:

文章历史

  • 2016-10-26: 更新ABP版本为v1.0.
  • 2016-07-19: 更新文章和ABP版本为v0.10.
  • 2015-06-08: 更新文章和ABP版本为 v0.6.3.1.
  • 2015-02-20: 添加单元测试文章的连接,更新示例工程
  • 2015-01-05: 更新ABP版本为 v0.5.
  • 2014-11-03: 更新文章和ABP版本为v0.4.1.
  • 2014-09-08: 更新文章和ABP版本为v0.3.2.
  • 2014-08-17: 更新ABP版本为 v0.3.1.2.
  • 2014-07-22: 更新ABP版本为 v0.3.0.1.
  • 2014-07-11: 添加 'Enable-Migrations' 命令的屏幕截图.
  • 2014-07-08: 更新示例工程和文章.
  • 2014-07-01: 首次发布文章.

参照

 [1] ASP.NET Boilerplate官方网站: http://www.aspnetboilerplate.com

 

posted @ 2017-08-22 23:26  Yung2022  阅读(1330)  评论(0编辑  收藏  举报