@ -1,24 +1,24 @@ |
|||
name: Merge branch rel-5.3 with rel-5.2 |
|||
name: Merge branch dev with rel-5.3 |
|||
on: |
|||
push: |
|||
branches: |
|||
- rel-5.2 |
|||
- rel-5.3 |
|||
jobs: |
|||
merge-rel-5-3-with-rel-5-2: |
|||
runs-on: ubuntu-latest |
|||
steps: |
|||
- uses: actions/checkout@v2 |
|||
with: |
|||
ref: rel-5.3 |
|||
ref: dev |
|||
- name: Reset promotion branch |
|||
run: | |
|||
git fetch origin rel-5.2:rel-5.2 |
|||
git reset --hard rel-5.2 |
|||
git fetch origin rel-5.3:rel-5.3 |
|||
git reset --hard rel-5.3 |
|||
- name: Create Pull Request |
|||
uses: peter-evans/create-pull-request@v3 |
|||
with: |
|||
branch: auto-merge/rel-5-2/${{github.run_number}} |
|||
title: Merge branch rel-5.3 with rel-5.2 |
|||
body: This PR generated automatically to merge rel-5.3 with rel-5.2. Please review the changed files before merging to prevent any errors that may occur. |
|||
branch: auto-merge/rel-5-3/${{github.run_number}} |
|||
title: Merge branch dev with rel-5.3 |
|||
body: This PR generated automatically to merge dev with rel-5.3. Please review the changed files before merging to prevent any errors that may occur. |
|||
reviewers: ${{github.actor}} |
|||
token: ${{ github.token }} |
|||
|
|||
@ -0,0 +1,51 @@ |
|||
# ABP.IO Platform 5.2 Final Has Been Released! |
|||
|
|||
[ABP Framework](https://abp.io/) and [ABP Commercial](https://commercial.abp.io/) 5.2 versions have been released today. |
|||
|
|||
## What's New With 5.2? |
|||
|
|||
Since all the new features are already explained in details with the [5.2 RC Announcement Post](https://blog.abp.io/abp/ABP.IO-Platform-5-2-RC-Has-Been-Published), I will not repeat all the details again. See the [RC Blog Post](https://blog.abp.io/abp/ABP.IO-Platform-5-2-RC-Has-Been-Published) for all the features and enhancements. |
|||
|
|||
## Creating New Solutions |
|||
|
|||
You can create a new solution with the ABP Framework version 5.2 by either using the `abp new` command or using the **direct download** tab on the [get started page](https://abp.io/get-started). |
|||
|
|||
> See the [getting started document](https://docs.abp.io/en/abp/latest/Getting-Started) for more. |
|||
|
|||
## How to Upgrade an Existing Solution |
|||
|
|||
### Install/Update the ABP CLI |
|||
|
|||
First of all, install the ABP CLI or upgrade to the latest version. |
|||
|
|||
If you haven't installed yet: |
|||
|
|||
```bash |
|||
dotnet tool install -g Volo.Abp.Cli |
|||
``` |
|||
|
|||
To update an existing installation: |
|||
|
|||
```bash |
|||
dotnet tool update -g Volo.Abp.Cli |
|||
``` |
|||
|
|||
### ABP UPDATE Command |
|||
|
|||
[ABP CLI](https://docs.abp.io/en/abp/latest/CLI) provides a handy command to update all the ABP related NuGet and NPM packages in your solution with a single command: |
|||
|
|||
```bash |
|||
abp update |
|||
``` |
|||
|
|||
Run this command in the root folder of your solution. |
|||
|
|||
## Migration Guide |
|||
|
|||
Check [the migration guide](https://docs.abp.io/en/abp/5.2/Migration-Guides/Abp-5_2) for the applications with the version 5.x upgrading to the version 5.2. |
|||
|
|||
## About the Next Version |
|||
|
|||
The next feature version will be 5.3. It is planned to release the 5.3 RC (Release Candidate) on May 03 and the final version on May 31, 2022. You can follow the [release planning here](https://github.com/abpframework/abp/milestones). |
|||
|
|||
Please [submit an issue](https://github.com/abpframework/abp/issues/new) if you have any problem with this version. |
|||
@ -0,0 +1,258 @@ |
|||
# ABP.IO Platform 5.3 RC Has Been Released |
|||
|
|||
Today, we are happy to release the [ABP Framework](https://abp.io/) and [ABP Commercial](https://commercial.abp.io/) version **5.3 RC** (Release Candidate). This blog post introduces the new features and important changes in this new version. |
|||
|
|||
> **The planned release date for the [5.3.0 Stable](https://github.com/abpframework/abp/milestone/69) version is May 31, 2022**. |
|||
|
|||
Please try this version and provide feedback for a more stable ABP version 5.3! Thank you all. |
|||
|
|||
## Get Started with the 5.3 RC |
|||
|
|||
Follow the steps below to try version 5.3.0 RC today: |
|||
|
|||
1) **Upgrade** the ABP CLI to version `5.3.0-rc.1` using a command line terminal: |
|||
|
|||
````bash |
|||
dotnet tool update Volo.Abp.Cli -g --version 5.3.0-rc.1 |
|||
```` |
|||
|
|||
**or install** it if you haven't before: |
|||
|
|||
````bash |
|||
dotnet tool install Volo.Abp.Cli -g --version 5.3.0-rc.1 |
|||
```` |
|||
|
|||
2) Create a **new application** with the `--preview` option: |
|||
|
|||
````bash |
|||
abp new BookStore --preview |
|||
```` |
|||
|
|||
See the [ABP CLI documentation](https://docs.abp.io/en/abp/latest/CLI) for all the available options. |
|||
|
|||
> You can also use the *Direct Download* tab on the [Get Started](https://abp.io/get-started) page by selecting the **Preview checkbox**. |
|||
|
|||
You can use any IDE that supports .NET 6.x, like **[Visual Studio 2022](https://visualstudio.microsoft.com/downloads/)**. |
|||
|
|||
## Migration Notes |
|||
|
|||
There is a change in this version that may effect your applications: |
|||
|
|||
* Upgraded the [AutoMapper](https://github.com/AutoMapper/AutoMapper) library to **v11.0.1**. So, you need to change your project's target SDK that use the **AutoMapper** library (typically your `*.Application` project). You can change it from `netstandard2.0` to `netstandard2.1` or `net6` if needed. You can write to [#12189](https://github.com/abpframework/abp/pull/12189) if you need any help. |
|||
|
|||
## What's New with ABP Framework 5.3? |
|||
|
|||
In this section, I will introduce some major features released with this version. Here is a brief list of titles explained in the next sections: |
|||
|
|||
* Single-layer option added to the [*Get Started*](https://abp.io/get-started) page |
|||
* PWA Support for Startup Templates |
|||
* Introduced the `Volo.Abp.Gdpr.Abstractions` package |
|||
* Batch Publish Events from Outbox to the Event Bus |
|||
* Improvements on **eShopOnAbp** Project & E-Book Announcement |
|||
* LeptonX Lite Documentations & Project Status & Roadmap |
|||
* OpenIddict Module & Keycloack Integration |
|||
* Deployment Documentations |
|||
* Other News |
|||
|
|||
### Single-layer Option on *Get Started* Page |
|||
|
|||
We've created a new startup template named `app-nolayers` and [announced](https://blog.abp.io/abp/ABP.IO-Platform-5-2-RC-Has-Been-Published) it in the previous version. In this version, we've also added this startup template option to the *Get Started* page. |
|||
|
|||
*You can examine the screenshot below to see how to create an `app-nolayers` template from the ["Get Started"](https://abp.io/get-started) page:* |
|||
|
|||
 |
|||
|
|||
### PWA Support for Startup Templates |
|||
|
|||
ABP v5.3 application startup template now supports PWA for Blazor WASM & Angular UIs. To create a startup template with the PWA support, you can use the `--pwa` parameter. |
|||
|
|||
Example: |
|||
|
|||
```bash |
|||
abp new MyProgressiveWebApp -t app -u blazor --pwa |
|||
``` |
|||
|
|||
### Introducing the `Volo.Abp.Gdpr.Abstractions` Package |
|||
|
|||
A new `Volo.Abp.Gdpr.Abstractions` package has been added to the framework. This is an abstraction package, so doesn't contain any actual GDPR implementation. It defines some classes and interfaces to put a standard for who want to implement a GDPR module that can run in a modular or microservice system. |
|||
|
|||
At that point, we are introducing the **GDPR Module** for the ABP Commercial customers and this module does the GDPR-related operations on behalf of you, such as *"Download/Delete Personal Data"*. I'll describe the **GDPR Module** later in this blog post. |
|||
|
|||
> Please see the **GDPR Module** section below to learn more about this module. |
|||
|
|||
### Batch Publish Events from Outbox to the Event Bus |
|||
|
|||
We introduced the "Transactional Outbox & Inbox Patterns" in [**ABP v5.0**](https://blog.abp.io/abp/ABP-IO-Platform-5.0-RC-1-Has-Been-Released), it was one of the most awaited features by several software developers. |
|||
|
|||
We've made some optimizations for the **Batch Event Publishing** in this version, you can examine the related development from [here](https://github.com/abpframework/abp/pull/11243). After the optimization, the results are impressive. It is enabled by default (if you have configured [event outbox](https://docs.abp.io/en/abp/latest/Distributed-Event-Bus#outbox-inbox-for-transactional-events)), so you don't need to any manual configuration. |
|||
|
|||
### Improvements on eShopOnAbp Project & E-Book Announcement |
|||
|
|||
There are some developments on the [eShopOnAbp project](https://github.com/abpframework/eShopOnAbp) made in this version. You can see the brief descriptions of some of the improvements below: |
|||
|
|||
* Local certificates have been created to use while working in Kubernetes and also Helm Charts have been updated. See [#107](https://github.com/abpframework/eShopOnAbp/pull/107). |
|||
* The Order Management page has been created. See [#92](https://github.com/abpframework/eShopOnAbp/pull/92). |
|||
* Database migration event handlers have been removed and "Distributed Locking" is now used for database migrations. See [#85](https://github.com/abpframework/eShopOnAbp/pull/85) and [#102](https://github.com/abpframework/eShopOnAbp/pull/102). |
|||
* Switched from Ocelot to YARP as the gateway. See [#97](https://github.com/abpframework/eShopOnAbp/pull/97). |
|||
|
|||
We have exciting news to share with the community, we're working on an "ABP Microservice Development" e-book. In this book, we're using the eShopOnAbp project as a reference microservice solution and we're trying to explain our experiences during the microservice application development process through this project. |
|||
|
|||
We're planning to create this book in nine chapters and make it available after the third chapter is written. After that, you will be able to download this free e-book from the [abp.io](https://abp.io/) website. |
|||
|
|||
### LeptonX Lite Documentations & Project Status & Roadmap |
|||
|
|||
It is finally here, we've released the **1.0.0-beta.1** version for the **LeptonX Lite**. |
|||
|
|||
 |
|||
|
|||
Lepton X Lite documents have been written for the three UI types within this version. You can see the related documentation from the screenshot above. You can follow these documents and try the new **LeptonX Lite Theme**. |
|||
|
|||
We don't suggest using the **beta.1** version on production, we highly demand you to test **LeptonX Lite** and provide feedback to us. It's really important for us to be able to release a more stable version. Thanks in advance. |
|||
|
|||
For the following versions (beta.2 and RC versions), we will focus on: |
|||
|
|||
* Fixing the reported bugs from the community |
|||
* Providing documentations as much as possible |
|||
* Adding new custom pages to the demo |
|||
|
|||
### OpenIddict Module & Keycloack Integration |
|||
|
|||
We have [announced the plan of replacing the IdentityServer](https://github.com/abpframework/abp/issues/11989). ABP currently uses **IdentityServer4** to add **OAuth** features as built-in on the server-side. However, since *IdentityServer4's support ends at the end of the year 2022*. Its replacement is Duende IdentityServer, which is not a free software anymore. (see [more](https://blog.duendesoftware.com/posts/20220111_fair_trade/)) |
|||
|
|||
Therefore, we've decided to completely drop the **IdentityServer4** from the ABP platform and implement the [OpenIddict](https://github.com/openiddict/openiddict-core) and install onto the startup templates. |
|||
|
|||
We've implemented both open source and commercial OpenIddict modules, we plan to remove Identity Server and replace it with OpenIddict for template projects in **ABP v5.4**. Please check [#12084](https://github.com/abpframework/abp/pull/12084) to see the development made on the open-source side. |
|||
|
|||
We're creating the documentation for the OpenIddict Module, if you want to have general knowledge about this module, you can check the documentation from [here](https://github.com/abpframework/abp/blob/dev/docs/en/Modules/OpenIddict.md). Currently, this is a draft documentation but it gives overall knowledge about the OpenIddict Module, we'll complete this documentation in ABP v5.4 and you'll be able to read it completely. |
|||
|
|||
Currently, we are also working on Keycloak integration possibilities in parallel to the OpenIddict integration research and we've prepared some samples that you can examine. You can see [#154](https://github.com/abpframework/abp-samples/pull/154) and [#158](https://github.com/abpframework/abp-samples/pull/158). |
|||
|
|||
### Deployment Documentations |
|||
|
|||
Deploying an ABP-based application is not so different than deploying any .NET or ASP.NET Core application. You can deploy it to a cloud provider (e.g. Azure, AWS, Google Could) or on-premise server, IIS or any other web server. However, we wanted to prepare a "Deployment Guide" to mention the important points and considerations. |
|||
|
|||
 |
|||
|
|||
In the [Deploying to a Clustered Environment](https://docs.abp.io/en/abp/5.3/Deployment/Clustered-Environment) documentation, we've documented the topics that you should consider when you are developing your application to a clustered environment and explained how you can deal with these topics in your ABP-based application. |
|||
|
|||
### Other News |
|||
|
|||
* Global Features were only accessible from the C# code. From this version and on, Global Features can be also provided from application configurations. See [#12043](https://github.com/abpframework/abp/pull/12043). |
|||
* Getting the user's detailed information (name, surname and phone number) from external login. See [#12085](https://github.com/abpframework/abp/pull/12085). |
|||
* Date Pipes for Angular. See [#11909](https://github.com/abpframework/abp/issues/11909). |
|||
|
|||
If you want to see more details, you can check [the release on GitHub](https://github.com/abpframework/abp/releases/tag/5.3.0-rc.1), which contains a list of all the issues and pull requests closed with this version. |
|||
|
|||
## What's New with ABP Commercial 5.3? |
|||
|
|||
### GDPR Module |
|||
|
|||
> **GDPR (General Data Protection Regulation)** is a regulation in EU law on data protection and known as the toughest privacy and security law in the world. GDPR applies to any organization operating within the EU, as well as any organizations outside of the EU which offer goods or services to customers or businesses in the EU. |
|||
|
|||
With this version, we are introducing the new **GDPR Module**. This was one of the most awaited features, so we've prioritized it and implemented it in this version. |
|||
|
|||
The GDPR Module is pre-installed in the [startup templates](https://docs.abp.io/en/commercial/5.3/startup-templates/index) for MVC. So, no need to manually install it. When you create a new startup template, you can directly use this module. We'll also implement this module for the other UI types as soon as possible and also add extra functionality such as "Cookie Consent" and more. |
|||
|
|||
Currently, there are two main functions of this module and they are "Download Personal Data" and "Delete Personal Data". |
|||
|
|||
 |
|||
|
|||
There is a "Personal Data" section in the user menu as in the screenshot above and when you click on this section, you'll be redirected to the "Personal Data" page. On that page, you can either request to "Download Personal Data" or "Delete Personal Data". |
|||
|
|||
 |
|||
|
|||
After you've requested to download "Personal Data", you need to wait for 1 hour by default (you can configure the related option). Because the GDPR module is developed by considering the distributed systems and therefore a specific time should be passed to ensure all the published events are handled and all personal data is collected. |
|||
|
|||
### CMS Kit Pro - Polling Feature |
|||
|
|||
We've added a **Polling** feature to the **CMS Kit Pro** module. This feature allows you to use a questionnaire/voting system in your application easily. You can create a question, define some options for it and the poll will be created for you. You can see the example poll in the screenshot below: |
|||
|
|||
 |
|||
|
|||
Also, there is an admin side of the Polling Feature. You can easily manage your polls in your admin (back-office) project. You can create, update, delete and show the results of the poll on the Polls page: |
|||
|
|||
 |
|||
|
|||
### OAuth Resource Owner Password as External Login Provider |
|||
|
|||
> The Resource Owner Password flow allows for the exchanging of the username and password of a user for an access token. When using the resource owner password credentials grant, the user provides the credentials (username and password) directly to the application. |
|||
|
|||
Now, you can login by entering a username and password from an OAuth server. |
|||
|
|||
Example: Use OAuth external login provider with Keycloak: |
|||
|
|||
 |
|||
|
|||
### Suite New Features & Enhancements |
|||
|
|||
In this version, there are some enhancements and new features in **Suite** and they are listed briefly below: |
|||
|
|||
* It's now possible to create an **app-nolayers (Application - single layer)** template via Suite and also code-generation is supported for the **app-nolayers** template with this version. |
|||
* Suite now allows users to see and download its logs. |
|||
* Suite now allows generating code via CLI. If you have a JSON file that contains code blocks, like entity configurations, you can use the `abp suite generate` command to generate CRUD pages based on it. |
|||
|
|||
Example: |
|||
|
|||
```bash |
|||
abp suite generate -e C:\Users\.suite\entities\Country.json -s C:\Users\my-proj\SuiteProj\SuiteProj.sln |
|||
``` |
|||
|
|||
### Suite Webinar: Take a closer look at the code generation |
|||
|
|||
 |
|||
|
|||
We've organized a webinar for Suite and in this webinar, we've talked about ABP Suite's capabilities, important features and more... |
|||
|
|||
You can watch the event from [here](https://www.youtube.com/watch?v=RFArBh60RSA&t=3s), if you haven't watched it yet. |
|||
|
|||
### Docker Compose Configurations for Single Layer Startup Template |
|||
|
|||
Dockerfiles, docker-compose files and build script files have been added to the Single Layer Startup Template (app-nolayers) with this version. |
|||
|
|||
And this way, applications created with this template now can be deployed more easily. |
|||
|
|||
### Microservice Solution Enhancements |
|||
|
|||
There are some enhancements made in the Microservice Solution. You can see the list of these enhancements: |
|||
|
|||
* Initial migration on the template has been updated with the small improvement that was made in the **Language Management** module. |
|||
* Database migration event handlers have been removed and "Distributed Locking" is now used for the database migrations. |
|||
|
|||
### PWA Support for the Application Pro Template |
|||
|
|||
Application Pro template also supports the PWA for Blazor WASM & Angular UIS. To create a startup template with the PWA support, you can use the `--pwa` parameter. |
|||
Example: |
|||
|
|||
```bash |
|||
abp new MyProgressiveWebApp -t app-pro -u blazor --pwa |
|||
``` |
|||
|
|||
## Community News |
|||
|
|||
### New ABP Community Posts |
|||
|
|||
* [Anto Subash](https://twitter.com/antosubash) created a series named ["Microservice with ABP"](https://blog.antosubash.com/posts/abp-microservice-series) and shared a couple of video posts about the ABP Microservice solution. |
|||
* [Francisco Kadzi](https://github.com/CiscoNinja) has created his first ABP Community article that shows how to ["Customize ABP Angular Application UI with AdminLTE"](https://community.abp.io/posts/customize-abp-angular-application-ui-with-adminlte.-7qu1m67s). |
|||
* [Jack Fistelmann](https://github.com/nebula2) has created an article to introduce a helpful project extension to speed up development on Visual Studio. You can read the article [here](https://community.abp.io/posts/using-switch-startup-project-extension-for-visual-studio-52yyw27v). |
|||
* [Jack Fistelmann](https://github.com/nebula2) has also created an article to show how you can generate PDF files with the `Sycyber.Core` package in ABP-based applications. You can read it [here](https://community.abp.io/posts/generate-pdfs-in-an-abp-framework-project-using-scryber.core-x9yh1vfa). |
|||
* [Halil Ibrahim Kalkan](https://twitter.com/hibrahimkalkan) has created an article to show ["Dealing with Multiple Implementations of a Service in ASP.NET Core & ABP Dependency Injection"](https://community.abp.io/posts/dealing-with-multiple-implementations-of-a-service-in-asp.net-core-abp-dependency-injection-ysfp4ho2) with examples. |
|||
* [Manoj Kumar](https://community.abp.io/members/manojkumar.t@shloklabs.com) submitted a new article about how to use "ABP authentication in a Flutter application". It was a frequently asked topic, which you can read [here](https://community.abp.io/posts/flutter-web-authentication-from-abp-mp6l2ehx). |
|||
* [Engincan Veske](https://twitter.com/EngincanVeske) created a new Community Article to show "Concurrency Check/Control in ABP". You can read it [here](https://community.abp.io/posts/handle-concurrency-with-ef-core-in-an-abp-framework-project-with-asp.net-core-mvc-jlkc3w8f). |
|||
|
|||
### ABP Community Talks 2022.4: How can you contribute to the open source ABP Framework? (May 10, 2022 - 17:00 UTC) |
|||
|
|||
 |
|||
|
|||
We've [asked you to pick the topic of the next Community Talks](https://twitter.com/abpframework/status/1514567683072745474?s=20&t=rJfHrB3DYDNsk2EXS8zBBQ) and you've chosen the "How to contribute to open source ABP Framework?" for the next talk topic. So, in this Community Talk, we will be talking about "How to contribute to ABP Framework" with one of the top contributors of the ABP Framework, [Ismail Yılmaz](https://github.com/iyilm4z). The event will be on **May 10, 2022, at 17:00 (UTC)** on YouTube. |
|||
|
|||
> You can register for the event from [here](https://kommunity.com/volosoft/events/abp-community-talks-20224-how-to-contribute-to-the-open-source-abp-framework-d9b50664), if you haven't registered yet. |
|||
|
|||
You can also [subscribe to the Volosoft channel](https://www.youtube.com/channel/UCO3XKlpvq8CA5MQNVS6b3dQ) to be informed about future ABP events and videos. |
|||
|
|||
### Discord Server |
|||
|
|||
We've created an official ABP Discord server so the ABP Community can interact with each other and created a blog-post to introduce it. You can read the [ABP Discord Server announcement post](https://blog.abp.io/abp/Official-ABP-Discord-Server-is-Here) to learn more about the ABP Discord Server. |
|||
|
|||
Thanks to the ABP Community, **700+** people joined our Discord Server so far and it grows every day. |
|||
|
|||
You can join our Discord Server from [here](https://discord.gg/abp), if you haven't yet. |
|||
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 371 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 58 KiB |
@ -0,0 +1,548 @@ |
|||
# Handle Concurrency with EF Core in an ABP Framework Project with ASP.NET Core MVC |
|||
|
|||
In this article, we'll create a basic application to demonstrate how "Concurrency Check/Control" can be implemented in an ABP project. |
|||
|
|||
## Creating the Solution |
|||
|
|||
For this article, we will create a simple BookStore application and add CRUD functionality to the pages. Hence we deal with the concurrency situation. |
|||
|
|||
We can create a new startup template with EF Core as a database provider and MVC for the UI Framework. |
|||
|
|||
> If you already have a project, you don't need to create a new startup template, you can directly implement the following steps to your project. So you can skip this section. |
|||
|
|||
We can create a new startup template by using the [ABP CLI](https://docs.abp.io/en/abp/latest/CLI). |
|||
|
|||
```bash |
|||
abp new Acme.BookStore |
|||
``` |
|||
|
|||
After running the above command, our project boilerplate will be downloaded. Then we can open the solution and start the development. |
|||
|
|||
## Starting the Development |
|||
|
|||
Let's start with defining our entities. |
|||
|
|||
### Creating Entities |
|||
|
|||
Create a `Book.cs` (/Books/Book.cs) class in the `.Domain` layer: |
|||
|
|||
```csharp |
|||
public class Book : AuditedAggregateRoot<Guid> |
|||
{ |
|||
public string Name { get; set; } |
|||
|
|||
public BookType Type { get; set; } |
|||
|
|||
public DateTime PublishDate { get; set; } |
|||
|
|||
public float Price { get; set; } |
|||
} |
|||
``` |
|||
|
|||
* To enable **Concurrency Check** for our entities, our entities should be implemented the `IHasConcurrencyStamp` interface, directly or indirectly. |
|||
|
|||
* [Aggregate Root](https://docs.abp.io/en/abp/5.2/Entities#aggregateroot-class) entity classes already implement the `IHasConcurrencyStamp` interface, so if we inherit our entities from one of these entity classes then we won't need to manually implement the `IHasConcurrencyStamp` interface. |
|||
|
|||
* And we've derived the `Book` entity from `AuditedAggregateRoot<TKey>` here, so we don't need to implement the `IHasConcurrencyStamp` interface because `AuditedAggregateRoot` class already implemented the `IHasConcurrencyStamp` interface. |
|||
|
|||
> You can read more details from the [Concurrency Check](https://docs.abp.io/en/abp/5.2/Concurrency-Check) documentation. |
|||
|
|||
Then, create a `BookType` (/Books/BookType.cs) enum in the `.Domain.Shared` layer: |
|||
|
|||
```csharp |
|||
public enum BookType |
|||
{ |
|||
Undefined, |
|||
Adventure, |
|||
Biography, |
|||
Dystopia, |
|||
Fantastic, |
|||
Horror, |
|||
Science, |
|||
ScienceFiction, |
|||
Poetry |
|||
} |
|||
``` |
|||
|
|||
### Database Integration |
|||
|
|||
Open the `BookStoreDbContext` (/EntityFrameworkCore/BookStoreDbContext.cs) class in the `*.EntityFrameworkCore` project and add the following `DbSet<Book>` statement: |
|||
|
|||
```csharp |
|||
namespace Acme.BookStore.EntityFrameworkCore; |
|||
|
|||
[ReplaceDbContext(typeof(IIdentityDbContext))] |
|||
[ReplaceDbContext(typeof(ITenantManagementDbContext))] |
|||
[ConnectionStringName("Default")] |
|||
public class BookStoreDbContext : |
|||
AbpDbContext<BookStoreDbContext>, |
|||
IIdentityDbContext, |
|||
ITenantManagementDbContext |
|||
{ |
|||
//Entities from the modules |
|||
|
|||
public DbSet<Book> Books { get; set; } //add this line |
|||
} |
|||
``` |
|||
|
|||
Then we can navigate to the `OnModelCreating` method in the same class and configure our tables/entities: |
|||
|
|||
```csharp |
|||
protected override void OnModelCreating(ModelBuilder builder) |
|||
{ |
|||
base.OnModelCreating(builder); |
|||
|
|||
/* Include modules to your migration db context */ |
|||
|
|||
builder.ConfigurePermissionManagement(); |
|||
... |
|||
|
|||
//* Configure your own tables/entities inside here */ |
|||
|
|||
builder.Entity<Book>(b => |
|||
{ |
|||
b.ToTable(BookStoreConsts.DbTablePrefix + "Books", |
|||
BookStoreConsts.DbSchema); |
|||
b.ConfigureByConvention(); //auto configure for the base class props |
|||
b.Property(x => x.Name).IsRequired().HasMaxLength(128); |
|||
}); |
|||
} |
|||
``` |
|||
|
|||
After the mapping configurations, we can create a new migration and apply changes to the database. |
|||
|
|||
To do this, open your command line terminal in the directory of the `EntityFrameworkCore` project and run the below command: |
|||
|
|||
```bash |
|||
dotnet ef migrations add Added_Books |
|||
``` |
|||
|
|||
After this command, a new migration will be generated and then we can run the `*.DbMigrator` project to apply the last changes to the database such as creating a new table named `Books` according to the last created migration. |
|||
|
|||
### Defining DTOs and Application Service Interfaces |
|||
|
|||
We can start to define the use cases of the application. |
|||
|
|||
Create the DTO classes (under the **Books** folder) in the `Application.Contracts` project: |
|||
|
|||
**BookDto.cs** |
|||
|
|||
```csharp |
|||
public class BookDto : AuditedEntityDto<Guid>, IHasConcurrencyStamp |
|||
{ |
|||
public string Name { get; set; } |
|||
|
|||
public BookType Type { get; set; } |
|||
|
|||
public DateTime PublishDate { get; set; } |
|||
|
|||
public float Price { get; set; } |
|||
|
|||
public string ConcurrencyStamp { get; set; } |
|||
} |
|||
``` |
|||
|
|||
* The `AuditedEntityDto<TKey>` class is not implemented from the `IHasConcurrencyStamp` interface, so for the **BookDto** class we need to implement the `IHasConcurrencyStamp`. |
|||
|
|||
* This is important, because we need to return books with their **ConcurrencyStamp** value. |
|||
|
|||
**CreateBookDto.cs** |
|||
|
|||
```csharp |
|||
public class CreateBookDto |
|||
{ |
|||
[Required] |
|||
[StringLength(128)] |
|||
public string Name { get; set; } |
|||
|
|||
[Required] |
|||
public BookType Type { get; set; } = BookType.Undefined; |
|||
|
|||
[Required] |
|||
[DataType(DataType.Date)] |
|||
public DateTime PublishDate { get; set; } = DateTime.Now; |
|||
|
|||
[Required] |
|||
public float Price { get; set; } |
|||
} |
|||
``` |
|||
|
|||
**UpdateBookDto.cs** |
|||
|
|||
```csharp |
|||
public class UpdateBookDto : IHasConcurrencyStamp |
|||
{ |
|||
[Required] |
|||
[StringLength(128)] |
|||
public string Name { get; set; } |
|||
|
|||
[Required] |
|||
public BookType Type { get; set; } = BookType.Undefined; |
|||
|
|||
[Required] |
|||
[DataType(DataType.Date)] |
|||
public DateTime PublishDate { get; set; } = DateTime.Now; |
|||
|
|||
[Required] |
|||
public float Price { get; set; } |
|||
|
|||
public string ConcurrencyStamp { get; set; } |
|||
} |
|||
``` |
|||
|
|||
* Here, we've implemented the `IHasConcurrencyStamp` interface for the **UpdateBookDto** class. |
|||
|
|||
* We will use this value while updating an existing book. ABP Framework will compare the current book's **ConcurrencyStamp** value with the provided one, if values are matched, this means everything is as it is supposed to be and will update the record. |
|||
|
|||
* If values are mismatched, then it means the record that we're trying to update is already updated by another user and we need to get the latest changes to be able to make changes on it. |
|||
|
|||
* Also, in that case, `AbpDbConcurrencyException` will be thrown by the ABP Framework and we can either handle this exception manually or let the ABP Framework handle it on behalf of us and show a user-friendly error message as in the image below. |
|||
|
|||
 |
|||
|
|||
Create a new `IBookAppService` (/Books/IBookAppService.cs) interface in the `Application.Contracts` project: |
|||
|
|||
```csharp |
|||
public interface IBookAppService : |
|||
ICrudAppService<BookDto, Guid, PagedAndSortedResultRequestDto, CreateBookDto, UpdateBookDto> |
|||
{ |
|||
} |
|||
``` |
|||
* We've implemented the `ICrudAppService` here, because we just need to perform CRUD operations and this interface helps us define common CRUD operation methods. |
|||
|
|||
### Application Service Implementations |
|||
|
|||
Create a `BookAppService` (/Books/BookAppService.cs) class inside the `*.Application` project and implement the application service methods, as shown below: |
|||
|
|||
```csharp |
|||
public class BookAppService : |
|||
CrudAppService<Book, BookDto, Guid, PagedAndSortedResultRequestDto, CreateBookDto, UpdateBookDto>, |
|||
IBookAppService |
|||
{ |
|||
public BookAppService(IRepository<Book, Guid> repository) |
|||
: base(repository) |
|||
{ |
|||
} |
|||
|
|||
public override async Task<BookDto> UpdateAsync(Guid id, UpdateBookDto input) |
|||
{ |
|||
var book = await Repository.GetAsync(id); |
|||
|
|||
book.Name = input.Name; |
|||
book.Price = input.Price; |
|||
book.Type = input.Type; |
|||
book.PublishDate = input.PublishDate; |
|||
|
|||
//set Concurrency Stamp value to the entity |
|||
book.ConcurrencyStamp = input.ConcurrencyStamp; |
|||
|
|||
var updatedBook = await Repository.UpdateAsync(book); |
|||
return ObjectMapper.Map<Book, BookDto>(updatedBook); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
* We've used the `CrudAppService` base class. This class implements all common CRUD operations and if we want to change a method, we can simply override the method and change it to our needs. |
|||
|
|||
> Normally, you don't need to override the `UpdateAsync` method to do **Concurrency Check**. Because the `UpdateAsync` method of the `CrudAppService` class by default map input values to the entity. But I wanted to override this method to show what we need to do for **Concurrency Check**. |
|||
|
|||
* We can look closer to the `UpdateAsync` method here, because as we've mentioned earlier we need to pass the provided **ConcurrencyStamp** value to be able to do **Concurrency Check/Control** to our entity while updating. |
|||
|
|||
* At that point, if the given record is already updated by any other user, a **ConcurrencyStamp** mismatch will occur and `AbpDbConcurrencyException` will be thrown thanks to the **Concurrency Check** system of ABP, data-consistency will be provided and the current record won't be overridden. |
|||
|
|||
* And if the values are matched, the record will be updated successfully. |
|||
|
|||
After implementing the application service methods, we can do the related mapping configurations, so open the `BookStoreApplicationAutoMapperProfile.cs` and update the content as below: |
|||
|
|||
```csharp |
|||
public class BookStoreApplicationAutoMapperProfile : Profile |
|||
{ |
|||
public BookStoreApplicationAutoMapperProfile() |
|||
{ |
|||
CreateMap<Book, BookDto>(); |
|||
CreateMap<CreateBookDto, Book>(); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### User Interface |
|||
|
|||
So far, we've applied the all necessary steps for the **Concurrency Check** system, let's see it in action. |
|||
|
|||
Create a razor page in the `.Web` layer named `Index` (**/Pages/Books/Index.cshtml**), open this file and replace the content with the following code block: |
|||
|
|||
```html |
|||
@page |
|||
@using Acme.BookStore.Localization |
|||
@using Microsoft.Extensions.Localization |
|||
@model Acme.BookStore.Web.Pages.Books.Index |
|||
|
|||
@section scripts |
|||
{ |
|||
<abp-script src="/Pages/Books/Index.js" /> |
|||
} |
|||
|
|||
<abp-card> |
|||
<abp-card-header> |
|||
<abp-row> |
|||
<abp-column size-md="_6"> |
|||
<abp-card-title>Books</abp-card-title> |
|||
</abp-column> |
|||
<abp-column size-md="_6" class="text-end"> |
|||
<abp-button id="NewBookButton" |
|||
text="New Book" |
|||
icon="plus" |
|||
button-type="Primary"/> |
|||
</abp-column> |
|||
</abp-row> |
|||
</abp-card-header> |
|||
<abp-card-body> |
|||
<abp-table striped-rows="true" id="BooksTable"></abp-table> |
|||
</abp-card-body> |
|||
</abp-card> |
|||
``` |
|||
|
|||
* We've defined a table and "New Book" button inside a card element here, we'll fill the table with our book records in the next step by using the **Datatables** library. |
|||
|
|||
Create an `Index.js` (**/Pages/Books/Index.js**) file and add the following code block: |
|||
|
|||
```js |
|||
$(function () { |
|||
var l = abp.localization.getResource('BookStore'); |
|||
var createModal = new abp.ModalManager(abp.appPath + 'Books/CreateModal'); |
|||
var editModal = new abp.ModalManager(abp.appPath + 'Books/EditModal'); |
|||
|
|||
var dataTable = $('#BooksTable').DataTable( |
|||
abp.libs.datatables.normalizeConfiguration({ |
|||
serverSide: true, |
|||
paging: true, |
|||
order: [[1, "asc"]], |
|||
searching: false, |
|||
scrollX: true, |
|||
ajax: abp.libs.datatables.createAjax(acme.bookStore.books.book.getList), |
|||
columnDefs: [ |
|||
{ |
|||
title: l('Actions'), |
|||
rowAction: { |
|||
items: |
|||
[ |
|||
{ |
|||
text: l('Edit'), |
|||
action: function (data) { |
|||
editModal.open({ id: data.record.id }); |
|||
} |
|||
} |
|||
] |
|||
} |
|||
}, |
|||
{ |
|||
title: l('Name'), |
|||
data: "name" |
|||
}, |
|||
{ |
|||
title: l('Type'), |
|||
data: "type", |
|||
render: function (data) { |
|||
return l('Enum:BookType:' + data); |
|||
} |
|||
}, |
|||
{ |
|||
title: l('PublishDate'), |
|||
data: "publishDate", |
|||
render: function (data) { |
|||
return luxon |
|||
.DateTime |
|||
.fromISO(data, { |
|||
locale: abp.localization.currentCulture.name |
|||
}).toLocaleString(); |
|||
} |
|||
}, |
|||
{ |
|||
title: l('Price'), |
|||
data: "price" |
|||
}, |
|||
{ |
|||
title: l('CreationTime'), |
|||
data: "creationTime", |
|||
render: function (data) { |
|||
return luxon |
|||
.DateTime |
|||
.fromISO(data, { |
|||
locale: abp.localization.currentCulture.name |
|||
}).toLocaleString(luxon.DateTime.DATETIME_SHORT); |
|||
} |
|||
} |
|||
] |
|||
}) |
|||
); |
|||
|
|||
createModal.onResult(function () { |
|||
dataTable.ajax.reload(); |
|||
}); |
|||
|
|||
editModal.onResult(function () { |
|||
dataTable.ajax.reload(); |
|||
}); |
|||
|
|||
$('#NewBookButton').click(function (e) { |
|||
e.preventDefault(); |
|||
createModal.open(); |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
* We've used the [Datatables](https://datatables.net/) to list our books. |
|||
|
|||
* Also defined **create** and **update** modals by using [ABP Modal Manager](https://docs.abp.io/en/abp/latest/UI/AspNetCore/Modals#modalmanager-reference), but we didn't create them yet, so let's create the modals. |
|||
|
|||
First, create a **CreateModal** razor page and update the **CreateModal.cshtml** and **CreateModal.cshtml.cs** files as below: |
|||
|
|||
**CreateModal.cshtml** |
|||
|
|||
```html |
|||
@page |
|||
@using Acme.BookStore.Web.Pages.Books |
|||
@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal |
|||
@model CreateModalModel |
|||
@{ |
|||
Layout = null; |
|||
} |
|||
<abp-dynamic-form abp-model="Book" asp-page="/Books/CreateModal"> |
|||
<abp-modal> |
|||
<abp-modal-header title="New Book"></abp-modal-header> |
|||
<abp-modal-body> |
|||
<abp-form-content /> |
|||
</abp-modal-body> |
|||
<abp-modal-footer buttons="@(AbpModalButtons.Cancel|AbpModalButtons.Save)"></abp-modal-footer> |
|||
</abp-modal> |
|||
</abp-dynamic-form> |
|||
``` |
|||
|
|||
* We've used `abp-dynamic-form` tag-helper and passed it a `Book` model, this tag helper will simply create form contents (inputs, select boxes etc.) on behalf of us. |
|||
|
|||
* **CreateModal.cshtml.cs** |
|||
|
|||
```csharp |
|||
using System.Threading.Tasks; |
|||
using Acme.BookStore.Books; |
|||
using Microsoft.AspNetCore.Mvc; |
|||
|
|||
namespace Acme.BookStore.Web.Pages.Books; |
|||
|
|||
public class CreateModalModel : BookStorePageModel |
|||
{ |
|||
[BindProperty] |
|||
public CreateBookDto Book { get; set; } |
|||
|
|||
private readonly IBookAppService _bookAppService; |
|||
|
|||
public CreateModalModel(IBookAppService bookAppService) |
|||
{ |
|||
_bookAppService = bookAppService; |
|||
} |
|||
|
|||
public void OnGet() |
|||
{ |
|||
Book = new CreateBookDto(); |
|||
} |
|||
|
|||
public async Task<IActionResult> OnPostAsync() |
|||
{ |
|||
await _bookAppService.CreateAsync(Book); |
|||
return NoContent(); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
* In this file, we simply define **CreateBookDto** as a bind property and we'll use this class's properties in the form. Thanks to the `abp-dynamic-form` tag-helper we don't need to define all of these form elements one by one, it will generate on behalf of us. |
|||
|
|||
We can create an **EditModal** razor page and update the **EditModal.cshtml** and **EditModal.cshtml.cs** files as below: |
|||
|
|||
**EditModal.cshtml** |
|||
|
|||
```html |
|||
@page |
|||
@using Acme.BookStore.Web.Pages.Books |
|||
@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal |
|||
@model EditModalModel |
|||
@{ |
|||
Layout = null; |
|||
} |
|||
<form asp-page="/Books/EditModal"> |
|||
<abp-modal> |
|||
<abp-modal-header title="Update"></abp-modal-header> |
|||
<abp-modal-body> |
|||
<abp-input asp-for="Id"/> |
|||
<abp-input asp-for="Book.Name"/> |
|||
<abp-input asp-for="Book.Price"/> |
|||
<abp-select asp-for="Book.Type"/> |
|||
<abp-input asp-for="Book.PublishDate"/> |
|||
<abp-input asp-for="Book.ConcurrencyStamp" type="hidden"/> |
|||
</abp-modal-body> |
|||
<abp-modal-footer buttons="@(AbpModalButtons.Cancel|AbpModalButtons.Save)"></abp-modal-footer> |
|||
</abp-modal> |
|||
</form> |
|||
``` |
|||
|
|||
* Here, we didn't use the `abp-dynamic-form` tag-helper and added all the necessary form elements to our form one by one. |
|||
|
|||
* As you may have noticed, we've set the input type as **hidden** for the **ConcurrencyStamp** input, because the end-user should not see this value. |
|||
|
|||
> Instead of doing it like that, we could create a view model class and use the `[HiddenInput]` data attribute for the **ConcurrencyStamp** property and use the `abp-dynamic-form` tag-helper. But to simplify the article I didn't want to do that, if you want you can create a view model and define the necessary data attributes for properties. |
|||
|
|||
**EditModal.cshtml.cs** |
|||
|
|||
```csharp |
|||
public class EditModalModel : BookStorePageModel |
|||
{ |
|||
[HiddenInput] |
|||
[BindProperty(SupportsGet = true)] |
|||
public Guid Id { get; set; } |
|||
|
|||
[BindProperty] |
|||
public UpdateBookDto Book { get; set; } |
|||
|
|||
private readonly IBookAppService _bookAppService; |
|||
|
|||
public EditModalModel(IBookAppService bookAppService) |
|||
{ |
|||
_bookAppService = bookAppService; |
|||
} |
|||
|
|||
public async Task OnGetAsync() |
|||
{ |
|||
var bookDto = await _bookAppService.GetAsync(Id); |
|||
Book = ObjectMapper.Map<BookDto, UpdateBookDto>(bookDto); |
|||
} |
|||
|
|||
public async Task<IActionResult> OnPostAsync() |
|||
{ |
|||
await _bookAppService.UpdateAsync(Id, Book); |
|||
return NoContent(); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
Lastly, we can define the necessary mapping configurations and run the application to see the result. |
|||
|
|||
Open the `BookStoreWebAutoMapperProfile.cs` class and update the content as below: |
|||
|
|||
```csharp |
|||
public class BookStoreWebAutoMapperProfile : Profile |
|||
{ |
|||
public BookStoreWebAutoMapperProfile() |
|||
{ |
|||
CreateMap<BookDto, UpdateBookDto>(); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
Then we can run the application, navigate to the **/Books** endpoint and see the result. |
|||
|
|||
 |
|||
|
|||
* In the image above, we can see that multiple users open the edit model to change a record and try to update the relevant record independently of each other. |
|||
|
|||
* After the first user updated the record, the second user tries to update the same record without getting the last state of the record. And therefore `AbpDbConcurrencyException` is thrown because **ConcurrencyStamp** values are different from each other. |
|||
|
|||
* The second user should close and re-open the model to get the last state of the record and then they can make changes to the current record. |
|||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 21 KiB |
@ -0,0 +1,11 @@ |
|||
# ABP Version 5.3 Migration Guide |
|||
|
|||
This document is a guide for upgrading ABP v5.2 solutions to ABP v5.3. There is a change in this version that may effect your applications, please read it carefully and apply the necessary changes to your application. |
|||
|
|||
## AutoMapper Upgraded to v11.0.1 |
|||
|
|||
AutoMapper library upgraded to **v11.0.1** in this version. So, you need to change your project's target SDK that use the **AutoMapper** library (typically your `*.Application` project). You can change it from `netstandard2.0` to `netstandard2.1` or `net6` if needed. Please see [#12189](https://github.com/abpframework/abp/pull/12189) for more info. |
|||
|
|||
## See Also |
|||
|
|||
* [Official blog post for the 5.3 release](https://blog.abp.io/abp/ABP.IO-Platform-5.3-RC-Has-Been-Published) |
|||
@ -0,0 +1,82 @@ |
|||
# LeptonX Lite Angular UI |
|||
LeptonX Lite has implementation for the ABP Framework Angular Client. It's a simplified variation of the [LeptonX Theme](https://x.leptontheme.com/). |
|||
|
|||
> If you are looking for a professional, enterprise ready theme, you can check the [LeptonX Theme](https://x.leptontheme.com/), which is a part of [ABP Commercial](https://commercial.abp.io/). |
|||
|
|||
> See the [Theming document](https://docs.abp.io/en/abp/latest/UI/AspNetCore/Theming) to learn about themes. |
|||
|
|||
## Installation |
|||
|
|||
To add `LeptonX-lite` into your project, |
|||
|
|||
* Install `@abp/ng.theme.lepton-x` |
|||
|
|||
`yarn add @abp/ng.theme.lepton-x@preview` |
|||
|
|||
* Install `bootstrap-icons` |
|||
|
|||
`yarn add bootstrap-icons` |
|||
|
|||
|
|||
* Then, we need to edit the styles array in `angular.json` to replace the existing style with the new one. |
|||
|
|||
Add the following style |
|||
|
|||
```json |
|||
"node_modules/bootstrap-icons/font/bootstrap-icons.css", |
|||
``` |
|||
|
|||
* Finally, remove `ThemeBasicModule` from `app.module.ts`, and import the related modules in `app.module.ts` |
|||
|
|||
```js |
|||
import { ThemeLeptonXModule } from '@abp/ng.theme.lepton-x'; |
|||
import { SideMenuLayoutModule } from '@abp/ng.theme.lepton-x/layouts'; |
|||
|
|||
@NgModule({ |
|||
imports: [ |
|||
// ... |
|||
|
|||
// do not forget to remove ThemeBasicModule |
|||
// ThemeBasicModule.forRoot(), |
|||
ThemeLeptonXModule.forRoot(), |
|||
SideMenuLayoutModule.forRoot(), |
|||
], |
|||
// ... |
|||
}) |
|||
export class AppModule {} |
|||
``` |
|||
|
|||
Note: If you employ [Resource Owner Password Flow](https://docs.abp.io/en/abp/latest/UI/Angular/Authorization#resource-owner-password-flow) for authorization, you should import the following module as well: |
|||
|
|||
```js |
|||
import { AccountLayoutModule } from '@abp/ng.theme.lepton-x/account'; |
|||
|
|||
@NgModule({ |
|||
// ... |
|||
imports: [ |
|||
// ... |
|||
AccountLayoutModule.forRoot(), |
|||
// ... |
|||
], |
|||
// ... |
|||
}) |
|||
export class AppModule {} |
|||
``` |
|||
|
|||
To change the logos and brand color of `LeptonX`, simply add the following CSS to the `styles.scss` |
|||
|
|||
```css |
|||
:root { |
|||
--lpx-logo: url('/assets/images/logo.png'); |
|||
--lpx-logo-icon: url('/assets/images/logo-icon.png'); |
|||
--lpx-brand: #edae53; |
|||
} |
|||
``` |
|||
|
|||
- `--lpx-logo` is used to place the logo in the menu. |
|||
- `--lpx-logo-icon` is a square icon used when the menu is collapsed. |
|||
- `--lpx-brand` is a color used throughout the application, especially on active elements. |
|||
|
|||
### Server Side |
|||
|
|||
In order to migrate to LeptonX on your server side projects (Host and/or IdentityServer projects), please follow the [Server Side Migration](mvc.md) document. |
|||
@ -0,0 +1,147 @@ |
|||
# LeptonX Lite Blazor UI |
|||
|
|||
````json |
|||
//[doc-params] |
|||
{ |
|||
"UI": ["Blazor", "BlazorServer"] |
|||
} |
|||
```` |
|||
|
|||
LeptonX Lite has implementation for the ABP Framework Blazor WebAssembly & Blazor Server. It's a simplified variation of the [LeptonX Theme](https://x.leptontheme.com/). |
|||
|
|||
> If you are looking for a professional, enterprise ready theme, you can check the [LeptonX Theme](https://x.leptontheme.com/), which is a part of [ABP Commercial](https://commercial.abp.io/). |
|||
|
|||
> See the [Theming document](https://docs.abp.io/en/abp/latest/UI/AspNetCore/Theming) to learn about themes. |
|||
|
|||
## Installation |
|||
|
|||
{{if UI == "Blazor"}} |
|||
- Complete the [MVC Razor Pages Installation](mvc.md#installation) for the **HttpApi.Host** application first. _If the solution is tiered/micro-service, complete the MVC steps for all MVC applications such as **HttpApi.Host** and if identity server is separated, install to the **IdentityServer**_. |
|||
|
|||
- Add **Volo.Abp.AspNetCore.Components.WebAssembly.LeptonXLiteTheme** package to your **Blazor WebAssembly** application. |
|||
```bash |
|||
dotnet add package Volo.Abp.AspNetCore.Components.WebAssembly.LeptonXLiteTheme |
|||
``` |
|||
|
|||
- Remove **Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme** reference from the project since it's not necessary after switching to LeptonX Lite. |
|||
|
|||
- Remove the old theme from the **DependsOn** attribute in your module class and add the **AbpAspNetCoreComponentsWebAssemblyLeptonXLiteThemeModule** type to the **DependsOn** attribute. |
|||
|
|||
```diff |
|||
[DependsOn( |
|||
// Remove BasicTheme module from DependsOn attribute |
|||
- typeof(AbpAspNetCoreComponentsWebAssemblyBasicThemeModule), |
|||
|
|||
// Add LeptonX Lite module to DependsOn attribute |
|||
+ typeof(AbpAspNetCoreComponentsWebAssemblyLeptonXLiteThemeModule), |
|||
)] |
|||
``` |
|||
|
|||
- Change startup App component with the LeptonX one. |
|||
|
|||
```csharp |
|||
// Make sure the 'App' comes from 'Volo.Abp.AspNetCore.Components.Web.LeptonXLiteTheme.Themes.LeptonXLite' namespace. |
|||
builder.RootComponents.Add<App>("#ApplicationContainer"); |
|||
``` |
|||
|
|||
- Run the `abp bundle` command in your **Blazor** application folder. |
|||
|
|||
{{end}} |
|||
|
|||
|
|||
{{if UI == "BlazorServer"}} |
|||
|
|||
- Complete the [MVC Razor Pages Installation](mvc.md#installation) first. _If the solution is tiered/micro-service, complete the MVC steps for all MVC applications such as **HttpApi.Host** and **IdentityServer**_. |
|||
|
|||
- Add **Volo.Abp.AspNetCore.Components.Server.LeptonXLiteTheme** package to your **Blazor server** application. |
|||
```bash |
|||
dotnet add package Volo.Abp.AspNetCore.Components.Server.LeptonXLiteTheme |
|||
``` |
|||
|
|||
- Remove **Volo.Abp.AspNetCore.Components.Server.BasicTheme** reference from the project since it's not necessary after switching to LeptonX Lite. |
|||
|
|||
|
|||
- Remove old theme from the **DependsOn** attribute in your module class and add the **AbpAspNetCoreComponentsServerLeptonXLiteThemeModule** type to the **DependsOn** attribute. |
|||
|
|||
```diff |
|||
[DependsOn( |
|||
// Remove BasicTheme module from DependsOn attribute |
|||
- typeof(AbpAspNetCoreComponentsServerBasicThemeModule), |
|||
|
|||
// Add LeptonX Lite module to DependsOn attribute |
|||
+ typeof(AbpAspNetCoreComponentsServerLeptonXLiteThemeModule) |
|||
)] |
|||
``` |
|||
|
|||
- Replace `BlazorBasicThemeBundles` with `BlazorLeptonXLiteThemeBundles` in `AbpBundlingOptions`: |
|||
```diff |
|||
options.StyleBundles.Configure( |
|||
// Remove following line |
|||
- BlazorBasicThemeBundles.Styles.Global, |
|||
// Add following line instead |
|||
+ BlazorLeptonXLiteThemeBundles.Styles.Global, |
|||
bundle => |
|||
{ |
|||
bundle.AddFiles("/blazor-global-styles.css"); |
|||
//You can remove the following line if you don't use Blazor CSS isolation for components |
|||
bundle.AddFiles("/MyProjectName.Blazor.styles.css"); |
|||
}); |
|||
``` |
|||
|
|||
- Update `_Host.cshtml` file. _(located under **Pages** folder by default.)_ |
|||
|
|||
- Add following usings to Locate **App** and **BlazorLeptonXLiteThemeBundles** classes. |
|||
```csharp |
|||
@using Volo.Abp.AspNetCore.Components.Web.LeptonXLiteTheme.Themes.LeptonXLite |
|||
@using Volo.Abp.AspNetCore.Components.Server.LeptonXLiteTheme.Bundling |
|||
``` |
|||
- Then replace script & style bundles as following: |
|||
```diff |
|||
// Remove following line |
|||
- <abp-style-bundle name="@BlazorBasicThemeBundles.Styles.Global" /> |
|||
// Add following line instead |
|||
+ <abp-style-bundle name="@BlazorLeptonXLiteThemeBundles.Styles.Global" /> |
|||
``` |
|||
|
|||
```diff |
|||
// Remove following line |
|||
- <abp-script-bundle name="@BlazorBasicThemeBundles.Scripts.Global" /> |
|||
// Add following line instead |
|||
+ <abp-script-bundle name="@BlazorLeptonXLiteThemeBundles.Scripts.Global" /> |
|||
``` |
|||
|
|||
{{end}} |
|||
|
|||
|
|||
--- |
|||
|
|||
## Customization |
|||
|
|||
### Toolbars |
|||
LeptonX Lite includes separeted toolbars for desktop & mobile. You can manage toolbars independently. Toolbar names can be accessible in the **LeptonXLiteToolbars** class. |
|||
|
|||
- `LeptonXLiteToolbars.Main` |
|||
- `LeptonXLiteToolbars.MainMobile` |
|||
|
|||
```csharp |
|||
public async Task ConfigureToolbarAsync(IToolbarConfigurationContext context) |
|||
{ |
|||
if (context.Toolbar.Name == LeptonXLiteToolbars.Main) |
|||
{ |
|||
context.Toolbar.Items.Add(new ToolbarItem(typeof(MyDesktopComponent))); |
|||
} |
|||
|
|||
if (context.Toolbar.Name == LeptonXLiteToolbars.MainMobile) |
|||
{ |
|||
context.Toolbar.Items.Add(new ToolbarItem(typeof(MyMobileComponent))); |
|||
} |
|||
|
|||
return Task.CompletedTask; |
|||
} |
|||
``` |
|||
|
|||
{{if UI == "BlazorServer"}} |
|||
|
|||
> _You can visit the [Toolbars Documentation](https://docs.abp.io/en/abp/latest/UI/Blazor/Toolbars) for better understanding._ |
|||
|
|||
{{end}} |
|||
@ -0,0 +1,74 @@ |
|||
# LeptonX Lite MVC UI |
|||
LeptonX Lite has implementation for the ABP Framework Razor Pages. It's a simplified variation of the [LeptonX Theme](https://x.leptontheme.com/). |
|||
|
|||
> If you are looking for a professional, enterprise ready theme, you can check the [LeptonX Theme](https://x.leptontheme.com/), which is a part of [ABP Commercial](https://commercial.abp.io/). |
|||
|
|||
> See the [Theming document](https://docs.abp.io/en/abp/latest/UI/AspNetCore/Theming) to learn about themes. |
|||
|
|||
## Installation |
|||
|
|||
- Add **Volo.Abp.AspNetCore.Mvc.UI.Theme.LeptonXLite** package to your **Web** application. |
|||
|
|||
```bash |
|||
dotnet add package Volo.Abp.AspNetCore.Mvc.UI.Theme.LeptonXLite --prerelease |
|||
``` |
|||
|
|||
- Remove **Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic** reference from the project since it's not necessary after switching to LeptonX Lite. |
|||
|
|||
- Make sure the old theme is removed and LeptonX is added in your Module class. |
|||
|
|||
```diff |
|||
[DependsOn( |
|||
// Remove BasicTheme module from DependsOn attribute |
|||
- typeof(AbpAspNetCoreMvcUiBasicThemeModule), |
|||
|
|||
// Add LeptonX Lite module to DependsOn attribute |
|||
+ typeof(AbpAspNetCoreMvcUiLeptonXLiteThemeModule), |
|||
)] |
|||
``` |
|||
|
|||
- Replace `BasicThemeBundles` with `LeptonXLiteThemeBundles` in AbpBundlingOptions |
|||
|
|||
```diff |
|||
Configure<AbpBundlingOptions>(options => |
|||
{ |
|||
options.StyleBundles.Configure( |
|||
// Remove following line |
|||
- BasicThemeBundles.Styles.Global, |
|||
// Add following line instead |
|||
+ LeptonXLiteThemeBundles.Styles.Global |
|||
bundle => |
|||
{ |
|||
bundle.AddFiles("/global-styles.css"); |
|||
} |
|||
); |
|||
}); |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Customization |
|||
|
|||
### Toolbars |
|||
LeptonX Lite includes separeted toolbars for desktop & mobile. You can manage toolbars independently. Toolbar names can be accessible in the **LeptonXLiteToolbars** class. |
|||
|
|||
- `LeptonXLiteToolbars.Main` |
|||
- `LeptonXLiteToolbars.MainMobile` |
|||
|
|||
```csharp |
|||
public class MyProjectNameMainToolbarContributor : IToolbarContributor |
|||
{ |
|||
public async Task ConfigureToolbarAsync(IToolbarConfigurationContext context) |
|||
{ |
|||
if (context.Toolbar.Name == LeptonXLiteToolbars.Main) |
|||
{ |
|||
context.Toolbar.Items.Add(new ToolbarItem(typeof(MyDesktopComponent))); |
|||
} |
|||
|
|||
if (context.Toolbar.Name == LeptonXLiteToolbars.MainMobile) |
|||
{ |
|||
context.Toolbar.Items.Add(new ToolbarItem(typeof(MyMobileComponent))); |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
@ -0,0 +1,29 @@ |
|||
# DateTime Format Pipes |
|||
|
|||
You can format date by Date pipe of angular. |
|||
|
|||
Example |
|||
|
|||
```html |
|||
<span> {{today | date 'dd/mm/yy'}}</span> |
|||
``` |
|||
|
|||
ShortDate, ShortTime and ShortDateTime format data like angular's data pipe but easier. Also the pipes get format from config service by culture. |
|||
|
|||
# ShortDate Pipe |
|||
|
|||
```html |
|||
<span> {{today | shortDatePipe }}</span> |
|||
``` |
|||
|
|||
# ShortTime Pipe |
|||
|
|||
```html |
|||
<span> {{today | shortTimePipe }}</span> |
|||
``` |
|||
|
|||
# ShortDateTime Pipe |
|||
|
|||
```html |
|||
<span> {{today | shortDateTimePipe }}</span> |
|||
``` |
|||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 106 KiB |
@ -0,0 +1,99 @@ |
|||
# 部署到群集环境 |
|||
|
|||
本文档介绍了在将应用程序部署到**多个应用程序实例同时运行**的集群环境中时应注意的内容, 并解释了如何在基于ABP的应用程序中处理这些内容. |
|||
|
|||
> 无论你使用的是单体式应用程序还是微服务解决方案, 本文档均有效. 适用于一个流程. 应用程序可以是单体式web应用程序、微服务解决方案中的服务、控制台应用程序或其他类型的可执行进程. |
|||
> |
|||
> 例如, 如果你将应用程序部署到Kubernetes并把应用程序或服务在多个POD中运行, 那么应用程序或服务将在集群环境中运行. |
|||
|
|||
## 了解集群环境 |
|||
|
|||
> 如果你已经熟悉集群部署和负载均衡器, 可以跳过本节. |
|||
|
|||
### 单实例部署 |
|||
|
|||
考虑作为**单个实例**部署的应用程序, 如下图所示: |
|||
|
|||
 |
|||
|
|||
浏览器和其他客户端应用程序可以直接向应用程序发出HTTP请求. 你可以在客户端和应用程序之间放置一个web服务器(例如IIS或NGINX), 但仍有一个应用程序实例在单个服务器或容器中运行. 单实例的配置**限于规模**, 因为它在一台服务器上运行, 并且你受到服务器容量的限制. |
|||
|
|||
### 集群部署 |
|||
|
|||
**集群部署**是在一台或多台服务器上**同时运行**应用程序**多个实例**的方式. 通过这种方式, 不同的实例可以满足不同的请求, 并且可以通过在系统中添加新服务器来扩展. 下图显示了集群使用**负载均衡器**的典型实现: |
|||
|
|||
 |
|||
|
|||
### 负载均衡器 |
|||
|
|||
[负载均衡器](https://en.wikipedia.org/wiki/Load_balancing_(computing)) 有很多特性, 但它们基本上会将**传入的HTTP请求转发**给应用程序的实例, 并将响应返回给客户端应用程序. |
|||
|
|||
负载平衡器可以使用不同的算法来选择应用程序实例, 同时确定用于传递传入请求的应用程序实例. **循环**是最简单、最常用的算法之一. 请求被轮流传递到应用程序实例. 第一个实例得到第一个请求, 第二个实例得到第二个请求, 依此类推. 在所有实例都被使用之后, 它返回到第一个实例, 并且下一个请求的算法也是类似的. |
|||
|
|||
### 潜在问题 |
|||
|
|||
一旦应用程序的多个实例并行运行, 你应该仔细考虑以下内容: |
|||
|
|||
* 当你有多个实例时, 存储在应用程序 **内存中的任何状态(数据)** 都将成为问题. 存储在应用程序实例内存中的状态可能在下一个请求中不可用, 因为下一个请求将由不同的应用程序实例处理. 虽然有一些解决方案(比如粘性会话)可以解决这个问题, 但如果你想在集群、容器或云中运行应用程序, **最好将其设计为无状态**. |
|||
* **内存缓存** 是一种内存状态, 不应在集群应用程序中使用. 你应该使用**分布式缓存**. |
|||
* 你不应该在**本地文件系统**中存储应用程序所有实例都可以使用的数据. 不同的应用程序实例可能在不同的容器或服务器中运行, 并且它们可能无法访问同一个文件系统. 你可以使用**云或外部存储提供商**作为解决方案. |
|||
* 如果你有**后台工作者**或**作业队列管理器**, 则应小心, 因为多个实例可能会尝试执行同一作业或同时执行同一工作. 因此, 你可能会多次完成相同的工作, 或者在尝试访问和更改相同的资源时可能会出现很多错误. |
|||
|
|||
集群部署可能会有更多问题, 但这些是最常见的问题. ABP被设计为与集群部署场景兼容. 以下各节介绍了将基于ABP的应用程序部署到集群环境时应执行的操作. |
|||
|
|||
## 切换分布式缓存 |
|||
|
|||
ASP.NET Core提供了不同类型的缓存功能. [内存缓存](https://docs.microsoft.com/en-us/aspnet/core/performance/caching/memory)将对象存储在本地服务器的内存中, 并且仅对存储该对象的应用程序可用. 集群环境中的非粘性会话应使用[分布式缓存](https://docs.microsoft.com/en-us/aspnet/core/performance/caching/distributed), 除了一些特定场景(例如, 你可以将本地CSS文件缓存到内存中. 它是只读数据, 在所有应用程序实例中都是相同的. 出于性能原因, 你可以将其缓存到内存中, 而不会出现任何问题). |
|||
|
|||
[ABP的分布式缓存](../Caching.md)扩展了[ASP.NET Core的分布式缓存](https://docs.microsoft.com/en-us/aspnet/core/performance/caching/distributed)的基础设施. 默认情况下, 它在内存中工作. 当你要将应用程序部署到集群环境时, 应该配置实际的分布式缓存提供程序. |
|||
|
|||
> 即使应用程序不直接使用`IDistributedCache`, 也应该为集群部署配置缓存提供程序. 因为ABP框架和预构建的[应用程序模块](../Modules/Index.md)正在使用分布式缓存. |
|||
|
|||
ASP.NET Core提供了可以用作分布式缓存提供程序的多种集成, 如[Redis](https://redis.io/)和[NCache](https://www.alachisoft.com/ncache/). 你可以按照[微软文档](https://docs.microsoft.com/en-us/aspnet/core/performance/caching/distributed)了解如何在应用程序中使用它们. |
|||
|
|||
如果你决定使用Redis作为分布式缓存提供程序, **请遵循[ABP的Redis缓存集成文档](../Redis-Cache.md)** 了解将其安装到应用程序并配置Redis所需遵循的步骤. |
|||
|
|||
> 根据你在创建新ABP解决方案时的偏好, Redis缓存可能会预先安装在你的解决方案中. 例如, 如果你在MVC UI中选择了*Tiered*选项, Redis缓存将进行预装. 因为, 在这种情况下, 解决方案中有两个应用程序, 它们应该使用相同的缓存源来保持一致. |
|||
|
|||
## 使用合适的BLOB存储提供程序 |
|||
|
|||
如果你在[文件系统提供程序](../Blob-Storing-File-System.md)中使用了ABP的[BLOB存储](../Blob-Storing.md)功能, 则应该在集群环境中使用另一个提供程序, 因为文件系统提供程序使用应用程序的本地文件系统. |
|||
|
|||
[数据库BLOB提供程序](../Blob-Storing-Database)是最简单的方法, 因为它使用应用程序的主数据库(或另一个数据库, 如果你配置的话)来存储BLOB. 但是, 你应该记住, BLOB是大型对象, 可能会迅速增加数据库的大小. |
|||
|
|||
> [ABP商业版](https://commercial.abp.io/)启动解决方案模板预装了数据库BLOB提供程序, 并将BLOB存储在应用程序的数据库中. |
|||
|
|||
查看[BLOB Storing](../Blob-Storing.md)文档以查看所有可用的BLOB存储提供程序. |
|||
|
|||
## 配置后台作业 |
|||
|
|||
ABP的[后台作业系统](../Background-Jobs.md)将要在后台执行的任务进行排队. 后台作业队列是持久性的, 排队的任务能够保证执行(如果失败, 将重新尝试). |
|||
|
|||
ABP的默认后台作业管理器与集群环境兼容. 它使用[分布式锁](../Distributed-Locking.md)来确保一次只能在单个应用程序实例中执行作业. 请参阅下面的*配置分布式锁提供程序*部分, 了解如何为应用程序配置分布式锁提供程序, 以便默认后台作业管理器在集群环境中正常工作. |
|||
|
|||
如果不想使用分布式锁提供程序, 可以使用以下选项: |
|||
|
|||
* 停止所有应用程序实例中的后台作业管理器(将`AbpBackgroundJobOptions.IsJobExecutionEnabled`设置为`false`)只保留其中一个应用程序实例, 以便只有单个实例执行作业(而其他应用程序实例仍可以对作业进行排队). |
|||
* 在所有应用程序实例中停止后台作业管理器(将`AbpBackgroundJobOptions.IsJobExecutionEnabled`设置为`false`), 并创建一个专用的应用程序(可能是在自己的容器中运行的控制台应用程序或在后台运行的Windows服务)来执行所有后台作业. 如果你的后台作业占用大量系统资源(CPU、RAM或磁盘), 那么这是一个不错的选择, 这样你就可以将该后台应用程序部署到专用服务器上, 并且后台作业不会影响应用程序的性能. |
|||
|
|||
> 如果你使用的是外部后台作业集成(例如[Hangfire](../Background-Workers-Hangfire.md)或[Quartz](../Background-Workers-Quartz.md))而不是默认的后台作业管理器, 请参阅提供程序的文档, 了解如何为集群环境配置它. |
|||
|
|||
## 配置分布式锁提供程序 |
|||
|
|||
ABP通过[分布式锁](https://github.com/madelson/DistributedLock)库实现了一个抽象的分布式锁. 分布式锁用于控制多个应用程序对共享资源的并发访问, 以防止由于并发写入而导致资源损坏. ABP框架和一些预构建的[应用程序模块](../Modules/Index.md)出于一些原因正在使用分布式锁. |
|||
|
|||
但是, 分布式锁系统默认在进程中工作. 这意味着它实际上不是分布式的, 除非配置分布式锁提供程序. 因此, 如果尚未配置应用程序的提供程序, 请按照[分布式锁](../Distributed-Locking.md)文档为其配置提供程序. |
|||
|
|||
## 实现后台工作者 |
|||
|
|||
ASP.NET Core[托管服务](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services), ABP提供[后台工作者](../Background-Workers.md)在应用程序的后台线程中执行任务. |
|||
|
|||
如果你的应用程序有在后台运行的任务, 你应该注意它们在集群环境中的行为, 尤其是在后台任务使用相同资源的情况下. 你应该设计后台任务, 以便它们在集群环境中继续正常工作. |
|||
|
|||
假设SaaS应用程序中的后台工作者检查用户订阅, 并在订阅续订日期临近时发送电子邮件. 如果后台任务在多个应用程序实例中运行, 可能会多次向某些用户发送同一封电子邮件, 这会影响他们. |
|||
|
|||
我们建议你使用以下方法之一来解决此问题: |
|||
|
|||
* 实现你的后台工作者, 以便他们在集群环境中工作时不会出现任何问题. 使用[分布式锁](../Distributed-Locking.md)来确保并发控制是一种方法. 应用程序实例中的后台工作者可能会处理分布式锁, 因此其他应用程序实例中的工作者将等待该锁. 这样, 只有一个工作者在实际工作, 而其他的则在等待. 如果你实现了这一点, 你的后台工作者就可以安全地运行, 不必关心应用程序是如何部署的. |
|||
* 停止所有应用程序实例中的后台工作者(将`AbpBackgroundWorkerOptions.IsEnabled`设置为`false`), 只保留其中一个应用程序实例, 因此只有单个实例运行这些后台工作者. |
|||
* 停止所有应用程序实例中的后台工作者(将`AbpBackgroundWorkerOptions.IsEnabled`设置为`false`), 并创建一个专用的应用程序(可能是在自己的容器中运行的控制台应用程序或在后台运行的Windows服务)来执行所有后台任务. 如果你的后台工作者消耗大量系统资源(CPU、RAM或磁盘), 那么这是一个不错的选择, 这样你就可以将该后台应用程序部署到专用服务器上, 并且你的后台任务不会影响应用程序的性能. |
|||
@ -0,0 +1,9 @@ |
|||
# 部署 |
|||
|
|||
部署ABP应用程序与部署其他.NET或ASP.NET Core应用程序并没有什么不同. 你可以将其部署到云服务提供商(例如Azure、AWS、Google)或内部部署服务器、IIS或任何其他web服务器. ABP的文档中没有太多关于部署的信息. 你可以参考提供商的文档. |
|||
|
|||
但是, 在部署应用程序时, 有些主题是你应该注意的. 其中大多数是一般的软件部署注意事项, 但你应该了解如何在基于ABP的应用程序中处理它们. 我们为此准备了指南, 建议你在设计部署配置之前仔细阅读这些指南. |
|||
|
|||
## 指南 |
|||
|
|||
* [部署到群集环境](Clustered-Environment.md): 讲解了当你希望同时运行应用程序的多个实例时, 如何来配置应用程序. |
|||
@ -0,0 +1,110 @@ |
|||
# 分布式锁 |
|||
分布式锁是一种管理多个应用程序访问同一资源的技术. 主要目的是同一时间只允许多个应用程序中的一个访问资源. 否则, 从不同的应用程序访问同一对象可能会破坏资源. |
|||
|
|||
> ABP当前的分布式锁实现基于[DistributedLock](https://github.com/madelson/DistributedLock)库. |
|||
|
|||
## 安装 |
|||
|
|||
你可以打开一个命令行终端并输入以下命令来安装[Volo.Abp.DistributedLocking](https://www.nuget.org/packages/Volo.Abp.DistributedLocking)到你的项目中: |
|||
|
|||
````bash |
|||
abp add-package Volo.Abp.DistributedLocking |
|||
```` |
|||
|
|||
这个库提供了使用分布式锁系统所需的API, 但是, 在使用它之前, 你应该配置一个提供程序. |
|||
|
|||
### 配置一个提供程序 |
|||
|
|||
[DistributedLock](https://github.com/madelson/DistributedLock)库对[Redis](https://github.com/madelson/DistributedLock/blob/master/docs/DistributedLock.Redis.md)和[ZooKeeper](https://github.com/madelson/DistributedLock/blob/master/docs/DistributedLock.ZooKeeper.md)提供[多种实现](https://github.com/madelson/DistributedLock#implementations). |
|||
|
|||
例如, 如果你想使用[Redis provider](https://github.com/madelson/DistributedLock/blob/master/docs/DistributedLock.Redis.md), 你应该将[DistributedLock.Redis](https://www.nuget.org/packages/DistributedLock.Redis) NuGet包添加到项目中, 然后将以下代码添加到ABP[模块](Module-Development-Basics.md)类的`ConfigureServices`方法中: |
|||
|
|||
````csharp |
|||
using Medallion.Threading; |
|||
using Medallion.Threading.Redis; |
|||
|
|||
namespace AbpDemo |
|||
{ |
|||
[DependsOn( |
|||
typeof(AbpDistributedLockingModule) |
|||
//If you have the other dependencies, you should do here |
|||
)] |
|||
public class MyModule : AbpModule |
|||
{ |
|||
public override void ConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
var configuration = context.Services.GetConfiguration(); |
|||
|
|||
context.Services.AddSingleton<IDistributedLockProvider>(sp => |
|||
{ |
|||
var connection = ConnectionMultiplexer |
|||
.Connect(configuration["Redis:Configuration"]); |
|||
return new |
|||
RedisDistributedSynchronizationProvider(connection.GetDatabase()); |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
此代码从[配置](Configuration.md)获取Redis连接字符串, 因此你可以将以下行添加到`appsettings.json`文件: |
|||
|
|||
````json |
|||
"Redis": { |
|||
"Configuration": "127.0.0.1" |
|||
} |
|||
```` |
|||
|
|||
## 使用 |
|||
|
|||
有两种方法可以使用分布式锁API: ABP的`IAbpDistributedLock`抽象和[DistributedLock](https://github.com/madelson/DistributedLock)库的API. |
|||
|
|||
### 使用IAbpDistributedLock服务 |
|||
|
|||
`IAbpDistributedLock`是ABP框架提供的一个用于简单使用分布式锁的服务. |
|||
|
|||
**实例: 使用`IAbpDistributedLock.TryAcquireAsync`方法** |
|||
|
|||
````csharp |
|||
using Volo.Abp.DistributedLocking; |
|||
|
|||
namespace AbpDemo |
|||
{ |
|||
public class MyService : ITransientDependency |
|||
{ |
|||
private readonly IAbpDistributedLock _distributedLock; |
|||
public MyService(IAbpDistributedLock distributedLock) |
|||
{ |
|||
_distributedLock = distributedLock; |
|||
} |
|||
|
|||
public async Task MyMethodAsync() |
|||
{ |
|||
await using (var handle = |
|||
await _distributedLock.TryAcquireAsync("MyLockName")) |
|||
{ |
|||
if (handle != null) |
|||
{ |
|||
// your code that access the shared resource |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
`TryAcquireAsync`可能无法获取锁. 如果无法获取锁, 则返回`null`. 在这种情况下, 你不应该访问资源. 如果句柄不为`null`, 则表示你已获得锁, 并且可以安全地访问资源. |
|||
|
|||
`TryAcquireAsync`方法拥有以下参数: |
|||
|
|||
* `name` (`string`, 必须): 锁的唯一名称. 不同的锁命名用于访问不同的资源. |
|||
* `timeout` (`TimeSpan`): 等待获取锁的超时值. 默认值为`TimeSpan.Zero`, 这意味着如果锁已经被另一个应用程序拥有, 它不会等待. |
|||
* `cancellationToken`: 取消令牌可在触发后取消操作. |
|||
|
|||
### 使用DistributedLock库的API |
|||
|
|||
ABP的`IAbpDistributedLock`服务非常有限, 主要用于ABP框架的内部使用. 对于你自己的应用程序, 可以使用DistributedLock库自己的API. 参见[文档](https://github.com/madelson/DistributedLock)详细信息. |
|||
|
|||
## Volo.Abp.DistributedLocking.Abstractions库 |
|||
|
|||
如果你正在构建一个可重用的库或应用程序模块, 那么对于作为单个实例运行的简单应用程序, 你可能不希望为模块带来额外的依赖关系. 在这种情况下, 你的库可以依赖于[Volo.Abp.DistributedLocking.Abstractions](https://nuget.org/packages/Volo.Abp.DistributedLocking.Abstractions)库, 它定义了`IAbpDistributedLock`服务, 并将其在进程内实现(实际上不是分布式的). 通过这种方式, 你的库可以在作为单个实例运行的应用程序中正常运行(没有分布式锁提供程序依赖项). 如果应用程序部署到[集群环境](Deployment/Clustered-Environment.md), 那么应用程序开发人员应该安装一个真正的分布式提供程序, 如*安装*部分所述. |
|||
@ -1,3 +1,128 @@ |
|||
# ABP Documentation |
|||
# 领域服务 |
|||
|
|||
待添加 |
|||
## 介绍 |
|||
|
|||
在 [领域驱动设计](Domain-Driven-Design.md) (DDD) 解决方案中,核心业务逻辑通常在聚合 ([实体](Entities.md)) 和领域服务中实现. 在以下情况下特别需要创建领域服务 |
|||
|
|||
* 你实现了依赖于某些服务(如存储库或其他外部服务)的核心域逻辑. |
|||
* 你需要实现的逻辑与多个聚合/实体相关,因此它不适合任何聚合. |
|||
|
|||
## ABP 领域服务基础设施 |
|||
|
|||
领域服务是简单的无状态类. 虽然你不必从任何服务或接口派生,但 ABP 框架提供了一些有用的基类和约定. |
|||
|
|||
### DomainService 和 IDomainService |
|||
|
|||
从 `DomainService` 基类派生领域服务或直接实现 `IDomainService` 接口. |
|||
|
|||
**示例: 创建从 `DomainService` 基类派生的领域服务.** |
|||
|
|||
````csharp |
|||
using Volo.Abp.Domain.Services; |
|||
namespace MyProject.Issues |
|||
{ |
|||
public class IssueManager : DomainService |
|||
{ |
|||
|
|||
} |
|||
} |
|||
```` |
|||
|
|||
当你这样做时: |
|||
|
|||
* ABP 框架自动将类注册为瞬态生命周期到依赖注入系统. |
|||
* 你可以直接使用一些常用服务作为基础属性,而无需手动注入 (例如 [ILogger](Logging.md) and [IGuidGenerator](Guid-Generation.md)). |
|||
|
|||
> 建议使用 `Manager` 或 `Service` 后缀命名领域服务. 我们通常使用如上面示例中的 `Manager` 后缀. |
|||
**示例: 实现将问题分配给用户的领域逻辑** |
|||
|
|||
````csharp |
|||
public class IssueManager : DomainService |
|||
{ |
|||
private readonly IRepository<Issue, Guid> _issueRepository; |
|||
public IssueManager(IRepository<Issue, Guid> issueRepository) |
|||
{ |
|||
_issueRepository = issueRepository; |
|||
} |
|||
|
|||
public async Task AssignAsync(Issue issue, AppUser user) |
|||
{ |
|||
var currentIssueCount = await _issueRepository |
|||
.CountAsync(i => i.AssignedUserId == user.Id); |
|||
|
|||
//Implementing a core business validation |
|||
if (currentIssueCount >= 3) |
|||
{ |
|||
throw new IssueAssignmentException(user.UserName); |
|||
} |
|||
issue.AssignedUserId = user.Id; |
|||
} |
|||
} |
|||
```` |
|||
|
|||
问题是定义如下所示的 [聚合根](Entities.md): |
|||
|
|||
````csharp |
|||
public class Issue : AggregateRoot<Guid> |
|||
{ |
|||
public Guid? AssignedUserId { get; internal set; } |
|||
|
|||
//... |
|||
} |
|||
```` |
|||
|
|||
* 使用 `internal` 的 set 确保外层调用者不能直接在调用 set ,并强制始终使用 `IssueManager` 为 `User` 分配 `Issue`. |
|||
|
|||
### 使用领域服务 |
|||
|
|||
领域服务通常用于 [应用程序服务](Application-Services.md). |
|||
|
|||
**示例: 使用 `IssueManager` 将问题分配给用户** |
|||
|
|||
````csharp |
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using MyProject.Users; |
|||
using Volo.Abp.Application.Services; |
|||
using Volo.Abp.Domain.Repositories; |
|||
namespace MyProject.Issues |
|||
{ |
|||
public class IssueAppService : ApplicationService, IIssueAppService |
|||
{ |
|||
private readonly IssueManager _issueManager; |
|||
private readonly IRepository<AppUser, Guid> _userRepository; |
|||
private readonly IRepository<Issue, Guid> _issueRepository; |
|||
public IssueAppService( |
|||
IssueManager issueManager, |
|||
IRepository<AppUser, Guid> userRepository, |
|||
IRepository<Issue, Guid> issueRepository) |
|||
{ |
|||
_issueManager = issueManager; |
|||
_userRepository = userRepository; |
|||
_issueRepository = issueRepository; |
|||
} |
|||
public async Task AssignAsync(Guid id, Guid userId) |
|||
{ |
|||
var issue = await _issueRepository.GetAsync(id); |
|||
var user = await _userRepository.GetAsync(userId); |
|||
await _issueManager.AssignAsync(issue, user); |
|||
await _issueRepository.UpdateAsync(issue); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
由于 `IssueAppService` 在应用层, 它不能直接将问题分配给用户.因此,它使用 `IssueManager`. |
|||
|
|||
## 应用程序服务与领域服务 |
|||
|
|||
虽然应用服务和领域服务都实现了业务规则,但存在根本的逻辑和形式差异; |
|||
虽然 [应用服务](Application-Services.md) 和领域服务都实现了业务规则,但存在根本的逻辑和形式差异: |
|||
|
|||
* 应用程序服务实现应用程序的 **用例** (典型 Web 应用程序中的用户交互), 而领域服务实现 **核心的、用例独立的领域逻辑**. |
|||
* 应用程序服务获取/返回 [数据传输对象](Data-Transfer-Objects.md), 领域服务方法通常获取和返回 **领域对象** ([实体](Entities.md), [值对象](Value-Objects.md)). |
|||
* 领域服务通常由应用程序服务或其他领域服务使用,而应用程序服务由表示层或客户端应用程序使用. |
|||
|
|||
## 生命周期 |
|||
|
|||
领域服务的生命周期是 [瞬态](https://docs.abp.io/en/abp/latest/Dependency-Injection) 的,它们会自动注册到依赖注入服务. |
|||
|
|||
@ -1,3 +1,55 @@ |
|||
# Background Jobs Module |
|||
# 后台作业模块 |
|||
|
|||
待添加 |
|||
后台作业模块实现了 `IBackgroundJobStore` 接口,并且可以使用ABP框架的默认后台作业管理.如果你不想使用这个模块,那么你需要自己实现 `IBackgroundJobStore` 接口. |
|||
|
|||
> 本文档仅介绍后台作业模块,该模块将后台作业持久化到数据库.有关后台作业系统的更多信息,请参阅[后台作业](../Background-Jobs.md)文档. |
|||
|
|||
## 如何使用 |
|||
|
|||
当你使用ABP框架[创建一个新的解决方案](https://abp.io/get-started)时,这个模块是(作为NuGet/NPM包)预先安装的.你可以继续将其作为软件包使用并轻松获取更新,也可以将其源代码包含到解决方案中(请参阅 `get-source` [CLI](../CLI.md)命令)以开发自定义模块. |
|||
|
|||
### 源代码 |
|||
|
|||
此模块的源代码可在[此处](https://github.com/abpframework/abp/tree/dev/modules/background-jobs)访问.源代码是由[MIT](https://choosealicense.com/licenses/mit/)授权的,所以你可以自由使用和定制它. |
|||
|
|||
## 内部结构 |
|||
|
|||
### 领域层 |
|||
|
|||
#### 聚合 |
|||
|
|||
- `BackgroundJobRecord` (聚合根): 表示后台工作记录. |
|||
|
|||
#### 仓储 |
|||
|
|||
为该模块定义了以下自定义仓储: |
|||
|
|||
- `IBackgroundJobRepository` |
|||
|
|||
### 数据库提供程序 |
|||
|
|||
#### 通用 |
|||
|
|||
##### 表/集合的前缀与架构 |
|||
|
|||
默认情况下,所有表/集合都使用 `Abp` 前缀.如果需要更改表前缀或设置架构名称(如果数据库提供程序支持),请在 `BackgroundJobsDbProperties` 类上设置静态属性. |
|||
|
|||
##### 连接字符串 |
|||
|
|||
此模块使用 `AbpBackgroundJobs` 作为连接字符串名称.如果不使用此名称定义连接字符串,它将返回 `Default` 连接字符串.有关详细信息,请参阅[连接字符串](https://docs.abp.io/en/abp/latest/Connection-Strings)文档. |
|||
|
|||
#### Entity Framework Core |
|||
|
|||
##### 表 |
|||
|
|||
- **AbpBackgroundJobs** |
|||
|
|||
#### MongoDB |
|||
|
|||
##### 集合 |
|||
|
|||
- **AbpBackgroundJobs** |
|||
|
|||
## 另请参阅 |
|||
|
|||
* [后台作业系统](../Background-Jobs.md) |
|||
|
|||
@ -1,3 +1,257 @@ |
|||
## 规约 |
|||
|
|||
TODO.. |
|||
规约模式用于为实体和其他业务对象定义 **命名、可复用、可组合和可测试的过滤器** . |
|||
|
|||
> 规约是领域层的一部分. |
|||
|
|||
## 安装 |
|||
|
|||
> 这个包 **已经安装** 在启动模板中.所以,大多数时候你不需要手动去安装. |
|||
|
|||
添加 [Volo.Abp.Specifications](https://abp.io/package-detail/Volo.Abp.Specifications) 包到你的项目. 如果当前文件夹是你的项目的根目录(`.csproj`)时,你可以在命令行终端中使用 [ABP CLI](CLI.md) *add package* 命令: |
|||
|
|||
````bash |
|||
abp add-package Volo.Abp.Specifications |
|||
```` |
|||
|
|||
## 定义规约 |
|||
|
|||
假设你定义了如下的顾客实体: |
|||
|
|||
````csharp |
|||
using System; |
|||
using Volo.Abp.Domain.Entities; |
|||
|
|||
namespace MyProject |
|||
{ |
|||
public class Customer : AggregateRoot<Guid> |
|||
{ |
|||
public string Name { get; set; } |
|||
|
|||
public byte Age { get; set; } |
|||
|
|||
public long Balance { get; set; } |
|||
|
|||
public string Location { get; set; } |
|||
} |
|||
} |
|||
```` |
|||
|
|||
你可以创建一个由 `Specification<Customer>` 派生的新规约类. |
|||
|
|||
**例如:规定选择一个18岁以上的顾客** |
|||
|
|||
````csharp |
|||
using System; |
|||
using System.Linq.Expressions; |
|||
using Volo.Abp.Specifications; |
|||
|
|||
namespace MyProject |
|||
{ |
|||
public class Age18PlusCustomerSpecification : Specification<Customer> |
|||
{ |
|||
public override Expression<Func<Customer, bool>> ToExpression() |
|||
{ |
|||
return c => c.Age >= 18; |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
你只需通过定义一个lambda[表达式](https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/operators/lambda-expressions)来定义规约. |
|||
|
|||
> 你也可以直接实现`ISpecification<T>`接口,但是基类`Specification<T>`做了大量简化. |
|||
|
|||
## 使用规约 |
|||
|
|||
这里有两种常见的规约用例. |
|||
|
|||
### IsSatisfiedBy |
|||
|
|||
`IsSatisfiedBy` 方法可以用于检查单个对象是否满足规约. |
|||
|
|||
**例如:如果顾客不满足年龄规定,则抛出异常** |
|||
|
|||
````csharp |
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.DependencyInjection; |
|||
|
|||
namespace MyProject |
|||
{ |
|||
public class CustomerService : ITransientDependency |
|||
{ |
|||
public async Task BuyAlcohol(Customer customer) |
|||
{ |
|||
if (!new Age18PlusCustomerSpecification().IsSatisfiedBy(customer)) |
|||
{ |
|||
throw new Exception( |
|||
"这位顾客不满足年龄规定!" |
|||
); |
|||
} |
|||
|
|||
//TODO... |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
### ToExpression & Repositories |
|||
|
|||
`ToExpression()` 方法可用于将规约转化为表达式.通过这种方式,你可以使用规约在**数据库查询时过滤实体**. |
|||
|
|||
````csharp |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.DependencyInjection; |
|||
using Volo.Abp.Domain.Repositories; |
|||
using Volo.Abp.Domain.Services; |
|||
|
|||
namespace MyProject |
|||
{ |
|||
public class CustomerManager : DomainService, ITransientDependency |
|||
{ |
|||
private readonly IRepository<Customer, Guid> _customerRepository; |
|||
|
|||
public CustomerManager(IRepository<Customer, Guid> customerRepository) |
|||
{ |
|||
_customerRepository = customerRepository; |
|||
} |
|||
|
|||
public async Task<List<Customer>> GetCustomersCanBuyAlcohol() |
|||
{ |
|||
var queryable = await _customerRepository.GetQueryableAsync(); |
|||
var query = queryable.Where( |
|||
new Age18PlusCustomerSpecification().ToExpression() |
|||
); |
|||
|
|||
return await AsyncExecuter.ToListAsync(query); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
> 规约被正确地转换为SQL/数据库查询语句,并且在DBMS端高效执行.虽然它与规约无关,但如果你想了解有关 `AsyncExecuter` 的更多信息,请参阅[仓储](Repositories.md)文档. |
|||
|
|||
实际上,没有必要使用 `ToExpression()` 方法,因为规约会自动转换为表达式.这也会起作用: |
|||
|
|||
````csharp |
|||
var queryable = await _customerRepository.GetQueryableAsync(); |
|||
var query = queryable.Where( |
|||
new Age18PlusCustomerSpecification() |
|||
); |
|||
```` |
|||
|
|||
## 编写规约 |
|||
|
|||
规约有一个强大的功能是,它们可以与`And`、`Or`、`Not`以及`AndNot`扩展方法组合使用. |
|||
|
|||
假设你有另一个规约,定义如下: |
|||
|
|||
```csharp |
|||
using System; |
|||
using System.Linq.Expressions; |
|||
using Volo.Abp.Specifications; |
|||
|
|||
namespace MyProject |
|||
{ |
|||
public class PremiumCustomerSpecification : Specification<Customer> |
|||
{ |
|||
public override Expression<Func<Customer, bool>> ToExpression() |
|||
{ |
|||
return (customer) => (customer.Balance >= 100000); |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
你可以将 `PremiumCustomerSpecification` 和 `Age18PlusCustomerSpecification` 结合起来,查询优质成人顾客的数量,如下所示: |
|||
|
|||
````csharp |
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.DependencyInjection; |
|||
using Volo.Abp.Domain.Repositories; |
|||
using Volo.Abp.Domain.Services; |
|||
using Volo.Abp.Specifications; |
|||
|
|||
namespace MyProject |
|||
{ |
|||
public class CustomerManager : DomainService, ITransientDependency |
|||
{ |
|||
private readonly IRepository<Customer, Guid> _customerRepository; |
|||
|
|||
public CustomerManager(IRepository<Customer, Guid> customerRepository) |
|||
{ |
|||
_customerRepository = customerRepository; |
|||
} |
|||
|
|||
public async Task<int> GetAdultPremiumCustomerCountAsync() |
|||
{ |
|||
return await _customerRepository.CountAsync( |
|||
new Age18PlusCustomerSpecification() |
|||
.And(new PremiumCustomerSpecification()).ToExpression() |
|||
); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
如果你想让这个组合成为一个可复用的规约,你可以创建这样一个组合的规约类,它派生自`AndSpecification`: |
|||
|
|||
````csharp |
|||
using Volo.Abp.Specifications; |
|||
|
|||
namespace MyProject |
|||
{ |
|||
public class AdultPremiumCustomerSpecification : AndSpecification<Customer> |
|||
{ |
|||
public AdultPremiumCustomerSpecification() |
|||
: base(new Age18PlusCustomerSpecification(), |
|||
new PremiumCustomerSpecification()) |
|||
{ |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
现在,你就可以向下面一样重新编写 `GetAdultPremiumCustomerCountAsync` 方法: |
|||
|
|||
````csharp |
|||
public async Task<int> GetAdultPremiumCustomerCountAsync() |
|||
{ |
|||
return await _customerRepository.CountAsync( |
|||
new AdultPremiumCustomerSpecification() |
|||
); |
|||
} |
|||
```` |
|||
|
|||
> 你可以从这些例子中看到规约的强大之处.如果你之后想要更改 `PremiumCustomerSpecification` ,比如将余额从 `100.000` 修改为 `200.000` ,所有查询语句和合并的规约都将受到本次更改的影响.这是减少代码重复的好方法! |
|||
|
|||
## 讨论 |
|||
|
|||
虽然规约模式通常与C#的lambda表达式相比较,算是一种更老的方式.一些开发人员可能认为不再需要它,我们可以直接将表达式传入到仓储或领域服务中,如下所示: |
|||
|
|||
````csharp |
|||
var count = await _customerRepository.CountAsync(c => c.Balance > 100000 && c.Age => 18); |
|||
```` |
|||
|
|||
自从ABP的[仓储](Repositories.md)支持表达式,这是一个完全有效的用法.你不必在应用程序中定义或使用任何规约,可以直接使用表达式. |
|||
|
|||
所以,规约的意义是什么?为什么或者应该在什么时候考虑去使用它? |
|||
|
|||
### 何时使用? |
|||
|
|||
使用规约的一些好处: |
|||
|
|||
- **可复用**:假设你在代码库的许多地方都需要用到优质顾客过滤器.如果使用表达式而不创建规约,那么如果以后更改“优质顾客”的定义会发生什么?假设你想将最低余额从100000美元更改为250000美元,并添加另一个条件,成为顾客超过3年.如果使用了规约,只需修改一个类.如果在任何其他地方重复(复制/粘贴)相同的表达式,则需要更改所有的表达式. |
|||
- **可组合**:可以组合多个规约来创建新规约.这是另一种可复用性. |
|||
- **命名**:`PremiumCustomerSpecification` 更好地解释了为什么使用规约,而不是复杂的表达式.因此,如果在你的业务中使用了一个有意义的表达式,请考虑使用规约. |
|||
- **可测试**:规约是一个单独(且易于)测试的对象. |
|||
|
|||
### 什么时侯不要使用? |
|||
|
|||
- **没有业务含义的表达式**:不要对与业务无关的表达式和操作使用规约. |
|||
- **报表**:如果只是创建报表,不要创建规约,而是直接使用 `IQueryable` 和LINQ表达式.你甚至可以使用普通SQL、视图或其他工具生成报表.DDD不关心报表,因此从性能角度来看,查询底层数据存储的方式可能很重要. |
|||
|
|||
@ -0,0 +1,768 @@ |
|||
# 自动化测试 |
|||
|
|||
## 介绍 |
|||
|
|||
ABP框架的设计考虑了可测试性. 有一些不同级别的自动化测试: |
|||
|
|||
* **单元测试**: 通常只测试一个类(或者一起测试几个类). 这些测试会很快. 然而, 你通常需要处理对服务依赖项的模拟. |
|||
* **集成测试**: 你通常会测试一个服务, 但这一次你不会模拟基本的基础设施和服务, 以查看它们是否正确地协同工作. |
|||
* **用户界面测试**: 测试应用程序的UI, 就像用户与应用程序交互一样. |
|||
|
|||
### 单元测试 vs 集成测试 |
|||
|
|||
与单元测试相比, 集成测试有一些显著的**优势**: |
|||
|
|||
* **编写更加简单** 因为你不需要模拟和处理依赖关系. |
|||
* 你的测试代码运行于所有真正的服务和基础设施(包括数据库映射和查询), 因此它更接近于**真正的应用程序测试**. |
|||
|
|||
同时它们有一些缺点: |
|||
|
|||
* 与单元测试相比, 它们**更慢**, 因为所有的基础设施都准备好了测试用例. |
|||
* 服务中的一个bug可能会导致多个测试用例失败, 因此在某些情况下, 可能会**更难找到真正的问题**. |
|||
|
|||
我们建议混合使用: 在必要的地方编写单元测试或集成测试, 并且有效的编写和维护它. |
|||
|
|||
## 应用程序启动模板 |
|||
|
|||
测试基础设施提供[应用程序启动模板](Startup-Templates/Application.md) , 并已经正确安装和配置. |
|||
|
|||
### 测试项目 |
|||
|
|||
请参见Visual Studio中的以下解决方案: |
|||
|
|||
 |
|||
|
|||
按层级系统分为多个测试项目: |
|||
|
|||
* `Domain.Tests` 用于测试领域层对象 (例如[领域服务](Domain-Services.md) 和 [实体](Entities.md)). |
|||
* `Application.Tests` 用于测试应用层对象 (例如[应用服务](Application-Services.md)). |
|||
* `EntityFrameworkCore.Tests` 用于测试你的自定义仓储实现或EF Core映射(如果你使用其他[数据访问](Data-Access.md))的话, 该项目将有所不同). |
|||
* `Web.Tests` 用于测试UI层(如页面、控制器和视图组件). 该项目仅适用于MVC / Razor页面应用程序. |
|||
* `TestBase` 包含一些由其他项目共享/使用的类. |
|||
|
|||
> `HttpApi.Client.ConsoleTestApp` 不是自动化测试的应用程序. 它是一个示例的控制台应用程序, 展示了如何从.NET控制台应用程序中调用HTTP API. |
|||
|
|||
以下的部分将介绍这些项目中包含的基类和其他基础设施. |
|||
|
|||
### 测试基础设施 |
|||
|
|||
解决方案中已经安装了以下库: |
|||
|
|||
* [xUnit](https://xunit.net/) 作为测试框架. |
|||
* [NSubstitute](https://nsubstitute.github.io/) 用于模拟. |
|||
* [Shouldly](https://github.com/shouldly/shouldly) 用于断言. |
|||
|
|||
虽然你可以用自己喜欢的工具替换它们, 但本文档和示例将基于这些工具. |
|||
|
|||
## 测试资源管理器 |
|||
|
|||
你可以在Visual Studio中使用测试资源管理器查看和运行测试. 其他IDE, 请参阅它们自己的文档. |
|||
|
|||
### 打开测试资源管理器 |
|||
|
|||
打开*测试*菜单下的*测试资源管理器*(如果尚未打开): |
|||
|
|||
 |
|||
|
|||
### 运行测试 |
|||
|
|||
然后, 你可以单击在视图中运行所有测试或运行按钮来运行测试. 初始启动模板为你提供了一些测试用例: |
|||
|
|||
 |
|||
|
|||
### 并行运行测试 |
|||
|
|||
支持并行运行测试. **强烈建议**并行运行所有测试, 这比逐个运行测试要快得多. |
|||
|
|||
要启用它, 请单击设置(齿轮)按钮附近的插入符号图标, 然后选择*并行运行测试*. |
|||
|
|||
 |
|||
|
|||
## 单元测试 |
|||
|
|||
对于单元测试, 不需要太多的配置. 通常会实例化你的类, 并对要测试的对象提供一些预先配置的模拟对象. |
|||
|
|||
### 没有依赖项的类 |
|||
|
|||
要测试的类没有依赖项是最简单的情况, 你可以直接实例化类, 调用其方法并做出断言. |
|||
|
|||
#### 示例: 测试实体 |
|||
|
|||
假设你有一个 `Issue` [实体](Entities.md), 如下所示: |
|||
|
|||
````csharp |
|||
using System; |
|||
using Volo.Abp.Domain.Entities; |
|||
|
|||
namespace MyProject.Issues |
|||
{ |
|||
public class Issue : AggregateRoot<Guid> |
|||
{ |
|||
public string Title { get; set; } |
|||
public string Description { get; set; } |
|||
public bool IsLocked { get; set; } |
|||
public bool IsClosed { get; private set; } |
|||
public DateTime? CloseDate { get; private set; } |
|||
|
|||
public void Close() |
|||
{ |
|||
IsClosed = true; |
|||
CloseDate = DateTime.UtcNow; |
|||
} |
|||
|
|||
public void Open() |
|||
{ |
|||
if (!IsClosed) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
if (IsLocked) |
|||
{ |
|||
throw new IssueStateException("You can not open a locked issue!"); |
|||
} |
|||
|
|||
IsClosed = true; |
|||
CloseDate = null; |
|||
} |
|||
} |
|||
} |
|||
|
|||
```` |
|||
|
|||
请注意, `IsClosed`和`CloseDate`属性具有私有setter, 可以使用`Open()`和`Close()`方法强制执行某些业务逻辑: |
|||
|
|||
* 无论何时关闭issue, `CloseDate`都应设置为[当前时间](Timing.md). |
|||
* 如果issue被锁定, 则无法重新打开. 如果它被重新打开, `CloseDate`应该设置为`null`. |
|||
|
|||
由于`Issue`实体是领域层的一部分, 所以我们应该在`Domain.Tests`项目中测试它. 在`Domain.Tests`项目中创建一个`Issue_Tests`类: |
|||
|
|||
````csharp |
|||
using Shouldly; |
|||
using Xunit; |
|||
|
|||
namespace MyProject.Issues |
|||
{ |
|||
public class Issue_Tests |
|||
{ |
|||
[Fact] |
|||
public void Should_Set_The_CloseDate_Whenever_Close_An_Issue() |
|||
{ |
|||
// Arrange |
|||
|
|||
var issue = new Issue(); |
|||
issue.CloseDate.ShouldBeNull(); // null at the beginning |
|||
|
|||
// Act |
|||
|
|||
issue.Close(); |
|||
|
|||
// Assert |
|||
|
|||
issue.IsClosed.ShouldBeTrue(); |
|||
issue.CloseDate.ShouldNotBeNull(); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
这个测试遵循AAA(Arrange-Act-Assert)模式: |
|||
|
|||
* **Arrange** 部分创建一个`Issue`实体, 并确保`CloseDate`在初始值为`null`. |
|||
* **Act** 部分执行我们想要测试的方法. |
|||
* **Assert** 部分检查`Issue`属性是否与我们预期的相同. |
|||
|
|||
`[Fact]`属性由[xUnit](https://xunit.net/)并将方法标记为测试方法. `Should...`扩展方法由[Shouldly](https://github.com/shouldly/shouldly)提供. 你可以直接使用xUnit中的`Assert`类, 使用Shouldly让它更舒适、更直观. |
|||
|
|||
当你执行测试时, 你将看到它成功通过: |
|||
|
|||
 |
|||
|
|||
让我们再添加两种测试方法: |
|||
|
|||
````csharp |
|||
[Fact] |
|||
public void Should_Allow_To_ReOpen_An_Issue() |
|||
{ |
|||
// Arrange |
|||
|
|||
var issue = new Issue(); |
|||
issue.Close(); |
|||
|
|||
// Act |
|||
|
|||
issue.Open(); |
|||
|
|||
// Assert |
|||
|
|||
issue.IsClosed.ShouldBeFalse(); |
|||
issue.CloseDate.ShouldBeNull(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Not_Allow_To_ReOpen_A_Locked_Issue() |
|||
{ |
|||
// Arrange |
|||
|
|||
var issue = new Issue(); |
|||
issue.Close(); |
|||
issue.IsLocked = true; |
|||
|
|||
// Act & Assert |
|||
|
|||
Assert.Throws<IssueStateException>(() => |
|||
{ |
|||
issue.Open(); |
|||
}); |
|||
} |
|||
```` |
|||
|
|||
`Assert.Throws` 检查执行的代码是否匹配引发的异常. |
|||
|
|||
> 有关这些库的更多信息, 请参阅xUnit & Shoudly的文档. |
|||
|
|||
### 具有依赖项的类 |
|||
|
|||
如果你的服务中有依赖项, 并且你想对该服务进行单元测试, 那么你需要模拟这些依赖项. |
|||
|
|||
#### 示例: 测试领域服务 |
|||
|
|||
假设你有一个`IssueManager` [领域服务](Domain-Services.md), 定义如下: |
|||
|
|||
````csharp |
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp; |
|||
using Volo.Abp.Domain.Services; |
|||
|
|||
namespace MyProject.Issues |
|||
{ |
|||
public class IssueManager : DomainService |
|||
{ |
|||
public const int MaxAllowedOpenIssueCountForAUser = 3; |
|||
|
|||
private readonly IIssueRepository _issueRepository; |
|||
|
|||
public IssueManager(IIssueRepository issueRepository) |
|||
{ |
|||
_issueRepository = issueRepository; |
|||
} |
|||
|
|||
public async Task AssignToUserAsync(Issue issue, Guid userId) |
|||
{ |
|||
var issueCount = await _issueRepository.GetIssueCountOfUserAsync(userId); |
|||
|
|||
if (issueCount >= MaxAllowedOpenIssueCountForAUser) |
|||
{ |
|||
throw new BusinessException( |
|||
code: "IM:00392", |
|||
message: $"You can not assign more" + |
|||
$"than {MaxAllowedOpenIssueCountForAUser} issues to a user!" |
|||
); |
|||
} |
|||
|
|||
issue.AssignedUserId = userId; |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
`IssueManager`依赖于`IssueRepository`服务, 在本例中将模拟该服务. |
|||
|
|||
**业务逻辑**: 示例`AssignToUserAsync`不允许向用户分配超过3个issue (`MaxAllowedOpenIssueCountForAUser`常量). 在这种情况下, 如果要分配issue, 首先需要取消现有issue的分配. |
|||
|
|||
下面的测试用例给出一个有效的赋值: |
|||
|
|||
````csharp |
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using NSubstitute; |
|||
using Shouldly; |
|||
using Volo.Abp; |
|||
using Xunit; |
|||
|
|||
namespace MyProject.Issues |
|||
{ |
|||
public class IssueManager_Tests |
|||
{ |
|||
[Fact] |
|||
public async Task Should_Assign_An_Issue_To_A_User() |
|||
{ |
|||
// Arrange |
|||
|
|||
var userId = Guid.NewGuid(); |
|||
|
|||
var fakeRepo = Substitute.For<IIssueRepository>(); |
|||
fakeRepo.GetIssueCountOfUserAsync(userId).Returns(1); |
|||
|
|||
var issueManager = new IssueManager(fakeRepo); |
|||
|
|||
var issue = new Issue(); |
|||
|
|||
// Act |
|||
|
|||
await issueManager.AssignToUserAsync(issue, userId); |
|||
|
|||
//Assert |
|||
|
|||
issue.AssignedUserId.ShouldBe(userId); |
|||
await fakeRepo.Received(1).GetIssueCountOfUserAsync(userId); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
* `Substitute.For<IIssueRepository>` 创建一个模拟(假)对象, 该对象被传递到`IssueManager`构造函数中. |
|||
* `fakeRepo.GetIssueCountOfUserAsync(userId).Returns(1)` 确保仓储中的`GetIssueContofuseRasync`方法返回`1`. |
|||
* `issueManager.AssignToUserAsync` 不会引发任何异常, 因为仓储统计当前分配的issue数量并且返回`1`. |
|||
* `issue.AssignedUserId.ShouldBe(userId);` 行检查`AssignedUserId`的值是否正确. |
|||
* `await fakeRepo.Received(1).GetIssueCountOfUserAsync(userId);` 检查 `IssueManager` 实际只调用了 `GetIssueCountOfUserAsync` 方法一次. |
|||
|
|||
让我们添加第二个测试, 看看它是否能阻止将issue分配给超过分配数量的用户: |
|||
|
|||
````csharp |
|||
[Fact] |
|||
public async Task Should_Not_Allow_To_Assign_Issues_Over_The_Limit() |
|||
{ |
|||
// Arrange |
|||
|
|||
var userId = Guid.NewGuid(); |
|||
|
|||
var fakeRepo = Substitute.For<IIssueRepository>(); |
|||
fakeRepo |
|||
.GetIssueCountOfUserAsync(userId) |
|||
.Returns(IssueManager.MaxAllowedOpenIssueCountForAUser); |
|||
|
|||
var issueManager = new IssueManager(fakeRepo); |
|||
|
|||
// Act & Assert |
|||
|
|||
var issue = new Issue(); |
|||
|
|||
await Assert.ThrowsAsync<BusinessException>(async () => |
|||
{ |
|||
await issueManager.AssignToUserAsync(issue, userId); |
|||
}); |
|||
|
|||
issue.AssignedUserId.ShouldBeNull(); |
|||
await fakeRepo.Received(1).GetIssueCountOfUserAsync(userId); |
|||
} |
|||
```` |
|||
|
|||
> 有关模拟的更多信息, 请参阅[NSubstitute](https://nsubstitute.github.io/)文档. |
|||
|
|||
模拟单个依赖项相对容易. 但是, 当依赖关系增长时, 设置测试对象和模拟所有依赖关系变得越来越困难. 请参阅不需要模拟依赖项的*Integration Tests*部分. |
|||
|
|||
### 提示: 共享测试类构造函数 |
|||
|
|||
[xUnit](https://xunit.net/) 为每个测试方法创建一个**新测试类实例**(本例中为`IssueManager_Tests`). 因此, 你可以将一些*Arrange*代码移动到构造函数中, 以减少代码重复. 构造函数将针对每个测试用例执行, 并且不会相互影响, 即使它们是并行工作. |
|||
|
|||
**示例: 重构`IssueManager_Tests`以减少代码重复** |
|||
|
|||
````csharp |
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using NSubstitute; |
|||
using Shouldly; |
|||
using Volo.Abp; |
|||
using Xunit; |
|||
|
|||
namespace MyProject.Issues |
|||
{ |
|||
public class IssueManager_Tests |
|||
{ |
|||
private readonly Guid _userId; |
|||
private readonly IIssueRepository _fakeRepo; |
|||
private readonly IssueManager _issueManager; |
|||
private readonly Issue _issue; |
|||
|
|||
public IssueManager_Tests() |
|||
{ |
|||
_userId = Guid.NewGuid(); |
|||
_fakeRepo = Substitute.For<IIssueRepository>(); |
|||
_issueManager = new IssueManager(_fakeRepo); |
|||
_issue = new Issue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Assign_An_Issue_To_A_User() |
|||
{ |
|||
// Arrange |
|||
_fakeRepo.GetIssueCountOfUserAsync(_userId).Returns(1); |
|||
|
|||
// Act |
|||
await _issueManager.AssignToUserAsync(_issue, _userId); |
|||
|
|||
//Assert |
|||
_issue.AssignedUserId.ShouldBe(_userId); |
|||
await _fakeRepo.Received(1).GetIssueCountOfUserAsync(_userId); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Not_Allow_To_Assign_Issues_Over_The_Limit() |
|||
{ |
|||
// Arrange |
|||
_fakeRepo |
|||
.GetIssueCountOfUserAsync(_userId) |
|||
.Returns(IssueManager.MaxAllowedOpenIssueCountForAUser); |
|||
|
|||
// Act & Assert |
|||
await Assert.ThrowsAsync<BusinessException>(async () => |
|||
{ |
|||
await _issueManager.AssignToUserAsync(_issue, _userId); |
|||
}); |
|||
|
|||
_issue.AssignedUserId.ShouldBeNull(); |
|||
await _fakeRepo.Received(1).GetIssueCountOfUserAsync(_userId); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
> 保持测试代码整洁, 以创建可维护的测试组件. |
|||
|
|||
## 集成测试 |
|||
|
|||
> 你还可以按照[Web应用程序开发教程](Tutorials/Part-1.md)学习开发全栈应用程序, 包括集成测试. |
|||
|
|||
### 集成测试基础 |
|||
|
|||
ABP为编写集成测试提供了完整的基础设施. 所有ABP基础设施和服务都将在你的测试中执行. 应用程序启动模板附带了为你预先配置的必要基础设施; |
|||
|
|||
#### 数据库 |
|||
|
|||
启动模板使用EF Core配置**内存中的SQLite**数据库(对于MongoDB, 它使用[Mongo2Go](https://github.com/Mongo2Go/Mongo2Go)). 因此, 所有配置和查询都是针对真实数据库执行的, 你甚至可以测试数据库事务. |
|||
|
|||
使用内存中的SQLite数据库有两个主要优点: |
|||
|
|||
* 它比外部DBMS更快. |
|||
* 它会为每个测试用例创建一个**新的数据库**, 这样测试就不会相互影响. |
|||
|
|||
> **提示**: 不要将EF Core的内存数据库用于高级集成测试. 它不是一个真正的DBMS, 在细节上有很多不同. 例如, 它不支持事务和回滚场景, 因此无法真正测试失败的场景. 另一方面, 内存中的SQLite是一个真正的DBMS, 支持SQL数据库的基本功能. |
|||
|
|||
### 种子数据 |
|||
|
|||
针对空数据库编写测试是不现实的. 在大多数情况下, 需要在数据库中保存一些初始数据. 例如, 如果你编写了一个查询、更新和删除产品的测试类, 那么在执行测试用例之前, 在数据库中有一些产品数据会很有帮助. |
|||
|
|||
ABP的[种子数据](Data-Seeding.md)系统是一种强大的初始化数据的方法. 应用程序启动模板在`.TestBase`项目中有一个*YourProject*TestDataSeedContributor类. 你可以在其中添加, 以获得可用于每个测试方法的初始数据. |
|||
|
|||
**示例: 创建一些Issue作为种子数据** |
|||
|
|||
````csharp |
|||
using System.Threading.Tasks; |
|||
using MyProject.Issues; |
|||
using Volo.Abp.Data; |
|||
using Volo.Abp.DependencyInjection; |
|||
|
|||
namespace MyProject |
|||
{ |
|||
public class MyProjectTestDataSeedContributor |
|||
: IDataSeedContributor, ITransientDependency |
|||
{ |
|||
private readonly IIssueRepository _issueRepository; |
|||
|
|||
public MyProjectTestDataSeedContributor(IIssueRepository issueRepository) |
|||
{ |
|||
_issueRepository = issueRepository; |
|||
} |
|||
|
|||
public async Task SeedAsync(DataSeedContext context) |
|||
{ |
|||
await _issueRepository.InsertAsync( |
|||
new Issue |
|||
{ |
|||
Title = "Test issue one", |
|||
Description = "Test issue one description", |
|||
AssignedUserId = TestData.User1Id |
|||
}); |
|||
|
|||
await _issueRepository.InsertAsync( |
|||
new Issue |
|||
{ |
|||
Title = "Test issue two", |
|||
Description = "Test issue two description", |
|||
AssignedUserId = TestData.User1Id |
|||
}); |
|||
|
|||
await _issueRepository.InsertAsync( |
|||
new Issue |
|||
{ |
|||
Title = "Test issue three", |
|||
Description = "Test issue three description", |
|||
AssignedUserId = TestData.User1Id |
|||
}); |
|||
|
|||
await _issueRepository.InsertAsync( |
|||
new Issue |
|||
{ |
|||
Title = "Test issue four", |
|||
Description = "Test issue four description", |
|||
AssignedUserId = TestData.User2Id |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
还创建了一个静态类来存储用户的 `Id`: |
|||
|
|||
````csharp |
|||
using System; |
|||
|
|||
namespace MyProject |
|||
{ |
|||
public static class TestData |
|||
{ |
|||
public static Guid User1Id = Guid.Parse("41951813-5CF9-4204-8B18-CD765DBCBC9B"); |
|||
public static Guid User2Id = Guid.Parse("2DAB4460-C21B-4925-BF41-A52750A9B999"); |
|||
} |
|||
} |
|||
```` |
|||
|
|||
通过这种方式, 我们可以使用这些已知Issue和用户的`Id`来运行测试. |
|||
|
|||
### 示例: 测试领域服务 |
|||
|
|||
`AbpIntegratedTest<T>`类 (定义在[Volo.Abp.TestBase](https://www.nuget.org/packages/Volo.Abp.TestBase)) 用于编写集成到ABP框架的测试. `T`是用于设置和初始化应用程序的根模块的类型. |
|||
|
|||
应用程序启动模板在每个测试项目中都有基类, 因此你可以从这些基类派生, 以使其更简单. |
|||
|
|||
`IssueManager`测试将被重写成集成测试 |
|||
|
|||
````csharp |
|||
using System.Threading.Tasks; |
|||
using Shouldly; |
|||
using Volo.Abp; |
|||
using Xunit; |
|||
|
|||
namespace MyProject.Issues |
|||
{ |
|||
public class IssueManager_Integration_Tests : MyProjectDomainTestBase |
|||
{ |
|||
private readonly IssueManager _issueManager; |
|||
private readonly Issue _issue; |
|||
|
|||
public IssueManager_Integration_Tests() |
|||
{ |
|||
_issueManager = GetRequiredService<IssueManager>(); |
|||
_issue = new Issue |
|||
{ |
|||
Title = "Test title", |
|||
Description = "Test description" |
|||
}; |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Not_Allow_To_Assign_Issues_Over_The_Limit() |
|||
{ |
|||
// Act & Assert |
|||
await Assert.ThrowsAsync<BusinessException>(async () => |
|||
{ |
|||
await _issueManager.AssignToUserAsync(_issue, TestData.User1Id); |
|||
}); |
|||
|
|||
_issue.AssignedUserId.ShouldBeNull(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Assign_An_Issue_To_A_User() |
|||
{ |
|||
// Act |
|||
await _issueManager.AssignToUserAsync(_issue, TestData.User2Id); |
|||
|
|||
//Assert |
|||
_issue.AssignedUserId.ShouldBe(TestData.User2Id); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
* 第一个测试方法将issue分配给User1, 其中User1已经分配了种子数据代码中的3个issue. 因此, 它抛出了一个`BusinessException`. |
|||
* 第二种测试方法将issue分配给User2, User2只分配了一个issue. 因此, 该方法成功了. |
|||
|
|||
这个类通常位于`.Domain.Tests`项目中, 因为它测试位于`.Domain`项目中的类. 它派生自`MyProjectDomainTestBase`, 并已经为正确运行测试进行了配置. |
|||
|
|||
编写这样一个集成测试类非常简单. 另一个好处是, 在以后向`IssueManager`类添加另一个依赖项时, 不需要更改测试类. |
|||
|
|||
### 示例: 测试应用服务 |
|||
|
|||
测试[应用服务](Application-Services.md)并没有太大的不同. 假设你已经创建了一个`IssueAppService`, 定义如下: |
|||
|
|||
````csharp |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.Application.Services; |
|||
|
|||
namespace MyProject.Issues |
|||
{ |
|||
public class IssueAppService : ApplicationService, IIssueAppService |
|||
{ |
|||
private readonly IIssueRepository _issueRepository; |
|||
|
|||
public IssueAppService(IIssueRepository issueRepository) |
|||
{ |
|||
_issueRepository = issueRepository; |
|||
} |
|||
|
|||
public async Task<List<IssueDto>> GetListAsync() |
|||
{ |
|||
var issues = await _issueRepository.GetListAsync(); |
|||
|
|||
return ObjectMapper.Map<List<Issue>, List<IssueDto>>(issues); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
*(假设你还定义了`IIssueAppService`和`IssueDto`, 并在`Issue`和`IssueDto`之间创建了[对象映射](Object-To-Object-Mapping.md))* |
|||
|
|||
现在, 你可以在`.Application.Tests`项目中编写一个测试类: |
|||
|
|||
````csharp |
|||
using System.Threading.Tasks; |
|||
using Shouldly; |
|||
using Xunit; |
|||
|
|||
namespace MyProject.Issues |
|||
{ |
|||
public class IssueAppService_Tests : MyProjectApplicationTestBase |
|||
{ |
|||
private readonly IIssueAppService _issueAppService; |
|||
|
|||
public IssueAppService_Tests() |
|||
{ |
|||
_issueAppService = GetRequiredService<IIssueAppService>(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Get_All_Issues() |
|||
{ |
|||
//Act |
|||
var issueDtos = await _issueAppService.GetListAsync(); |
|||
|
|||
//Assert |
|||
issueDtos.Count.ShouldBeGreaterThan(0); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
就这么简单. 此测试方法测试的所有内容, 包括应用服务、EF Core映射、对象到对象映射和仓储实现. 通过这种方式, 你可以完全测试解决方案的应用层和领域层. |
|||
|
|||
### 处理集成测试中的工作单元 |
|||
|
|||
ABP的[工作单元](Unit-Of-Work.md)系统控制应用程序中的数据库连接和事务管理. 它可以在你编写应用程序代码时无缝工作, 因此你可能没有意识到它. |
|||
|
|||
在ABP框架中, 所有数据库操作都必须在一个工作单元作用域内执行. 当你测试[应用服务](Application-Services.md)方法时, 工作单元的作用域将是应用服务方法的作用域. 如果你正在测试[仓储](Repositories.md)方法, 那么工作单元作用域将是你的仓储方法的作用域. |
|||
|
|||
在某些情况下, 你可能需要手动控制工作单元作用域. 可以考虑下面的测试方法: |
|||
|
|||
````csharp |
|||
public class IssueRepository_Tests : MyProjectDomainTestBase |
|||
{ |
|||
private readonly IRepository<Issue, Guid> _issueRepository; |
|||
|
|||
public IssueRepository_Tests() |
|||
{ |
|||
_issueRepository = GetRequiredService<IRepository<Issue, Guid>>(); |
|||
} |
|||
|
|||
public async Task Should_Query_By_Title() |
|||
{ |
|||
IQueryable<Issue> queryable = await _issueRepository.GetQueryableAsync(); |
|||
var issue = queryable.FirstOrDefaultAsync(i => i.Title == "My issue title"); |
|||
issue.ShouldNotBeNull(); |
|||
} |
|||
} |
|||
```` |
|||
|
|||
我们正在使用`_issueRepository.GetQueryableAsync`获取`IQueryable<Issue>` 对象. 然后, 我们使用`FirstOrDefaultAsync`方法按标题查询issue. 此时执行数据库查询, 你将会得到一个异常, 表明没有起作用的工作单元. |
|||
|
|||
要使该测试正常工作, 你应该手动启动工作单元作用域, 如下所示: |
|||
|
|||
````csharp |
|||
public class IssueRepository_Tests : MyProjectDomainTestBase |
|||
{ |
|||
private readonly IRepository<Issue, Guid> _issueRepository; |
|||
private readonly IUnitOfWorkManager _unitOfWorkManager; |
|||
|
|||
public IssueRepository_Tests() |
|||
{ |
|||
_issueRepository = GetRequiredService<IRepository<Issue, Guid>>(); |
|||
_unitOfWorkManager = GetRequiredService<IUnitOfWorkManager>(); |
|||
} |
|||
|
|||
public async Task Should_Query_By_Title() |
|||
{ |
|||
using (var uow = _unitOfWorkManager.Begin()) |
|||
{ |
|||
IQueryable<Issue> queryable = await _issueRepository.GetQueryableAsync(); |
|||
var issue = queryable.FirstOrDefaultAsync(i => i.Title == "My issue title"); |
|||
issue.ShouldNotBeNull(); |
|||
await uow.CompleteAsync(); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
我们已经使用了`IUnitOfWorkManager`服务来创建一个工作单元作用域, 然后在该作用域内调用了`FirstOrDefaultAsync`方法, 所以不再有问题了. |
|||
|
|||
> 请注意, 我们测试了`FirstOrDefaultAsync`来演示工作单元的问题. 作为一个好的标准, 编写自己的代码. |
|||
|
|||
### 使用DbContext |
|||
|
|||
在某些情况下, 你可能希望使用Entity Framework的`DbContext`对象来执行测试方法中的数据库操作. 在这种情况下, 可以使用`IDbContextProvider<T>`服务在工作单元内获取`DbContext`实例. |
|||
|
|||
下面的示例展示了如何在测试方法中创建`DbContext`对象: |
|||
|
|||
````csharp |
|||
public class MyDbContext_Tests : MyProjectDomainTestBase |
|||
{ |
|||
private readonly IDbContextProvider<MyProjectDbContext> _dbContextProvider; |
|||
private readonly IUnitOfWorkManager _unitOfWorkManager; |
|||
|
|||
public IssueRepository_Tests() |
|||
{ |
|||
_dbContextProvider = GetRequiredService<IDbContextProvider<MyProjectDbContext>>(); |
|||
_unitOfWorkManager = GetRequiredService<IUnitOfWorkManager>(); |
|||
} |
|||
|
|||
public async Task Should_Query_By_Title() |
|||
{ |
|||
using (var uow = _unitOfWorkManager.Begin()) |
|||
{ |
|||
var dbContext = await _dbContextProvider.GetDbContextAsync(); |
|||
var issue = await dbContext.Issues.FirstOrDefaultAsync(i => i.Title == "My issue title"); |
|||
issue.ShouldNotBeNull(); |
|||
await uow.CompleteAsync(); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
就像我们在*集成测试中处理工作单元*一节中所做的那样, 我们应该在起作用的工作单元内执行`DbContext`操作. |
|||
|
|||
对于[MongoDB](MongoDB.md), 你可以使用`IMongoDbContextProvider<T>`服务获取`DbContext`对象, 并在测试方法中直接使用MongoDB APIs. |
|||
|
|||
## 用户界面测试 |
|||
|
|||
一般来说, 有两种类型的UI测试: |
|||
|
|||
### 非可视化测试 |
|||
|
|||
此类测试完全取决于UI框架的选择: |
|||
|
|||
* 对于MVC / Razor页面UI, 通常向服务器发出请求, 获取HTML, 并测试返回的结果中是否存在一些预期的DOM元素. |
|||
* Angular有自己的基础设施和实践来测试组件、视图和服务. |
|||
|
|||
请参阅以下文档以了解非可视化UI测试: |
|||
|
|||
* [Testing in ASP.NET Core MVC / Razor Pages](UI/AspNetCore/Testing.md) |
|||
* [Testing in Angular](UI/Angular/Testing.md) |
|||
* [Testing in Blazor](UI/Blazor/Testing.md) |
|||
|
|||
### 可视化测试 |
|||
|
|||
与真实用户一样, 可视化测试用于与应用程序UI交互. 它全面测试应用程序, 包括页面和组件的外观. |
|||
|
|||
可视化UI测试超出了ABP框架的范围. 行业中有很多工具(比如[Selenium](https://www.selenium.dev/))可以用来测试应用程序的UI. |
|||
@ -0,0 +1,380 @@ |
|||
# Angular UI 单元测试 |
|||
|
|||
ABP Angular UI的测试与其他Angular应用程序一样. 所以, [这里的指南](https://angular.io/guide/testing)也适用于ABP. 也就是说, 我们想指出一些**特定于ABP Angular应用程序的单元测试内容**. |
|||
|
|||
## 设置 |
|||
|
|||
在Angular中, 单元测试默认使用[Karma](https://karma-runner.github.io/)和[Jasmine](https://jasmine.github.io). 虽然我们更喜欢Jest, 但我们选择不偏离这些默认设置, 因此**你下载的应用程序模板将预先配置Karma和Jasmine**. 你可以在根目录中的 _karma.conf.js_ 文件中找到Karma配置. 你什么都不用做. 添加一个spec文件并运行`npm test`即可. |
|||
|
|||
## 基础 |
|||
|
|||
简化版的spec文件如下所示: |
|||
|
|||
```js |
|||
import { CoreTestingModule } from "@abp/ng.core/testing"; |
|||
import { ThemeBasicTestingModule } from "@abp/ng.theme.basic/testing"; |
|||
import { ThemeSharedTestingModule } from "@abp/ng.theme.shared/testing"; |
|||
import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; |
|||
import { NgxValidateCoreModule } from "@ngx-validate/core"; |
|||
import { MyComponent } from "./my.component"; |
|||
|
|||
describe("MyComponent", () => { |
|||
let fixture: ComponentFixture<MyComponent>; |
|||
|
|||
beforeEach( |
|||
waitForAsync(() => { |
|||
TestBed.configureTestingModule({ |
|||
declarations: [MyComponent], |
|||
imports: [ |
|||
CoreTestingModule.withConfig(), |
|||
ThemeSharedTestingModule.withConfig(), |
|||
ThemeBasicTestingModule.withConfig(), |
|||
NgxValidateCoreModule, |
|||
], |
|||
providers: [ |
|||
/* mock providers here */ |
|||
], |
|||
}).compileComponents(); |
|||
}) |
|||
); |
|||
|
|||
beforeEach(() => { |
|||
fixture = TestBed.createComponent(MyComponent); |
|||
fixture.detectChanges(); |
|||
}); |
|||
|
|||
it("should be initiated", () => { |
|||
expect(fixture.componentInstance).toBeTruthy(); |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
如果你看一下导入内容, 你会注意到我们已经准备了一些测试模块来取代内置的ABP模块. 这对于模拟某些特性是必要的, 否则这些特性会破坏你的测试. 请记住**使用测试模块**并**调用其`withConfig`静态方法**. |
|||
|
|||
## 提示 |
|||
|
|||
### Angular测试库 |
|||
|
|||
虽然你可以使用Angular TestBed测试代码, 但你可以找到一个好的替代品[Angular测试库](https://testing-library.com/docs/angular-testing-library/intro). |
|||
|
|||
上面的简单示例可以用Angular测试库编写, 如下所示: |
|||
|
|||
```js |
|||
import { CoreTestingModule } from "@abp/ng.core/testing"; |
|||
import { ThemeBasicTestingModule } from "@abp/ng.theme.basic/testing"; |
|||
import { ThemeSharedTestingModule } from "@abp/ng.theme.shared/testing"; |
|||
import { ComponentFixture } from "@angular/core/testing"; |
|||
import { NgxValidateCoreModule } from "@ngx-validate/core"; |
|||
import { render } from "@testing-library/angular"; |
|||
import { MyComponent } from "./my.component"; |
|||
|
|||
describe("MyComponent", () => { |
|||
let fixture: ComponentFixture<MyComponent>; |
|||
|
|||
beforeEach(async () => { |
|||
const result = await render(MyComponent, { |
|||
imports: [ |
|||
CoreTestingModule.withConfig(), |
|||
ThemeSharedTestingModule.withConfig(), |
|||
ThemeBasicTestingModule.withConfig(), |
|||
NgxValidateCoreModule, |
|||
], |
|||
providers: [ |
|||
/* mock providers here */ |
|||
], |
|||
}); |
|||
|
|||
fixture = result.fixture; |
|||
}); |
|||
|
|||
it("should be initiated", () => { |
|||
expect(fixture.componentInstance).toBeTruthy(); |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
正如你所见, 二者非常相似. 当我们使用查询和触发事件时, 真正的区别就显现出来了. |
|||
|
|||
```js |
|||
// other imports |
|||
import { getByLabelText, screen } from "@testing-library/angular"; |
|||
import userEvent from "@testing-library/user-event"; |
|||
|
|||
describe("MyComponent", () => { |
|||
beforeEach(/* removed for sake of brevity */); |
|||
|
|||
it("should display advanced filters", () => { |
|||
const filters = screen.getByTestId("author-filters"); |
|||
const nameInput = getByLabelText(filters, /name/i) as HTMLInputElement; |
|||
expect(nameInput.offsetWidth).toBe(0); |
|||
|
|||
const advancedFiltersBtn = screen.getByRole("link", { name: /advanced/i }); |
|||
userEvent.click(advancedFiltersBtn); |
|||
|
|||
expect(nameInput.offsetWidth).toBeGreaterThan(0); |
|||
|
|||
userEvent.type(nameInput, "fooo{backspace}"); |
|||
expect(nameInput.value).toBe("foo"); |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
**Angular测试库中的查询遵循可维护测试**, 用户事件库提供了与DOM的**类人交互**, 并且该库通常有**清晰的API**简化组件测试. 下面提供一些有用的链接: |
|||
|
|||
- [查询](https://testing-library.com/docs/dom-testing-library/api-queries) |
|||
- [用户事件](https://testing-library.com/docs/ecosystem-user-event) |
|||
- [范例](https://github.com/testing-library/angular-testing-library/tree/main/apps/example-app/src/app/examples) |
|||
|
|||
### 在每个Spec之后清除DOM |
|||
|
|||
需要记住的一点是, Karma在真实的浏览器实例中运行测试. 这意味着, 你将能够看到测试代码的结果, 但也会遇到与文档正文连接的组件的问题, 这些组件可能无法在每次测试后都清除, 即使你配置了Karma也一样无法清除. |
|||
|
|||
我们准备了一个简单的函数, 可以在每次测试后清除所有剩余的DOM元素. |
|||
|
|||
```js |
|||
// other imports |
|||
import { clearPage } from "@abp/ng.core/testing"; |
|||
|
|||
describe("MyComponent", () => { |
|||
let fixture: ComponentFixture<MyComponent>; |
|||
|
|||
afterEach(() => clearPage(fixture)); |
|||
|
|||
beforeEach(async () => { |
|||
const result = await render(MyComponent, { |
|||
/* removed for sake of brevity */ |
|||
}); |
|||
fixture = result.fixture; |
|||
}); |
|||
|
|||
// specs here |
|||
}); |
|||
``` |
|||
|
|||
请确保你使用它, 否则Karma将无法删除对话框, 并且你将有多个模态对话框、确认框等的副本. |
|||
|
|||
### 等待 |
|||
|
|||
一些组件, 特别是在检测周期之外工作的模态对话框. 换句话说, 你无法在打开这些组件后立即访问这些组件插入的DOM元素. 同样, 插入的元素在关闭时也不会立即销毁. |
|||
|
|||
为此, 我们准备了一个`wait`函数. |
|||
|
|||
```js |
|||
// other imports |
|||
import { wait } from "@abp/ng.core/testing"; |
|||
|
|||
describe("MyComponent", () => { |
|||
beforeEach(/* removed for sake of brevity */); |
|||
|
|||
it("should open a modal", async () => { |
|||
const openModalBtn = screen.getByRole("button", { name: "Open Modal" }); |
|||
userEvent.click(openModalBtn); |
|||
|
|||
await wait(fixture); |
|||
|
|||
const modal = screen.getByRole("dialog"); |
|||
|
|||
expect(modal).toBeTruthy(); |
|||
|
|||
/* wait again after closing the modal */ |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
`wait`函数接受第二个参数, 即超时(默认值为`0`). 但是尽量不要使用它. 使用大于`0`的超时通常表明某些不正确事情发生了. |
|||
|
|||
## 测试示例 |
|||
|
|||
下面是一个测试示例. 它并没有涵盖所有内容, 但却能够对测试有一个更好的了解. |
|||
|
|||
```js |
|||
import { clearPage, CoreTestingModule, wait } from "@abp/ng.core/testing"; |
|||
import { ThemeBasicTestingModule } from "@abp/ng.theme.basic/testing"; |
|||
import { ThemeSharedTestingModule } from "@abp/ng.theme.shared/testing"; |
|||
import { ComponentFixture } from "@angular/core/testing"; |
|||
import { |
|||
NgbCollapseModule, |
|||
NgbDatepickerModule, |
|||
NgbDropdownModule, |
|||
} from "@ng-bootstrap/ng-bootstrap"; |
|||
import { NgxValidateCoreModule } from "@ngx-validate/core"; |
|||
import { CountryService } from "@proxy/countries"; |
|||
import { |
|||
findByText, |
|||
getByLabelText, |
|||
getByRole, |
|||
getByText, |
|||
queryByRole, |
|||
render, |
|||
screen, |
|||
} from "@testing-library/angular"; |
|||
import userEvent from "@testing-library/user-event"; |
|||
import { BehaviorSubject, of } from "rxjs"; |
|||
import { CountryComponent } from "./country.component"; |
|||
|
|||
const list$ = new BehaviorSubject({ |
|||
items: [{ id: "ID_US", name: "United States of America" }], |
|||
totalCount: 1, |
|||
}); |
|||
|
|||
describe("Country", () => { |
|||
let fixture: ComponentFixture<CountryComponent>; |
|||
|
|||
afterEach(() => clearPage(fixture)); |
|||
|
|||
beforeEach(async () => { |
|||
const result = await render(CountryComponent, { |
|||
imports: [ |
|||
CoreTestingModule.withConfig(), |
|||
ThemeSharedTestingModule.withConfig(), |
|||
ThemeBasicTestingModule.withConfig(), |
|||
NgxValidateCoreModule, |
|||
NgbCollapseModule, |
|||
NgbDatepickerModule, |
|||
NgbDropdownModule, |
|||
], |
|||
providers: [ |
|||
{ |
|||
provide: CountryService, |
|||
useValue: { |
|||
getList: () => list$, |
|||
}, |
|||
}, |
|||
], |
|||
}); |
|||
|
|||
fixture = result.fixture; |
|||
}); |
|||
|
|||
it("should display advanced filters", () => { |
|||
const filters = screen.getByTestId("country-filters"); |
|||
const nameInput = getByLabelText(filters, /name/i) as HTMLInputElement; |
|||
expect(nameInput.offsetWidth).toBe(0); |
|||
|
|||
const advancedFiltersBtn = screen.getByRole("link", { name: /advanced/i }); |
|||
userEvent.click(advancedFiltersBtn); |
|||
|
|||
expect(nameInput.offsetWidth).toBeGreaterThan(0); |
|||
|
|||
userEvent.type(nameInput, "fooo{backspace}"); |
|||
expect(nameInput.value).toBe("foo"); |
|||
|
|||
userEvent.click(advancedFiltersBtn); |
|||
expect(nameInput.offsetWidth).toBe(0); |
|||
}); |
|||
|
|||
it("should have a heading", () => { |
|||
const heading = screen.getByRole("heading", { name: "Countries" }); |
|||
expect(heading).toBeTruthy(); |
|||
}); |
|||
|
|||
it("should render list in table", async () => { |
|||
const table = await screen.findByTestId("country-table"); |
|||
|
|||
const name = getByText(table, "United States of America"); |
|||
expect(name).toBeTruthy(); |
|||
}); |
|||
|
|||
it("should display edit modal", async () => { |
|||
const actionsBtn = screen.queryByRole("button", { name: /actions/i }); |
|||
userEvent.click(actionsBtn); |
|||
|
|||
const editBtn = screen.getByRole("button", { name: /edit/i }); |
|||
userEvent.click(editBtn); |
|||
|
|||
await wait(fixture); |
|||
|
|||
const modal = screen.getByRole("dialog"); |
|||
const modalHeading = queryByRole(modal, "heading", { name: /edit/i }); |
|||
expect(modalHeading).toBeTruthy(); |
|||
|
|||
const closeBtn = getByText(modal, "×"); |
|||
userEvent.click(closeBtn); |
|||
|
|||
await wait(fixture); |
|||
|
|||
expect(screen.queryByRole("dialog")).toBeFalsy(); |
|||
}); |
|||
|
|||
it("should display create modal", async () => { |
|||
const newBtn = screen.getByRole("button", { name: /new/i }); |
|||
userEvent.click(newBtn); |
|||
|
|||
await wait(fixture); |
|||
|
|||
const modal = screen.getByRole("dialog"); |
|||
const modalHeading = queryByRole(modal, "heading", { name: /new/i }); |
|||
|
|||
expect(modalHeading).toBeTruthy(); |
|||
}); |
|||
|
|||
it("should validate required name field", async () => { |
|||
const newBtn = screen.getByRole("button", { name: /new/i }); |
|||
userEvent.click(newBtn); |
|||
|
|||
await wait(fixture); |
|||
|
|||
const modal = screen.getByRole("dialog"); |
|||
const nameInput = getByRole(modal, "textbox", { |
|||
name: /^name/i, |
|||
}) as HTMLInputElement; |
|||
|
|||
userEvent.type(nameInput, "x"); |
|||
userEvent.type(nameInput, "{backspace}"); |
|||
|
|||
const nameError = await findByText(modal, /required/i); |
|||
expect(nameError).toBeTruthy(); |
|||
}); |
|||
|
|||
it("should delete a country", () => { |
|||
const getSpy = spyOn(fixture.componentInstance.list, "get"); |
|||
const deleteSpy = jasmine.createSpy().and.returnValue(of(null)); |
|||
fixture.componentInstance.service.delete = deleteSpy; |
|||
|
|||
const actionsBtn = screen.queryByRole("button", { name: /actions/i }); |
|||
userEvent.click(actionsBtn); |
|||
|
|||
const deleteBtn = screen.getByRole("button", { name: /delete/i }); |
|||
userEvent.click(deleteBtn); |
|||
|
|||
const confirmText = screen.getByText("AreYouSure"); |
|||
expect(confirmText).toBeTruthy(); |
|||
|
|||
const confirmBtn = screen.getByRole("button", { name: "Yes" }); |
|||
userEvent.click(confirmBtn); |
|||
|
|||
expect(deleteSpy).toHaveBeenCalledWith(list$.value.items[0].id); |
|||
expect(getSpy).toHaveBeenCalledTimes(1); |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
## CI配置 |
|||
|
|||
你的CI环境需要不同的配置. 要为单元测试设置新的配置, 请在测试项目中找到 _angular.json_ 文件, 或者如下所示添加一个: |
|||
|
|||
```json |
|||
// angular.json |
|||
|
|||
"test": { |
|||
"builder": "@angular-devkit/build-angular:karma", |
|||
"options": { /* several options here */ }, |
|||
"configurations": { |
|||
"production": { |
|||
"karmaConfig": "karma.conf.prod.js" |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
现在你可以复制 _karma.conf.js_ 作为 _karma.conf.prod.js_ 并在其中使用你喜欢的任何配置. 请查看[Karma配置文档](http://karma-runner.github.io/5.2/config/configuration-file.html)配置选项. |
|||
|
|||
最后, 不要忘记使用以下命令运行CI测试: |
|||
|
|||
```sh |
|||
npm test -- --prod |
|||
``` |
|||
|
|||
## 另请参阅 |
|||
|
|||
- [ABP Community Video - Unit Testing with the Angular UI](https://community.abp.io/articles/unit-testing-with-the-angular-ui-p4l550q3) |
|||
@ -0,0 +1,220 @@ |
|||
# ASP.NET Core MVC / Razor Pages: 测试 |
|||
|
|||
> 你可以参考[ASP.NET Core集成测试文档](https://docs.microsoft.com/en-us/aspnet/core/test/integration-tests)了解ASP.NET Core集成测试的详细内容. 本文档解释了ABP框架提供的附加测试基础设施. |
|||
|
|||
## 应用程序启动模板 |
|||
|
|||
应用程序启动模板的`.Web`项目其中包含应用程序的UI视图/页面/组件, 并提供`.Web.Tests`项目来测试这些内容. |
|||
|
|||
 |
|||
|
|||
## 测试Razor页面 |
|||
|
|||
假设你已经创建了一个名为`Issues.cshtml`的Razor页面, 包含以下内容; |
|||
|
|||
**Issues.cshtml.cs** |
|||
|
|||
````csharp |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Mvc.RazorPages; |
|||
using MyProject.Issues; |
|||
|
|||
namespace MyProject.Web.Pages |
|||
{ |
|||
public class IssuesModel : PageModel |
|||
{ |
|||
public List<IssueDto> Issues { get; set; } |
|||
|
|||
private readonly IIssueAppService _issueAppService; |
|||
|
|||
public IssuesModel(IIssueAppService issueAppService) |
|||
{ |
|||
_issueAppService = issueAppService; |
|||
} |
|||
|
|||
public async Task OnGetAsync() |
|||
{ |
|||
Issues = await _issueAppService.GetListAsync(); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
**Issues.cshtml** |
|||
|
|||
````html |
|||
@page |
|||
@model MyProject.Web.Pages.IssuesModel |
|||
<h2>Issue List</h2> |
|||
<table id="IssueTable" class="table"> |
|||
<thead> |
|||
<tr> |
|||
<th>Issue</th> |
|||
<th>Closed?</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
@foreach (var issue in Model.Issues) |
|||
{ |
|||
<tr> |
|||
<td>@issue.Title</td> |
|||
<td> |
|||
@if (issue.IsClosed) |
|||
{ |
|||
<span>Closed</span> |
|||
} |
|||
else |
|||
{ |
|||
<span>Open</span> |
|||
} |
|||
</td> |
|||
</tr> |
|||
} |
|||
</tbody> |
|||
</table> |
|||
```` |
|||
|
|||
本页仅创建一个包含issue的表格: |
|||
|
|||
 |
|||
|
|||
你可以在`.Web.Tests`项目中编写一个测试类如下所示: |
|||
|
|||
````csharp |
|||
using System.Threading.Tasks; |
|||
using HtmlAgilityPack; |
|||
using Shouldly; |
|||
using Xunit; |
|||
|
|||
namespace MyProject.Pages |
|||
{ |
|||
public class Issues_Tests : MyProjectWebTestBase |
|||
{ |
|||
[Fact] |
|||
public async Task Should_Get_Table_Of_Issues() |
|||
{ |
|||
// Act |
|||
|
|||
var response = await GetResponseAsStringAsync("/Issues"); |
|||
|
|||
//Assert |
|||
|
|||
var htmlDocument = new HtmlDocument(); |
|||
htmlDocument.LoadHtml(response); |
|||
|
|||
var tableElement = htmlDocument.GetElementbyId("IssueTable"); |
|||
tableElement.ShouldNotBeNull(); |
|||
|
|||
var trNodes = tableElement.SelectNodes("//tbody/tr"); |
|||
trNodes.Count.ShouldBeGreaterThan(0); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
`GetResponseAsStringAsync`是一个快捷方法, 它来自执行HTTP GET请求的基类, 检查生成的HTTP状态是否为`200`, 并将响应作为`string`返回. |
|||
|
|||
> 你可以使用`Client`对象(类型为`HttpClient`)对服务器执行任何类型的请求, 并读取响应.`GetResponseAsStringAsync`只是一种快捷方法. |
|||
|
|||
本例使用[HtmlAgilityPack](https://html-agility-pack.net/)库来解析传入的HTML并测试它是否包含issue表格. |
|||
|
|||
> 本例假设的数据库中存在一些初始issue. 请参阅[测试文档](../../Testing.md)的*种子数据*部分, 了解如何设置种子数据, 以便可以假定数据库中有一些可用的初始数据. |
|||
|
|||
## 控制器测试 |
|||
|
|||
测试控制器也不例外. 只需使用正确的URL向服务器执行请求, 获取响应并做出断言. |
|||
|
|||
### 查看结果 |
|||
|
|||
如果控制器返回一个视图, 你可以使用类似的代码来测试返回的HTML. 参见上面的Razor页面示例. |
|||
|
|||
### 对象结果 |
|||
|
|||
如果控制器返回对象结果, 则可以使用`GetResponseAsObjectAsync`方法. |
|||
|
|||
假设你有一个如下定义的控制器: |
|||
|
|||
````csharp |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Mvc; |
|||
using MyProject.Issues; |
|||
using Volo.Abp.AspNetCore.Mvc; |
|||
|
|||
namespace MyProject.Web.Controllers |
|||
{ |
|||
[Route("api/issues")] |
|||
public class IssueController : AbpController |
|||
{ |
|||
private readonly IIssueAppService _issueAppService; |
|||
|
|||
public IssueController(IIssueAppService issueAppService) |
|||
{ |
|||
_issueAppService = issueAppService; |
|||
} |
|||
|
|||
[HttpGet] |
|||
public async Task<List<IssueDto>> GetAsync() |
|||
{ |
|||
return await _issueAppService.GetListAsync(); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
你可以编写测试代码来调用API并获得结果: |
|||
|
|||
````csharp |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using MyProject.Issues; |
|||
using Shouldly; |
|||
using Xunit; |
|||
|
|||
namespace MyProject.Pages |
|||
{ |
|||
public class Issues_Tests : MyProjectWebTestBase |
|||
{ |
|||
[Fact] |
|||
public async Task Should_Get_Issues_From_Api() |
|||
{ |
|||
var issues = await GetResponseAsObjectAsync<List<IssueDto>>("/api/issues"); |
|||
|
|||
issues.ShouldNotBeNull(); |
|||
issues.Count.ShouldBeGreaterThan(0); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
## 测试JavaScript代码 |
|||
|
|||
ABP框架不提供任何基础设施来测试JavaScript代码. 你可以使用任何测试框架和工具来测试JavaScript代码. |
|||
|
|||
## 测试基础设施 |
|||
|
|||
[Volo.Abp.AspNetCore.TestBase](https://www.nuget.org/packages/Volo.Abp.AspNetCore.TestBase) 提供了集成到ABP框架和ASP.NET Core的测试基础设施. |
|||
|
|||
> Volo.Abp.AspNetCore.TestBase 已经安装在 `.Web.Tests` 项目中. |
|||
|
|||
此包提供的`AbpAspNetCoreIntegratedTestBase`作为派生测试类的基类. 上面使用的`MyProjectWebTestBase`继承自`AbpAspNetCoreIntegratedTestBase`, 因此我们间接继承了`AbpAspNetCoreIntegratedTestBase`. |
|||
|
|||
### 基本属性 |
|||
|
|||
`AbpAspNetCoreIntegratedTestBase` 提供了测试中使用的以下基本属性: |
|||
|
|||
* `Server`: 在测试中托管web应用程序的`TestServer`实例. |
|||
* `Client`: 为执行对测试服务器的请求配置`HttpClient`实例. |
|||
* `ServiceProvider`: 可以在你需要时处理服务提供服务. |
|||
|
|||
### 基本方法 |
|||
|
|||
`AbpAspNetCoreIntegratedTestBase` 提供了以下方法, 如果需要自定义测试服务器, 可以重写这些方法: |
|||
|
|||
* `ConfigureServices` 仅为派生测试类注册/替换服务时可以重写使用. |
|||
* `CreateHostBuilder` 可用于自定义生成 `IHostBuilder`. |
|||
|
|||
另请参阅 |
|||
|
|||
* [总览/服务器端测试](../../Testing.md) |
|||
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 30 KiB |
@ -0,0 +1,18 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations; |
|||
|
|||
[Serializable] |
|||
public class ApplicationGlobalFeatureConfigurationDto |
|||
{ |
|||
public HashSet<string> EnabledFeatures { get; set; } |
|||
|
|||
public Dictionary<string, List<string>> ModuleEnabledFeatures { get; set; } |
|||
|
|||
public ApplicationGlobalFeatureConfigurationDto() |
|||
{ |
|||
EnabledFeatures = new HashSet<string>(); |
|||
ModuleEnabledFeatures = new Dictionary<string, List<string>>(); |
|||
} |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
using System.Linq; |
|||
|
|||
namespace Volo.Abp.Cli.ProjectBuilding.Building.Steps; |
|||
|
|||
public class MoveFileStep : ProjectBuildPipelineStep |
|||
{ |
|||
private readonly string _filePath; |
|||
private readonly string _newPath; |
|||
|
|||
public MoveFileStep(string filePath, string newPath) |
|||
{ |
|||
_filePath = filePath; |
|||
_newPath = newPath; |
|||
} |
|||
|
|||
public override void Execute(ProjectBuildContext context) |
|||
{ |
|||
var fileToMove = context.Files.Find(x => x.Name == _filePath); |
|||
var newFileExist = context.Files.Any(x => x.Name == _newPath); |
|||
|
|||
if (fileToMove == null || newFileExist) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
fileToMove.SetName(_newPath); |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
using System; |
|||
|
|||
namespace Volo.Abp.Cli.ProjectBuilding.Building.Steps; |
|||
|
|||
public class RemoveFileStep : ProjectBuildPipelineStep |
|||
{ |
|||
private readonly string _filePath; |
|||
public RemoveFileStep(string filePath) |
|||
{ |
|||
_filePath = filePath; |
|||
} |
|||
|
|||
public override void Execute(ProjectBuildContext context) |
|||
{ |
|||
var fileToRemove = context.Files.Find(x => x.Name == _filePath);; |
|||
if (fileToRemove != null) |
|||
{ |
|||
context.Files.Remove(fileToRemove); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,35 @@ |
|||
using System; |
|||
using System.IO; |
|||
using System.Linq; |
|||
using Volo.Abp.Cli.Utils; |
|||
using Volo.Abp.DependencyInjection; |
|||
|
|||
namespace Volo.Abp.Cli.ProjectModification; |
|||
|
|||
public class AngularPwaSupportAdder : ITransientDependency |
|||
{ |
|||
protected ICmdHelper CmdHelper { get; } |
|||
protected PackageJsonFileFinder PackageJsonFileFinder { get; } |
|||
|
|||
public AngularPwaSupportAdder( |
|||
ICmdHelper cmdHelper, |
|||
PackageJsonFileFinder packageJsonFileFinder) |
|||
{ |
|||
CmdHelper = cmdHelper; |
|||
PackageJsonFileFinder = packageJsonFileFinder; |
|||
} |
|||
|
|||
public virtual void AddPwaSupport(string rootDirectory) |
|||
{ |
|||
var fileList = PackageJsonFileFinder.Find(rootDirectory).Where(x => File.Exists(x.RemovePostFix("package.json") + "angular.json")).ToList(); |
|||
|
|||
fileList.ForEach(AddPwaSupportToProject); |
|||
} |
|||
|
|||
protected virtual void AddPwaSupportToProject(string filePath) |
|||
{ |
|||
var directory = Path.GetDirectoryName(filePath).EnsureEndsWith(Path.DirectorySeparatorChar); |
|||
|
|||
CmdHelper.RunCmd("ng add @angular/pwa --skip-confirmation", workingDirectory: directory); |
|||
} |
|||
} |
|||