@ -1,20 +0,0 @@ |
|||
@ECHO off |
|||
cls |
|||
|
|||
ECHO Deleting all BIN and OBJ folders... |
|||
ECHO. |
|||
|
|||
FOR /d /r . %%d in (bin,obj) DO ( |
|||
IF EXIST "%%d" ( |
|||
ECHO %%d | FIND /I "\node_modules\" > Nul && ( |
|||
ECHO.Skipping: %%d |
|||
) || ( |
|||
ECHO.Deleting: %%d |
|||
rd /s/q "%%d" |
|||
) |
|||
) |
|||
) |
|||
|
|||
ECHO. |
|||
ECHO.BIN and OBJ folders have been successfully deleted. Press any key to exit. |
|||
pause > nul |
|||
@ -1,37 +0,0 @@ |
|||
const { resolve } = require("path"); |
|||
const { readdir, rmdir } = require("fs").promises; |
|||
|
|||
async function* removeFolder(dir) { |
|||
await rmdir(dir, { recursive: true }); |
|||
yield dir; |
|||
} |
|||
|
|||
async function* getBinObj(dir) { |
|||
const dirents = await readdir(dir, { withFileTypes: true }); |
|||
|
|||
for (const dirent of dirents) { |
|||
if (!dirent.isDirectory()) continue; |
|||
const name = dirent.name; |
|||
|
|||
if (name === "node_modules") continue; |
|||
|
|||
const res = resolve(dir, dirent.name); |
|||
|
|||
if (name === "bin" || name === "obj") { |
|||
yield* removeFolder(res); |
|||
continue; |
|||
} |
|||
|
|||
yield* getBinObj(res); |
|||
} |
|||
} |
|||
|
|||
(async () => { |
|||
console.log("\x1b[36m%s\x1b[0m", "Deleting all BIN and OBJ folders..."); |
|||
|
|||
for await (const dir of getBinObj(".")) { |
|||
console.log("\x1b[33m%s\x1b[0m", `Removed: ${dir}`); |
|||
} |
|||
|
|||
console.log("\x1b[36m%s\x1b[0m", "All BIN and OBJ folders are deleted."); |
|||
})(); |
|||
@ -0,0 +1,217 @@ |
|||
# Static C# API Client Proxies |
|||
|
|||
ABP can create C# API client proxy code to call your remote HTTP services (REST APIs). In this way, you don't need to deal with `HttpClient` and other low level details to call remote services and get results. |
|||
|
|||
Static C# proxies automatically handle the following stuff for you; |
|||
|
|||
* Maps C# **method calls** to remote server **HTTP calls** by considering the HTTP method, route, query string parameters, request payload and other details. |
|||
* **Authenticates** the HTTP Client by adding access token to the HTTP header. |
|||
* **Serializes** to and deserialize from JSON. |
|||
* Handles HTTP API **versioning**. |
|||
* Add **correlation id**, current **tenant** id and the current **culture** to the request. |
|||
* Properly **handles the error messages** sent by the server and throws proper exceptions. |
|||
|
|||
This system can be used by any type of .NET client to consume your HTTP APIs. |
|||
|
|||
## Static vs Dynamic Client Proxies |
|||
|
|||
ABP provides **two types** of client proxy generation system. This document explains the **static client proxies**, which generates client-side code in your development time. You can also see the [Dynamic C# API Client Proxies](Dynamic-CSharp-API-Clients.md) documentation to learn how to use proxies generated on runtime. |
|||
|
|||
Development-time (static) client proxy generation has a **performance advantage** since it doesn't need to obtain the HTTP API definition on runtime. However, you should **re-generate** the client proxy code whenever you change your API endpoint definition. On the other hand, dynamic client proxies are generated on runtime and provides an **easier development experience**. |
|||
|
|||
## Service Interface |
|||
|
|||
Your service/controller should implement an interface that is shared between the server and the client. So, first define a service interface in a shared library project, typically in the `Application.Contracts` project if you've created your solution using the startup templates. |
|||
|
|||
Example: |
|||
|
|||
````csharp |
|||
public interface IBookAppService : IApplicationService |
|||
{ |
|||
Task<List<BookDto>> GetListAsync(); |
|||
} |
|||
```` |
|||
|
|||
> Your interface should implement the `IRemoteService` interface to be automatically discovered. Since the `IApplicationService` inherits the `IRemoteService` interface, the `IBookAppService` above satisfies this condition. |
|||
|
|||
Implement this class in your service application. You can use [auto API controller system](Auto-API-Controllers.md) to expose the service as a REST API endpoint. |
|||
|
|||
## Client Proxy Generation |
|||
|
|||
First, add [Volo.Abp.Http.Client](https://www.nuget.org/packages/Volo.Abp.Http.Client) nuget package to your client project: |
|||
|
|||
```` |
|||
Install-Package Volo.Abp.Http.Client |
|||
```` |
|||
|
|||
Then add `AbpHttpClientModule` dependency to your module: |
|||
|
|||
````csharp |
|||
[DependsOn(typeof(AbpHttpClientModule))] //add the dependency |
|||
public class MyClientAppModule : AbpModule |
|||
{ |
|||
} |
|||
```` |
|||
|
|||
Now, it's ready to configure the application for the static client proxy generation. Example: |
|||
|
|||
````csharp |
|||
[DependsOn( |
|||
typeof(AbpHttpClientModule), //used to create client proxies |
|||
typeof(BookStoreApplicationContractsModule) //contains the application service interfaces |
|||
)] |
|||
public class MyClientAppModule : AbpModule |
|||
{ |
|||
public override void ConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
// Prepare for static client proxy generation |
|||
context.Services.AddStaticHttpClientProxies( |
|||
typeof(BookStoreApplicationContractsModule).Assembly |
|||
); |
|||
} |
|||
} |
|||
```` |
|||
|
|||
`AddStaticHttpClientProxies` method gets an assembly, finds all service interfaces in the given assembly, and prepares for static client proxy generation. |
|||
|
|||
> The [application startup template](../Startup-Templates/Application.md) comes pre-configured for the **dynamic** client proxy generation, in the `HttpApi.Client` project. If you want to switch to the **static** client proxies, change `context.Services.AddHttpClientProxies` to `context.Services.AddStaticHttpClientProxies` in the module class of your `HttpApi.Client` project. |
|||
|
|||
### Endpoint Configuration |
|||
|
|||
`RemoteServices` section in the `appsettings.json` file is used to get remote service address by default. The simplest configuration is shown below: |
|||
|
|||
```json |
|||
{ |
|||
"RemoteServices": { |
|||
"Default": { |
|||
"BaseUrl": "http://localhost:53929/" |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
See the *AbpRemoteServiceOptions* section below for more detailed configuration. |
|||
|
|||
### Code Generation |
|||
|
|||
Server side must be up and running while generating the client proxy code. So, run your application that serves the HTTP APIs on the `BaseUrl` that is configured like explained in the *Endpoint Configuration* section. |
|||
|
|||
Open a command-line terminal in the root folder of your client project (`.csproj`) and type the following command: |
|||
|
|||
````bash |
|||
abp generate-proxy -t csharp -u http://localhost:53929/ |
|||
```` |
|||
|
|||
> If you haven't installed yet, you should install the [ABP CLI](../CLI.md). |
|||
|
|||
This command should generate the following files under the `ClientProxies` folder: |
|||
|
|||
 |
|||
|
|||
`BookClientProxy.Generated.cs` is the actual generated proxy class in this example. `BookClientProxy` is a `partial` class where you can write your custom code (ABP won't override it). `app-generate-proxy.json` contains information about the remote HTTP endpoint, so ABP can properly perform HTTP requests. |
|||
|
|||
> `generate-proxy` command generates proxies for only the APIs you've defined in your application. If you are developing a modular application, you can specify the `-m` (or `--module`) parameter to specify the module you want to generate proxies. See the *generate-proxy* section in the [ABP CLI](../CLI.md) documentation for other options. |
|||
|
|||
## Usage |
|||
|
|||
It's straightforward to use the client proxies. Just inject the service interface in the client application code: |
|||
|
|||
````csharp |
|||
public class MyService : ITransientDependency |
|||
{ |
|||
private readonly IBookAppService _bookService; |
|||
|
|||
public MyService(IBookAppService bookService) |
|||
{ |
|||
_bookService = bookService; |
|||
} |
|||
|
|||
public async Task DoItAsync() |
|||
{ |
|||
var books = await _bookService.GetListAsync(); |
|||
foreach (var book in books) |
|||
{ |
|||
Console.WriteLine($"[BOOK {book.Id}] Name={book.Name}"); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
This sample injects the `IBookAppService` service interface defined above. The static client proxy implementation makes an HTTP call whenever a service method is called by the client. |
|||
|
|||
## Configuration |
|||
|
|||
### AbpRemoteServiceOptions |
|||
|
|||
`AbpRemoteServiceOptions` is automatically set from the `appsettings.json` by default. Alternatively, you can configure it in the `ConfigureServices` method of your [module](../Module-Development-Basics.md) to set or override it. Example: |
|||
|
|||
````csharp |
|||
public override void ConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
context.Services.Configure<AbpRemoteServiceOptions>(options => |
|||
{ |
|||
options.RemoteServices.Default = |
|||
new RemoteServiceConfiguration("http://localhost:53929/"); |
|||
}); |
|||
|
|||
//... |
|||
} |
|||
```` |
|||
|
|||
### Multiple Remote Service Endpoints |
|||
|
|||
The examples above have configured the "Default" remote service endpoint. You may have different endpoints for different services (as like in a microservice approach where each microservice has different endpoints). In this case, you can add other endpoints to your configuration file: |
|||
|
|||
````json |
|||
{ |
|||
"RemoteServices": { |
|||
"Default": { |
|||
"BaseUrl": "http://localhost:53929/" |
|||
}, |
|||
"BookStore": { |
|||
"BaseUrl": "http://localhost:48392/" |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
`AddStaticHttpClientProxies` method can get an additional parameter for the remote service name. Example: |
|||
|
|||
````csharp |
|||
context.Services.AddStaticHttpClientProxies( |
|||
typeof(BookStoreApplicationContractsModule).Assembly, |
|||
remoteServiceConfigurationName: "BookStore" |
|||
); |
|||
```` |
|||
|
|||
`remoteServiceConfigurationName` parameter matches the service endpoint configured via `AbpRemoteServiceOptions`. If the `BookStore` endpoint is not defined then it fallbacks to the `Default` endpoint. |
|||
|
|||
### Retry/Failure Logic & Polly Integration |
|||
|
|||
If you want to add retry logic for the failing remote HTTP calls for the client proxies, you can configure the `AbpHttpClientBuilderOptions` in the `PreConfigureServices` method of your module class. |
|||
|
|||
**Example: Use the [Polly](https://github.com/App-vNext/Polly) library to re-try 3 times on a failure** |
|||
|
|||
````csharp |
|||
public override void PreConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
PreConfigure<AbpHttpClientBuilderOptions>(options => |
|||
{ |
|||
options.ProxyClientBuildActions.Add((remoteServiceName, clientBuilder) => |
|||
{ |
|||
clientBuilder.AddTransientHttpErrorPolicy(policyBuilder => |
|||
policyBuilder.WaitAndRetryAsync( |
|||
3, |
|||
i => TimeSpan.FromSeconds(Math.Pow(2, i)) |
|||
) |
|||
); |
|||
}); |
|||
}); |
|||
} |
|||
```` |
|||
|
|||
This example uses the [Microsoft.Extensions.Http.Polly](https://www.nuget.org/packages/Microsoft.Extensions.Http.Polly) package. You also need to import the `Polly` namespace (`using Polly;`) to be able to use the `WaitAndRetryAsync` method. |
|||
|
|||
## See Also |
|||
|
|||
* [Dynamic C# Client Proxies](Dynamic-CSharp-API-Clients.md) |
|||
@ -0,0 +1,196 @@ |
|||
# ABP.IO Platform 5.2 RC.1 Has Been Released |
|||
|
|||
Today, we are happy to release the [ABP Framework](https://abp.io/) and [ABP Commercial](https://commercial.abp.io/) version **5.2 RC** (Release Candidate). This blog post introduces the new features and important changes in this new version. |
|||
|
|||
> **The planned release date for the [5.2.0 Stable](https://github.com/abpframework/abp/milestone/66) version is April 05, 2022**. |
|||
|
|||
Please try this version and provide feedback for a more stable ABP version 5.2! Thank you all. |
|||
|
|||
## Get Started with the 5.2 RC |
|||
|
|||
Follow the steps below to try the version 5.2.0 RC today: |
|||
|
|||
1) **Upgrade** the ABP CLI to version `5.2.0-rc.1` using a command line terminal: |
|||
|
|||
````bash |
|||
dotnet tool update Volo.Abp.Cli -g --version 5.2.0-rc.1 |
|||
```` |
|||
|
|||
**or install** it if you haven't before: |
|||
|
|||
````bash |
|||
dotnet tool install Volo.Abp.Cli -g --version 5.2.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 Guides |
|||
|
|||
Please see the migration guides if you are upgrading from 5.x versions: |
|||
|
|||
* [ABP Framework 5.x to 5.2 migration guide](https://docs.abp.io/en/abp/5.2/Migration-Guides/Abp-5_2) |
|||
* [ABP Commercial 5.x to 5.2 migration guide](https://docs.abp.io/en/commercial/5.2/migration-guides/index) |
|||
|
|||
## What's New with ABP Framework 5.2? |
|||
|
|||
In this section, I will introduce some major features released with this version. Here, a brief list of titles explained in the next sections: |
|||
|
|||
* Single-layer Solution Template |
|||
* API Versioning |
|||
* libs Folder Has been Removed from Source Control |
|||
* Hiding the Default ABP Endpoints from Swagger UI |
|||
* Custom Global CSS and JavaScript for the CMS Kit module |
|||
* Other news |
|||
|
|||
Let's begin with the first section. |
|||
|
|||
### Single-layer Solution Template |
|||
|
|||
ABP's [application startup template](https://docs.abp.io/en/abp/latest/Startup-Templates/Application) is a well-organized and layered solution to create maintainable business applications. However, some developers find it a little bit complex for simple and short-term applications. For such applications, we've created a new startup template that has no layering and built as simple as possible. It has the same functionality, features and modules on runtime, but the development model is minimal and everything is in the single project (`csproj`), as shown in the following figure: |
|||
|
|||
 |
|||
|
|||
Use the `app-nolayers` as the template name while creating your solution: |
|||
|
|||
````bash |
|||
abp new BookStore -t app-nolayers --preview |
|||
```` |
|||
|
|||
[ABP Commercial](https://commercial.abp.io/) developers can use the `app-nolayers-pro` as the startup template: |
|||
|
|||
````csharp |
|||
abp new BookStore -t app-nolayers-pro --preview |
|||
```` |
|||
|
|||
> There is a bug for the `app-nolayers-pro` related to the licensing system, which will be fixed with 5.2.0-rc.2: `appsettings.secrets.json` is missing in the project folder (should be near to `appsettings.json` and contains the license code normally). As a workaround, create a new solution with the layered startup template, find and copy that file to the single-layer solution. |
|||
|
|||
One note about the single-layer project is it doesn't have Blazor WebAssembly UI, because it requires 3 projects at least (one server-side, one UI and one shared library project between these two). We will consider to add Blazor UI support based on your feedback. You can continue to develop Blazor WASM projects using the standard layered solution template. |
|||
|
|||
#### Database Migrations for EF Core |
|||
|
|||
After creating your solution, you need to create the database before running the application. We've added a parameter to the application that can be specified to migrate the database and seed the initial data. Open the project's directory (that contains the `csproj` file) in a command-line terminal and type the following command: |
|||
|
|||
````bash |
|||
dotnet run --migrate-database |
|||
```` |
|||
|
|||
It will run, migrate the database and exit. You can then run the application as normal. |
|||
|
|||
You could use the standard `dotnet ef database update` command (or `Update-Database` command in Visual Studio's Package Manager Console). It can successfully create the database tables. However, it doesn't seed the initial data that is necessary to run the application. |
|||
|
|||
To keep the solution simple, we haven't added an external application (like the `DbMigrator` in the layered application startup template) to migrate the database. |
|||
|
|||
Using the same application to migrate the database is simple and useful for development environment, and it can also be used in production environment. However, there are other ways of migrating a database. Please read more on [Microsoft's documentation](https://docs.microsoft.com/en-us/ef/core/managing-schemas/migrations). |
|||
|
|||
#### Other UI and Database Options |
|||
|
|||
The new single-layer solution template also supports Angular and Blazor UI, and MongoDB for the database side. For the UI, you can use `mvc` (default), `angular` or `blazor-server` with the `-u` (or `--ui`) parameter. For the database provider, you can use `ef` (default) or `mongodb` with the `-d` (or `--database-provider`) parameter. Example: |
|||
|
|||
````bash |
|||
abp new BookStore -t app-nolayers -u angular -d mongodb --preview |
|||
```` |
|||
|
|||
This will create a single layer template with Angular as the UI framework and MongoDB as the database provider. |
|||
|
|||
#### Single-Layer Tutorial |
|||
|
|||
[Hamza Albreem](https://twitter.com/st_braim) has created a video tutorial to explain how to develop a simple application with this startup template. |
|||
|
|||
 |
|||
|
|||
You can [watch it here](https://community.abp.io/posts/developing-a-minimalist-application-with-the-abp-framework-mvad01ca). |
|||
|
|||
### API Versioning |
|||
|
|||
API versioning has always been possible with the ABP Framework, but we haven't had a documentation for it. With version 5.2, we've created a document to explain how to implement API versioning for your applications and add versioning support to your standard application service classes. See the [documentation here](https://docs.abp.io/en/abp/5.2/API/API-Versioning). |
|||
|
|||
### libs Folder Has been Removed from Source Control |
|||
|
|||
> **NOTICE: This can be a breaking change for your development environment and CI/CD pipelines. So, please read it carefully and take the necessary actions.** |
|||
|
|||
When you create solutions with MVC (Razor Pages) or Blazor Server UI, your application will have a `wwwroot/libs` folder in the UI project as shown below: |
|||
|
|||
 |
|||
|
|||
The `libs` folder contains all the client-side (mostly JavaScript and CSS) library dependencies. For example, in the figure above, the `bootstap` folder contains the necessary files for the Bootstrap library. The folder's content is copied from the `node_modules` folder (it only copies the minimum files to make the library work, not the whole folder) with ABP CLI's `abp install-libs` command. |
|||
|
|||
Before version 5.2, the `libs` folder was coming with the startup template and committed into your source control system (e.g. Git). With version 5.2, this folder is excluded from the source control by default, so every developer getting the solution must run `abp install-libs` in the UI project's root directory to install these libraries. This approach saves a huge amount of size of the solution. For example, the initial size of an MVC UI application reduces from `9.83MB` to `0.23MB` (you read it right!). |
|||
|
|||
When you create a new solution with ABP CLI, `abp install-libs` command is automatically executed, so your application directly works. However, if your teammates (or CI/CD system) get the solution from the source control, they should run the `abp install-libs` before running the solution. If you don't want that, you can simply remove the `**/wwwroot/libs/*` line from the `.gitignore` file in the root folder of your solution, then the `libs` folder is added to your source control again (if you are using a source control system other than Git, you should apply your system's rules to include/exclude that folder). |
|||
|
|||
### Hiding the Default ABP Endpoints from Swagger UI |
|||
|
|||
[Engincan Veske](https://twitter.com/EngincanVeske) had written [an article](https://community.abp.io/posts/how-to-hide-abp-related-endpoints-on-swagger-ui-mb2w01fe) to explain how to hide ABP's default endpoints from Swagger UI. Then We thought that could be a good built-in option in the ABP Framework and added a `HideAbpEndpoints` method to the `AddAbpSwaggerGen` method, which can be used as in the following code example: |
|||
|
|||
````csharp |
|||
services.AddAbpSwaggerGen( |
|||
options => |
|||
{ |
|||
//... other options |
|||
|
|||
//Hides ABP Related endpoints on Swagger UI |
|||
options.HideAbpEndpoints(); |
|||
} |
|||
) |
|||
```` |
|||
|
|||
After that, ABP's default endpoints will still be functional, but will be hidden in Swagger UI. |
|||
|
|||
### Custom Global CSS and JavaScript for the CMS Kit module |
|||
|
|||
We are improving the [CMS Kit module](https://docs.abp.io/en/abp/5.2/Modules/Cms-Kit/Index) and adding new features constantly. A new feature with version 5.2 is a global resources system, where you can write custom JavaScript or CSS code on the application's UI (added a new page for it), which will be immediately available in all your application pages: |
|||
|
|||
 |
|||
|
|||
In this way, you can customize your application's look and behavior on runtime. See [the documentation](https://docs.abp.io/en/abp/5.2/Modules/Cms-Kit/Global-Resources) for more information. |
|||
|
|||
> Note that the [pages](https://docs.abp.io/en/abp/5.2/Modules/Cms-Kit/Pages) feature already have a page-basis script/style editor. But this new feature allows you to write script/style that is applied to all pages of your application. |
|||
|
|||
### Other news |
|||
|
|||
* Upgraded the [Blazorise](https://blazorise.com/) library to v1.0 for Blazor UI. After the upgrade, ensure that all Blazorise-related packages are using v1.0 in your application. |
|||
|
|||
If you want to see more details, you can check [the release on GitHub](https://github.com/abpframework/abp/releases/tag/5.2.0-rc.1), that contains a list of all issues and pull requests closed with this version. |
|||
|
|||
## What's New with ABP Commercial 5.2? |
|||
|
|||
We've also made many enhancements for [ABP Commercial](https://commercial.abp.io/), and also made the necessary changes and improvements to align with the v5.2 release of the ABP Framework. For example, the **single-layer solution template** is also available for ABP Commercial as explained above. |
|||
|
|||
### Many to Many Relations on Code Generation |
|||
|
|||
One exciting new feature with ABP Commercial v5.2 is supporting many-to-many entity relations while generating CRUD code with [ABP Suite](https://commercial.abp.io/tools/suite). |
|||
|
|||
When you open ABP Suite v5.2, you will see an "Add navigation collection" button in the Navigation tab. Here, you can click that button, select the target entity and other options: |
|||
|
|||
 |
|||
|
|||
With this new feature, you can automatically generate advanced user interfaces without touching the code, then customize the generated code to implement your business logic. |
|||
|
|||
## Community News |
|||
|
|||
We organized the 3rd live [ABP Community Talks](https://community.abp.io/events) event on February 23rd. ABP community has a good interest in these events and we will continue to organize such a live event in every month. March's event will be announced in a few days. [Follow us on twitter](https://twitter.com/abpframework). |
|||
|
|||
[ABP Community](https://community.abp.io/) website is being a huge resource of articles and video tutorials on the ABP Framework and .NET. There have been 93 articles/tutorials submitted so far. Here's a list of a few contents posted in the last weeks: |
|||
|
|||
* [Ahmet Urel](https://twitter.com/YellowDraqon) submitted a series of articles to demonstrate the usage of [MudBlazor](https://www.mudblazor.com/) library with the ABP Framework Blazor UI: [Part 1](https://community.abp.io/posts/mudblazor-theme-in-abp-blazor-webassembly-ae23zz17), [Part 2](https://community.abp.io/posts/mudblazor-theme-in-abp-blazor-webassembly-part-2-tkvrvyvm) and [Part 3](https://community.abp.io/posts/mudblazor-theme-in-abp-blazor-webassembly-part-3-c8hwx00l). |
|||
* [Enis Necipoğlu](https://twitter.com/EnisNecipoglu) has created two articles for ABP & **.NET MAUI** integration: See [Part 1](https://community.abp.io/posts/integrating-maui-client-via-using-openid-connect-aqjjwsdf) and [Part 2](https://community.abp.io/posts/using-abp-client-proxies-in-maui-with-openid-connect-em7x1s8k). [Bart Van Hoey](https://twitter.com/bartvanhoey) also created [an article](https://community.abp.io/posts/abp-framework-consumed-by-a-.net-maui-app-e74fmblw) for .NET MAUI and the ABP Framework. |
|||
* [Enis Necipoğlu](https://twitter.com/EnisNecipoglu) has also created [an article](https://community.abp.io/posts/using-autofilterer-with-abp-framework-uuqv81jm) to demonstrate how to use his own open source [AutoFilterer](https://github.com/enisn/AutoFilterer) library with the ABP Framework. |
|||
* [Jonathan Potts](https://github.com/jonathanpotts) has created his first ABP Community article that shows how to use Bootswatch themes with the ABP Framework. [See it here](https://community.abp.io/posts/customizing-the-abp-basic-theme-with-bootswatch-4luoqzr0). |
|||
|
|||
Thanks to all the contributors. It is appreciated if you want to submit your post and share your knowledge with the ABP community: https://community.abp.io/posts/submit |
|||
|
|||
## Conclusion |
|||
|
|||
This version was mostly about minor features and enhancements for existing features, and getting the framework, architecture and solution structure more mature. Currently, we are working on middle and long term features, changes and goals. You can find most of them on the [road map here](https://docs.abp.io/en/abp/5.2/Road-Map). |
|||
|
|||
The planned release date for the [5.2.0 Stable](https://github.com/abpframework/abp/milestone/66) version is April 05, 2022. Please try the ABP v5.2 RC and provide feedback to have a more stable release. |
|||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 111 KiB |
@ -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,601 @@ |
|||
# Integrating MAUI Client via using OpenID Connect |
|||
This is a demonstration for connecting ABP backend from MAUI app via using openid connect. |
|||
|
|||
In this flow, a web browser will be opened when user tries to log in and user will perform login operation in the browser. Then IdentityServer will redirect user to application with login credentials (state, token etc.) will be handled by application. |
|||
|
|||
> This is by intent. The code flow does not allow the user to log in using a native view in the app. The reason being that this flow ensures that the username and password are never seen by the client (except the browser, which is part of the OS system - aka we trust it). You could enable using a native login view with the Resource Owner Password Credentials (ROPC) flow. But this is also an attack vector. Suppose someone makes a fraud duplicate of your application and tricking users into entering their credentials. The fraudulent app could store those credentials in-between. You just got to enjoy those tin-foil-hat moments when doing security. In other words, using the code flow does not give an attacker that opportunity and therefore is the recommended option for mobile clients. |
|||
> |
|||
> - [@Mark Allibone](https://mallibone.com/post/xamarin-oidc) |
|||
|
|||
By the way, my motivation for building this sample is presenting just another way for authentication. **Resource Owner Password Credentials** authentication is already provided and it's more common way to do. This is yet another way to authenticate users. |
|||
|
|||
## Source Code |
|||
You can also find source code on GitHub in ABP-Samples. |
|||
- [abpframework/abp-samples/MAUI-OpenId](https://github.com/abpframework/abp-samples/tree/master/MAUI-OpenId) |
|||
|
|||
## Creating projects |
|||
- Create an ABP project without UI |
|||
|
|||
```bash |
|||
abp new Acme.BookStore -t app --no-ui -d mongodb --no-random-ports |
|||
``` |
|||
|
|||
- Create a maui application |
|||
|
|||
```bash |
|||
mkdir maui |
|||
cd maui |
|||
dotnet new maui -n Acme.BookStore.MauiClient |
|||
``` |
|||
|
|||
There is a long way for configuring scopes and callback urls for both server and clients. We'll use [WebAuthenticator](https://docs.microsoft.com/en-us/xamarin/essentials/web-authenticator?tabs=android) to perform this operation. |
|||
|
|||
## Configuring IdentityServer |
|||
|
|||
- Go to DbMigrator folder and MAUI client in **appsettings.json**. Add following client code in **IdentityServer:Clients** path: |
|||
|
|||
```json |
|||
"BookStore_Maui": { |
|||
"ClientId": "BookStore_Maui", |
|||
"ClientSecret": "1q2w3e*", |
|||
"RootUrl": "bookstore://" |
|||
} |
|||
``` |
|||
|
|||
- Go to **IdentityServerDataSeedContributor** in Domain project under IdentityServer folder. Append following code section into **CreateClientsAsync()** method. |
|||
|
|||
```csharp |
|||
// Maui Client |
|||
var mauiClientId = configurationSection["BookStore_Maui:ClientId"]; |
|||
if (!mauiClientId.IsNullOrWhiteSpace()) |
|||
{ |
|||
var mauiRootUrl = configurationSection["BookStore_Maui:RootUrl"]; |
|||
|
|||
await CreateClientAsync( |
|||
name: mauiClientId, |
|||
scopes: commonScopes, |
|||
grantTypes: new[] { "authorization_code" }, |
|||
secret: configurationSection["BookStore_Maui:ClientSecret"]?.Sha256(), |
|||
requireClientSecret: false, |
|||
redirectUri: $"{mauiRootUrl}" |
|||
); |
|||
} |
|||
``` |
|||
|
|||
- Run DbMigrator |
|||
|
|||
- Then run HttpApi.Host |
|||
|
|||
### Configuring NGROK |
|||
Client will check configuration from `/.well-known/openid-configuration` path and it must be a secured connection between client & server. I prefer to use ngrok to open my backend app to entire web. |
|||
|
|||
- Go to [getting started](https://dashboard.ngrok.com/get-started/setup) page of ngrok _(login or register first)_ and download the ngrok tool. |
|||
|
|||
- Don't forget to login from tool: |
|||
|
|||
```bash |
|||
ngrok authtoken XXX |
|||
``` |
|||
|
|||
_A sample command is being displayed at dashboard where you download ngrok from_ |
|||
|
|||
- Open your HttpApi.Host with ngrok |
|||
|
|||
```bash |
|||
.\ngrok.exe http https://localhost:44350 |
|||
``` |
|||
|
|||
- You'll see a generated xxx.ngrok.io url. Navigate to `/.well-known/openid-configuration` to check if it's working right. |
|||
|
|||
You should see something like that: |
|||
 |
|||
Issuer must be your URL, not localhost! If you see still localhost, try to disable host header rewrite. |
|||
|
|||
|
|||
- Also, ValidIssuers must be defined to validate tokens. |
|||
|
|||
- Add **ValidIssuers** section to your `appsettings.json` of HttpApi.Host |
|||
|
|||
```js |
|||
"AuthServer": { |
|||
"Authority": "https://localhost:44350", |
|||
"RequireHttpsMetadata": "false", |
|||
"SwaggerClientId": "BookStore_Swagger", |
|||
"SwaggerClientSecret": "1q2w3e*", |
|||
"ValidIssuers": [ |
|||
"https://46fd-45-156-29-175.ngrok.io" |
|||
] |
|||
}, |
|||
``` |
|||
|
|||
- Then define it in **ConfigureAuthentication** method in Module class |
|||
|
|||
```csharp |
|||
private void ConfigureAuthentication(ServiceConfigurationContext context, IConfiguration configuration) |
|||
{ |
|||
context.Services.AddAuthentication() |
|||
.AddJwtBearer(options => |
|||
{ |
|||
// ... |
|||
options.TokenValidationParameters.ValidIssuers = configuration.GetSection("AuthServer:ValidIssuers").Get<string[]>(); |
|||
}); |
|||
} |
|||
``` |
|||
|
|||
We're done with backend. Let's continue with MAUI app. |
|||
|
|||
|
|||
## Developing MAUI App |
|||
|
|||
Before we go, there is something to do like configuring dependency injection to get rid of unnecessary huge class coupling. |
|||
|
|||
### Configuring Dependency Injection |
|||
|
|||
- Go to **MauiApplication** class and add `MainPage` in services. |
|||
|
|||
```csharp |
|||
public static MauiApp CreateMauiApp() |
|||
{ |
|||
var builder = MauiApp.CreateBuilder(); |
|||
builder |
|||
.UseMauiApp<App>() |
|||
.ConfigureFonts(fonts => |
|||
{ |
|||
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); |
|||
}); |
|||
|
|||
builder.Services.AddTransient<MainPage>(); |
|||
|
|||
return builder.Build(); |
|||
} |
|||
``` |
|||
|
|||
- And inject MainPage from constructor in **App.xaml.cs** |
|||
|
|||
```csharp |
|||
public App(MainPage mainPage) |
|||
{ |
|||
InitializeComponent(); |
|||
|
|||
MainPage = mainPage; |
|||
} |
|||
``` |
|||
|
|||
Now MainPage is ready for injecting dependencies to it. |
|||
|
|||
|
|||
### Configuring OIDC |
|||
|
|||
- Add `IdentityModel.OidcClient` package to project |
|||
|
|||
```xml |
|||
<ItemGroup> |
|||
<PackageReference Include="IdentityModel.OidcClient" Version="5.0.0" /> |
|||
</ItemGroup> |
|||
``` |
|||
|
|||
- Create **WebAuthenticatorBrowser** |
|||
|
|||
```csharp |
|||
internal class WebAuthenticatorBrowser : IBrowser |
|||
{ |
|||
public async Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default) |
|||
{ |
|||
try |
|||
{ |
|||
WebAuthenticatorResult authResult = |
|||
await WebAuthenticator.AuthenticateAsync(new Uri(options.StartUrl), new Uri(options.EndUrl)); |
|||
var authorizeResponse = ToRawIdentityUrl(options.EndUrl, authResult); |
|||
|
|||
return new BrowserResult |
|||
{ |
|||
Response = authorizeResponse |
|||
}; |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
Debug.WriteLine(ex); |
|||
return new BrowserResult() |
|||
{ |
|||
ResultType = BrowserResultType.UnknownError, |
|||
Error = ex.ToString() |
|||
}; |
|||
} |
|||
} |
|||
|
|||
public string ToRawIdentityUrl(string redirectUrl, WebAuthenticatorResult result) |
|||
{ |
|||
IEnumerable<string> parameters = result.Properties.Select(pair => $"{pair.Key}={pair.Value}"); |
|||
var values = string.Join("&", parameters); |
|||
|
|||
return $"{redirectUrl}#{values}"; |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Configure **OidcClient** in **MauiProgram** |
|||
|
|||
```csharp |
|||
builder.Services.AddTransient<WebAuthenticatorBrowser>(); |
|||
|
|||
builder.Services.AddTransient<OidcClient>(sp => |
|||
new OidcClient(new OidcClientOptions |
|||
{ |
|||
// Use your own ngrok url: |
|||
Authority = "https://46fd-45-156-29-175.ngrok.io", |
|||
ClientId = "BookStore_Maui", |
|||
RedirectUri = "bookstore://", |
|||
Scope = "openid email profile role BookStore", |
|||
ClientSecret = "1q2w3E*", |
|||
Browser = sp.GetRequiredService<WebAuthenticatorBrowser>(), |
|||
}) |
|||
); |
|||
``` |
|||
|
|||
- Go to **MainPage.xaml**, remove everyting and add a button for login |
|||
|
|||
```xml |
|||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" |
|||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" |
|||
x:Class="MauiApp1.MainPage"> |
|||
|
|||
<ScrollView> |
|||
<Grid RowSpacing="25" RowDefinitions="Auto,Auto,Auto,Auto,*" |
|||
Padding="{OnPlatform iOS='30,60,30,30', Default='30'}"> |
|||
|
|||
<Button Text="Click to Log In" Clicked="OnLoginClicked" VerticalOptions="CenterAndExpand" HorizontalOptions="Center"/> |
|||
|
|||
</Grid> |
|||
</ScrollView> |
|||
</ContentPage> |
|||
``` |
|||
|
|||
- Inject **OidcClient** in **MainPage.xaml.cs** and make login operation. |
|||
|
|||
```csharp |
|||
using IdentityModel.OidcClient; |
|||
|
|||
namespace Acme.BookStore.MauiClient; |
|||
|
|||
public partial class MainPage : ContentPage |
|||
{ |
|||
protected OidcClient OidcClient { get; } |
|||
|
|||
public MainPage(OidcClient oidcClient) |
|||
{ |
|||
InitializeComponent(); |
|||
OidcClient = oidcClient; |
|||
} |
|||
|
|||
private async void OnLoginClicked(object sender, EventArgs e) |
|||
{ |
|||
try |
|||
{ |
|||
var loginResult = await OidcClient.LoginAsync(new LoginRequest()); |
|||
await DisplayAlert("Login Result", "Access Token is:\n\n" + loginResult.AccessToken, "Close"); |
|||
|
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
await DisplayAlert("Error", ex.ToString(), "ok"); |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
It still won't work because there is something more to do for each platform. Check out the next step and configure the platforms that you're using. |
|||
|
|||
|
|||
## Platform Specific Configurations |
|||
|
|||
Each platform (UWP, OSX, iOS and Android) requires some configuration to use authentication from browser. In that step, we'll open a browser and user will login on the browser. After that, as you see in IdentityServer configurations, IdentityServer will redirect 'bookstore://' url that only contains scheme and that scheme is not http. Our application will handle that scheme and will be launched with parameters. |
|||
|
|||
### Android |
|||
|
|||
- Start with creating a new Activity named **BookStoreWebAuthenticatorCallbackActivity** |
|||
|
|||
```csharp |
|||
using Android.App; |
|||
using Android.Content; |
|||
using Android.Content.PM; |
|||
|
|||
namespace Acme.BookStore.MauiClient.Platforms.Android; |
|||
|
|||
[Activity(NoHistory = true, LaunchMode = LaunchMode.SingleTop)] |
|||
[IntentFilter(new[] { Intent.ActionView }, |
|||
Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable }, |
|||
DataScheme = CALLBACK_SCHEME)] |
|||
public class BookStoreWebAuthenticatorCallbackActivity : Microsoft.Maui.Essentials.WebAuthenticatorCallbackActivity |
|||
{ |
|||
const string CALLBACK_SCHEME = "bookstore"; |
|||
} |
|||
``` |
|||
|
|||
- Add `CustomTabsService` to **AndroidManifest.xml** as below. _(queries tags only.)_ |
|||
|
|||
```xml |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
|||
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="30" /> |
|||
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true"></application> |
|||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> |
|||
<queries> |
|||
<intent> |
|||
<action android:name="android.support.customtabs.action.CustomTabsService" /> |
|||
</intent> |
|||
</queries> |
|||
</manifest> |
|||
``` |
|||
|
|||
> For some reason, an error occurs with my emulator while targeting SDK 31, so I've changed the target SDK to 30. |
|||
|
|||
- Run the Application and perform a login operation. |
|||
AccessToken will be retrieved. |
|||
|
|||
 |
|||
|
|||
--- |
|||
|
|||
### iOS/MacCatalyst |
|||
|
|||
- Add following key to **Info.plist** |
|||
|
|||
```xml |
|||
<key>CFBundleURLTypes</key> |
|||
<array> |
|||
<dict> |
|||
<key>CFBundleURLName</key> |
|||
<string>mauiessentials</string> |
|||
<key>CFBundleURLSchemes</key> |
|||
<array> |
|||
<string>bookstore</string> |
|||
</array> |
|||
<key>CFBundleTypeRole</key> |
|||
<string>Editor</string> |
|||
</dict> |
|||
</array> |
|||
``` |
|||
|
|||
- Open **AppDelegate** class and override `OpenUrl` and `ContinueUserActivity` methods |
|||
|
|||
```csharp |
|||
public override bool OpenUrl(UIApplication app, NSUrl url, NSDictionary options) |
|||
{ |
|||
if (Microsoft.Maui.Essentials.Platform.OpenUrl(app, url, options)) |
|||
return true; |
|||
|
|||
return base.OpenUrl(app, url, options); |
|||
} |
|||
|
|||
public override bool ContinueUserActivity(UIApplication application, NSUserActivity userActivity, UIApplicationRestorationHandler completionHandler) |
|||
{ |
|||
if (Microsoft.Maui.Platform.ContinueUserActivity(application, userActivity, completionHandler)) |
|||
return true; |
|||
return base.ContinueUserActivity(application, userActivity, completionHandler); |
|||
} |
|||
``` |
|||
|
|||
- Make all steps for MacCatalyst, too. |
|||
|
|||
|
|||
> **Tip:** If your IDE struggles while displaying references and namespace suggestions, make sure you're displaying that file with iOS Target Framework. |
|||
> |
|||
> You'll find it at the top of the editor. |
|||
>  |
|||
|
|||
|
|||
 |
|||
|
|||
 |
|||
|
|||
|
|||
--- |
|||
|
|||
### UWP (Windows) |
|||
|
|||
- Add following protocol extension in `Package.appxmanifest` file. |
|||
|
|||
```xml |
|||
<Applications> |
|||
<Application Id="App" |
|||
Executable="$targetnametoken$.exe" |
|||
EntryPoint="$targetentrypoint$"> |
|||
<Extensions> |
|||
<uap:Extension Category="windows.protocol"> |
|||
<uap:Protocol Name="bookstore"> |
|||
<uap:DisplayName>BookStore</uap:DisplayName> |
|||
</uap:Protocol> |
|||
</uap:Extension> |
|||
</Extensions> |
|||
</Application> |
|||
</Applications> |
|||
``` |
|||
|
|||
> Currently UWP has a bug in MAUI Essentials, I believe the MAUI team will solve it as soon as possible. You can track the status of issue: |
|||
> https://github.com/dotnet/maui/issues/2702 |
|||
|
|||
- That's it on Windows side. Run the application. |
|||
|
|||
|
|||
## Refreshing the access token |
|||
|
|||
IdentityServer doesn't return a refresh token by default. So we have to add `offline_access` to our scope while sending login request. |
|||
|
|||
- Add `offline_access` to scope in **MauiApplication.cs** that we configured before. |
|||
|
|||
```csharp |
|||
builder.Services.AddTransient<OidcClient>(sp => |
|||
new OidcClient(new OidcClientOptions |
|||
{ |
|||
// Use your own ngrok url: |
|||
Authority = "https://46fd-45-156-29-175.ngrok.io", |
|||
ClientId = "BookStore_Maui", |
|||
RedirectUri = "bookstore://", |
|||
Scope = "openid email profile role BookStore offline_access", // <-- Final state must be like this. |
|||
ClientSecret = "1q2w3E*", |
|||
Browser = sp.GetRequiredService<WebAuthenticatorBrowser>(), |
|||
}) |
|||
); |
|||
``` |
|||
|
|||
- Then check if it's working or not in **MainPage.xaml.cs**. Update OnLoginClicked method as below |
|||
```csharp |
|||
private async void OnLoginClicked(object sender, EventArgs e) |
|||
{ |
|||
try |
|||
{ |
|||
var loginResult = await OidcClient.LoginAsync(new LoginRequest()); |
|||
await DisplayAlert("Login Result", "Access Token is:\n\n" + loginResult.AccessToken, "Close"); |
|||
|
|||
var refreshResult = await OidcClient.RefreshTokenAsync(loginResult.RefreshToken); |
|||
await DisplayAlert("Refresh Result", "New Access Token is: \n\n" + refreshResult.AccessToken, "Close"); |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
await DisplayAlert("Error", ex.ToString(), "ok"); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
## Storing the access token |
|||
|
|||
In this step we have to store access token & refresh token for future requests. |
|||
|
|||
> [Secure Storage](https://docs.microsoft.com/en-us/xamarin/essentials/secure-storage?tabs=android) is highly recommended to store this kind of sensitive data. But it's not topic of this article. You can configure and use SecureStorage on your own. I'll go with `App Properties`. |
|||
|
|||
- Add following class to store key names instead of using magic strings in code. |
|||
```csharp |
|||
namespace Acme.BookStore.MauiClient; |
|||
|
|||
public static class OidcConsts |
|||
{ |
|||
internal const string AccessTokenKeyName = "__access_token"; |
|||
internal const string RefreshTokenKeyName = "__refresh_token"; |
|||
} |
|||
``` |
|||
|
|||
- Then go back to **MainPage.xaml.cs** and save our tokens after a successfull login. |
|||
|
|||
```csharp |
|||
private async void OnLoginClicked(object sender, EventArgs e) |
|||
{ |
|||
try |
|||
{ |
|||
var loginResult = await OidcClient.LoginAsync(new LoginRequest()); |
|||
|
|||
App.Current.Properties[OidcConsts.AccessTokenKeyName] = loginResult.AccessToken; |
|||
App.Current.Properties[OidcConsts.RefreshTokenKeyName] = loginResult.RefreshToken; |
|||
|
|||
await App.Current.SavePropertiesAsync(); |
|||
|
|||
// Navigate to an inner page here. |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
await DisplayAlert("Error", ex.ToString(), "ok"); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Add following **AccessTokenHttpMessageHandler** to append AccessToken to our requests & refresh token when required. |
|||
|
|||
```csharp |
|||
public class AccessTokenHttpMessageHandler : DelegatingHandler |
|||
{ |
|||
protected OidcClient OidcClient { get; } |
|||
|
|||
public AccessTokenHttpMessageHandler(OidcClient oidcClient) : base(new HttpClientHandler()) |
|||
{ |
|||
OidcClient = oidcClient; |
|||
} |
|||
|
|||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) |
|||
{ |
|||
if (App.Current.Properties.TryGetValue(OidcConsts.AccessTokenKeyName, out object currentTokenValue) && currentTokenValue != null) |
|||
{ |
|||
request.SetBearerToken(currentTokenValue?.ToString()); |
|||
request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); |
|||
} |
|||
|
|||
var response = await base.SendAsync(request, cancellationToken); |
|||
|
|||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) |
|||
{ |
|||
if (App.Current.Properties.TryGetValue(OidcConsts.RefreshTokenKeyName, out object refreshTokenValue) && refreshTokenValue != null) |
|||
{ |
|||
var refreshResult = await OidcClient.RefreshTokenAsync(refreshTokenValue?.ToString()); |
|||
|
|||
App.Current.Properties[OidcConsts.AccessTokenKeyName] = refreshResult.AccessToken; |
|||
App.Current.Properties[OidcConsts.RefreshTokenKeyName] = refreshResult.RefreshToken; |
|||
await App.Current.SavePropertiesAsync(); |
|||
|
|||
request.SetBearerToken(refreshResult.AccessToken); |
|||
|
|||
return await base.SendAsync(request, cancellationToken); |
|||
} |
|||
else |
|||
{ |
|||
var result = await OidcClient.LoginAsync(new LoginRequest()); |
|||
request.SetBearerToken(result.AccessToken); |
|||
|
|||
App.Current.Properties[OidcConsts.AccessTokenKeyName] = result.AccessToken; |
|||
App.Current.Properties[OidcConsts.RefreshTokenKeyName] = result.RefreshToken; |
|||
await App.Current.SavePropertiesAsync(); |
|||
request.SetBearerToken(result.AccessToken); |
|||
|
|||
return await base.SendAsync(request, cancellationToken); |
|||
} |
|||
} |
|||
|
|||
return response; |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Then register to dependency injection. |
|||
|
|||
```csharp |
|||
builder.Services.AddSingleton<AccessTokenHttpMessageHandler>(); |
|||
builder.Services.AddTransient<HttpClient>(sp => |
|||
new HttpClient(sp.GetRequiredService<AccessTokenHttpMessageHandler>()) |
|||
{ |
|||
BaseAddress = new Uri("https://46fd-45-156-29-175.ngrok.io") |
|||
}); |
|||
``` |
|||
|
|||
- Now make we can send request to backend with authentication. Go to **MainPage.xaml.cs** and send a request right after authentication. |
|||
|
|||
```csharp |
|||
private async void OnLoginClicked(object sender, EventArgs e) |
|||
{ |
|||
try |
|||
{ |
|||
var loginResult = await OidcClient.LoginAsync(new LoginRequest()); |
|||
|
|||
App.Current.Properties[OidcConsts.AccessTokenKeyName] = loginResult.AccessToken; |
|||
App.Current.Properties[OidcConsts.RefreshTokenKeyName] = loginResult.RefreshToken; |
|||
|
|||
await App.Current.SavePropertiesAsync(); |
|||
|
|||
var json = await httpClient.GetStringAsync("/api/identity/users"); |
|||
|
|||
await DisplayAlert("/api/identity/users", json, "close"); |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
await DisplayAlert("Error", ex.ToString(), "ok"); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Following result will be returned from API. |
|||
|
|||
<img src="art/identity-users-request-result.png" height="480"> |
|||
|
|||
--- |
|||
|
|||
## Recap |
|||
|
|||
The purpose of this arcitle is connecting to ABP backend with access token and it's working properly. |
|||
|
|||
I'm planning to integrate HttpApi.Client library of backend project instead of making requests manually as a second part of this article. I'll get inspired by [hikalkan/maui-abp-playing](https://github.com/hikalkan/maui-abp-playing) repo to achive that. |
|||
|
|||
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 508 KiB |
|
After Width: | Height: | Size: 646 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 46 KiB |
@ -0,0 +1,529 @@ |
|||
# Using ABP Client Proxies in MAUI with OpenID Connect |
|||
The purpose of this article is to integrate ABP Core into the MAUI project and initialize it as an **AbpModule** then make able consuming API using ABP IAppServices. |
|||
|
|||
Before we start, I offer my special thanks to [@hikalkan](https://github.com/hikalkan/maui-abp-playing) because this repository ( [hikalkan/maui-abp-playing](https://github.com/hikalkan/maui-abp-playing) ) is a fantastic inspiration for the purpose of this article. |
|||
|
|||
## Getting Started |
|||
|
|||
In this article, we'll work on an application that was built on the previous article: [Integrating MAUI Client via Using OpenID Connect](https://community.abp.io/posts/integrating-maui-client-via-using-openid-connect-aqjjwsdf). |
|||
|
|||
|
|||
## Source Code |
|||
Source code is available on GitHub: |
|||
[abpframework/abp-samples/MAUI-OpenId](https://github.com/abpframework/abp-samples/tree/master/MAUI-OpenId) |
|||
|
|||
|
|||
## Configuring ABP Core |
|||
|
|||
As a first step, Dependency Injection will be changed with module initialization. We have to initialize our application as an ABP Module first. |
|||
|
|||
- Add the following dependencies to MAUI Client. |
|||
|
|||
```xml |
|||
<PackageReference Include="Volo.Abp.Http.Client.IdentityModel" Version="5.1.3" /> |
|||
<PackageReference Include="Volo.Abp.Autofac" Version="5.1.3" /> |
|||
``` |
|||
|
|||
- Add HttpApi.Client project reference |
|||
|
|||
```xml |
|||
<ProjectReference Include="..\..\aspnet-core\src\Acme.BookStore.HttpApi.Client\Acme.BookStore.HttpApi.Client.csproj" /> |
|||
``` |
|||
And run `abp build` command under MAUI application folder. |
|||
|
|||
> `abp build` command is equivalent of `dotnet build /graphBuild`, it's like a shortcut to graphBuild. The graphBuild finds all dependency tree and build them recursively. |
|||
|
|||
|
|||
- Create **BookStoreMauiClientModule**. |
|||
|
|||
```csharp |
|||
using IdentityModel.OidcClient; |
|||
using Microsoft.Extensions.Configuration; |
|||
using Microsoft.Extensions.Options; |
|||
using Volo.Abp.Autofac; |
|||
using Volo.Abp.Http.Client.IdentityModel; |
|||
using Volo.Abp.Modularity; |
|||
|
|||
namespace Acme.BookStore.MauiClient; |
|||
|
|||
[DependsOn( |
|||
typeof(AbpAutofacModule), |
|||
typeof(AbpHttpClientIdentityModelModule), |
|||
typeof(BookStoreHttpApiClientModule) |
|||
)] |
|||
public class BookStoreMauiClientModule : AbpModule |
|||
{ |
|||
public override void ConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
var configuration = context.Services.GetConfiguration(); |
|||
|
|||
Configure<OidcClientOptions>(configuration.GetSection("Oidc:Options")); |
|||
|
|||
context.Services.AddTransient<OidcClient>(sp => |
|||
{ |
|||
var options = sp.GetRequiredService<IOptions<OidcClientOptions>>().Value; |
|||
options.Browser = sp.GetRequiredService<WebAuthenticatorBrowser>(); |
|||
return new OidcClient(options); |
|||
}); |
|||
|
|||
context.Services.AddTransient<HttpClient>(sp => |
|||
new HttpClient(sp.GetRequiredService<AccessTokenHttpMessageHandler>()) |
|||
{ |
|||
// Temporarily. We'll use ABP's Proxy for sendind requests. |
|||
BaseAddress = new Uri(configuration.GetValue<string>("RemoteServices:Default:BaseUrl")) |
|||
}); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Mark all dependencies with interfaces for registering as services. |
|||
|
|||
```csharp |
|||
internal class WebAuthenticatorBrowser : IBrowser, ITransientDependency |
|||
``` |
|||
|
|||
```csharp |
|||
public partial class MainPage : ContentPage, ITransientDependency |
|||
``` |
|||
|
|||
```csharp |
|||
public class AccessTokenHttpMessageHandler : DelegatingHandler, ISingletonDependency |
|||
``` |
|||
|
|||
- Add `appsettings.json` file to root path of your application and mark it as **Embedded resource**. |
|||
|
|||
```json |
|||
{ |
|||
"Oidc": { |
|||
"Options": { |
|||
"Authority": "https://46fd-45-156-29-175.ngrok.io", |
|||
"ClientId": "BookStore_Maui", |
|||
"RedirectUri": "bookstore://", |
|||
"Scope": "openid email profile role BookStore offline_access", |
|||
"ClientSecret": "1q2w3E*" |
|||
} |
|||
}, |
|||
"RemoteServices": { |
|||
"Default": { |
|||
"BaseUrl": "https://46fd-45-156-29-175.ngrok.io" |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Finally, Go back `MauiApplication.cs` and clear old codes and initialize ABP. |
|||
|
|||
```csharp |
|||
using Microsoft.Extensions.Configuration; |
|||
using Microsoft.Extensions.FileProviders; |
|||
using System.Reflection; |
|||
using Volo.Abp; |
|||
using Volo.Abp.Autofac; |
|||
|
|||
namespace Acme.BookStore.MauiClient; |
|||
|
|||
public static class MauiProgram |
|||
{ |
|||
public static MauiApp CreateMauiApp() |
|||
{ |
|||
var builder = MauiApp.CreateBuilder(); |
|||
builder.ConfigureContainer(new AbpAutofacServiceProviderFactory(new Autofac.ContainerBuilder()), containerBuilder => |
|||
{ |
|||
|
|||
}); |
|||
builder |
|||
.UseMauiApp<App>() |
|||
.ConfigureFonts(fonts => |
|||
{ |
|||
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); |
|||
}); |
|||
|
|||
ConfigureConfiguration(builder); |
|||
|
|||
builder.Services.AddApplication<BookStoreMauiClientModule>(options => |
|||
{ |
|||
options.Services.ReplaceConfiguration(builder.Configuration); |
|||
}); |
|||
|
|||
var app = builder.Build(); |
|||
|
|||
app.Services.GetRequiredService<IAbpApplicationWithExternalServiceProvider>() |
|||
.Initialize(app.Services); |
|||
|
|||
return app; |
|||
} |
|||
|
|||
private static void ConfigureConfiguration(MauiAppBuilder builder) |
|||
{ |
|||
var assembly = typeof(App).GetTypeInfo().Assembly; |
|||
builder.Configuration.AddJsonFile(new EmbeddedFileProvider(assembly), "appsettings.json", optional: false, false); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
Now application is runnable and all behaviors are the same with previos state. But it uses power of ABP right now. |
|||
|
|||
### Switching to SecureStorage |
|||
.Net MAUI supports a secure storage by default. Before we go further, we need to switch to secure storage instead of using app properties. Just update login method as below at **MainPage.xaml.cs** |
|||
|
|||
```csharp |
|||
private async void OnLoginClicked(object sender, EventArgs e) |
|||
{ |
|||
var loginResult = await OidcClient.LoginAsync(new LoginRequest()); |
|||
if (loginResult.IsError) |
|||
{ |
|||
await DisplayAlert("Error", loginResult.Error, "Close"); |
|||
return; |
|||
} |
|||
|
|||
await SecureStorage.SetAsync(OidcConsts.AccessTokenKeyName, loginResult.AccessToken); |
|||
await SecureStorage.SetAsync(OidcConsts.RefreshTokenKeyName, loginResult.RefreshToken); |
|||
} |
|||
``` |
|||
|
|||
> Additionally, please configure each platform according to [Secure Storage documentation](https://docs.microsoft.com/en-us/xamarin/essentials/secure-storage?tabs=android) |
|||
|
|||
|
|||
## Configuring Client Proxies |
|||
|
|||
ABP Client-Proxies don't use HttpClient directly. They use `IHttpClientFactory` to activate a new HttpClient instead of injecting it directly. So, we won't need **AccessTokenHttpMessageHandler** anymore. But still there is a way needed to set access token in requests. No worries, ABP has `IRemoteServiceHttpClientAuthenticator` to do that operation. Implementing it and registering to container will solve that issue and the client will be able to make authorized request to server. |
|||
|
|||
|
|||
- Remove **AccessTokenHttpMessageHandler.cs** from the project. |
|||
|
|||
- Add **AccessTokenRemoteServiceHttpClientAuthenticator.cs** instead. |
|||
|
|||
```csharp |
|||
using IdentityModel.Client; |
|||
using IdentityModel.OidcClient; |
|||
using System.IdentityModel.Tokens.Jwt; |
|||
using Volo.Abp.DependencyInjection; |
|||
using Volo.Abp.Http.Client.Authentication; |
|||
using DependencyAttribute = Volo.Abp.DependencyInjection.DependencyAttribute; |
|||
|
|||
namespace Acme.BookStore.MauiClient; |
|||
|
|||
[Dependency(ReplaceServices = true)] |
|||
[ExposeServices(typeof(IRemoteServiceHttpClientAuthenticator))] |
|||
public class AccessTokenRemoteServiceHttpClientAuthenticator : IRemoteServiceHttpClientAuthenticator, ITransientDependency |
|||
{ |
|||
protected OidcClient OidcClient { get; } |
|||
|
|||
public AccessTokenRemoteServiceHttpClientAuthenticator(OidcClient oidcClient) |
|||
{ |
|||
OidcClient = oidcClient; |
|||
} |
|||
|
|||
public async Task Authenticate(RemoteServiceHttpClientAuthenticateContext context) |
|||
{ |
|||
var currentAccessToken = await SecureStorage.GetAsync(OidcConsts.AccessTokenKeyName); |
|||
|
|||
if (!currentAccessToken.IsNullOrEmpty()) |
|||
{ |
|||
// TODO: Find better way to find if token is expired instead of parsing it. |
|||
var jwtToken = new JwtSecurityTokenHandler().ReadJwtToken(currentAccessToken) as JwtSecurityToken; |
|||
if (jwtToken.ValidTo <= DateTime.UtcNow) |
|||
{ |
|||
var refreshToken = await SecureStorage.GetAsync(OidcConsts.RefreshTokenKeyName); |
|||
if (!refreshToken.IsNullOrEmpty()) |
|||
{ |
|||
var refreshResult = await OidcClient.RefreshTokenAsync(refreshToken); |
|||
|
|||
await SecureStorage.SetAsync(OidcConsts.AccessTokenKeyName, refreshResult.AccessToken); |
|||
await SecureStorage.SetAsync(OidcConsts.RefreshTokenKeyName, refreshResult.RefreshToken); |
|||
|
|||
context.Request.SetBearerToken(refreshResult.AccessToken); |
|||
} |
|||
else |
|||
{ |
|||
var loginResult = await OidcClient.LoginAsync(new LoginRequest()); |
|||
|
|||
await SecureStorage.SetAsync(OidcConsts.AccessTokenKeyName, loginResult.AccessToken); |
|||
await SecureStorage.SetAsync(OidcConsts.RefreshTokenKeyName, loginResult.RefreshToken); |
|||
|
|||
context.Request.SetBearerToken(loginResult.AccessToken); |
|||
} |
|||
} |
|||
|
|||
context.Request.SetBearerToken(currentAccessToken); |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Now we are ready to inject IAppServices to communicate with backend. |
|||
|
|||
## Displaying Data in UI |
|||
|
|||
- Go back to **Acme.BookStore.Domain** project and add a simple data seed contributor to generate some example data for users. |
|||
|
|||
```csharp |
|||
public class UsersDataSeederContributor : IDataSeedContributor, ITransientDependency |
|||
{ |
|||
protected IIdentityUserRepository repository; |
|||
|
|||
protected IGuidGenerator guidGenerator; |
|||
public UsersDataSeederContributor(IIdentityUserRepository repository, IGuidGenerator guidGenerator) |
|||
{ |
|||
this.repository = repository; |
|||
this.guidGenerator = guidGenerator; |
|||
} |
|||
|
|||
public async Task SeedAsync(DataSeedContext context) |
|||
{ |
|||
var count = await repository.GetCountAsync(); |
|||
if(count <= 1) // Not sure 'admin' user was seeded before or not. |
|||
{ |
|||
// All the names below were generated by https://www.name-generator.org.uk/quick/ |
|||
// The names does not represent real people. |
|||
await repository.InsertManyAsync(new []{ |
|||
new IdentityUser(guidGenerator.Create(), "john.doe", "john.doe@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Zane.Frost", "Zane.Frost@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Oscar.Landry", "Oscar.Landry@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Yasemin.Roberts", "Yasemin.Roberts@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Yasmine.Perez", "Yasmine.Perez@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Tobi.Becker", "Tobi.Becker@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Fox.Gilmore", "Fox.Gilmore@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Benny.Burris", "Benny.Burris@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Chad.Camacho", "Chad.Camacho@abp.io"), |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Run the **Acme.BookStore.DbMigrator** project. |
|||
|
|||
- Turn back to MAUI app, and create a folder named **ViewModels** and add a simple `UsersViewModel.cs` under it. |
|||
|
|||
```csharp |
|||
public class UsersViewModel : BindableObject, ITransientDependency |
|||
{ |
|||
protected IIdentityUserAppService IdentityUserAppService { get; } |
|||
|
|||
public GetIdentityUsersInput Input { get; } = new(); |
|||
|
|||
public ObservableCollection<IdentityUserDto> Items { get; } = new(); |
|||
|
|||
public Command RefreshCommand { get; } |
|||
|
|||
private bool isBusy; |
|||
public bool IsBusy { get => isBusy; set => SetProperty(ref isBusy, value); } |
|||
|
|||
public UsersViewModel(IIdentityUserAppService identityUserAppService) |
|||
{ |
|||
IdentityUserAppService = identityUserAppService; |
|||
GetUsersAsync(); |
|||
RefreshCommand = new Command(GetUsersAsync); |
|||
} |
|||
|
|||
protected async void GetUsersAsync() |
|||
{ |
|||
if (IsBusy) |
|||
{ |
|||
return; // For preventing parallel request while searching. |
|||
} |
|||
|
|||
IsBusy = true; |
|||
|
|||
Items.Clear(); |
|||
|
|||
var result = await IdentityUserAppService.GetListAsync(Input); |
|||
foreach (var user in result.Items) |
|||
{ |
|||
Items.Add(user); |
|||
} |
|||
|
|||
IsBusy = false; |
|||
} |
|||
|
|||
protected void SetProperty<T>(ref T backField, T value, [CallerMemberName] string propertyName = null) |
|||
{ |
|||
backField = value; |
|||
OnPropertyChanged(propertyName); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Create a folder named **Pages** and add a content page named `UsersPage`. |
|||
|
|||
_(Make sure you're adding MAUI Content Page)_ |
|||
|
|||
 |
|||
|
|||
- And inject `UsersViewModel` into it. |
|||
|
|||
```csharp |
|||
public partial class UsersPage : ContentPage, ITransientDependency |
|||
{ |
|||
public UsersViewModel ViewModel { get; } |
|||
|
|||
public UsersPage(UsersViewModel viewModel) |
|||
{ |
|||
ViewModel = viewModel; |
|||
InitializeComponent(); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- And use that ViewModel in XAML design page. |
|||
|
|||
```xml |
|||
<?xml version="1.0" encoding="utf-8" ?> |
|||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" |
|||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" |
|||
x:Class="Acme.BookStore.MauiClient.UsersPage" |
|||
Title="UsersPage" |
|||
x:Name="page" |
|||
BindingContext="{Binding ViewModel, Source={x:Reference page}}"> |
|||
<StackLayout> |
|||
<ListView |
|||
IsPullToRefreshEnabled="True" |
|||
ItemsSource="{Binding Items}" |
|||
IsRefreshing="{Binding IsBusy}" |
|||
RefreshCommand="{Binding RefreshCommand}"> |
|||
<ListView.Header> |
|||
<SearchBar Text="{Binding Input.Filter}" SearchCommand="{Binding RefreshCommand}" /> |
|||
</ListView.Header> |
|||
<ListView.ItemTemplate> |
|||
<DataTemplate> |
|||
<TextCell |
|||
Text="{Binding UserName, StringFormat='@{0}'}" |
|||
Detail="{Binding Email}"/> |
|||
</DataTemplate> |
|||
</ListView.ItemTemplate> |
|||
</ListView> |
|||
</StackLayout> |
|||
</ContentPage> |
|||
``` |
|||
|
|||
> I've used binding while setting **BindingContext** as **ViewModel** because of IntelliSense support. With this method, you'll see intellisense will suggest properties from your ViewModel. |
|||
> |
|||
>  |
|||
|
|||
|
|||
After a couple of try, I realized, only AppShell supports dependency injection while navigating between pages. So, adding a new AppShell will help to build app menus and navigating with route. We can pass parameters with querystring with this way. |
|||
|
|||
- Add `Shell Pagae (MAUI)` to root of your application with name **AppShell.xaml**. |
|||
|
|||
_I've got some help for design of shell page from microsoft's articles._ |
|||
|
|||
_Additionally, you might want to put [abp_icon.svg](maui/Acme.BookStore.MauiClient/Resources/Images/abp_logo.svg) file under your **Resources/Images** folder._ |
|||
```xml |
|||
<?xml version="1.0" encoding="utf-8" ?> |
|||
<Shell x:Class="Acme.BookStore.MauiClient.AppShell" |
|||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui" |
|||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" |
|||
xmlns:local="clr-namespace:Acme.BookStore.MauiClient"> |
|||
<Shell.Resources> |
|||
<ResourceDictionary> |
|||
<Color x:Key="Primary">#512BD4</Color> |
|||
<Style x:Key="BaseStyle" TargetType="Element"> |
|||
<Setter Property="Shell.BackgroundColor" Value="{StaticResource Primary}" /> |
|||
<Setter Property="Shell.ForegroundColor" Value="White" /> |
|||
<Setter Property="Shell.TitleColor" Value="White" /> |
|||
<Setter Property="Shell.DisabledColor" Value="#B4FFFFFF" /> |
|||
<Setter Property="Shell.UnselectedColor" Value="#95FFFFFF" /> |
|||
<Setter Property="Shell.TabBarBackgroundColor" Value="{StaticResource Primary}" /> |
|||
<Setter Property="Shell.TabBarForegroundColor" Value="White"/> |
|||
<Setter Property="Shell.TabBarUnselectedColor" Value="#95FFFFFF"/> |
|||
<Setter Property="Shell.TabBarTitleColor" Value="White"/> |
|||
</Style> |
|||
<Style TargetType="TabBar" BasedOn="{StaticResource BaseStyle}" /> |
|||
<Style TargetType="FlyoutItem" BasedOn="{StaticResource BaseStyle}" /> |
|||
<Style Class="FlyoutItemLabelStyle" TargetType="Label"> |
|||
<Setter Property="TextColor" Value="White"></Setter> |
|||
<Setter Property="Margin" Value="16"></Setter> |
|||
</Style> |
|||
<Style Class="FlyoutItemLayoutStyle" TargetType="Layout" ApplyToDerivedTypes="True"> |
|||
<Setter Property="VisualStateManager.VisualStateGroups"> |
|||
<VisualStateGroupList> |
|||
<VisualStateGroup x:Name="CommonStates"> |
|||
<VisualState x:Name="Normal"> |
|||
<VisualState.Setters> |
|||
<Setter Property="BackgroundColor" Value="{x:OnPlatform UWP=Transparent, iOS=White, Android=White}" /> |
|||
<Setter TargetName="FlyoutItemLabel" Property="Label.TextColor" Value="{StaticResource Primary}" /> |
|||
</VisualState.Setters> |
|||
</VisualState> |
|||
<VisualState x:Name="Selected"> |
|||
<VisualState.Setters> |
|||
<Setter Property="BackgroundColor" Value="{StaticResource Primary}" /> |
|||
</VisualState.Setters> |
|||
</VisualState> |
|||
</VisualStateGroup> |
|||
</VisualStateGroupList> |
|||
</Setter> |
|||
</Style> |
|||
|
|||
<Style Class="MenuItemLayoutStyle" TargetType="Layout" ApplyToDerivedTypes="True"> |
|||
<Setter Property="VisualStateManager.VisualStateGroups"> |
|||
<VisualStateGroupList> |
|||
<VisualStateGroup x:Name="CommonStates"> |
|||
<VisualState x:Name="Normal"> |
|||
<VisualState.Setters> |
|||
<Setter TargetName="FlyoutItemLabel" Property="Label.TextColor" Value="{StaticResource Primary}" /> |
|||
</VisualState.Setters> |
|||
</VisualState> |
|||
</VisualStateGroup> |
|||
</VisualStateGroupList> |
|||
</Setter> |
|||
</Style> |
|||
</ResourceDictionary> |
|||
</Shell.Resources> |
|||
|
|||
<FlyoutItem Title="Home"> |
|||
<ShellContent ContentTemplate="{DataTemplate local:MainPage}" Route="main" /> |
|||
</FlyoutItem> |
|||
|
|||
<FlyoutItem Title="Users"> |
|||
<ShellContent ContentTemplate="{DataTemplate local:UsersPage}" Route="UsersPage" /> |
|||
</FlyoutItem> |
|||
|
|||
<Shell.FlyoutHeader> |
|||
<StackLayout> |
|||
<Image |
|||
Source="abp_logo.svg" |
|||
HorizontalOptions="Center" |
|||
Margin="25"/> |
|||
</StackLayout> |
|||
</Shell.FlyoutHeader> |
|||
|
|||
</Shell> |
|||
``` |
|||
|
|||
- One more step is required. Go to **App.xaml.cs** and replace MainPage with AppShell. |
|||
|
|||
```csharp |
|||
public partial class App : Application |
|||
{ |
|||
public App() |
|||
{ |
|||
InitializeComponent(); |
|||
|
|||
MainPage = new AppShell(); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Run the application. |
|||
|
|||
- Login once if you haven't done before. |
|||
|
|||
- Navigate to Users page with hamburger menu at the right top. |
|||
|
|||
| Android| iOS| |
|||
| --- | --- | |
|||
|  |  | |
|||
|
|||
| UWP | |
|||
| --- | |
|||
|  | |
|||
|
|||
| MacCatalyst | |
|||
| --- | |
|||
|  | |
|||
|
|||
|
|||
## Conclusion |
|||
ABP Framework can be implemented any platform that runs on dotnet without suffer. ABP provides reusable abstractions layers and HttpApi Clients. In this article we've used powerful ABP core features such as Dependency Injection, Client Proxies, Validation and more. |
|||
|
|||
|
After Width: | Height: | Size: 704 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 53 KiB |
@ -0,0 +1,529 @@ |
|||
# Using ABP Client Proxies in MAUI with OpenID Connect |
|||
The purpose of this article is to integrate ABP Core into the MAUI project and initialize it as an **AbpModule** then make able consuming API using ABP IAppServices. |
|||
|
|||
Before we start, I offer my special thanks to [@hikalkan](https://github.com/hikalkan/maui-abp-playing) because this repository ( [hikalkan/maui-abp-playing](https://github.com/hikalkan/maui-abp-playing) ) is a fantastic inspiration for the purpose of this article. |
|||
|
|||
## Getting Started |
|||
|
|||
In this article, we'll work on an application that was built on the previous article: [Integrating MAUI Client via Using OpenID Connect](https://community.abp.io/posts/integrating-maui-client-via-using-openid-connect-aqjjwsdf). |
|||
|
|||
|
|||
## Source Code |
|||
Source code is available on GitHub: |
|||
[abpframework/abp-samples/MAUI-OpenId](https://github.com/abpframework/abp-samples/tree/master/MAUI-OpenId) |
|||
|
|||
|
|||
## Configuring ABP Core |
|||
|
|||
As a first step, Dependency Injection will be changed with module initialization. We have to initialize our application as an ABP Module first. |
|||
|
|||
- Add the following dependencies to MAUI Client. |
|||
|
|||
```xml |
|||
<PackageReference Include="Volo.Abp.Http.Client.IdentityModel" Version="5.1.3" /> |
|||
<PackageReference Include="Volo.Abp.Autofac" Version="5.1.3" /> |
|||
``` |
|||
|
|||
- Add HttpApi.Client project reference |
|||
|
|||
```xml |
|||
<ProjectReference Include="..\..\aspnet-core\src\Acme.BookStore.HttpApi.Client\Acme.BookStore.HttpApi.Client.csproj" /> |
|||
``` |
|||
And run `abp build` command under MAUI application folder. |
|||
|
|||
> `abp build` command is equivalent of `dotnet build /graphBuild`, it's like a shortcut to graphBuild. The graphBuild finds all dependency tree and build them recursively. |
|||
|
|||
|
|||
- Create **BookStoreMauiClientModule**. |
|||
|
|||
```csharp |
|||
using IdentityModel.OidcClient; |
|||
using Microsoft.Extensions.Configuration; |
|||
using Microsoft.Extensions.Options; |
|||
using Volo.Abp.Autofac; |
|||
using Volo.Abp.Http.Client.IdentityModel; |
|||
using Volo.Abp.Modularity; |
|||
|
|||
namespace Acme.BookStore.MauiClient; |
|||
|
|||
[DependsOn( |
|||
typeof(AbpAutofacModule), |
|||
typeof(AbpHttpClientIdentityModelModule), |
|||
typeof(BookStoreHttpApiClientModule) |
|||
)] |
|||
public class BookStoreMauiClientModule : AbpModule |
|||
{ |
|||
public override void ConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
var configuration = context.Services.GetConfiguration(); |
|||
|
|||
Configure<OidcClientOptions>(configuration.GetSection("Oidc:Options")); |
|||
|
|||
context.Services.AddTransient<OidcClient>(sp => |
|||
{ |
|||
var options = sp.GetRequiredService<IOptions<OidcClientOptions>>().Value; |
|||
options.Browser = sp.GetRequiredService<WebAuthenticatorBrowser>(); |
|||
return new OidcClient(options); |
|||
}); |
|||
|
|||
context.Services.AddTransient<HttpClient>(sp => |
|||
new HttpClient(sp.GetRequiredService<AccessTokenHttpMessageHandler>()) |
|||
{ |
|||
// Temporarily. We'll use ABP's Proxy for sendind requests. |
|||
BaseAddress = new Uri(configuration.GetValue<string>("RemoteServices:Default:BaseUrl")) |
|||
}); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Mark all dependencies with interfaces for registering as services. |
|||
|
|||
```csharp |
|||
internal class WebAuthenticatorBrowser : IBrowser, ITransientDependency |
|||
``` |
|||
|
|||
```csharp |
|||
public partial class MainPage : ContentPage, ITransientDependency |
|||
``` |
|||
|
|||
```csharp |
|||
public class AccessTokenHttpMessageHandler : DelegatingHandler, ISingletonDependency |
|||
``` |
|||
|
|||
- Add `appsettings.json` file to root path of your application and mark it as **Embedded resource**. |
|||
|
|||
```json |
|||
{ |
|||
"Oidc": { |
|||
"Options": { |
|||
"Authority": "https://46fd-45-156-29-175.ngrok.io", |
|||
"ClientId": "BookStore_Maui", |
|||
"RedirectUri": "bookstore://", |
|||
"Scope": "openid email profile role BookStore offline_access", |
|||
"ClientSecret": "1q2w3E*" |
|||
} |
|||
}, |
|||
"RemoteServices": { |
|||
"Default": { |
|||
"BaseUrl": "https://46fd-45-156-29-175.ngrok.io" |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Finally, Go back `MauiApplication.cs` and clear old codes and initialize ABP. |
|||
|
|||
```csharp |
|||
using Microsoft.Extensions.Configuration; |
|||
using Microsoft.Extensions.FileProviders; |
|||
using System.Reflection; |
|||
using Volo.Abp; |
|||
using Volo.Abp.Autofac; |
|||
|
|||
namespace Acme.BookStore.MauiClient; |
|||
|
|||
public static class MauiProgram |
|||
{ |
|||
public static MauiApp CreateMauiApp() |
|||
{ |
|||
var builder = MauiApp.CreateBuilder(); |
|||
builder.ConfigureContainer(new AbpAutofacServiceProviderFactory(new Autofac.ContainerBuilder()), containerBuilder => |
|||
{ |
|||
|
|||
}); |
|||
builder |
|||
.UseMauiApp<App>() |
|||
.ConfigureFonts(fonts => |
|||
{ |
|||
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); |
|||
}); |
|||
|
|||
ConfigureConfiguration(builder); |
|||
|
|||
builder.Services.AddApplication<BookStoreMauiClientModule>(options => |
|||
{ |
|||
options.Services.ReplaceConfiguration(builder.Configuration); |
|||
}); |
|||
|
|||
var app = builder.Build(); |
|||
|
|||
app.Services.GetRequiredService<IAbpApplicationWithExternalServiceProvider>() |
|||
.Initialize(app.Services); |
|||
|
|||
return app; |
|||
} |
|||
|
|||
private static void ConfigureConfiguration(MauiAppBuilder builder) |
|||
{ |
|||
var assembly = typeof(App).GetTypeInfo().Assembly; |
|||
builder.Configuration.AddJsonFile(new EmbeddedFileProvider(assembly), "appsettings.json", optional: false, false); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
Now application is runnable and all behaviors are the same with previos state. But it uses power of ABP right now. |
|||
|
|||
### Switching to SecureStorage |
|||
.Net MAUI supports a secure storage by default. Before we go further, we need to switch to secure storage instead of using app properties. Just update login method as below at **MainPage.xaml.cs** |
|||
|
|||
```csharp |
|||
private async void OnLoginClicked(object sender, EventArgs e) |
|||
{ |
|||
var loginResult = await OidcClient.LoginAsync(new LoginRequest()); |
|||
if (loginResult.IsError) |
|||
{ |
|||
await DisplayAlert("Error", loginResult.Error, "Close"); |
|||
return; |
|||
} |
|||
|
|||
await SecureStorage.SetAsync(OidcConsts.AccessTokenKeyName, loginResult.AccessToken); |
|||
await SecureStorage.SetAsync(OidcConsts.RefreshTokenKeyName, loginResult.RefreshToken); |
|||
} |
|||
``` |
|||
|
|||
> Additionally, please configure each platform according to [Secure Storage documentation](https://docs.microsoft.com/en-us/xamarin/essentials/secure-storage?tabs=android) |
|||
|
|||
|
|||
## Configuring Client Proxies |
|||
|
|||
ABP Client-Proxies don't use HttpClient directly. They use `IHttpClientFactory` to activate a new HttpClient instead of injecting it directly. So, we won't need **AccessTokenHttpMessageHandler** anymore. But still there is a way needed to set access token in requests. No worries, ABP has `IRemoteServiceHttpClientAuthenticator` to do that operation. Implementing it and registering to container will solve that issue and the client will be able to make authorized request to server. |
|||
|
|||
|
|||
- Remove **AccessTokenHttpMessageHandler.cs** from the project. |
|||
|
|||
- Add **AccessTokenRemoteServiceHttpClientAuthenticator.cs** instead. |
|||
|
|||
```csharp |
|||
using IdentityModel.Client; |
|||
using IdentityModel.OidcClient; |
|||
using System.IdentityModel.Tokens.Jwt; |
|||
using Volo.Abp.DependencyInjection; |
|||
using Volo.Abp.Http.Client.Authentication; |
|||
using DependencyAttribute = Volo.Abp.DependencyInjection.DependencyAttribute; |
|||
|
|||
namespace Acme.BookStore.MauiClient; |
|||
|
|||
[Dependency(ReplaceServices = true)] |
|||
[ExposeServices(typeof(IRemoteServiceHttpClientAuthenticator))] |
|||
public class AccessTokenRemoteServiceHttpClientAuthenticator : IRemoteServiceHttpClientAuthenticator, ITransientDependency |
|||
{ |
|||
protected OidcClient OidcClient { get; } |
|||
|
|||
public AccessTokenRemoteServiceHttpClientAuthenticator(OidcClient oidcClient) |
|||
{ |
|||
OidcClient = oidcClient; |
|||
} |
|||
|
|||
public async Task Authenticate(RemoteServiceHttpClientAuthenticateContext context) |
|||
{ |
|||
var currentAccessToken = await SecureStorage.GetAsync(OidcConsts.AccessTokenKeyName); |
|||
|
|||
if (!currentAccessToken.IsNullOrEmpty()) |
|||
{ |
|||
// TODO: Find better way to find if token is expired instead of parsing it. |
|||
var jwtToken = new JwtSecurityTokenHandler().ReadJwtToken(currentAccessToken) as JwtSecurityToken; |
|||
if (jwtToken.ValidTo <= DateTime.UtcNow) |
|||
{ |
|||
var refreshToken = await SecureStorage.GetAsync(OidcConsts.RefreshTokenKeyName); |
|||
if (!refreshToken.IsNullOrEmpty()) |
|||
{ |
|||
var refreshResult = await OidcClient.RefreshTokenAsync(refreshToken); |
|||
|
|||
await SecureStorage.SetAsync(OidcConsts.AccessTokenKeyName, refreshResult.AccessToken); |
|||
await SecureStorage.SetAsync(OidcConsts.RefreshTokenKeyName, refreshResult.RefreshToken); |
|||
|
|||
context.Request.SetBearerToken(refreshResult.AccessToken); |
|||
} |
|||
else |
|||
{ |
|||
var loginResult = await OidcClient.LoginAsync(new LoginRequest()); |
|||
|
|||
await SecureStorage.SetAsync(OidcConsts.AccessTokenKeyName, loginResult.AccessToken); |
|||
await SecureStorage.SetAsync(OidcConsts.RefreshTokenKeyName, loginResult.RefreshToken); |
|||
|
|||
context.Request.SetBearerToken(loginResult.AccessToken); |
|||
} |
|||
} |
|||
|
|||
context.Request.SetBearerToken(currentAccessToken); |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Now we are ready to inject IAppServices to communicate with backend. |
|||
|
|||
## Displaying Data in UI |
|||
|
|||
- Go back to **Acme.BookStore.Domain** project and add a simple data seed contributor to generate some example data for users. |
|||
|
|||
```csharp |
|||
public class UsersDataSeederContributor : IDataSeedContributor, ITransientDependency |
|||
{ |
|||
protected IIdentityUserRepository repository; |
|||
|
|||
protected IGuidGenerator guidGenerator; |
|||
public UsersDataSeederContributor(IIdentityUserRepository repository, IGuidGenerator guidGenerator) |
|||
{ |
|||
this.repository = repository; |
|||
this.guidGenerator = guidGenerator; |
|||
} |
|||
|
|||
public async Task SeedAsync(DataSeedContext context) |
|||
{ |
|||
var count = await repository.GetCountAsync(); |
|||
if(count <= 1) // Not sure 'admin' user was seeded before or not. |
|||
{ |
|||
// All the names below were generated by https://www.name-generator.org.uk/quick/ |
|||
// The names does not represent real people. |
|||
await repository.InsertManyAsync(new []{ |
|||
new IdentityUser(guidGenerator.Create(), "john.doe", "john.doe@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Zane.Frost", "Zane.Frost@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Oscar.Landry", "Oscar.Landry@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Yasemin.Roberts", "Yasemin.Roberts@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Yasmine.Perez", "Yasmine.Perez@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Tobi.Becker", "Tobi.Becker@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Fox.Gilmore", "Fox.Gilmore@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Benny.Burris", "Benny.Burris@abp.io"), |
|||
new IdentityUser(guidGenerator.Create(), "Chad.Camacho", "Chad.Camacho@abp.io"), |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Run the **Acme.BookStore.DbMigrator** project. |
|||
|
|||
- Turn back to MAUI app, and create a folder named **ViewModels** and add a simple `UsersViewModel.cs` under it. |
|||
|
|||
```csharp |
|||
public class UsersViewModel : BindableObject, ITransientDependency |
|||
{ |
|||
protected IIdentityUserAppService IdentityUserAppService { get; } |
|||
|
|||
public GetIdentityUsersInput Input { get; } = new(); |
|||
|
|||
public ObservableCollection<IdentityUserDto> Items { get; } = new(); |
|||
|
|||
public Command RefreshCommand { get; } |
|||
|
|||
private bool isBusy; |
|||
public bool IsBusy { get => isBusy; set => SetProperty(ref isBusy, value); } |
|||
|
|||
public UsersViewModel(IIdentityUserAppService identityUserAppService) |
|||
{ |
|||
IdentityUserAppService = identityUserAppService; |
|||
GetUsersAsync(); |
|||
RefreshCommand = new Command(GetUsersAsync); |
|||
} |
|||
|
|||
protected async void GetUsersAsync() |
|||
{ |
|||
if (IsBusy) |
|||
{ |
|||
return; // For preventing parallel request while searching. |
|||
} |
|||
|
|||
IsBusy = true; |
|||
|
|||
Items.Clear(); |
|||
|
|||
var result = await IdentityUserAppService.GetListAsync(Input); |
|||
foreach (var user in result.Items) |
|||
{ |
|||
Items.Add(user); |
|||
} |
|||
|
|||
IsBusy = false; |
|||
} |
|||
|
|||
protected void SetProperty<T>(ref T backField, T value, [CallerMemberName] string propertyName = null) |
|||
{ |
|||
backField = value; |
|||
OnPropertyChanged(propertyName); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Create a folder named **Pages** and add a content page named `UsersPage`. |
|||
|
|||
_(Make sure you're adding MAUI Content Page)_ |
|||
|
|||
 |
|||
|
|||
- And inject `UsersViewModel` into it. |
|||
|
|||
```csharp |
|||
public partial class UsersPage : ContentPage, ITransientDependency |
|||
{ |
|||
public UsersViewModel ViewModel { get; } |
|||
|
|||
public UsersPage(UsersViewModel viewModel) |
|||
{ |
|||
ViewModel = viewModel; |
|||
InitializeComponent(); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- And use that ViewModel in XAML design page. |
|||
|
|||
```xml |
|||
<?xml version="1.0" encoding="utf-8" ?> |
|||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" |
|||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" |
|||
x:Class="Acme.BookStore.MauiClient.UsersPage" |
|||
Title="UsersPage" |
|||
x:Name="page" |
|||
BindingContext="{Binding ViewModel, Source={x:Reference page}}"> |
|||
<StackLayout> |
|||
<ListView |
|||
IsPullToRefreshEnabled="True" |
|||
ItemsSource="{Binding Items}" |
|||
IsRefreshing="{Binding IsBusy}" |
|||
RefreshCommand="{Binding RefreshCommand}"> |
|||
<ListView.Header> |
|||
<SearchBar Text="{Binding Input.Filter}" SearchCommand="{Binding RefreshCommand}" /> |
|||
</ListView.Header> |
|||
<ListView.ItemTemplate> |
|||
<DataTemplate> |
|||
<TextCell |
|||
Text="{Binding UserName, StringFormat='@{0}'}" |
|||
Detail="{Binding Email}"/> |
|||
</DataTemplate> |
|||
</ListView.ItemTemplate> |
|||
</ListView> |
|||
</StackLayout> |
|||
</ContentPage> |
|||
``` |
|||
|
|||
> I've used binding while setting **BindingContext** as **ViewModel** because of IntelliSense support. With this method, you'll see intellisense will suggest properties from your ViewModel. |
|||
> |
|||
>  |
|||
|
|||
|
|||
After a couple of try, I realized, only AppShell supports dependency injection while navigating between pages. So, adding a new AppShell will help to build app menus and navigating with route. We can pass parameters with querystring with this way. |
|||
|
|||
- Add `Shell Pagae (MAUI)` to root of your application with name **AppShell.xaml**. |
|||
|
|||
_I've got some help for design of shell page from microsoft's articles._ |
|||
|
|||
_Additionally, you might want to put [abp_icon.svg](maui/Acme.BookStore.MauiClient/Resources/Images/abp_logo.svg) file under your **Resources/Images** folder._ |
|||
```xml |
|||
<?xml version="1.0" encoding="utf-8" ?> |
|||
<Shell x:Class="Acme.BookStore.MauiClient.AppShell" |
|||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui" |
|||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" |
|||
xmlns:local="clr-namespace:Acme.BookStore.MauiClient"> |
|||
<Shell.Resources> |
|||
<ResourceDictionary> |
|||
<Color x:Key="Primary">#512BD4</Color> |
|||
<Style x:Key="BaseStyle" TargetType="Element"> |
|||
<Setter Property="Shell.BackgroundColor" Value="{StaticResource Primary}" /> |
|||
<Setter Property="Shell.ForegroundColor" Value="White" /> |
|||
<Setter Property="Shell.TitleColor" Value="White" /> |
|||
<Setter Property="Shell.DisabledColor" Value="#B4FFFFFF" /> |
|||
<Setter Property="Shell.UnselectedColor" Value="#95FFFFFF" /> |
|||
<Setter Property="Shell.TabBarBackgroundColor" Value="{StaticResource Primary}" /> |
|||
<Setter Property="Shell.TabBarForegroundColor" Value="White"/> |
|||
<Setter Property="Shell.TabBarUnselectedColor" Value="#95FFFFFF"/> |
|||
<Setter Property="Shell.TabBarTitleColor" Value="White"/> |
|||
</Style> |
|||
<Style TargetType="TabBar" BasedOn="{StaticResource BaseStyle}" /> |
|||
<Style TargetType="FlyoutItem" BasedOn="{StaticResource BaseStyle}" /> |
|||
<Style Class="FlyoutItemLabelStyle" TargetType="Label"> |
|||
<Setter Property="TextColor" Value="White"></Setter> |
|||
<Setter Property="Margin" Value="16"></Setter> |
|||
</Style> |
|||
<Style Class="FlyoutItemLayoutStyle" TargetType="Layout" ApplyToDerivedTypes="True"> |
|||
<Setter Property="VisualStateManager.VisualStateGroups"> |
|||
<VisualStateGroupList> |
|||
<VisualStateGroup x:Name="CommonStates"> |
|||
<VisualState x:Name="Normal"> |
|||
<VisualState.Setters> |
|||
<Setter Property="BackgroundColor" Value="{x:OnPlatform UWP=Transparent, iOS=White, Android=White}" /> |
|||
<Setter TargetName="FlyoutItemLabel" Property="Label.TextColor" Value="{StaticResource Primary}" /> |
|||
</VisualState.Setters> |
|||
</VisualState> |
|||
<VisualState x:Name="Selected"> |
|||
<VisualState.Setters> |
|||
<Setter Property="BackgroundColor" Value="{StaticResource Primary}" /> |
|||
</VisualState.Setters> |
|||
</VisualState> |
|||
</VisualStateGroup> |
|||
</VisualStateGroupList> |
|||
</Setter> |
|||
</Style> |
|||
|
|||
<Style Class="MenuItemLayoutStyle" TargetType="Layout" ApplyToDerivedTypes="True"> |
|||
<Setter Property="VisualStateManager.VisualStateGroups"> |
|||
<VisualStateGroupList> |
|||
<VisualStateGroup x:Name="CommonStates"> |
|||
<VisualState x:Name="Normal"> |
|||
<VisualState.Setters> |
|||
<Setter TargetName="FlyoutItemLabel" Property="Label.TextColor" Value="{StaticResource Primary}" /> |
|||
</VisualState.Setters> |
|||
</VisualState> |
|||
</VisualStateGroup> |
|||
</VisualStateGroupList> |
|||
</Setter> |
|||
</Style> |
|||
</ResourceDictionary> |
|||
</Shell.Resources> |
|||
|
|||
<FlyoutItem Title="Home"> |
|||
<ShellContent ContentTemplate="{DataTemplate local:MainPage}" Route="main" /> |
|||
</FlyoutItem> |
|||
|
|||
<FlyoutItem Title="Users"> |
|||
<ShellContent ContentTemplate="{DataTemplate local:UsersPage}" Route="UsersPage" /> |
|||
</FlyoutItem> |
|||
|
|||
<Shell.FlyoutHeader> |
|||
<StackLayout> |
|||
<Image |
|||
Source="abp_logo.svg" |
|||
HorizontalOptions="Center" |
|||
Margin="25"/> |
|||
</StackLayout> |
|||
</Shell.FlyoutHeader> |
|||
|
|||
</Shell> |
|||
``` |
|||
|
|||
- One more step is required. Go to **App.xaml.cs** and replace MainPage with AppShell. |
|||
|
|||
```csharp |
|||
public partial class App : Application |
|||
{ |
|||
public App() |
|||
{ |
|||
InitializeComponent(); |
|||
|
|||
MainPage = new AppShell(); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- Run the application. |
|||
|
|||
- Login once if you haven't done before. |
|||
|
|||
- Navigate to Users page with hamburger menu at the right top. |
|||
|
|||
| Android| iOS| |
|||
| --- | --- | |
|||
|  |  | |
|||
|
|||
| UWP | |
|||
| --- | |
|||
|  | |
|||
|
|||
| MacCatalyst | |
|||
| --- | |
|||
|  | |
|||
|
|||
|
|||
## Conclusion |
|||
ABP Framework can be implemented any platform that runs on dotnet without suffer. ABP provides reusable abstractions layers and HttpApi Clients. In this article we've used powerful ABP core features such as Dependency Injection, Client Proxies, Validation and more. |
|||
|
|||
@ -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,113 @@ |
|||
# Dealing with Multiple Implementations of a Service in ASP.NET Core & ABP Dependency Injection |
|||
|
|||
ASP.NET Core provides a built-in [dependency injection system](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection) to register your services to the dependency injection container and inject/resolve them whenever you need. ABP's [dependency injection infrastructure](https://docs.abp.io/en/abp/latest/Dependency-Injection) is built on ASP.NET Core's DI system, automates service registration by conventions and provides some additional features. |
|||
|
|||
In this tutorial, I will explain how you can register multiple implementations of the same service interface and inject/resolve all these implementations when you need them. |
|||
|
|||
## Defining Services |
|||
|
|||
Assume that you have an `IExternalLogger` interface with two implementations: |
|||
|
|||
````csharp |
|||
public interface IExternalLogger |
|||
{ |
|||
Task LogAsync(string logText); |
|||
} |
|||
|
|||
public class ElasticsearchExternalLogger : IExternalLogger |
|||
{ |
|||
public async Task LogAsync(string logText) |
|||
{ |
|||
// TODO... |
|||
} |
|||
} |
|||
|
|||
public class AzureExternalLogger : IExternalLogger |
|||
{ |
|||
public async Task LogAsync(string logText) |
|||
{ |
|||
// TODO... |
|||
} |
|||
} |
|||
```` |
|||
|
|||
An example service injecting the `IExternalLogger` interface: |
|||
|
|||
````csharp |
|||
public class MyService : ITransientDependency |
|||
{ |
|||
private readonly IExternalLogger _externalLogger; |
|||
|
|||
public MyService(IExternalLogger externalLogger) |
|||
{ |
|||
_externalLogger = externalLogger; |
|||
} |
|||
|
|||
public async Task DemoAsync() |
|||
{ |
|||
await _externalLogger.LogAsync("Example log message..."); |
|||
} |
|||
} |
|||
```` |
|||
|
|||
In this example, we haven't registered any of the implementation classes to the dependency injection system yet. So, if we try to use the `MyService` class, we get an error indicating that no implementation found for the `IExternalLogger` service. We should register at least one implementation for the `IExternalLogger` interface. |
|||
|
|||
## Registering Services |
|||
|
|||
If we register both of the `ElasticsearchExternalLogger` and `AzureExternalLogger` services for the `IExternalLogger` interface, and then try to inject the `IExternalLogger` interface, the last registered implementation will be used. However, how to determine the last registered implementation? |
|||
|
|||
If we implement one of the [dependency interfaces](https://docs.abp.io/en/abp/latest/Dependency-Injection#dependency-interfaces) (e.g. `ITransientDependency`), then the registration order will be uncertain (it may depend on the namespaces of the classes). The *last registered implementation* can be different than you expect. So, it is not suggested to use the dependency interfaces to register multiple implementations. |
|||
|
|||
You can register your services in the `ConfigureServices` method of your module: |
|||
|
|||
````csharp |
|||
public override void ConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
context.Services.AddTransient<IExternalLogger, ElasticsearchExternalLogger>(); |
|||
context.Services.AddTransient<IExternalLogger, AzureExternalLogger>(); |
|||
} |
|||
```` |
|||
|
|||
In this case, you get an `AzureExternalLogger` instance when you inject the `IExternalLogger` interface, because the last registered implementation is the `AzureExternalLogger` class. |
|||
|
|||
## Injecting Multiple Implementations |
|||
|
|||
When you have multiple implementation of an interface, you may want to work with all these implementations. For this example, you may want to write log to all the external loggers. We can change the `MyService` implementation as the following: |
|||
|
|||
````csharp |
|||
public class MyService : ITransientDependency |
|||
{ |
|||
private readonly IEnumerable<IExternalLogger> _externalLoggers; |
|||
|
|||
public MyService(IEnumerable<IExternalLogger> externalLoggers) |
|||
{ |
|||
_externalLoggers = externalLoggers; |
|||
} |
|||
|
|||
public async Task DemoAsync() |
|||
{ |
|||
foreach (var externalLogger in _externalLoggers) |
|||
{ |
|||
await externalLogger.LogAsync("Example log message..."); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
In this example, we are injecting `IEnumerable<IExternalLogger>` instead of `IExternalLogger`, so we have a collection of the `IExternalLogger` implementations. Then we are using a `foreach` loop to write the same log text to all the `IExternalLogger` implementations. |
|||
|
|||
If you are using `IServiceProvider` to resolve dependencies, then use its `GetServices` method to obtain a collection of the service implementations: |
|||
|
|||
````csharp |
|||
IEnumerable<IExternalLogger> services = _serviceProvider.GetServices<IExternalLogger>(); |
|||
```` |
|||
|
|||
## Further Reading |
|||
|
|||
In this small tutorial, I explained how you can register multiple implementations of the same interface to the dependency injection system and inject/resolve all of them when you need. |
|||
|
|||
If you want to get more information about ABP's and ASP.NET Core's dependency injection systems, you can read the following documents: |
|||
|
|||
* [ABP's Dependency Injection documentation](https://docs.abp.io/en/abp/latest/Dependency-Injection) |
|||
* [ASP.NET Core Dependency Injection best practices, tips & tricks](https://medium.com/volosoft/asp-net-core-dependency-injection-best-practices-tips-tricks-c6e9c67f9d96) |
|||
* [ASP.NET Core's Dependency Injection documentation](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection) |
|||
@ -1,3 +1,147 @@ |
|||
## Concurrency Check |
|||
|
|||
TODO |
|||
### Introduction |
|||
|
|||
Concurrency Check (also known as **Concurrency Control**) refers to specific mechanisms used to ensure data consistency in the presence of concurrent changes (multiple processes, users access or change the same data in a database at the same time). |
|||
|
|||
There are two commonly used concurrency control mechanisms/approaches: |
|||
* **Optimistic Concurrency Control**: Optimistic Concurrency Control allows multiple users to attempt to **update** the same record without informing the users that others are also attempting to **update** it. |
|||
|
|||
* If a user successfully updates the record, the other users need to get the latest changes for the current record to be able to make changes. |
|||
* ABP's concurrency check system uses the **Optimistic Concurrency Control**. |
|||
|
|||
* **Pessimistic Concurrency Control**: Pessimistic Concurrency Control prevents simultaneous updates to records and uses a locking mechanism. For more information please see [here](https://www.martinfowler.com/eaaCatalog/pessimisticOfflineLock.html). |
|||
|
|||
### Usage |
|||
|
|||
#### `IHasConcurrencyStamp` Interface |
|||
|
|||
To enable **concurrency control** to your entity class, you should implement the `IHasConcurrencyStamp` interface, directly or indirectly. |
|||
|
|||
```csharp |
|||
public interface IHasConcurrencyStamp |
|||
{ |
|||
public string ConcurrencyStamp { get; set; } |
|||
} |
|||
``` |
|||
|
|||
* It is the base interface for **concurrency control** and only has a simple property named `ConcurrencyStamp`. |
|||
* While a new record is **creating**, if the entity implements the `IHasConcurrencyStamp` interface, ABP Framework automatically sets a unique value to the **ConcurrencyStamp** property. |
|||
* While a record is **updating**, ABP Framework compares the **ConcurrencyStamp** property of the entity with the provided **ConcurrencyStamp** value by the user and if the values match, it automatically updates the **ConcurrencyStamp** property with the new unique value. If there is a mismatch, `AbpDbConcurrencyException` is thrown. |
|||
|
|||
**Example: Applying Concurrency Control for the Book Entity** |
|||
|
|||
Implement the `IHasConcurrencyStamp` interface for your entity: |
|||
|
|||
```csharp |
|||
public class Book : Entity<Guid>, IHasConcurrencyStamp |
|||
{ |
|||
public string ConcurrencyStamp { get; set; } |
|||
|
|||
//... |
|||
} |
|||
``` |
|||
|
|||
Also, implement your output and update the DTO classes from the `IHasConcurrencyStamp` interface: |
|||
|
|||
```csharp |
|||
public class BookDto : EntityDto<Guid>, IHasConcurrencyStamp |
|||
{ |
|||
//... |
|||
|
|||
public string ConcurrencyStamp { get; set; } |
|||
} |
|||
|
|||
public class UpdateBookDto : IHasConcurrencyStamp |
|||
{ |
|||
//... |
|||
|
|||
public string ConcurrencyStamp { get; set; } |
|||
} |
|||
``` |
|||
|
|||
Set the **ConcurrencyStamp** input value to the entity in the **UpdateAsync** method of your application service as below: |
|||
|
|||
```csharp |
|||
public class BookAppService : ApplicationService, IBookAppService |
|||
{ |
|||
//... |
|||
|
|||
public virtual async Task<BookDto> UpdateAsync(Guid id, UpdateBookDto input) |
|||
{ |
|||
var book = await BookRepository.GetAsync(id); |
|||
|
|||
book.ConcurrencyStamp = input.ConcurrencyStamp; |
|||
|
|||
//set other input values to the entity ... |
|||
|
|||
await BookRepository.UpdateAsync(book); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
* After that, when multiple users try to update the same record at the same time, the concurrency stamp mismatch occurs and `AbpDbConcurrencyException` is thrown. |
|||
|
|||
#### Base Classes |
|||
|
|||
[Aggregate Root](./Entities.md#aggregateroot-class) entity classes already implement the `IHasConcurrencyStamp` interface. So, if you are deriving from one of these base classes, you don't need to manually implement the `IHasConcurrencyStamp` interface: |
|||
|
|||
- `AggregateRoot`, `AggregateRoot<TKey>` |
|||
- `CreationAuditedAggregateRoot`, `CreationAuditedAggregateRoot<TKey>` |
|||
- `AuditedAggregateRoot`, `AuditedAggregateRoot<TKey>` |
|||
- `FullAuditedAggregateRoot`, `FullAuditedAggregateRoot<TKey>` |
|||
|
|||
**Example: Applying Concurrency Control for the Book Entity** |
|||
|
|||
You can inherit your entity from one of [the base classes](#base-classes): |
|||
|
|||
```csharp |
|||
public class Book : FullAuditedAggregateRoot<Guid> |
|||
{ |
|||
//... |
|||
} |
|||
``` |
|||
|
|||
Then, you can implement your output and update the DTO classes from the `IHasConcurrencyStamp` interface: |
|||
|
|||
```csharp |
|||
public class BookDto : EntityDto<Guid>, IHasConcurrencyStamp |
|||
{ |
|||
//... |
|||
|
|||
public string ConcurrencyStamp { get; set; } |
|||
} |
|||
|
|||
public class UpdateBookDto : IHasConcurrencyStamp |
|||
{ |
|||
//... |
|||
|
|||
public string ConcurrencyStamp { get; set; } |
|||
} |
|||
``` |
|||
|
|||
Set the **ConcurrencyStamp** input value to the entity in the **UpdateAsync** method of your application service as below: |
|||
|
|||
```csharp |
|||
public class BookAppService : ApplicationService, IBookAppService |
|||
{ |
|||
//... |
|||
|
|||
public virtual async Task<BookDto> UpdateAsync(Guid id, UpdateBookDto input) |
|||
{ |
|||
var book = await BookRepository.GetAsync(id); |
|||
|
|||
book.ConcurrencyStamp = input.ConcurrencyStamp; |
|||
|
|||
//set other input values to the entity ... |
|||
|
|||
await BookRepository.UpdateAsync(book); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
After that, when multiple users try to update the same record at the same time, the concurrency stamp mismatch occurs and `AbpDbConcurrencyException` is thrown. You can either handle the exception manually or let the ABP Framework handle it for you. |
|||
|
|||
ABP Framework shows a user-friendly error message as in the image below, if you don't handle the exception manually. |
|||
|
|||
 |
|||
|
|||
@ -0,0 +1,99 @@ |
|||
# Deploying to a Clustered Environment |
|||
|
|||
This document introduces the topics that you should consider when you are deploying your application to a clustered environment where **multiple instances of your application run concurrently**, and explains how you can deal with these topics in your ABP based application. |
|||
|
|||
> This document is valid regardless you have a monolith application or a microservice solution. The Application term is used for a process. An application can be a monolith web application, a service in a microservice solution, a console application, or another kind of an executable process. |
|||
> |
|||
> For example, if you are deploying your application to Kubernetes and configure your application or service to run in multiple pods, then your application or service runs in a clustered environment. |
|||
|
|||
## Understanding the Clustered Environment |
|||
|
|||
> You can skip this section if you are already familiar with clustered deployment and load balancers. |
|||
|
|||
### Single Instance Deployment |
|||
|
|||
Consider an application deployed as a **single instance**, as illustrated in the following figure: |
|||
|
|||
 |
|||
|
|||
Browsers and other client applications can directly make HTTP requests to your application. You can put a web server (e.g. IIS or NGINX) between the clients and your application, but you still have a single application instance running in a single server or container. Single-instance configuration is **limited to scale** since it runs in a single server and you are limited with the server's capacity. |
|||
|
|||
### Clustered Deployment |
|||
|
|||
**Clustered deployment** is the way of running **multiple instances** of your application **concurrently** in a single or multiple servers. In this way, different instances can serve different requests and you can scale by adding new servers to the system. The following figure shows a typical implementation of clustering using a **load balancer**: |
|||
|
|||
 |
|||
|
|||
### Load Balancers |
|||
|
|||
[Load balancers](https://en.wikipedia.org/wiki/Load_balancing_(computing)) have a lot of features, but they fundamentally **forward an incoming HTTP request** to an instance of your application and return your response back to the client application. |
|||
|
|||
Load balancers can use different algorithms for selecting the application instance while determining the application instance that is used to deliver the incoming request. **Round Robin** is one of the simplest and most used algorithms. Requests are delivered to the application instances in rotation. First instance gets the first request, second instance gets the second, and so on. It returns to the first instance after all the instances are used, and the algorithm goes like that for the next requests. |
|||
|
|||
### Potential Problems |
|||
|
|||
Once multiple instances of your application run in parallel, you should carefully consider the following topics: |
|||
|
|||
* Any **state (data) stored in memory** of your application will become a problem when you have multiple instances. A state stored in memory of an application instance may not be available in the next request since the next request will be handled by a different application instance. While there are some solutions (like sticky sessions) to overcome this problem user-basis, it is a **best practice to design your application as stateless** if you want to run it in a cluster, container or/and cloud. |
|||
* **In-memory caching** is a kind of in-memory state and should not be used in a clustered application. You should use **distributed caching** instead. |
|||
* You shouldn't store data in the **local file system**. It should be available to all instances of your application. Different application instance may run in different containers or servers and they may not be able to have access to the same file system. You can use a **cloud or external storage provider** as a solution. |
|||
* If you have **background workers** or **job queue managers**, you should be careful since multiple instances may try to execute the same job or perform the same work concurrently. As a result, you may have the same work done multiple times or you may get a lot of errors while trying to access and change the same resources. |
|||
|
|||
You may have more problems with clustered deployment, but these are the most common ones. ABP has been designed to be compatible with the clustered deployment scenario. The following sections explain what you should do when you are deploying your ABP based application to a clustered environment. |
|||
|
|||
## Switching to a Distributed Cache |
|||
|
|||
ASP.NET Core provides different kind of caching features. [In-memory cache](https://docs.microsoft.com/en-us/aspnet/core/performance/caching/memory) stores your objects in the memory of the local server and is only available to the application that stored the object. Non-sticky sessions in a clustered environment should use the [distributed caching](https://docs.microsoft.com/en-us/aspnet/core/performance/caching/distributed) except some specific scenarios (for example, you can cache a local CSS file into memory. It is read-only data and it is the same in all application instances. You can cache it in memory for performance reasons without any problem). |
|||
|
|||
[ABP's Distributed Cache](../Caching.md) extends [ASP.NET Core's distributed cache](https://docs.microsoft.com/en-us/aspnet/core/performance/caching/distributed) infrastructure. It works in-memory by default. You should configure an actual distributed cache provider when you want to deploy your application to a clustered environment. |
|||
|
|||
> You should configure the cache provider for clustered deployment, even if your application doesn't directly use `IDistributedCache`. Because the ABP Framework and the pre-built [application modules](../Modules/Index.md) are using distributed cache. |
|||
|
|||
ASP.NET Core provides multiple integrations to use as your distributed cache provider, like [Redis](https://redis.io/) and [NCache](https://www.alachisoft.com/ncache/). You can follow [Microsoft's documentation](https://docs.microsoft.com/en-us/aspnet/core/performance/caching/distributed) to learn how to use them in your applications. |
|||
|
|||
If you decided to use Redis as your distributed cache provider, **follow [ABP's Redis Cache Integration document](../Redis-Cache.md)** for the steps you need to follow to install it into your application and setup your Redis configuration. |
|||
|
|||
> Based on your preferences while creating a new ABP solution, Redis cache might be pre-installed in your solution. For example, if you have selected the *Tiered* option with the MVC UI, Redis cache comes as pre-installed. Because, in this case, you have two applications in your solution and they should use the same cache source to be consistent. |
|||
|
|||
## Using a Proper BLOB Storage Provider |
|||
|
|||
If you have used ABP's [BLOB Storing](../Blob-Storing.md) feature with the [File System provider](../Blob-Storing-File-System.md), you should use another provider in your clustered environment since the File System provider uses the application's local file system. |
|||
|
|||
The [Database BLOB provider](../Blob-Storing-Database) is the easiest way since it uses your application's main database (or another database if you configure) to store BLOBs. However, you should remember that BLOBs are large objects and may quickly increase your database's size. |
|||
|
|||
> [ABP Commercial](https://commercial.abp.io/) startup solution templates come with the database BLOB provider as pre-installed, and stores BLOBs in the application's database. |
|||
|
|||
Check the [BLOB Storing](../Blob-Storing.md) document to see all the available BLOG storage providers. |
|||
|
|||
## Configuring Background Jobs |
|||
|
|||
ABP's [background job system](../Background-Jobs.md) is used to queue tasks to be executed in the background. Background job queue is persistent and a queued task is guaranteed to be executed (it is re-tried if it fails). |
|||
|
|||
ABP's default background job manager is compatible with clustered environments. It uses a [distributed lock](../Distributed-Locking.md) to ensure that the jobs are executed only in a single application instance at a time. See the *Configuring a Distributed Lock Provider* section below to learn how to configure a distributed lock provider for your application, so the default background job manager properly works in a clustered environment. |
|||
|
|||
If you don't want to use a distributed lock provider, you may go with the following options: |
|||
|
|||
* Stop the background job manager (set `AbpBackgroundJobOptions.IsJobExecutionEnabled` to `false`) in all application instances except one of them, so only the single instance executes the jobs (while other application instances can still queue jobs). |
|||
* Stop the background job manager (set `AbpBackgroundJobOptions.IsJobExecutionEnabled` to `false`) in all application instances and create a dedicated application (maybe a console application running in its own container or a Windows Service running in the background) to execute all the background jobs. This can be a good option if your background jobs consume high system resources (CPU, RAM or Disk), so you can deploy that background application to a dedicated server and your background jobs don't affect your application's performance. |
|||
|
|||
> If you are using an external background job integration (e.g. [Hangfire](../Background-Workers-Hangfire.md) or [Quartz](../Background-Workers-Quartz.md)) instead of the default background job manager, then please refer to your provider's documentation to learn how it should be configured for a clustered environment. |
|||
|
|||
## Configuring a Distributed Lock Provider |
|||
|
|||
ABP provides a distributed locking abstraction with an implementation made with the [DistributedLock](https://github.com/madelson/DistributedLock) library. A distributed lock is used to control concurrent access to a shared resource by multiple applications to prevent corruption of the resource because of concurrent writes. The ABP Framework and some pre-built [application modules](../Modules/Index.md) are using distributed locking for several reasons. |
|||
|
|||
However, the distributed lock system works in-process by default. That means it is not distributed actually, unless you configure a distributed lock provider. So, please follow the [distributed lock](../Distributed-Locking.md) document to configure a provider for your application, if it is not already configured. |
|||
|
|||
## Implementing Background Workers |
|||
|
|||
ASP.NET Core provides [hosted services](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services) and ABP provides [background workers](../Background-Workers.md) to perform tasks in background threads in your application. |
|||
|
|||
If your application has tasks running in the background, you should consider how they will behave in a clustered environment, especially if your background tasks are using the same resources. You should design your background tasks so that they continue to work properly in the clustered environment. |
|||
|
|||
Assume that your background worker in your SaaS application checks user subscriptions and sends emails if their subscription renewal date approaches. If the background task runs in multiple application instances, it is probable to send the same email many times to some users, which will disturb them. |
|||
|
|||
We suggest you to use one of the following approaches to overcome the problem: |
|||
|
|||
* Implement your background workers so that they work in a clustered environment without any problem. Using the [distributed lock](../Distributed-Locking.md) to ensure concurrency control is a way of doing that. A background worker in an application instance may handle a distributed lock, so the workers in other application instances will wait for the lock. In this way, only one worker does the actual work, while others wait in idle. If you implement this, your workers run safely without caring about how the application is deployed. |
|||
* Stop the background workers (set `AbpBackgroundWorkerOptions.IsEnabled` to `false`) in all application instances except one of them, so only the single instance runs the workers. |
|||
* Stop the background workers (set `AbpBackgroundWorkerOptions.IsEnabled` to `false`) in all application instances and create a dedicated application (maybe a console application running in its own container or a Windows Service running in the background) to execute all the background tasks. This can be a good option if your background workers consume high system resources (CPU, RAM or Disk), so you can deploy that background application to a dedicated server and your background tasks don't affect your application's performance. |
|||
@ -0,0 +1,9 @@ |
|||
# Deployment |
|||
|
|||
Deploying an ABP application is not 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. ABP's documentation doesn't contain much information on deployment. You can refer to your provider's documentation. |
|||
|
|||
However, there are some topics that you should care about when you are deploying your applications. Most of them are general software deployment considerations, but you should understand how to handle them within your ABP based applications. We've prepared guides for this purpose and we suggest you to read these guides carefully before designing your deployment configuration. |
|||
|
|||
## Guides |
|||
|
|||
* [Deploying to a clustered environment](Clustered-Environment.md): Explains how to configure your application when you want to run multiple instances of your application concurrently. |
|||
@ -0,0 +1,110 @@ |
|||
# Distributed Locking |
|||
Distributed locking is a technique to manage many applications that try to access the same resource. The main purpose is to allow only one of many applications to access the same resource at the same time. Otherwise, accessing the same object from various applications may corrupt the value of the resources. |
|||
|
|||
> ABP's current distributed locking implementation is based on the [DistributedLock](https://github.com/madelson/DistributedLock) library. |
|||
|
|||
## Installation |
|||
|
|||
You can open a command-line terminal and type the following command to install the [Volo.Abp.DistributedLocking](https://www.nuget.org/packages/Volo.Abp.DistributedLocking) package into your project: |
|||
|
|||
````bash |
|||
abp add-package Volo.Abp.DistributedLocking |
|||
```` |
|||
|
|||
This package provides the necessary API to use the distributed locking system, however, you should configure a provider before using it. |
|||
|
|||
### Configuring a Provider |
|||
|
|||
The [DistributedLock](https://github.com/madelson/DistributedLock) library provides [various of implementations](https://github.com/madelson/DistributedLock#implementations) for the locking, like [Redis](https://github.com/madelson/DistributedLock/blob/master/docs/DistributedLock.Redis.md) and [ZooKeeper](https://github.com/madelson/DistributedLock/blob/master/docs/DistributedLock.ZooKeeper.md). |
|||
|
|||
For example, if you want to use the [Redis provider](https://github.com/madelson/DistributedLock/blob/master/docs/DistributedLock.Redis.md), you should add [DistributedLock.Redis](https://www.nuget.org/packages/DistributedLock.Redis) NuGet package to your project, then add the following code into the `ConfigureServices` method of your ABP [module](Module-Development-Basics.md) class: |
|||
|
|||
````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()); |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
This code gets the Redis connection string from the [configuration](Configuration.md), so you can add the following lines to your `appsettings.json` file: |
|||
|
|||
````json |
|||
"Redis": { |
|||
"Configuration": "127.0.0.1" |
|||
} |
|||
```` |
|||
|
|||
## Usage |
|||
|
|||
There are two ways to use the distributed locking API: ABP's `IAbpDistributedLock` abstraction and [DistributedLock](https://github.com/madelson/DistributedLock) library's API. |
|||
|
|||
### Using the IAbpDistributedLock Service |
|||
|
|||
`IAbpDistributedLock` is a simple service provided by the ABP framework for simple usage of distributed locking. |
|||
|
|||
**Example: Using the `IAbpDistributedLock.TryAcquireAsync` method** |
|||
|
|||
````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` may not acquire the lock. It returns `null` if the lock could not be acquired. In this case, you shouldn't access the resource. If the handle is not `null`, it means that you've obtained the lock and can safely access the resource. |
|||
|
|||
`TryAcquireAsync` method gets the following parameters: |
|||
|
|||
* `name` (`string`, required): Unique name of your lock. Different named locks are used to access different resources. |
|||
* `timeout` (`TimeSpan`): A timeout value to wait to obtain the lock. Default value is `TimeSpan.Zero`, which means it doesn't wait if the lock is already owned by another application. |
|||
* `cancellationToken`: A cancellation token that can be triggered later to cancel the operation. |
|||
|
|||
### Using DistributedLock Library's API |
|||
|
|||
ABP's `IAbpDistributedLock` service is very limited and mainly designed to be internally used by the ABP Framework. For your own applications, you can use the DistributedLock library's own API. See its [own documentation](https://github.com/madelson/DistributedLock) for details. |
|||
|
|||
## The Volo.Abp.DistributedLocking.Abstractions Package |
|||
|
|||
If you are building a reusable library or an application module, then you may not want to bring an additional dependency to your module for simple applications that run as a single instance. In this case, your library can depend on the [Volo.Abp.DistributedLocking.Abstractions](https://nuget.org/packages/Volo.Abp.DistributedLocking.Abstractions) package which defines the `IAbpDistributedLock` service and implements it as in-process (not distributed actually). In this way, your library can run properly (without a distributed lock provider dependency) in an application that runs as a single instance. If the application is deployed to a [clustered environment](Deployment/Clustered-Environment.md), then the application developer should install a real distributed provider as explained in the *Installation* section. |
|||
@ -1,8 +1,3 @@ |
|||
# Getting Started with the Startup Templates |
|||
This document has been [moved to here](Getting-Started.md). |
|||
|
|||
See the following tutorials to learn how to get started with the ABP Framework using the pre-built application startup templates: |
|||
|
|||
* [Getting Started With the ASP.NET Core MVC / Razor Pages UI](Getting-Started?UI=MVC&DB=EF&Tiered=No) |
|||
* [Getting Started with the Angular UI](Getting-Started?UI=NG&DB=EF&Tiered=No) |
|||
|
|||
<!-- TODO: this document has been moved, it should be deleted in the future. --> |
|||
<!-- TODO: this document has been moved, it should be deleted in the future. --> |
|||
|
|||
@ -0,0 +1,52 @@ |
|||
# ABP Version 5.2 Migration Guide |
|||
|
|||
This document is a guide for upgrading ABP v5.x solutions to ABP v5.2. Please read them all since v5.2 has some changes you should take care. |
|||
|
|||
|
|||
## MongoDB |
|||
|
|||
- `IMongoDbRepositoryFilterer.AddGlobalFilters()` method is replaced with async one `IMongoDbRepositoryFilterer.AddGlobalFiltersAsync()` |
|||
|
|||
## Blazor UI |
|||
If you use Blazor WASM or Blazor Server UI, you should follow this section. |
|||
|
|||
### Blazorise 1.0 |
|||
We've upgraded to Blazorise 1.0 stable version. So there is some breaking changes that you have to apply in your project. |
|||
|
|||
Also You can review that pull request [#11649 - Blazorise 1.0 Migration](https://github.com/abpframework/abp/pull/11649) |
|||
|
|||
- `NumericEdit` is now made around the native `input type="number"` so a lot of its formating features are moved to the new `NumericPicker` component. Replace NumericEdit with NumericPicker. |
|||
- Rename `DecimalsSeparator` to `DecimalSeparator` on the `DataGridColumn` and `NumericPicker`. |
|||
- Rename `MaxMessageSize` to `MaxChunkSize`. |
|||
- Remove `Fullscreen` parameter on `<ModalContent>` and replace it with `Size="ModalSize.Fullscreen"` parameter. |
|||
- Remove `NotificationType`, `Message`, and `Title` parameter from `<NotificationAlert>` component. |
|||
- Move `RightAligned` parameter from `<BarDropdownMenu>` to `<BarDropdown>` component. |
|||
- Rename any usage of the `ChangeTextOnKeyPress` parameter into `Immediate`. |
|||
- Rename any usage of `DelayTextOnKeyPress` parameter into `Debounce` and `DelayTextOnKeyPressInterval` into DebounceInterval. |
|||
- Replace all `Left` and `Right` enums with `Start` and `End` for the following enum types: `Direction`, `Float`, `Placement`, `NotificationLocation`, `Side`, `SnackbarLocation`, `SnackbarStackLocation`, `TabPosition`, and `TextAlignment`. |
|||
- Replace all `FromLeft`, `FromRight`, `RoundedLeft`, and `RoundedRight` enums with `FromStart`, `FromEnd`, `RoundedStart`, and `RoundedEnd` for the `Border` utilities. |
|||
- Replace all `FromLeft` and `FromRight` with `FromStart`, `FromEnd` for the Margin and `Padding` utilities. |
|||
- Replace all `AddLabel` with `AddLabels` method on chart instance. |
|||
- Change enum value from `None` to `Default` for the following enum types: `Color`, `Background`, `TextColor`, `Alignment`, `BorderRadius`, `BorderSize`, `Direction`, `DisplayDirection`, `FigureSize`, `IconSize`, `JustifyContent`, `OverflowType`, `SnackbarColor`, `Target`, `TextAlignment`, `TextOverflow`, `TextTransform`, `TextWeight`, `VerticalAlignment`, `Visibility`, `Size`, and `SnackbarLocation`. |
|||
- Obsolete typography parameters `Alignment`, `Color`, `Transform`, and `Weight` are removed in favor of `TextAlignment`, `TextColor`, `TextTransform`, and `TextWeight`. |
|||
- Remove any use of an obsolete component `<InlineField>`. |
|||
- The Datagrid's obsolete `Direction` parameter has now been removed. Instead, please use the `SortDirection` parameter if you weren't already.. |
|||
- Rename `<Tabs>` `Mode` parameter into `RenderMode`. |
|||
|
|||
> _Check out [Blazorise Release Notes](https://preview.blazorise.com/news/release-notes/100) for more information._ |
|||
|
|||
## MVC - Razor Pages UI |
|||
|
|||
If you use MVC Razor Pages UI, you should follow this section. |
|||
|
|||
### Client libraries |
|||
The `libs` folder no longer exists in templates after v5.2. That change greatly reduced the size of templates and brought some other advantages. |
|||
|
|||
You can use `abp install-libs` command for installing or updating client libraries. You should run this command after updating v5.2. |
|||
|
|||
> If you're creating a new project, you don't have to be concerned about it, ABP CLI installs client libraries after automatically. |
|||
|
|||
## See Also |
|||
|
|||
* [Official blog post for the 5.2 release](https://blog.abp.io/abp/ABP.IO-Platform-5-2-RC-Has-Been-Published) |
|||
|
|||