ASP.NET Core Web API Course

Build RESTful services using ASP.NET Core 3.1

تعلم برمجة الـ RESTful services أو الـ Web APIs بإستخدام ASP.NET Core بطريقة مفصلة وتدريجية

الصفحة الرئيسية
خدمة RSS

للتواصل

3. Models

2020-04-11






مشكلة إنشاء كنترولر بإستخدام أداة dotnet-aspnet-codegenerator هي إعتماد الكنترولر على الـ database context الخاص بـ entity framework ولو أردنا فيما بعد الإستغناء عن Entity Framework أو تغيير قاعدة البيانات الى Mongo أو Cosmos DB أو غيرها سيصعب علينا ذلك بسبب الإعتمادية العالية high coupling الموجوده في الكود.

Repository Pattern

سنعدل على الكود لتقل الإعتمادية على backend معيّن بإستخدام نموذج البناء هذا وذلك بإتباع الخطوات التالية:

قم بإنشاء مجلد جديد بإسم Data وبداخله ملف بإسم IRepository.cs:

using System.Collections.Generic;
using System.Threading.Tasks;

namespace aspnetcorewebapiproject.Data
{
    public interface IRepository<T> where T : class
    { 
        Task<List<T>> GetAllAsync();
        Task<T> GetAsync(int id);
        Task<int> InsertAsync(T entity);
        Task<int> UpdateAsync(T entity);        
        Task<T> DeleteAsync(int id);
    } 
}

من الملاحظ أنه لا يوجد في أسماء الدوال في الـ interface ما يدل على أنها متعلقة بالـ Employee. فلم نقل GetEmployeesِAsync بل GetAsync ولم نقل InsertEmployeeAsync بل InsertAsync وكذلك بقية الدوال، فنحن هنا جعلنا هذا الـ interface عام generic بدون تخصيص entity معينة.

والآن جاء دور إضافة الـ Repository الذي ستطبق هذا الـ interface. هنا يختلف الكود حسب قاعدة البيانات وإطار العمل framework المستخدم.

سوف ننشئ الآن repository خاص بـ Entity Framework بإسم EfRepository داخل مجلد Data وعلى النحو التالي:

using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;

namespace aspnetcorewebapiproject.Data
{
    public class EfRepository <TEntity, TContext> : IRepository<TEntity>
        where TEntity : class
        where TContext : DbContext
    {
        private TContext _context;
        private DbSet<TEntity> _table;

        public EfRepository(TContext context)
        {
            _context = context;
            _table = _context.Set<TEntity>();
        }

        public async Task<List<TEntity>> GetAllAsync()
        {
            return await _table.ToListAsync();
        }
        
        public async Task<TEntity> GetAsync(int id)
        {
            return await _table.FindAsync(id);
        }
                
        public async Task<int> InsertAsync(TEntity entity)
        {
            _table.Add(entity);            
            return await _context.SaveChangesAsync();            
        }

        public async Task<int> UpdateAsync(TEntity entity)
        {
            _context.Entry(entity).State = EntityState.Modified;
            return await _context.SaveChangesAsync();
        }

        public async Task<TEntity> DeleteAsync(int id)
        {
            var entity = await GetAsync(id);
            if (entity == null)            
                return null;            

            _table.Remove(entity);
            await _context.SaveChangesAsync();
            return entity;
        }
    }
}

الآن في EmployeesController سنعدل على الكود الذي يعتمد على MainDbContext مباشرة ونجعله يعتمد على EfRepository بدلاً منه عن طريق إتباع الخطوات التاليه:

نحذف أولاً هذا السطر:

private readonly MainDbContext _context;

ونضيف بدلاً منه السطر التالي:

Data.IRepository<Entities.Employee> _repo;

ويصبح الـ constructor بالشكل التالي:

public EmployeesController(Data.IRepository<Entities.Employee> repo)
{
	_repo = repo;
}

بالنسبة لـ ()GetEmployees فهنالك تغييران يجب علينا القيام بها، الأول هو أن نعيد Http Status Code مع البيانات المسترجعة وذلك ممكن بإحتواء البيانات داخل الدالة ()Ok. أما الثاني فهو إرجاع البيانات من الـ repo الجديد وليس من الـ MainDbContext السابق.

// GET: api/Employees
[HttpGet]
public async Task<ActionResult<IEnumerable<Employee>>> GetEmployees()
{
	return Ok( await _repo.GetAllAsync() );
}

()GetEmployee أيضاً ستقوم بإرجاع Http Status Code 200 في حالة وجدنا الموظف الذي يحمل نفس الـ Id، أما في حالة لم نجده فهي من الأساس ترجع القيمة Http Status Code 404 عن طريق الدالة ()NotFound وبذلك تصبح:

// GET: api/Employees/5
[HttpGet("{id}")]
public async Task<ActionResult<Employee>> GetEmployee(int id)
{
	var employee = await _repo.GetAsync(id);

	if (employee == null)
	{
		return NotFound();
	}

	return Ok( employee );
}

بالنسبة لـ PUT ففي حالة لم يكن هتلك مشكلة سيتم التحديث ونعيد ()NoContent، أما في حالة وجود مشكلة فإما تكون بسبب أن المعلومات الممره خاطئة فنعيد ()BadRequest أو لا يوجد موظف بهذا الـ Id وفي هذه الحالة نعيد ()NotFound أما في حالة وجود مشكلة في الـ backend نعيد Http Status Code 500:

// PUT: api/Employees/5
[HttpPut("{id}")]
public async Task<IActionResult> PutEmployee(int id, Employee employee)
{
	if (id != employee.Id)
	{
		return BadRequest();
	}

	try
	{
		await _repo.UpdateAsync(employee);
	}
	catch (Exception)
	{
		if (await _repo.GetAsync(id) == null)
		{
			return NotFound();
		}
		else
		{
			return StatusCode(StatusCodes.Status500InternalServerError);
		}
	}

	return NoContent();
}

وعند إضافة معلومات موظف جديد بإستخدام الأمر ()PostEmployee فإننا نعيد توجيه الـ request القادم الى ()GetEmployee:

// POST: api/Employees
[HttpPost]
public async Task<ActionResult<Employee>> PostEmployee(Employee employee)
{
	await _repo.InsertAsync(employee);

	return CreatedAtAction(nameof(GetEmployee), new { id = employee.Id }, employee);
}

وعندما نحذف موظفف فإن كان غير موجود في النظام نعيد ()NotFound وإذا موجود وتم حذف بياناته نعيد معلوماته والـ Http Status Code 200:

// DELETE: api/Employees/5
[HttpDelete("{id}")]
public async Task<ActionResult<Employee>> DeleteEmployee(int id)
{
	Employee employee = await _repo.DeleteAsync(id);

	if (employee == null)
	{
		return NotFound();
	}

	return Ok( employee );
}

والذي يتبقى علينا فعله في هذا الـ controller هو حذف الدالة ()EmployeeExists حيث لا حاجة لها.

في الأخير، يجب أن تكون EmployeeController على الشكل التالي:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.HttpsPolicy;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace aspnetcorewebapiproject
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllers();

            services.AddDbContext<DbContexts.MainDbContext>(
                options => options.UseSqlServer(Configuration.GetConnectionString("MainDbContext"))
                );

            services.AddScoped<Data.IRepository<Entities.Employee>, Data.EfRepository<Entities.Employee, DbContexts.MainDbContext>>();
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseHttpsRedirection();

            app.UseRouting();

            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
            });
        }
    }
}

والآن علينا إخبار ASP.NET Core أنه كل ما تم طلب

Data.IRepository<Entities.Employee>

فإننا نستبدله بـ object من نوع

Data.EfRepository<Entities.Employee, DbContexts.MainDbContext>

وذلك عن طريق الـ dependancy injection. ولنقوم بذلك، نضيف السطر التالي على نهاية الدالة ()ConfigureServices في Startup.cs:

services.AddScoped<Data.IRepository<Entities.Employee>, Data.EfRepository<Entities.Employee, DbContexts.MainDbContext>>();

Data Transfer Objects (DTOs)

حالياً الـ API التي أنشأناها تأخذ من المستخدم وتعيد له الـ database entity وتظهرها له بكامل محتوياتها وهذا أمر قد لا نرغبه في جميع الأحوال. فأحيانا نحتاج الى إعادة تشكيل البيانات manipulate المدخله أو المسترجعة ولذلك نستخدم ال DTOs.

سوف نقوم بتعديل بسيط على الـ Employee entity حيث سنضيف property جديدة بإسم LastModified وسنجعل Entity Framework مسؤولة عن إسناد قيمة لـ CreatedDate و LastModified عند إضافة سجل جديد وإسناد قيمة الى LastModified فقط عند التعديل على السجل.

ولعمل ذلك ننشئ أولاً interface جديد في مجلد Entities بحيث يكون إسم الملف IHasCreatedAndLastModifiedDates.cs ومحتواه:

using System;

namespace aspnetcorewebapiproject.Entities
{
    public interface IHasCreatedAndLastModifiedDates
    {
        DateTime LastModified { get; set; }
        DateTime CreatedDate { get; set; }
    }
}

ونجعل الـ Employee يرث منها وبذلك يكون:

public class Employee : IHasCreatedAndLastModifiedDates
{
	[Key]
	public int Id { get; set; }

	[Required]
	[StringLength(100)]
	public string FirstName { get; set; }
	
	[Required]
	[StringLength(100)]
	public string LastName { get; set; }
	
	public bool IsManager { get; set; }
	
	public int Salary { get; set; }
	
	[DataType(DataType.Date)]
	public DateTime EnrollmentDate { get; set; }
	
	public DateTime LastModified { get; set; }

	public DateTime CreatedDate { get; set; }
}

ونعدل الآن على الـ MainDbContext ونطلب منها أن تتابع الـ entities وهي الـ DbSet properties المعرفة فيها فمتى ما تم إضافة سجل جديد أو التعديل عليه نرى إذا كانت هذه الـ entity ترث من IHasCreatedAndLastModifiedDates أم لا. فإذا كانت الإجابة بنعم نقوم بالتعديل على الـ CreatedDate و LastModified جسب الحاجة:

    public class MainDbContext : DbContext
    {
        public DbSet<Entities.Employee> Employees { get; set; }

        public MainDbContext(DbContextOptions<MainDbContext> options)
            : base(options)
        {
            ChangeTracker.Tracked += OnEntityTracked;
            ChangeTracker.StateChanged += OnEntityStateChanged;
        }
        
        void OnEntityTracked(object sender, EntityTrackedEventArgs e)
        {
            if (!e.FromQuery && e.Entry.State == EntityState.Added 
                    && e.Entry.Entity is Entities.IHasCreatedAndLastModifiedDates entity)
            {
                entity.CreatedDate = DateTime.UtcNow;
                entity.LastModified = DateTime.UtcNow;
            }
        }

        void OnEntityStateChanged(object sender, EntityStateChangedEventArgs e)
        {
            if (e.NewState == EntityState.Modified 
                    && e.Entry.Entity is Entities.IHasCreatedAndLastModifiedDates entity)
                entity.LastModified = DateTime.UtcNow;
        }
    }

نضيف بعد ذلك الـ migration الجديد ثم نحدث قاعدة البيانات:

dotnet ef migrations add AutoPopulateCreatedAndLastModifiedDates
dotnet ef database update

وبإمكاننا بعد ذلك رؤية التعديلات التي تمت على قاعدة البيانات عن طريق Azure Data Studio:

AutoMapper

وللتحويل بين database entities والـ DTOs سنستخدم أداة AutoMapper. ولعمل ذلك نضيف الباقة أولاً:

dotnet add package AutoMapper.Extensions.Microsoft.DependencyInjection

ثم في نهاية الدالة ()ConfigureServices في Startup.cs نضيف التالي:

services.AddAutoMapper(typeof(Startup));

ويجب علينا الا ننسى إضافة الـ namespace في نفس الملف:

using AutoMapper;

ننشئ الآن مجلد بإسم Profiles ولكل entity ننشئ class جديد. وعلى ذلك ننشئ ملف جديد بإسم EmployeesProfile.cs:

using AutoMapper;

namespace aspnetcorewebapiproject.Profiles
{
    public class EmployeesProfile : Profile
    {
        public EmployeesProfile()
        {

        }
    }
}

نقوم أولاً بـوراثة inherit خصائص الـ Profile class من AutoMapper وفي الـ constructor نقول لـ AutoMapper طريقة التحويل من الـ entity الى الـ DTO والعكس.

ولتتمكن EmployeeController من الإستفادة مما قمنا به علينا القيام بالتالي في ملف Employee.cs:

إضافة الـ namespace:

using AutoMapper;

إضافة الـ field التالي:

private IMapper _mapper;

تعديل الـ constructor ليصبح على النحو التالي:

public EmployeesController(Data.IRepository<Entities.Employee> repo, IMapper mapper)
{
	_repo = repo;
	_mapper = mapper;
}

تفاصيل الموظف

نبدأ الآن بالـ DTO المتعلقة بالمعلومات المسترجعة، لو عدنا للـ Employee entity نلاحظ أنها تعيد لنا راتب salary الموظف والتاريخ الذي تم فيه إضافة السجل الى قاعدة البيانات CreatedDate أو تاريخ التعديل على السجل LastModified. ولنفترض أننا لا نريد إعادة هذه المعلومات للمستخدم هنا ولكننا سننشئ نظام آخر لإدارة الموارد البشرية يمكن له إستعراضها والتعديل عليها.

ننشئ أولاً مجلد جديد بإسم Models وبداخله مجلد آخر بإسم Employees وداخله ملف بإسم EmployeeDetailsDto.cs ومحتواه:

using System;

namespace aspnetcorewebapiproject.Models.Employees
{
    public class EmployeeDetailsDto
    {
        public int Id { get; set; }
        public string FirstName { get; set; }        
        public string LastName { get; set; }
        public bool IsManager { get; set; }
        public DateTime EnrollmentDate { get; set; }
    }
}

نعدل الأن على EmployeesProfile ونضيف السطر التالي في الـ constructor:

// Map from Employee to EmployeeDetailsDto
CreateMap<Entities.Employee, Models.Employees.EmployeeDetailsDto>();

بما أننا لم نقم بأي نوع من أنواع التخصيص customization فإن AutoMapper ستمر على كل property في Employee والبحث عن ما يقابلها في EmployeeDetailsDto وإسناد نفس القيمة اليها.

ونقوم بالتعديل على ()GetEmployees لتصبح:

// GET: api/Employees
[HttpGet]
public async Task<ActionResult<IEnumerable<EmployeeDetailsDto>>> GetEmployees()
{
	var employeeEntities = await _repo.GetAllAsync();
	var employeeDetailsDtos = _mapper.Map<List<EmployeeDetailsDto>>(employeeEntities);

	return Ok( employeeDetailsDtos );
}

ونجرب في Postman:

و ()GetEmployee تصبح:

// GET: api/Employees/5
[HttpGet("{id}")]
public async Task<ActionResult<EmployeeDetailsDto>> GetEmployee(int id)
{
	var employeeEntity = await _repo.GetAsync(id);

	if (employeeEntity == null)
	{
		return NotFound();
	}

	var employeeDetailsDto = _mapper.Map<EmployeeDetailsDto>(employeeEntity);

	return Ok( employeeDetailsDto );
}

وعندما نجربها في Postman تظهر لنا النتيجة المطلوبة:

إضافة موظف

ننشئ ملف جديد داخل المجلد Models/Employees بإسم EmployeeInsertDto.cs ومحتواه:

using System;
using System.ComponentModel.DataAnnotations;

namespace aspnetcorewebapiproject.Models.Employees
{
    public class EmployeeInsertDto
    {
        [Required]
        [StringLength(100)]
        public string FirstName { get; set; }
        
        [Required]
        [StringLength(100)]
        public string LastName { get; set; }
        
        public bool IsManager { get; set; }
        
        public int Salary { get; set; }
        
        [DataType(DataType.Date)]
        public DateTime EnrollmentDate { get; set; }
    }
}

نعدل الأن على EmployeesProfile ونضيف السطر التالي في نهاية الـ constructor:

// Map from EmployeeInsertDto to Employee
CreateMap<Models.Employees.EmployeeInsertDto, Entities.Employee>();

و ()PostEmployee تصبح:

// POST: api/Employees
[HttpPost]
public async Task<ActionResult<EmployeeDetailsDto>> PostEmployee(EmployeeInsertDto employeeInsertDto)
{
	if( !ModelState.IsValid )
		return BadRequest();

	var employeeEntity = _mapper.Map<Employee>(employeeInsertDto);

	await _repo.InsertAsync(employeeEntity);

	var employeeDetailsDto = _mapper.Map<EmployeeDetailsDto>(employeeEntity);

	return CreatedAtAction(nameof(GetEmployee), new { id = employeeDetailsDto.Id }, employeeDetailsDto);
}

وهذه نتيجة التجربة في Postman:

تعديل بيانات موظف

ننشئ ملف جديد داخل المجلد Models/Employees بإسم EmployeeUpdateDto.cs ومحتواه:


using System;
using System.ComponentModel.DataAnnotations;

namespace aspnetcorewebapiproject.Models.Employees
{
    public class EmployeeUpdateDto
    {
        [Required]
        public int Id { get; set; }

        [Required]
        [StringLength(100)]
        public string FirstName { get; set; }
        
        [Required]
        [StringLength(100)]
        public string LastName { get; set; }
        
        public bool IsManager { get; set; }
        
        public int Salary { get; set; }
        
        [DataType(DataType.Date)]
        public DateTime EnrollmentDate { get; set; }
    }
}

نعدل الأن على EmployeesProfile ونضيف السطر التالي في نهاية الـ constructor:

// Map from EmployeeUpdateDto to Employee
CreateMap<Models.Employees.EmployeeUpdateDto, Entities.Employee>(); 

و ()PutEmployee تصبح:

// PUT: api/Employees/5
[HttpPut("{id}")]
public async Task<IActionResult> PutEmployee(int id, EmployeeUpdateDto employeeUpdateDto)
{
	if ( (!ModelState.IsValid) || (id != employeeUpdateDto.Id) )
	{
		return BadRequest();
	}

	try
	{
		var employeeEntity = _mapper.Map<Employee>(employeeUpdateDto);
		await _repo.UpdateAsync(employeeEntity);
	}
	catch (Exception)
	{
		if (await _repo.GetAsync(id) == null)
		{
			return NotFound();
		}
		else
		{
			return StatusCode(StatusCodes.Status500InternalServerError);
		}
	}

	return NoContent();
}

وهذه نتيجة التجربة في Postman:

جرب إسترجاع معلومات هذا الموظف وسترى بأنها تعدلت بشكل صحيح.

حذف موظف

سنستفيد هنا مما تم إنشاؤه مسبقاً وكل ما علينا فعله هو التعديل على ()DeleteEmployee ليصبح كالتالي:

// DELETE: api/Employees/5
[HttpDelete("{id}")]
public async Task<ActionResult<EmployeeDetailsDto>> DeleteEmployee(int id)
{
	Employee employeeEntity = await _repo.DeleteAsync(id);

	if (employeeEntity == null)            
		return NotFound();            
	
	var employeeDetailsDto = _mapper.Map<EmployeeDetailsDto>(employeeEntity);

	return Ok( employeeDetailsDto );
}