使用 ASP.NET MVC、Angularjs、EntityFramework 和 ASP.NET Boilerplate 构建多租户(SaaS)应用程序






4.94/5 (97投票s)
使用 ASP.NET Boilerplate、ASP.NET MVC、Angularjs 和 EntityFramework 创建一个多租户(SaaS)Web 应用程序
- 从 Github 仓库 获取源代码
目录

请查看 在线演示。
引言
在本文中,我们将使用以下框架开发一个 SaaS(多租户)应用程序
- ASP.NET Boilerplate 作为应用程序框架
- ASP.NET MVC 和 ASP.NET Web API 作为 Web 框架
- Entity Framework 作为 ORM
- Angularjs 作为 SPA 框架
- Bootstrap 作为 HTML/CSS 框架
在阅读本文之前,您可以先查看 在线演示。
从模板创建应用程序
ASP.NET Boilerplate 提供模板,让项目启动更加容易。我们从 https://aspnetboilerplate.com/Templates 创建启动模板

我选择了 ASP.NET MVC 5.x、Angularjs 和 Entity Framework,包括“身份验证”。它为我们创建了一个可用的解决方案,包括一个登录页面、导航和一个基于 Bootstrap 的布局。下载解决方案并在Visual Studio 2017+中打开后,我们将看到一个分层的解决方案结构,其中包含一个单元测试项目

首先,我们将 EventCloud.Web 设为启动项目。解决方案附带Entity Framework Code-First 迁移。因此,(在恢复 nuget 包后)我们打开程序包管理器控制台(PMC)并运行Update-Database命令来创建数据库

程序包管理器控制台的默认项目应为 EventCloud.EntityFramework(因为它包含迁移)。此命令将在本地 SQL Server 中创建 EventCloud 数据库 (您可以在 web.config 文件中更改连接字符串)。
现在,我们可以运行应用程序了。我们将看到预先构建的登录页面。我们可以输入default作为租户名称,admin作为用户,123qwe作为密码进行登录

登录后,我们将看到基于 Bootstrap 的基本布局,包含两个页面:Home 和 About

这是一个本地化的 UI,带有动态菜单。Angular 布局、路由和基本基础结构都已正常工作。我将此项目作为 event cloud 项目的基础。
Event Cloud 项目
在本文中,我将展示项目的关键部分并进行解释。因此,请在阅读本文其余部分之前下载示例项目,在 Visual Studio 2017+ 中打开它并像上面一样运行迁移(请确保在运行迁移之前没有名为 EventCloud 的数据库)。我将遵循一些DDD(领域驱动设计)技术来创建领域(业务)层和应用层。
Event Cloud 是一个免费的 SaaS(多租户)应用程序。我们可以创建一个拥有自己的活动、用户、角色等的租户。在创建、取消和注册活动时会应用一些简单的业务规则。
那么,让我们开始探索源代码吧。
实体
实体是我们领域层的一部分,位于 EventCloud.Core 项目下。ASP.NET Boilerplate 启动模板附带Tenant、User、Role... 等大多数应用程序通用的实体。我们可以根据需要自定义它们。当然,我们也可以添加应用程序特有的实体。
event cloud 项目的基本实体是 Event 实体
[Table("AppEvents")]
public class Event : FullAuditedEntity<Guid>, IMustHaveTenant
{
    public const int MaxTitleLength = 128;
    public const int MaxDescriptionLength = 2048;
    public virtual int TenantId { get; set; }
    [Required]
    [StringLength(MaxTitleLength)]
    public virtual string Title { get; protected set; }
    [StringLength(MaxDescriptionLength)]
    public virtual string Description { get; protected set; }
    public virtual DateTime Date { get; protected set; }
    public virtual bool IsCancelled { get; protected set; }
    /// <summary>
    /// Gets or sets the maximum registration count.
    /// 0: Unlimited.
    /// </summary>
    [Range(0, int.MaxValue)]
    public virtual int MaxRegistrationCount { get; protected set; }
    [ForeignKey("EventId")]
    public virtual ICollection<EventRegistration> Registrations { get; protected set; }
    /// <summary>
    /// We don't make constructor public and forcing to 
    /// create events using <see cref="Create"/> method.
    /// But constructor can not be private since it's used by EntityFramework.
    /// That's why we did it protected.
    /// </summary>
    protected Event()
    {
    }
    public static Event Create(int tenantId, string title, 
           DateTime date, string description = null, int maxRegistrationCount = 0)
    {
        var @event = new Event
        {
            Id = Guid.NewGuid(),
            TenantId = tenantId,
            Title = title,
            Description = description,
            MaxRegistrationCount = maxRegistrationCount
        };
        @event.SetDate(date);
        @event.Registrations = new Collection<EventRegistration>();
        return @event;
    }
    public bool IsInPast()
    {
        return Date < Clock.Now;
    }
    public bool IsAllowedCancellationTimeEnded()
    {
        return Date.Subtract(Clock.Now).TotalHours <= 2.0; //2 hours can be defined 
                                            //as Event property and determined per event
    }
    public void ChangeDate(DateTime date)
    {
        if (date == Date)
        {
            return;
        }
        SetDate(date);
        DomainEvents.EventBus.Trigger(new EventDateChangedEvent(this));
    }
    internal void Cancel()
    {
        AssertNotInPast();
        IsCancelled = true;
    }
    private void SetDate(DateTime date)
    {
        AssertNotCancelled();
        if (date < Clock.Now)
        {
            throw new UserFriendlyException("Can not set an event's date in the past!");
        }
        if (date <= Clock.Now.AddHours(3)) //3 can be configurable per tenant
        {
            throw new UserFriendlyException
                  ("Should set an event's date 3 hours before at least!");
        }
        Date = date;
        DomainEvents.EventBus.Trigger(new EventDateChangedEvent(this));
    }
    private void AssertNotInPast()
    {
        if (IsInPast())
        {
            throw new UserFriendlyException("This event was in the past");
        }
    }
    private void AssertNotCancelled()
    {
        if (IsCancelled)
        {
            throw new UserFriendlyException("This event is canceled!");
        }
    }
}
Event 实体不仅仅有 get/set 属性。实际上,它没有公共的 setter,setter 是受保护的。它包含一些领域逻辑。所有属性都必须由 Event 实体本身进行更改,以确保领域逻辑得到执行。
Event 实体的构造函数也是受保护的。因此,创建 Event 的唯一方法是 Event.Create 方法(通常可以是 private,但 private setter 与 Entity Framework 配合不佳,因为 Entity Framework 在从数据库检索实体时无法设置私有成员)。
Event 实现 IMustHaveTenant 接口。这是 ASP.NET Boilerplate (ABP) 框架的一个接口,用于确保此实体是按租户划分的。这对于多租户是必需的。因此,不同的租户将拥有不同的活动,并且无法看到彼此的活动。ABP 会自动过滤当前租户的实体。
Event 类继承自 FullAuditedEntity,其中包含创建、修改和删除的审计列。FullAuditedEntity 还实现了 ISoftDelete,因此活动无法从数据库中删除。当您删除活动时,它们会被标记为已删除。当您查询数据库时,ABP 会自动过滤(隐藏)已删除的实体。
在 DDD 中,实体具有领域(业务)逻辑。我们有一些简单的业务规则,当您检查实体时可以轻松理解。
我们应用程序的第二个实体是 EventRegistration
[Table("AppEventRegistrations")]
public class EventRegistration : CreationAuditedEntity, IMustHaveTenant
{
    public int TenantId { get; set; }
    [ForeignKey("EventId")]
    public virtual Event Event { get; protected set; }
    public virtual Guid EventId { get; protected set; }
    [ForeignKey("UserId")]
    public virtual User User { get; protected set; }
    public virtual long UserId { get; protected set; }
    /// <summary>
    /// We don't make constructor public and forcing 
    /// to create registrations using <see cref="CreateAsync"/> method.
    /// But constructor can not be private since it's used by EntityFramework.
    /// That's why we did it protected.
    /// </summary>
    protected EventRegistration()
    {
            
    }
    public async static Task<EventRegistration> 
    CreateAsync(Event @event, User user, IEventRegistrationPolicy registrationPolicy)
    {
        await registrationPolicy.CheckRegistrationAttemptAsync(@event, user);
        return new EventRegistration
        {
            TenantId = @event.TenantId,
            EventId = @event.Id,
            Event = @event,
            UserId = @user.Id,
            User = user
        };
    }
    public async Task CancelAsync(IRepository<EventRegistration> repository)
    {
        if (repository == null) { throw new ArgumentNullException("repository"); }
        if (Event.IsInPast())
        {
            throw new UserFriendlyException("Can not cancel event which is in the past!");
        }
        if (Event.IsAllowedCancellationTimeEnded())
        {
            throw new UserFriendlyException("It's too late to cancel your registration!");
        }
        await repository.DeleteAsync(this);
    }
}
与 Event 类似,我们有一个 static create 方法。创建新的 EventRegistration 的唯一方法是此 CreateAsync 方法。它接收一个event、一个user和一个registration policy。它使用 registrationPolicy.CheckRegistrationAttemptAsync 方法来检查给定用户是否可以注册到该活动。如果给定用户无法注册到给定活动,此方法将抛出异常。通过这种设计,我们可以确保在创建注册时应用了所有业务规则。在不使用注册策略的情况下,无法创建注册。
有关实体的更多信息,请参阅 实体文档。
活动注册策略
EventRegistrationPolicy 类定义如下
public class EventRegistrationPolicy : EventCloudServiceBase, IEventRegistrationPolicy
{
    private readonly IRepository<EventRegistration> _eventRegistrationRepository;
    public EventRegistrationPolicy(IRepository<EventRegistration> eventRegistrationRepository)
    {
        _eventRegistrationRepository = eventRegistrationRepository;
    }
    public async Task CheckRegistrationAttemptAsync(Event @event, User user)
    {
        if (@event == null) { throw new ArgumentNullException("event"); }
        if (user == null) { throw new ArgumentNullException("user"); }
        CheckEventDate(@event);
        await CheckEventRegistrationFrequencyAsync(user);
    }
    private static void CheckEventDate(Event @event)
    {
        if (@event.IsInPast())
        {
            throw new UserFriendlyException("Can not register event in the past!");
        }
    }
    private async Task CheckEventRegistrationFrequencyAsync(User user)
    {
        var oneMonthAgo = Clock.Now.AddDays(-30);
        var maxAllowedEventRegistrationCountInLast30DaysPerUser = 
            await SettingManager.GetSettingValueAsync<int>
            (EventCloudSettingNames.MaxAllowedEventRegistrationCountInLast30DaysPerUser);
        if (maxAllowedEventRegistrationCountInLast30DaysPerUser > 0)
        {
            var registrationCountInLast30Days = 
                await _eventRegistrationRepository.CountAsync
                (r => r.UserId == user.Id && r.CreationTime >= oneMonthAgo);
            if (registrationCountInLast30Days > 
                maxAllowedEventRegistrationCountInLast30DaysPerUser)
            {
                throw new UserFriendlyException(string.Format
                ("Can not register to more than {0}", 
                maxAllowedEventRegistrationCountInLast30DaysPerUser));
            }
        }
    }
}
这是我们领域的重要组成部分。在创建活动注册时,我们有两个规则
- 用户不能注册过去的活动。
- 用户在 30 天内最多可以注册数量限制的活动。因此,我们有限制注册频率。
活动管理器
EventManager 实现活动的业务(领域)逻辑。所有 Event 操作都应通过此类执行。其定义如下
public class EventManager : IEventManager
{
    public IEventBus EventBus { get; set; }
    private readonly IEventRegistrationPolicy _registrationPolicy;
    private readonly IRepository<EventRegistration> _eventRegistrationRepository;
    private readonly IRepository<Event, Guid> _eventRepository;
    public EventManager(
        IEventRegistrationPolicy registrationPolicy,
        IRepository<EventRegistration> eventRegistrationRepository,
        IRepository<Event, Guid> eventRepository)
    {
        _registrationPolicy = registrationPolicy;
        _eventRegistrationRepository = eventRegistrationRepository;
        _eventRepository = eventRepository;
        EventBus = NullEventBus.Instance;
    }
    public async Task<Event> GetAsync(Guid id)
    {
        var @event = await _eventRepository.FirstOrDefaultAsync(id);
        if (@event == null)
        {
            throw new UserFriendlyException
                  ("Could not found the event, maybe it's deleted!");
        }
        return @event;
    }
    public async Task CreateAsync(Event @event)
    {
        await _eventRepository.InsertAsync(@event);
    }
    public void Cancel(Event @event)
    {
        @event.Cancel();
        EventBus.Trigger(new EventCancelledEvent(@event));
    }
    public async Task<EventRegistration> RegisterAsync(Event @event, User user)
    {
        return await _eventRegistrationRepository.InsertAsync(
            await EventRegistration.CreateAsync(@event, user, _registrationPolicy)
            );
    }
    public async Task CancelRegistrationAsync(Event @event, User user)
    {
        var registration = await _eventRegistrationRepository.FirstOrDefaultAsync
                           (r => r.EventId == @event.Id && r.UserId == user.Id);
        if (registration == null)
        {
            //No need to cancel since there is no such a registration
            return;
        }
        await registration.CancelAsync(_eventRegistrationRepository);
    }
    public async Task<IReadOnlyList<User>> GetRegisteredUsersAsync(Event @event)
    {
        return await _eventRegistrationRepository
            .GetAll()
            .Include(registration => registration.User)
            .Where(registration => registration.EventId == @event.Id)
            .Select(registration => registration.User)
            .ToListAsync();
    }
}
它执行领域逻辑并触发必要的事件。
有关领域服务的更多信息,请参阅 领域服务文档。
领域事件
我们可能希望在应用程序中的某些状态更改时定义和触发一些特定于领域的事件。我定义了两个特定于领域的事件
- EventCancelledEvent:当活动被取消时触发。它在- EventManager.Cancel方法中触发。
- EventDateChangedEvent:当活动日期更改时触发。它在- Event.ChangeDate方法中触发。
我们处理这些事件并将相关用户通知这些更改。此外,我还处理 EntityCreatedEventDate<Event>(这是一个预定义的 ABP 事件,会自动触发)。
要处理事件,我们应该定义一个事件处理程序类。我定义了 EventUserEmailer,以便在需要时向用户发送电子邮件
public class EventUserEmailer : 
    IEventHandler<EntityCreatedEventData<Event>>,
    IEventHandler<EventDateChangedEvent>, 
    IEventHandler<EventCancelledEvent>,
    ITransientDependency
{
    public ILogger Logger { get; set; }
    private readonly IEventManager _eventManager;
    private readonly UserManager _userManager;
    public EventUserEmailer(
        UserManager userManager, 
        IEventManager eventManager)
    {
        _userManager = userManager;
        _eventManager = eventManager;
        Logger = NullLogger.Instance;
    }
    [UnitOfWork]
    public virtual void HandleEvent(EntityCreatedEventData<Event> eventData)
    {
        //TODO: Send email to all tenant users as a notification
        var users = _userManager
            .Users
            .Where(u => u.TenantId == eventData.Entity.TenantId)
            .ToList();
        foreach (var user in users)
        {
            var message = string.Format("Hey! There is a new event '{0}' 
            on {1}! Want to register?",eventData.Entity.Title, eventData.Entity.Date);
            Logger.Debug(string.Format("TODO: Send email to {0} -> {1}", 
                         user.EmailAddress, message));
        }
    }
    public void HandleEvent(EventDateChangedEvent eventData)
    {
        //TODO: Send email to all registered users!
        var registeredUsers = AsyncHelper.RunSync(() => 
            _eventManager.GetRegisteredUsersAsync(eventData.Entity));
        foreach (var user in registeredUsers)
        {
            var message = eventData.Entity.Title + " event's date is changed! 
                          New date is: " + eventData.Entity.Date;
            Logger.Debug(string.Format("TODO: Send email to {0} -> {1}",
                         user.EmailAddress, message));
        }
    }
    public void HandleEvent(EventCancelledEvent eventData)
    {
        //TODO: Send email to all registered users!
        var registeredUsers = AsyncHelper.RunSync(() => 
                              _eventManager.GetRegisteredUsersAsync(eventData.Entity));
        foreach (var user in registeredUsers)
        {
            var message = eventData.Entity.Title + " event is canceled!";
            Logger.Debug(string.Format("TODO: Send email to {0} -> {1}", 
                         user.EmailAddress, message));
        }
    }
}
我们可以处理不同类中的相同事件,或者在同一个类中处理不同的事件(如本示例所示)。这里,我们处理这些事件并将电子邮件发送给相关用户作为通知(实际上并未实现邮件发送,以使示例应用程序更简单)。事件处理程序应实现 IEventHandler<event-type> 接口。当相关事件发生时,ABP 会自动调用处理程序。
有关领域事件的更多信息,请参阅 EventBus 文档。
应用服务
应用服务使用领域层来实现应用程序的用例(通常由表示层使用)。EventAppService 执行活动的应用程序逻辑。
[AbpAuthorize]
public class EventAppService : EventCloudAppServiceBase, IEventAppService
{
    private readonly IEventManager _eventManager;
    private readonly IRepository<Event, Guid> _eventRepository;
    public EventAppService(
        IEventManager eventManager, 
        IRepository<Event, Guid> eventRepository)
    {
        _eventManager = eventManager;
        _eventRepository = eventRepository;
    }
    public async Task<ListResultOutput<EventListDto>> GetList(GetEventListInput input)
    {
        var events = await _eventRepository
            .GetAll()
            .Include(e => e.Registrations)
            .WhereIf(!input.IncludeCanceledEvents, e => !e.IsCancelled)
            .OrderByDescending(e => e.CreationTime)
            .ToListAsync();
        return new ListResultOutput<EventListDto>(events.MapTo<List<EventListDto>>());
    }
    public async Task<EventDetailOutput> GetDetail(EntityRequestInput<Guid> input)
    {
        var @event = await _eventRepository
            .GetAll()
            .Include(e => e.Registrations)
            .Where(e => e.Id == input.Id)
            .FirstOrDefaultAsync();
        if (@event == null)
        {
            throw new UserFriendlyException("Could not found the event, maybe it's deleted.");
        }
        return @event.MapTo<EventDetailOutput>();
    }
    public async Task Create(CreateEventInput input)
    {
        var @event = Event.Create(AbpSession.GetTenantId(), 
        input.Title, input.Date, input.Description, input.MaxRegistrationCount);
        await _eventManager.CreateAsync(@event);
    }
    public async Task Cancel(EntityRequestInput<Guid> input)
    {
        var @event = await _eventManager.GetAsync(input.Id);
        _eventManager.Cancel(@event);
    }
    public async Task<EventRegisterOutput> Register(EntityRequestInput<Guid> input)
    {
        var registration = await RegisterAndSaveAsync(
            await _eventManager.GetAsync(input.Id),
            await GetCurrentUserAsync()
            );
        return new EventRegisterOutput
        {
            RegistrationId = registration.Id
        };
    }
    public async Task CancelRegistration(EntityRequestInput<Guid> input)
    {
        await _eventManager.CancelRegistrationAsync(
            await _eventManager.GetAsync(input.Id),
            await GetCurrentUserAsync()
            );
    }
    private async Task<EventRegistration> RegisterAndSaveAsync(Event @event, User user)
    {
        var registration = await _eventManager.RegisterAsync(@event, user);
        await CurrentUnitOfWork.SaveChangesAsync();
        return registration;
    }
}
正如您所见,应用服务本身并不实现领域逻辑。它只是使用实体和领域服务(EventManager)来执行用例。
有关应用服务的更多信息,请参阅 应用服务文档。
表示层
此应用程序的表示层使用 Angularjs 作为 SPA 构建。
活动列表
当我们登录应用程序时,我们首先看到一个活动列表

我们直接使用 EventAppService 来获取活动列表。以下是创建此页面的 Angular 控制器
(function() {
    var controllerId = 'app.views.events.index';
    angular.module('app').controller(controllerId, [
        '$scope', '$modal', 'abp.services.app.event',
        function ($scope, $modal, eventService) {
            var vm = this;
            vm.events = [];
            vm.filters = {
                includeCanceledEvents: false
            };
            function loadEvents() {
                eventService.getList(vm.filters).success(function (result) {
                    vm.events = result.items;
                });
            };
            vm.openNewEventDialog = function() {
                var modalInstance = $modal.open({
                    templateUrl: abp.appPath + 'App/Main/views/events/createDialog.cshtml',
                    controller: 'app.views.events.createDialog as vm',
                    size: 'md'
                });
                modalInstance.result.then(function () {
                    loadEvents();
                });
            };
            $scope.$watch('vm.filters.includeCanceledEvents', function (newValue, oldValue) {
                if (newValue != oldValue) {
                    loadEvents();
                }
            });
            loadEvents();
        }
    ]);
})();
我们将 EventAppService 作为 'abp.services.app.event' 注入到 Angular 控制器中。我们使用了 ABP 的 动态 Web API 层功能。它自动且动态地创建所需的 Web API 控制器和 Angularjs 服务。因此,我们可以像调用常规 JavaScript 函数一样调用应用程序服务方法。要调用 EventAppService.GetList C# 方法,我们只需调用 eventService.getList JavaScript 函数,该函数返回一个 promise(Angular 的 $q)。
当用户点击“+ New event”按钮时,我们还会打开一个“新建活动”模态框(对话框)(该按钮会触发 vm.openNewEventDialog 函数)。我将不详细介绍 Angular 视图,因为它们更简单,您可以在源代码中查看它们。
活动详情
当我们点击活动的“Details”按钮时,我们会进入活动详情页面,URL 类似“http://eventcloud.aspnetboilerplate.com/#/events/e9499e3e-35c0-492c-98ce-7e410461103f”。GUID 是活动的 ID。

在这里,我们将看到带注册用户的活动详情。我们可以注册活动或取消注册。此视图的控制器在 detail.js 中定义,如下所示
(function () {
    var controllerId = 'app.views.events.detail';
    angular.module('app').controller(controllerId, [
        '$scope', '$state','$stateParams', 'abp.services.app.event',
        function ($scope, $state, $stateParams, eventService) {
            var vm = this;
            function loadEvent() {
                eventService.getDetail({
                    id: $stateParams.id
                }).<span lang="tr">then</span>(function (result) {
                    vm.event = result<span lang="tr">.data</span>;
                });
            }
            vm.isRegistered = function () {
                if (!vm.event) {
                    return false;
                }
                return _.find(vm.event.registrations, function(registration) {
                    return registration.userId == abp.session.userId;
                });
            };
            vm.isEventCreator = function() {
                return vm.event && vm.event.creatorUserId == abp.session.userId;
            };
            vm.getUserThumbnail = function(registration) {
                return registration.userName.substr(0, 1).toLocaleUpperCase();
            };
            vm.register = function() {
                eventService.register({
                    id: vm.event.id
                }).<span lang="tr">then</span>(function (result) {
                    abp.notify.success('Successfully registered to event. 
                    Your registration id: ' + 
                    result<span lang="tr">.data</span>.registrationId + ".");
                    loadEvent();
                });
            };
            vm.cancelRegistertration = function() {
                eventService.cancelRegistration({
                    id: vm.event.id
                }).<span lang="tr">then</span>(function () {
                    abp.notify.info('Canceled your registration.');
                    loadEvent();
                });
            };
            vm.cancelEvent = function() {
                eventService.cancel({
                    id: vm.event.id
                }).<span lang="tr">then</span>(function () {
                    abp.notify.info('Canceled the event.');
                    vm.backToEventsPage();
                });
            };
            vm.backToEventsPage = function() {
                $state.go('events');
            };
            loadEvent();
        }
    ]);
})();
我们只需使用活动应用程序服务来执行操作。
主菜单
顶部菜单由 ABP 模板自动创建。我们在 EventCloudNavigationProvider 类中定义菜单项
public class EventCloudNavigationProvider : NavigationProvider
{
    public override void SetNavigation(INavigationProviderContext context)
    {
        context.Manager.MainMenu
            .AddItem(
                new MenuItemDefinition(
                    AppPageNames.Events,
                    new LocalizableString("Events", EventCloudConsts.LocalizationSourceName),
                    url: "#/",
                    icon: "fa fa-calendar-check-o"
                    )
            ).AddItem(
                new MenuItemDefinition(
                    AppPageNames.About,
                    new LocalizableString("About", EventCloudConsts.LocalizationSourceName),
                    url: "#/about",
                    icon: "fa fa-info"
                    )
            );
    }
}
我们可以在这里添加新的菜单项。有关更多信息,请参阅 导航文档。
Angular 路由
仅定义菜单会在页面上显示它。Angular 有自己的路由系统。此应用程序使用 Angular ui-router。路由在 app.js 中定义,如下所示
//Configuration for Angular UI routing.
app.config([
    '$stateProvider', '$urlRouterProvider',
    function($stateProvider, $urlRouterProvider) {
        $urlRouterProvider.otherwise('/events');
        $stateProvider
            .state('events', {
                url: '/events',
                templateUrl: '/App/Main/views/events/index.cshtml',
                menu: 'Events' //Matches to name of 'Events' menu 
                               //in EventCloudNavigationProvider
            })
            .state('eventDetail', {
                url: '/events/:id',
                templateUrl: '/App/Main/views/events/detail.cshtml',
                menu: 'Events' //Matches to name of 'Events' menu 
                               //in EventCloudNavigationProvider
            })
            .state('about', {
                url: '/about',
                templateUrl: '/App/Main/views/about/about.cshtml',
                menu: 'About' //Matches to name of 'About' menu 
                              //in EventCloudNavigationProvider
            });
    }
]);
单元和集成测试
ASP.NET Boilerplate 提供了工具来简化单元和集成测试。您可以在项目的 源代码中找到所有测试代码。在此,我将简要解释基本测试。解决方案包含 EventAppService_Tests 类,用于测试 EventAppService。请看该类中的两个测试
public class EventAppService_Tests : EventCloudTestBase
{
    private readonly IEventAppService _eventAppService;
    public EventAppService_Tests()
    {
        _eventAppService = Resolve<IEventAppService>();
    }
    [Fact]
    public async Task Should_Create_Event()
    {
        //Arrange
        var eventTitle = Guid.NewGuid().ToString();
        //Act
        await _eventAppService.Create(new CreateEventInput
        {
            Title = eventTitle,
            Description = "A description",
            Date = Clock.Now.AddDays(2)
        });
        //Assert
        UsingDbContext(context =>
        {
            context.Events.FirstOrDefault(e => e.Title == eventTitle).ShouldNotBe(null);
        });
    }
    [Fact]
    public async Task Should_Not_Create_Events_In_The_Past()
    {
        //Arrange
        var eventTitle = Guid.NewGuid().ToString();
        //Act
        await Assert.ThrowsAsync<UserFriendlyException>(async () =>
        {
            await _eventAppService.Create(new CreateEventInput
            {
                Title = eventTitle,
                Description = "A description",
                Date = Clock.Now.AddDays(-1)
            });
        });
    }
    private Event GetTestEvent()
    {
        return UsingDbContext(context => GetTestEvent(context));
    }
    private static Event GetTestEvent(EventCloudDbContext context)
    {
        return context.Events.Single(e => e.Title == TestDataBuilder.TestEventTitle);
    }
}
我们使用 xUnit 作为测试框架。在第一个测试中,我们创建一个活动并检查数据库中是否存在。在第二个测试中,我们故意尝试在过去创建活动。由于我们的业务规则不允许这样做,因此我们应该会收到一个异常。
通过这些测试,我们测试了从应用程序服务开始的所有内容,包括 ASP.NET Boilerplate 的所有方面(如验证、单元工作等)。有关单元测试的详细信息,请参阅我的 使用 xUnit、Entity Framework、Effort 和 ASP.NET Boilerplate 进行 C# 单元测试 文章。
基于令牌的身份验证
启动模板使用基于 Cookie 的身份验证来处理浏览器。但是,如果您想从移动应用程序中调用 Web API 或应用程序服务(这些通过 动态 Web API 公开),您可能需要一个基于令牌的身份验证机制。启动模板包含 Bearer 令牌身份验证基础结构。.WebApi 项目中的 AccountController 包含 Authenticate 操作以获取令牌。然后,我们可以使用该令牌进行后续请求。
这里,我们将使用 Postman(Chrome 扩展)来演示请求和响应。
身份验证
只需向  https://:6334/api/Account/Authenticate 发送一个 POST 请求,并将 Context-Type="application/json" 头部设置如下

我们发送了一个包含 tenancyName、userNameOrEmailAddress 和 password 的JSON 请求体。tenancyName 对于 host 用户不是必需的。如上所示,返回的 JSON 的 result 属性包含令牌。我们可以保存它并在后续请求中使用。
使用 API
在进行身份验证并获取 token 后,我们可以使用它来调用任何授权操作。所有应用程序服务都可以远程使用。例如,我们可以使用 EventAppService 获取活动列表

只需向  https://:6334/api/services/app/event/GetList 发送一个 POST 请求,并设置 Content-Type="application/json" 和 Authorization="Bearer  your-auth-token"。请求体只是空的 {}。当然,不同 API 的请求和响应体会有所不同。
UI 上几乎所有的操作都作为 Web API 提供(因为 UI 使用相同的 Web API),并且可以轻松调用。
源代码
您可以在 https://github.com/aspnetboilerplate/eventcloud 获取最新的源代码。
摘要
在本文中,我介绍了一个基于 ASP.NET Boilerplate (ABP) 框架的多租户(SaaS)应用程序。有关 ASP.NET Boilerplate 的更多信息,请使用以下链接
- 官方网站和文档:aspnetboilerplate.com
- Github 仓库: github.com/aspnetboilerplate
- 在 Twitter 上关注: @aspboilerplate
历史
- 2018年2月18日- 升级到 ABP v3.4.0
- 更新了文章中的一些截图和文本
 
- 2017年6月28日- 升级到 ABP v2.1.3
- 更新了项目创建部分
 
- 2016年7月19日- 更新了图片并修订了内容
- 为关于页面添加了统计信息
- 将 Abp.* nuget 包升级到 v0.10
 
- 2016年1月8日- 添加了“单元和集成测试”部分
- 将 Abp.* nuget 包升级到 v0.7.7.1
 
- 2015年12月4日- 添加了“社交媒体登录”和“基于令牌的身份验证”部分。
- 本地化 UI
- 升级到 .NET Framework 4.5.2
- 更新了 Abp.* nuget 包到 v0.7.5
 
- 2015年10月26日- 首次发布文章
 


