بناء واجهة برمجة تطبيقات ويب ASP.NET باستخدام ASP.NET Core
نشرت: 2022-03-11مقدمة
منذ عدة سنوات ، حصلت على كتاب "Pro ASP.NET Web API". هذا المقال هو فرع من الأفكار من هذا الكتاب ، وقليل من CQRS ، وتجربتي الخاصة في تطوير أنظمة خادم العميل.
في هذه المقالة ، سأغطي:
- كيفية إنشاء واجهة برمجة تطبيقات REST من البداية باستخدام .NET Core و EF Core و AutoMapper و XUnit
- كيف تتأكد من أن API يعمل بعد التغييرات
- كيفية تبسيط تطوير ودعم نظام REST API قدر الإمكان
لماذا ASP.NET Core؟
يوفر ASP.NET Core العديد من التحسينات على ASP.NET MVC / Web API. أولاً ، إنه الآن إطار واحد وليس اثنين. يعجبني حقًا لأنه مناسب ويوجد به ارتباك أقل. ثانيًا ، لدينا تسجيلات وحاويات DI بدون أي مكتبات إضافية ، مما يوفر لي الوقت ويسمح لي بالتركيز على كتابة كود أفضل بدلاً من اختيار أفضل المكتبات وتحليلها.
ما هي معالجات الاستعلام؟
معالج الاستعلام هو نهج يتم فيه تغليف منطق الأعمال المتعلق بكيان واحد من النظام في خدمة واحدة ويتم تنفيذ أي وصول أو إجراءات مع هذا الكيان من خلال هذه الخدمة. عادة ما تسمى هذه الخدمة QueryProcessor {EntityPluralName}. إذا لزم الأمر ، يتضمن معالج الاستعلام طرق CRUD (إنشاء ، قراءة ، تحديث ، حذف) لهذا الكيان. اعتمادًا على المتطلبات ، قد لا يتم تنفيذ جميع الطرق. لإعطاء مثال محدد ، دعنا نلقي نظرة على ChangePassword. إذا كانت طريقة معالج الاستعلام تتطلب بيانات إدخال ، فيجب تقديم البيانات المطلوبة فقط. عادة ، لكل طريقة ، يتم إنشاء فئة استعلام منفصلة ، وفي الحالات البسيطة ، من الممكن (ولكن غير مرغوب فيه) إعادة استخدام فئة الاستعلام.
هدفنا
في هذه المقالة ، سأوضح لك كيفية إنشاء واجهة برمجة تطبيقات لنظام إدارة تكلفة صغير ، بما في ذلك الإعدادات الأساسية للمصادقة والتحكم في الوصول ، لكنني لن أخوض في نظام المصادقة الفرعي. سوف أقوم بتغطية منطق العمل الكامل للنظام باختبارات معيارية وإنشاء اختبار تكامل واحد على الأقل لكل طريقة من طرق واجهة برمجة التطبيقات في مثال لكيان واحد.
متطلبات النظام المطور: يمكن للمستخدم إضافة وتعديل وحذف مصاريفه والاطلاع على مصاريفها فقط.
الكود الكامل لهذا النظام متاح على موقع Github.
لذا ، لنبدأ في تصميم نظامنا الصغير ولكنه مفيد للغاية.
طبقات API
يوضح الرسم التخطيطي أن النظام سيكون له أربع طبقات:
- قاعدة البيانات - هنا نقوم بتخزين البيانات ولا شيء أكثر ، بلا منطق.
- DAL - للوصول إلى البيانات ، نستخدم نمط وحدة العمل ، وفي التنفيذ ، نستخدم ORM EF Core مع الرمز أولاً وأنماط الترحيل.
- منطق الأعمال - لتغليف منطق الأعمال ، نستخدم معالجات الاستعلام ، فقط هذه الطبقة تعالج منطق الأعمال. الاستثناء هو أبسط عملية تحقق مثل الحقول الإلزامية ، والتي سيتم تنفيذها عن طريق المرشحات في واجهة برمجة التطبيقات.
- REST API - سيتم تنفيذ الواجهة الفعلية التي يمكن للعملاء من خلالها العمل مع API الخاص بنا من خلال ASP.NET Core. يتم تحديد تكوينات المسار من خلال السمات.
بالإضافة إلى الطبقات الموصوفة ، لدينا عدة مفاهيم مهمة. الأول هو فصل نماذج البيانات. يستخدم نموذج بيانات العميل بشكل أساسي في طبقة REST API. يقوم بتحويل الاستعلامات إلى نماذج المجال والعكس بالعكس من نموذج المجال إلى نموذج بيانات العميل ، ولكن يمكن أيضًا استخدام نماذج الاستعلام في معالجات الاستعلام. يتم التحويل باستخدام AutoMapper.
هيكل المشروع
لقد استخدمت VS 2017 Professional لإنشاء المشروع. عادةً ما أشارك الكود المصدري والاختبارات في مجلدات مختلفة. إنه مريح ، يبدو جيدًا ، الاختبارات في CI تعمل بشكل ملائم ، ويبدو أن Microsoft توصي بالقيام بذلك بهذه الطريقة:
وصف المشروع:
المشروع | وصف |
---|---|
نفقات | مشروع لوحدات التحكم ، ورسم الخرائط بين نموذج المجال ونموذج API ، وتكوين API |
المصاريف. Api.Common | في هذه المرحلة ، توجد فئات استثناء مجمعة يتم تفسيرها بطريقة معينة بواسطة المرشحات لإرجاع رموز HTTP الصحيحة مع وجود أخطاء إلى المستخدم |
المصاريف | مشروع لنماذج API |
المصاريف والبيانات والوصول | مشروع واجهات وتنفيذ وحدة نمط العمل |
المصاريف. البيانات. النموذج | مشروع لنموذج المجال |
المصاريف | مشروع لمعالجات الاستعلام والفئات الخاصة بالاستعلام |
المصروفات | مشروع للواجهة وتنفيذ سياق أمان المستخدم الحالي |
المراجع بين المشاريع:
المصاريف التي تم إنشاؤها من النموذج:
مشاريع أخرى في مجلد src حسب النموذج:
جميع المشاريع في مجلد الاختبارات حسب النموذج:
تطبيق
لن تصف هذه المقالة الجزء المرتبط بواجهة المستخدم ، على الرغم من تنفيذها.
كانت الخطوة الأولى هي تطوير نموذج بيانات موجود في تجميع Expenses.Data.Model
. Data.Model:
تحتوي فئة Expense
على السمات التالية:
public class Expense { public int Id { get; set; } public DateTime Date { get; set; } public string Description { get; set; } public decimal Amount { get; set; } public string Comment { get; set; } public int UserId { get; set; } public virtual User User { get; set; } public bool IsDeleted { get; set; } }
تدعم هذه الفئة "الحذف الناعم" عن طريق السمة IsDeleted
وتحتوي على جميع البيانات لحساب واحد لمستخدم معين والتي ستكون مفيدة لنا في المستقبل.
تشير الفئات User
و Role
و UserRole
إلى نظام الوصول الفرعي ؛ لا يتظاهر هذا النظام بأنه نظام العام ولا يعتبر وصف هذا النظام الفرعي الغرض من هذه المقالة ؛ لذلك ، سيتم حذف نموذج البيانات وبعض تفاصيل التنفيذ. يمكن استبدال نظام الوصول إلى المنظمة بنظام أكثر كمالا دون تغيير منطق الأعمال.
بعد ذلك ، تم تنفيذ نموذج وحدة العمل في تجميع البيانات Expenses.Data.Access
. الوصول ، يظهر هيكل هذا المشروع:
المكتبات التالية مطلوبة للتجميع:
-
Microsoft.EntityFrameworkCore.SqlServer
من الضروري تنفيذ سياق EF
سيجد تلقائيًا التعيينات في مجلد معين:
public class MainDbContext : DbContext { public MainDbContext(DbContextOptions<MainDbContext> options) : base(options) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { var mappings = MappingsHelper.GetMainMappings(); foreach (var mapping in mappings) { mapping.Visit(modelBuilder); } } }
يتم إجراء التعيين من خلال فئة MappingsHelper
:
public static class MappingsHelper { public static IEnumerable<IMap> GetMainMappings() { var assemblyTypes = typeof(UserMap).GetTypeInfo().Assembly.DefinedTypes; var mappings = assemblyTypes // ReSharper disable once AssignNullToNotNullAttribute .Where(t => t.Namespace != null && t.Namespace.Contains(typeof(UserMap).Namespace)) .Where(t => typeof(IMap).GetTypeInfo().IsAssignableFrom(t)); mappings = mappings.Where(x => !x.IsAbstract); return mappings.Select(m => (IMap) Activator.CreateInstance(m.AsType())).ToArray(); } }
التعيين للفئات موجود في مجلد Maps
، والتعيين Expenses
:
public class ExpenseMap : IMap { public void Visit(ModelBuilder builder) { builder.Entity<Expense>() .ToTable("Expenses") .HasKey(x => x.Id); } }
واجهة IUnitOfWork
:
public interface IUnitOfWork : IDisposable { ITransaction BeginTransaction(IsolationLevel isolationLevel = IsolationLevel.Snapshot); void Add<T>(T obj) where T: class ; void Update<T>(T obj) where T : class; void Remove<T>(T obj) where T : class; IQueryable<T> Query<T>() where T : class; void Commit(); Task CommitAsync(); void Attach<T>(T obj) where T : class; }
تنفيذه عبارة عن غلاف لـ EF DbContext
:
public class EFUnitOfWork : IUnitOfWork { private DbContext _context; public EFUnitOfWork(DbContext context) { _context = context; } public DbContext Context => _context; public ITransaction BeginTransaction(IsolationLevel isolationLevel = IsolationLevel.Snapshot) { return new DbTransaction(_context.Database.BeginTransaction(isolationLevel)); } public void Add<T>(T obj) where T : class { var set = _context.Set<T>(); set.Add(obj); } public void Update<T>(T obj) where T : class { var set = _context.Set<T>(); set.Attach(obj); _context.Entry(obj).State = EntityState.Modified; } void IUnitOfWork.Remove<T>(T obj) { var set = _context.Set<T>(); set.Remove(obj); } public IQueryable<T> Query<T>() where T : class { return _context.Set<T>(); } public void Commit() { _context.SaveChanges(); } public async Task CommitAsync() { await _context.SaveChangesAsync(); } public void Attach<T>(T newUser) where T : class { var set = _context.Set<T>(); set.Attach(newUser); } public void Dispose() { _context = null; } }
لن يتم استخدام واجهة ITransaction
المنفذة في هذا التطبيق:
public interface ITransaction : IDisposable { void Commit(); void Rollback(); }
يؤدي تنفيذه ببساطة إلى إنهاء معاملة EF
:
public class DbTransaction : ITransaction { private readonly IDbContextTransaction _efTransaction; public DbTransaction(IDbContextTransaction efTransaction) { _efTransaction = efTransaction; } public void Commit() { _efTransaction.Commit(); } public void Rollback() { _efTransaction.Rollback(); } public void Dispose() { _efTransaction.Dispose(); } }
أيضًا في هذه المرحلة ، بالنسبة لاختبارات الوحدة ، هناك حاجة إلى واجهة ISecurityContext
، والتي تحدد المستخدم الحالي لواجهة برمجة التطبيقات (المشروع هو Expenses.Security
):
public interface ISecurityContext { User User { get; } bool IsAdministrator { get; } }
بعد ذلك ، تحتاج إلى تحديد واجهة وتنفيذ معالج الاستعلام ، والذي سيحتوي على كل منطق الأعمال للتعامل مع التكاليف - في حالتنا ، IExpensesQueryProcessor
و ExpensesQueryProcessor
:
public interface IExpensesQueryProcessor { IQueryable<Expense> Get(); Expense Get(int id); Task<Expense> Create(CreateExpenseModel model); Task<Expense> Update(int id, UpdateExpenseModel model); Task Delete(int id); } public class ExpensesQueryProcessor : IExpensesQueryProcessor { public IQueryable<Expense> Get() { throw new NotImplementedException(); } public Expense Get(int id) { throw new NotImplementedException(); } public Task<Expense> Create(CreateExpenseModel model) { throw new NotImplementedException(); } public Task<Expense> Update(int id, UpdateExpenseModel model) { throw new NotImplementedException(); } public Task Delete(int id) { throw new NotImplementedException(); } }
الخطوة التالية هي تكوين مجموعة Expenses.Queries.Tests
. لقد قمت بتثبيت المكتبات التالية:
- موك
- FluentAssertions
ثم في تجميع Expenses.Queries.Tests
، الاستفسارات ، الاختبارات ، نحدد تركيبات اختبارات الوحدة ونصف اختبارات الوحدة لدينا:
public class ExpensesQueryProcessorTests { private Mock<IUnitOfWork> _uow; private List<Expense> _expenseList; private IExpensesQueryProcessor _query; private Random _random; private User _currentUser; private Mock<ISecurityContext> _securityContext; public ExpensesQueryProcessorTests() { _random = new Random(); _uow = new Mock<IUnitOfWork>(); _expenseList = new List<Expense>(); _uow.Setup(x => x.Query<Expense>()).Returns(() => _expenseList.AsQueryable()); _currentUser = new User{Id = _random.Next()}; _securityContext = new Mock<ISecurityContext>(MockBehavior.Strict); _securityContext.Setup(x => x.User).Returns(_currentUser); _securityContext.Setup(x => x.IsAdministrator).Returns(false); _query = new ExpensesQueryProcessor(_uow.Object, _securityContext.Object); } [Fact] public void GetShouldReturnAll() { _expenseList.Add(new Expense{UserId = _currentUser.Id}); var result = _query.Get().ToList(); result.Count.Should().Be(1); } [Fact] public void GetShouldReturnOnlyUserExpenses() { _expenseList.Add(new Expense { UserId = _random.Next() }); _expenseList.Add(new Expense { UserId = _currentUser.Id }); var result = _query.Get().ToList(); result.Count().Should().Be(1); result[0].UserId.Should().Be(_currentUser.Id); } [Fact] public void GetShouldReturnAllExpensesForAdministrator() { _securityContext.Setup(x => x.IsAdministrator).Returns(true); _expenseList.Add(new Expense { UserId = _random.Next() }); _expenseList.Add(new Expense { UserId = _currentUser.Id }); var result = _query.Get(); result.Count().Should().Be(2); } [Fact] public void GetShouldReturnAllExceptDeleted() { _expenseList.Add(new Expense { UserId = _currentUser.Id }); _expenseList.Add(new Expense { UserId = _currentUser.Id, IsDeleted = true}); var result = _query.Get(); result.Count().Should().Be(1); } [Fact] public void GetShouldReturnById() { var expense = new Expense { Id = _random.Next(), UserId = _currentUser.Id }; _expenseList.Add(expense); var result = _query.Get(expense.Id); result.Should().Be(expense); } [Fact] public void GetShouldThrowExceptionIfExpenseOfOtherUser() { var expense = new Expense { Id = _random.Next(), UserId = _random.Next() }; _expenseList.Add(expense); Action get = () => { _query.Get(expense.Id); }; get.ShouldThrow<NotFoundException>(); } [Fact] public void GetShouldThrowExceptionIfItemIsNotFoundById() { var expense = new Expense { Id = _random.Next(), UserId = _currentUser.Id }; _expenseList.Add(expense); Action get = () => { _query.Get(_random.Next()); }; get.ShouldThrow<NotFoundException>(); } [Fact] public void GetShouldThrowExceptionIfUserIsDeleted() { var expense = new Expense { Id = _random.Next(), UserId = _currentUser.Id, IsDeleted = true}; _expenseList.Add(expense); Action get = () => { _query.Get(expense.Id); }; get.ShouldThrow<NotFoundException>(); } [Fact] public async Task CreateShouldSaveNew() { var model = new CreateExpenseModel { Description = _random.Next().ToString(), Amount = _random.Next(), Comment = _random.Next().ToString(), Date = DateTime.Now }; var result = await _query.Create(model); result.Description.Should().Be(model.Description); result.Amount.Should().Be(model.Amount); result.Comment.Should().Be(model.Comment); result.Date.Should().BeCloseTo(model.Date); result.UserId.Should().Be(_currentUser.Id); _uow.Verify(x => x.Add(result)); _uow.Verify(x => x.CommitAsync()); } [Fact] public async Task UpdateShouldUpdateFields() { var user = new Expense {Id = _random.Next(), UserId = _currentUser.Id}; _expenseList.Add(user); var model = new UpdateExpenseModel { Comment = _random.Next().ToString(), Description = _random.Next().ToString(), Amount = _random.Next(), Date = DateTime.Now }; var result = await _query.Update(user.Id, model); result.Should().Be(user); result.Description.Should().Be(model.Description); result.Amount.Should().Be(model.Amount); result.Comment.Should().Be(model.Comment); result.Date.Should().BeCloseTo(model.Date); _uow.Verify(x => x.CommitAsync()); } [Fact] public void UpdateShoudlThrowExceptionIfItemIsNotFound() { Action create = () => { var result = _query.Update(_random.Next(), new UpdateExpenseModel()).Result; }; create.ShouldThrow<NotFoundException>(); } [Fact] public async Task DeleteShouldMarkAsDeleted() { var user = new Expense() { Id = _random.Next(), UserId = _currentUser.Id}; _expenseList.Add(user); await _query.Delete(user.Id); user.IsDeleted.Should().BeTrue(); _uow.Verify(x => x.CommitAsync()); } [Fact] public async Task DeleteShoudlThrowExceptionIfItemIsNotBelongTheUser() { var expense = new Expense() { Id = _random.Next(), UserId = _random.Next() }; _expenseList.Add(expense); Action execute = () => { _query.Delete(expense.Id).Wait(); }; execute.ShouldThrow<NotFoundException>(); } [Fact] public void DeleteShoudlThrowExceptionIfItemIsNotFound() { Action execute = () => { _query.Delete(_random.Next()).Wait(); }; execute.ShouldThrow<NotFoundException>(); }
بعد وصف اختبارات الوحدة ، يتم وصف تنفيذ معالج الاستعلام:
public class ExpensesQueryProcessor : IExpensesQueryProcessor { private readonly IUnitOfWork _uow; private readonly ISecurityContext _securityContext; public ExpensesQueryProcessor(IUnitOfWork uow, ISecurityContext securityContext) { _uow = uow; _securityContext = securityContext; } public IQueryable<Expense> Get() { var query = GetQuery(); return query; } private IQueryable<Expense> GetQuery() { var q = _uow.Query<Expense>() .Where(x => !x.IsDeleted); if (!_securityContext.IsAdministrator) { var userId = _securityContext.User.Id; q = q.Where(x => x.UserId == userId); } return q; } public Expense Get(int id) { var user = GetQuery().FirstOrDefault(x => x.Id == id); if (user == null) { throw new NotFoundException("Expense is not found"); } return user; } public async Task<Expense> Create(CreateExpenseModel model) { var item = new Expense { UserId = _securityContext.User.Id, Amount = model.Amount, Comment = model.Comment, Date = model.Date, Description = model.Description, }; _uow.Add(item); await _uow.CommitAsync(); return item; } public async Task<Expense> Update(int id, UpdateExpenseModel model) { var expense = GetQuery().FirstOrDefault(x => x.Id == id); if (expense == null) { throw new NotFoundException("Expense is not found"); } expense.Amount = model.Amount; expense.Comment = model.Comment; expense.Description = model.Description; expense.Date = model.Date; await _uow.CommitAsync(); return expense; } public async Task Delete(int id) { var user = GetQuery().FirstOrDefault(u => u.Id == id); if (user == null) { throw new NotFoundException("Expense is not found"); } if (user.IsDeleted) return; user.IsDeleted = true; await _uow.CommitAsync(); } }
بمجرد أن يصبح منطق الأعمال جاهزًا ، أبدأ في كتابة اختبارات تكامل API لتحديد عقد API.
الخطوة الأولى هي إعداد مشروع Expenses.Api.IntegrationTests
- تثبيت حزم nuget:
- FluentAssertions
- موك
- Microsoft.AspNetCore.TestHost
- قم بإعداد هيكل المشروع
- قم بإنشاء CollectionDefinition بمساعدة نحدد من خلالها المورد الذي سيتم إنشاؤه في بداية كل تشغيل اختباري وسيتم إتلافه في نهاية كل تشغيل اختباري.
[CollectionDefinition("ApiCollection")] public class DbCollection : ICollectionFixture<ApiServer> { } ~~~ And define our test server and the client to it with the already authenticated user by default:
ApiServer للفئة العامة: قابل للتخصيص {public const string Username = “admin” ؛ سلسلة const عامة Password = "admin" ؛
private IConfigurationRoot _config; public ApiServer() { _config = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json") .Build(); Server = new TestServer(new WebHostBuilder().UseStartup<Startup>()); Client = GetAuthenticatedClient(Username, Password); } public HttpClient GetAuthenticatedClient(string username, string password) { var client = Server.CreateClient(); var response = client.PostAsync("/api/Login/Authenticate", new JsonContent(new LoginModel {Password = password, Username = username})).Result; response.EnsureSuccessStatusCode(); var data = JsonConvert.DeserializeObject<UserWithTokenModel>(response.Content.ReadAsStringAsync().Result); client.DefaultRequestHeaders.Add("Authorization", "Bearer " + data.Token); return client; } public HttpClient Client { get; private set; } public TestServer Server { get; private set; } public void Dispose() { if (Client != null) { Client.Dispose(); Client = null; } if (Server != null) { Server.Dispose(); Server = null; } } } ~~~
لتسهيل التعامل مع طلبات HTTP
في اختبارات التكامل ، كتبت مساعدًا:
public class HttpClientWrapper { private readonly HttpClient _client; public HttpClientWrapper(HttpClient client) { _client = client; } public HttpClient Client => _client; public async Task<T> PostAsync<T>(string url, object body) { var response = await _client.PostAsync(url, new JsonContent(body)); response.EnsureSuccessStatusCode(); var respnoseText = await response.Content.ReadAsStringAsync(); var data = JsonConvert.DeserializeObject<T>(respnoseText); return data; } public async Task PostAsync(string url, object body) { var response = await _client.PostAsync(url, new JsonContent(body)); response.EnsureSuccessStatusCode(); } public async Task<T> PutAsync<T>(string url, object body) { var response = await _client.PutAsync(url, new JsonContent(body)); response.EnsureSuccessStatusCode(); var respnoseText = await response.Content.ReadAsStringAsync(); var data = JsonConvert.DeserializeObject<T>(respnoseText); return data; } }
في هذه المرحلة ، أحتاج إلى تحديد عقد REST API لكل كيان ، وسأكتبه لتغطية نفقات REST API:
URL | طريقة | نوع الجسم | نوع النتيجة | وصف |
---|---|---|---|---|
مصروف | احصل على | - | DataResult <ExpenseModel> | احصل على جميع النفقات مع الاستخدام المحتمل للفلاتر وأدوات الفرز في "أوامر" معلمة طلب البحث |
المصاريف / {id} | احصل على | - | نموذج المصاريف | الحصول على حساب بواسطة معرف |
نفقات | بريد | CreateExpenseModel | نموذج المصاريف | إنشاء سجل حساب جديد |
المصاريف / {id} | وضع | UpdateExpenseModel | نموذج المصاريف | تحديث حساب موجود |
عندما تطلب قائمة بالتكاليف ، يمكنك تطبيق أوامر تصفية وفرز مختلفة باستخدام مكتبة AutoQueryable. مثال على استعلام باستخدام التصفية والفرز:
/expenses?commands=take=25%26amount%3E=12%26orderbydesc=date

قيمة معلمة أوامر فك التشفير هي take=25&amount>=12&orderbydesc=date
. حتى نتمكن من العثور على أجزاء الترحيل والتصفية والفرز في الاستعلام. جميع خيارات الاستعلام تشبه إلى حد بعيد بناء جملة OData ، ولكن لسوء الحظ ، فإن OData ليس جاهزًا بعد لـ .NET Core ، لذلك أنا أستخدم مكتبة أخرى مفيدة.
يوضح الجزء السفلي جميع النماذج المستخدمة في واجهة برمجة التطبيقات هذه:
public class DataResult<T> { public T[] Data { get; set; } public int Total { get; set; } } public class ExpenseModel { public int Id { get; set; } public DateTime Date { get; set; } public string Description { get; set; } public decimal Amount { get; set; } public string Comment { get; set; } public int UserId { get; set; } public string Username { get; set; } } public class CreateExpenseModel { [Required] public DateTime Date { get; set; } [Required] public string Description { get; set; } [Required] [Range(0.01, int.MaxValue)] public decimal Amount { get; set; } [Required] public string Comment { get; set; } } public class UpdateExpenseModel { [Required] public DateTime Date { get; set; } [Required] public string Description { get; set; } [Required] [Range(0.01, int.MaxValue)] public decimal Amount { get; set; } [Required] public string Comment { get; set; } }
يستخدم الطرازان CreateExpenseModel
و UpdateExpenseModel
سمات التعليقات التوضيحية للبيانات لإجراء فحوصات بسيطة على مستوى REST API من خلال السمات.
بعد ذلك ، لكل طريقة HTTP
، يتم إنشاء مجلد منفصل في المشروع ويتم إنشاء الملفات الموجودة فيه بواسطة تركيبات لكل طريقة HTTP
يدعمها المورد:
تنفيذ اختبار التكامل للحصول على قائمة المصروفات:
[Collection("ApiCollection")] public class GetListShould { private readonly ApiServer _server; private readonly HttpClient _client; public GetListShould(ApiServer server) { _server = server; _client = server.Client; } public static async Task<DataResult<ExpenseModel>> Get(HttpClient client) { var response = await client.GetAsync($"api/Expenses"); response.EnsureSuccessStatusCode(); var responseText = await response.Content.ReadAsStringAsync(); var items = JsonConvert.DeserializeObject<DataResult<ExpenseModel>>(responseText); return items; } [Fact] public async Task ReturnAnyList() { var items = await Get(_client); items.Should().NotBeNull(); } }
تنفيذ اختبار التكامل للحصول على بيانات المصاريف حسب المعرف:
[Collection("ApiCollection")] public class GetItemShould { private readonly ApiServer _server; private readonly HttpClient _client; private Random _random; public GetItemShould(ApiServer server) { _server = server; _client = _server.Client; _random = new Random(); } [Fact] public async Task ReturnItemById() { var item = await new PostShould(_server).CreateNew(); var result = await GetById(_client, item.Id); result.Should().NotBeNull(); } public static async Task<ExpenseModel> GetById(HttpClient client, int id) { var response = await client.GetAsync(new Uri($"api/Expenses/{id}", UriKind.Relative)); response.EnsureSuccessStatusCode(); var result = await response.Content.ReadAsStringAsync(); return JsonConvert.DeserializeObject<ExpenseModel>(result); } [Fact] public async Task ShouldReturn404StatusIfNotFound() { var response = await _client.GetAsync(new Uri($"api/Expenses/-1", UriKind.Relative)); response.StatusCode.ShouldBeEquivalentTo(HttpStatusCode.NotFound); } }
تنفيذ اختبار التكامل لإنشاء حساب:
[Collection("ApiCollection")] public class PostShould { private readonly ApiServer _server; private readonly HttpClientWrapper _client; private Random _random; public PostShould(ApiServer server) { _server = server; _client = new HttpClientWrapper(_server.Client); _random = new Random(); } [Fact] public async Task<ExpenseModel> CreateNew() { var requestItem = new CreateExpenseModel() { Amount = _random.Next(), Comment = _random.Next().ToString(), Date = DateTime.Now.AddMinutes(-15), Description = _random.Next().ToString() }; var createdItem = await _client.PostAsync<ExpenseModel>("api/Expenses", requestItem); createdItem.Id.Should().BeGreaterThan(0); createdItem.Amount.Should().Be(requestItem.Amount); createdItem.Comment.Should().Be(requestItem.Comment); createdItem.Date.Should().Be(requestItem.Date); createdItem.Description.Should().Be(requestItem.Description); createdItem.Username.Should().Be("admin admin"); return createdItem; } }
تنفيذ اختبار التكامل لتغيير المصروفات:
[Collection("ApiCollection")] public class PutShould { private readonly ApiServer _server; private readonly HttpClientWrapper _client; private readonly Random _random; public PutShould(ApiServer server) { _server = server; _client = new HttpClientWrapper(_server.Client); _random = new Random(); } [Fact] public async Task UpdateExistingItem() { var item = await new PostShould(_server).CreateNew(); var requestItem = new UpdateExpenseModel { Date = DateTime.Now, Description = _random.Next().ToString(), Amount = _random.Next(), Comment = _random.Next().ToString() }; await _client.PutAsync<ExpenseModel>($"api/Expenses/{item.Id}", requestItem); var updatedItem = await GetItemShould.GetById(_client.Client, item.Id); updatedItem.Date.Should().Be(requestItem.Date); updatedItem.Description.Should().Be(requestItem.Description); updatedItem.Amount.Should().Be(requestItem.Amount); updatedItem.Comment.Should().Contain(requestItem.Comment); } }
تنفيذ اختبار التكامل لإزالة المصاريف:
[Collection("ApiCollection")] public class DeleteShould { private readonly ApiServer _server; private readonly HttpClient _client; public DeleteShould(ApiServer server) { _server = server; _client = server.Client; } [Fact] public async Task DeleteExistingItem() { var item = await new PostShould(_server).CreateNew(); var response = await _client.DeleteAsync(new Uri($"api/Expenses/{item.Id}", UriKind.Relative)); response.EnsureSuccessStatusCode(); } }
في هذه المرحلة ، قمنا بتعريف عقد REST API بشكل كامل ويمكنني الآن البدء في تنفيذه على أساس ASP.NET Core.
تنفيذ API
اعداد مصاريف المشروع. لهذا ، أحتاج إلى تثبيت المكتبات التالية:
- AutoMapper
- AutoQueryable.AspNetCore.Filter
- Microsoft.ApplicationInsights.AspNetCore
- Microsoft.EntityFrameworkCore.SqlServer
- Microsoft.EntityFrameworkCore.SqlServer.Design
- Microsoft.EntityFrameworkCore. أدوات
- Swashbuckle.AspNetCore
بعد ذلك ، تحتاج إلى البدء في إنشاء الترحيل الأولي لقاعدة البيانات عن طريق فتح وحدة تحكم مدير الحزمة ، والتبديل إلى مشروع Expenses.Data.Access
(لأن سياق EF
يقع هناك) وتشغيل الأمر Add-Migration InitialCreate
:
في الخطوة التالية ، قم بإعداد ملف التكوين appsettings.json مقدمًا ، والذي سيظل بحاجة بعد الإعداد إلى نسخه في Expenses.Api.IntegrationTests
لأنه من هناك ، سنقوم بتشغيل مثيل اختبار API.
{ "Logging": { "IncludeScopes": false, "LogLevel": { "Default": "Debug", "System": "Information", "Microsoft": "Information" } }, "Data": { "main": "Data Source=.; Initial Catalog=expenses.main; Integrated Security=true; Max Pool Size=1000; Min Pool Size=12; Pooling=True;" }, "ApplicationInsights": { "InstrumentationKey": "Your ApplicationInsights key" } }
يتم إنشاء قسم التسجيل تلقائيًا. أضفت قسم Data
لتخزين سلسلة الاتصال بقاعدة البيانات ومفتاح ApplicationInsights
الخاص بي.
تكوين التطبيق
يجب عليك تكوين خدمات مختلفة متاحة في تطبيقنا:
تشغيل ApplicationInsights
: services.AddApplicationInsightsTelemetry(Configuration);
سجل خدماتك من خلال مكالمة: ContainerSetup.Setup(services, Configuration);
ContainerSetup
عبارة عن فئة تم إنشاؤها بحيث لا نضطر إلى تخزين جميع تسجيلات الخدمة في فئة Startup
. يقع الفصل في مجلد IoC الخاص بمشروع المصاريف:
public static class ContainerSetup { public static void Setup(IServiceCollection services, IConfigurationRoot configuration) { AddUow(services, configuration); AddQueries(services); ConfigureAutoMapper(services); ConfigureAuth(services); } private static void ConfigureAuth(IServiceCollection services) { services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>(); services.AddScoped<ITokenBuilder, TokenBuilder>(); services.AddScoped<ISecurityContext, SecurityContext>(); } private static void ConfigureAutoMapper(IServiceCollection services) { var mapperConfig = AutoMapperConfigurator.Configure(); var mapper = mapperConfig.CreateMapper(); services.AddSingleton(x => mapper); services.AddTransient<IAutoMapper, AutoMapperAdapter>(); } private static void AddUow(IServiceCollection services, IConfigurationRoot configuration) { var connectionString = configuration["Data:main"]; services.AddEntityFrameworkSqlServer(); services.AddDbContext<MainDbContext>(options => options.UseSqlServer(connectionString)); services.AddScoped<IUnitOfWork>(ctx => new EFUnitOfWork(ctx.GetRequiredService<MainDbContext>())); services.AddScoped<IActionTransactionHelper, ActionTransactionHelper>(); services.AddScoped<UnitOfWorkFilterAttribute>(); } private static void AddQueries(IServiceCollection services) { var exampleProcessorType = typeof(UsersQueryProcessor); var types = (from t in exampleProcessorType.GetTypeInfo().Assembly.GetTypes() where t.Namespace == exampleProcessorType.Namespace && t.GetTypeInfo().IsClass && t.GetTypeInfo().GetCustomAttribute<CompilerGeneratedAttribute>() == null select t).ToArray(); foreach (var type in types) { var interfaceQ = type.GetTypeInfo().GetInterfaces().First(); services.AddScoped(interfaceQ, type); } } }
تتحدث جميع الكودات الموجودة في هذه الفئة تقريبًا عن نفسها ، لكني أرغب في الخوض في طريقة ConfigureAutoMapper
أكثر من ذلك بقليل.
private static void ConfigureAutoMapper(IServiceCollection services) { var mapperConfig = AutoMapperConfigurator.Configure(); var mapper = mapperConfig.CreateMapper(); services.AddSingleton(x => mapper); services.AddTransient<IAutoMapper, AutoMapperAdapter>(); }
تستخدم هذه الطريقة فئة المساعد للعثور على جميع التعيينات بين النماذج والكيانات والعكس صحيح والحصول على واجهة IMapper
لإنشاء غلاف IAutoMapper
الذي سيتم استخدامه في وحدات التحكم. لا يوجد شيء مميز حول هذا الغلاف - فهو يوفر فقط واجهة ملائمة لأساليب AutoMapper
.
public class AutoMapperAdapter : IAutoMapper { private readonly IMapper _mapper; public AutoMapperAdapter(IMapper mapper) { _mapper = mapper; } public IConfigurationProvider Configuration => _mapper.ConfigurationProvider; public T Map<T>(object objectToMap) { return _mapper.Map<T>(objectToMap); } public TResult[] Map<TSource, TResult>(IEnumerable<TSource> sourceQuery) { return sourceQuery.Select(x => _mapper.Map<TResult>(x)).ToArray(); } public IQueryable<TResult> Map<TSource, TResult>(IQueryable<TSource> sourceQuery) { return sourceQuery.ProjectTo<TResult>(_mapper.ConfigurationProvider); } public void Map<TSource, TDestination>(TSource source, TDestination destination) { _mapper.Map(source, destination); } }
لتكوين AutoMapper ، يتم استخدام فئة المساعد ، وتتمثل مهمتها في البحث عن التعيينات لفئات مساحة أسماء محددة. توجد جميع التعيينات في مجلد المصاريف / الخرائط:
public static class AutoMapperConfigurator { private static readonly object Lock = new object(); private static MapperConfiguration _configuration; public static MapperConfiguration Configure() { lock (Lock) { if (_configuration != null) return _configuration; var thisType = typeof(AutoMapperConfigurator); var configInterfaceType = typeof(IAutoMapperTypeConfigurator); var configurators = thisType.GetTypeInfo().Assembly.GetTypes() .Where(x => !string.IsNullOrWhiteSpace(x.Namespace)) // ReSharper disable once AssignNullToNotNullAttribute .Where(x => x.Namespace.Contains(thisType.Namespace)) .Where(x => x.GetTypeInfo().GetInterface(configInterfaceType.Name) != null) .Select(x => (IAutoMapperTypeConfigurator)Activator.CreateInstance(x)) .ToArray(); void AggregatedConfigurator(IMapperConfigurationExpression config) { foreach (var configurator in configurators) { configurator.Configure(config); } } _configuration = new MapperConfiguration(AggregatedConfigurator); return _configuration; } } }
All mappings must implement a specific interface:
public interface IAutoMapperTypeConfigurator { void Configure(IMapperConfigurationExpression configuration); }
An example of mapping from entity to model:
public class ExpenseMap : IAutoMapperTypeConfigurator { public void Configure(IMapperConfigurationExpression configuration) { var map = configuration.CreateMap<Expense, ExpenseModel>(); map.ForMember(x => x.Username, x => x.MapFrom(y => y.User.FirstName + " " + y.User.LastName)); } }
Also, in the Startup.ConfigureServices
method, authentication through JWT Bearer tokens is configured:
services.AddAuthorization(auth => { auth.AddPolicy("Bearer", new AuthorizationPolicyBuilder() .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) .RequireAuthenticatedUser().Build()); });
And the services registered the implementation of ISecurityContext
, which will actually be used to determine the current user:
public class SecurityContext : ISecurityContext { private readonly IHttpContextAccessor _contextAccessor; private readonly IUnitOfWork _uow; private User _user; public SecurityContext(IHttpContextAccessor contextAccessor, IUnitOfWork uow) { _contextAccessor = contextAccessor; _uow = uow; } public User User { get { if (_user != null) return _user; var username = _contextAccessor.HttpContext.User.Identity.Name; _user = _uow.Query<User>() .Where(x => x.Username == username) .Include(x => x.Roles) .ThenInclude(x => x.Role) .FirstOrDefault(); if (_user == null) { throw new UnauthorizedAccessException("User is not found"); } return _user; } } public bool IsAdministrator { get { return User.Roles.Any(x => x.Role.Name == Roles.Administrator); } } }
Also, we changed the default MVC registration a little in order to use a custom error filter to convert exceptions to the right error codes:
services.AddMvc(options => { options.Filters.Add(new ApiExceptionFilter()); });
Implementing the ApiExceptionFilter
filter:
public class ApiExceptionFilter : ExceptionFilterAttribute { public override void OnException(ExceptionContext context) { if (context.Exception is NotFoundException) { // handle explicit 'known' API errors var ex = context.Exception as NotFoundException; context.Exception = null; context.Result = new JsonResult(ex.Message); context.HttpContext.Response.StatusCode = (int)HttpStatusCode.NotFound; } else if (context.Exception is BadRequestException) { // handle explicit 'known' API errors var ex = context.Exception as BadRequestException; context.Exception = null; context.Result = new JsonResult(ex.Message); context.HttpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest; } else if (context.Exception is UnauthorizedAccessException) { context.Result = new JsonResult(context.Exception.Message); context.HttpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized; } else if (context.Exception is ForbiddenException) { context.Result = new JsonResult(context.Exception.Message); context.HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; } base.OnException(context); } }
من المهم ألا تنسى Swagger
، من أجل الحصول على وصف ممتاز لواجهة برمجة التطبيقات لمطوري ASP.net الآخرين:
services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new Info {Title = "Expenses", Version = "v1"}); c.OperationFilter<AuthorizationHeaderParameterOperationFilter>(); });
يضيف أسلوب Startup.Configure
استدعاءًا إلى طريقة InitDatabase
، والتي تقوم تلقائيًا بترحيل قاعدة البيانات حتى آخر عملية ترحيل:
private void InitDatabase(IApplicationBuilder app) { using (var serviceScope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope()) { var context = serviceScope.ServiceProvider.GetService<MainDbContext>(); context.Database.Migrate(); } }
يتم تشغيل Swagger
فقط إذا كان التطبيق يعمل في بيئة التطوير ولا يتطلب مصادقة للوصول إليه:
app.UseSwagger(); app.UseSwaggerUI(c => { c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1"); });
بعد ذلك ، نقوم بتوصيل المصادقة (يمكن العثور على التفاصيل في المستودع):
ConfigureAuthentication(app);
في هذه المرحلة ، يمكنك إجراء اختبارات التكامل والتأكد من تجميع كل شيء ولكن لا شيء يعمل والانتقال إلى وحدة التحكم ExpensesController
.
ملاحظة: توجد جميع وحدات التحكم في مجلد المصاريف / الخادم ويتم تقسيمها بشكل مشروط إلى مجلدين: وحدات التحكم و RestApi. في المجلد ، تكون وحدات التحكم عبارة عن وحدات تحكم تعمل كوحدات تحكم في MVC القديم الجيد - أي إرجاع العلامات ، وفي وحدات التحكم RestApi و REST.
يجب عليك إنشاء فئة Expenses / Server / RestApi / ExpensesController وترثها من فئة وحدة التحكم:
public class ExpensesController : Controller { }
بعد ذلك ، قم بتكوين توجيه نوع ~ / api / Expenses
عن طريق تعليم الفئة بالسمة [Route ("api / [controller]")]
.
للوصول إلى مخطط الأعمال ومنطق الأعمال ، تحتاج إلى إدخال الخدمات التالية:
private readonly IExpensesQueryProcessor _query; private readonly IAutoMapper _mapper; public ExpensesController(IExpensesQueryProcessor query, IAutoMapper mapper) { _query = query; _mapper = mapper; }
في هذه المرحلة ، يمكنك البدء في تنفيذ الأساليب. الطريقة الأولى هي الحصول على قائمة المصروفات:
[HttpGet] [QueryaCollectionDefinitionbleResult] public IQueryable<ExpenseModel> Get() { var result = _query.Get(); var models = _mapper.Map<Expense, ExpenseModel>(result); return models; }
تنفيذ الطريقة بسيط للغاية ، نحصل على استعلام إلى قاعدة البيانات التي تم تعيينها في IQueryable <ExpenseModel>
من ExpensesQueryProcessor
، والتي بدورها تعود كنتيجة.
السمة المخصصة هنا هي QueryableResult
، والتي تستخدم مكتبة AutoQueryable
للتعامل مع الترحيل والتصفية والفرز على جانب الخادم. توجد السمة في مجلد Expenses/Filters
. نتيجة لذلك ، يقوم عامل التصفية هذا بإرجاع بيانات من نوع DataResult <ExpenseModel>
إلى عميل API.
public class QueryableResult : ActionFilterAttribute { public override void OnActionExecuted(ActionExecutedContext context) { if (context.Exception != null) return; dynamic query = ((ObjectResult)context.Result).Value; if (query == null) throw new Exception("Unable to retreive value of IQueryable from context result."); Type entityType = query.GetType().GenericTypeArguments[0]; var commands = context.HttpContext.Request.Query.ContainsKey("commands") ? context.HttpContext.Request.Query["commands"] : new StringValues(); var data = QueryableHelper.GetAutoQuery(commands, entityType, query, new AutoQueryableProfile {UnselectableProperties = new string[0]}); var total = System.Linq.Queryable.Count(query); context.Result = new OkObjectResult(new DataResult{Data = data, Total = total}); } }
أيضًا ، لنلقِ نظرة على تنفيذ طريقة Post ، وإنشاء تدفق:
[HttpPost] [ValidateModel] public async Task<ExpenseModel> Post([FromBody]CreateExpenseModel requestModel) { var item = await _query.Create(requestModel); var model = _mapper.Map<ExpenseModel>(item); return model; }
هنا ، يجب الانتباه إلى السمة ValidateModel
، التي تقوم بالتحقق البسيط من بيانات الإدخال وفقًا لسمات التعليق التوضيحي للبيانات ويتم ذلك من خلال فحوصات MVC المضمنة.
public class ValidateModelAttribute : ActionFilterAttribute { public override void OnActionExecuting(ActionExecutingContext context) { if (!context.ModelState.IsValid) { context.Result = new BadRequestObjectResult(context.ModelState); } } }
الكود الكامل ExpensesController
:
[Route("api/[controller]")] public class ExpensesController : Controller { private readonly IExpensesQueryProcessor _query; private readonly IAutoMapper _mapper; public ExpensesController(IExpensesQueryProcessor query, IAutoMapper mapper) { _query = query; _mapper = mapper; } [HttpGet] [QueryableResult] public IQueryable<ExpenseModel> Get() { var result = _query.Get(); var models = _mapper.Map<Expense, ExpenseModel>(result); return models; } [HttpGet("{id}")] public ExpenseModel Get(int id) { var item = _query.Get(id); var model = _mapper.Map<ExpenseModel>(item); return model; } [HttpPost] [ValidateModel] public async Task<ExpenseModel> Post([FromBody]CreateExpenseModel requestModel) { var item = await _query.Create(requestModel); var model = _mapper.Map<ExpenseModel>(item); return model; } [HttpPut("{id}")] [ValidateModel] public async Task<ExpenseModel> Put(int id, [FromBody]UpdateExpenseModel requestModel) { var item = await _query.Update(id, requestModel); var model = _mapper.Map<ExpenseModel>(item); return model; } [HttpDelete("{id}")] public async Task Delete(int id) { await _query.Delete(id); } }
خاتمة
سأبدأ بالمشكلات: المشكلة الرئيسية هي تعقيد التكوين الأولي للحل وفهم طبقات التطبيق ، ولكن مع التعقيد المتزايد للتطبيق ، فإن تعقيد النظام لم يتغير تقريبًا ، وهو أمر كبير زائد عند مرافقة مثل هذا النظام. ومن المهم جدًا أن يكون لدينا واجهة برمجة تطبيقات بها مجموعة من اختبارات التكامل ومجموعة كاملة من اختبارات الوحدة لمنطق الأعمال. منطق الأعمال منفصل تمامًا عن تقنية الخادم المستخدمة ويمكن اختباره بالكامل. هذا الحل مناسب تمامًا للأنظمة ذات واجهة برمجة تطبيقات معقدة ومنطق أعمال معقد.
إذا كنت تبحث عن إنشاء تطبيق Angular يستهلك واجهة برمجة التطبيقات الخاصة بك ، فراجع Angular 5 و ASP.NET Core بواسطة زميلك Toptaler Pablo Albella.