RestfulApi 學習筆記——分頁和排序(五)

前言

分頁和排序時一些非常常規的操作,同樣也有一些我們注意的點。

正文

分頁

先來談及分頁。

看下前端傳遞的參數。

public class EmployeeDtoParameters
{
	private const int MaxPageSize = 20;
	public string Gender { get; set; }
	public string Q { get; set; }
	public int PageNumber { get; set; } = 1;
	private int _pageSize = 5;

	public int PageSize
	{
		get => _pageSize;
		set => _pageSize = (value > MaxPageSize) ? MaxPageSize : value;
	}
}

第一個注意的地方就是避免一些攻擊,比如說人家設置_pageSize 非常大的話,那麼很有可能會讓你的機器宕機,同樣你也來不及反應抵擋數據爬取。

所以針對這個你可以設置一下最大數量。

然後呢,這上面有個不完善的地方在於由很多類可能都會使用到這個MaxPageSize 和_pageSize 這些,這時候我們最好去提取出來作爲一個基礎類,然後繼承,這樣統一了風格。

接着我們可以這樣寫分頁。

public async Task<IEnumerable<Company>> GetCompaniesAsync(CompanyDtoParameters parameters)
{
	if (parameters == null)
	{
		throw new ArgumentNullException(nameof(parameters));
	}
   
	var queryExpression = _context.Companies as IQueryable<Company>;

	if (!string.IsNullOrWhiteSpace(parameters.CompanyName))
	{
		parameters.CompanyName = parameters.CompanyName.Trim();
		queryExpression = queryExpression.Where(x => x.Name == parameters.CompanyName);
	}

	if (!string.IsNullOrWhiteSpace(parameters.SearchTerm))
	{
		parameters.SearchTerm = parameters.SearchTerm.Trim();
		queryExpression = queryExpression.Where(x => x.Name.Contains(parameters.SearchTerm) ||
													 x.Introduction.Contains(parameters.SearchTerm));
	}

	return await queryExpression.Skip(parameters.PageNumber-1).Take(parameters.PageSize);
}

利用skip和take,但是這樣寫不好。

爲什麼這麼說呢? 是這樣子的,我們返回分頁數據的時候,一般來說,要帶上總頁數或者說記錄數。

爲了方便起見呢,後臺同樣要返回是否有前一頁和後一頁,還有前一頁的地址,和後一頁的地址,這樣前端寫起來方便。

同樣的不要談什麼讓前端去計算,因爲這會讓前端苦不堪言,而現在的前端並不是就是幹ui切圖的,人家還有很多思考性的問題。

在這樣還有一個問題,就是以前我們返回的時候一般json數據是這樣子的。

{
   currentPage:1,
   totalPages:20,
   prePageLink:"",
   nextPageLink:"",
   items:[{數據},{數據}],
}

這樣寫是不符合restful 風格的,比如說api/companies,人家需要的是companies的資源,而不是什麼currentPage和totalPages 等等信息。

這些不屬於它要請求的資源,那麼這些資源應該放到另外一個地方去。很多情況下放到header中,業界一般放在X-Pagination中,那麼看下如何實現吧。

首先建立一個pageList 來作爲非請求資源的存儲,比如說currentPage、totalPages等

public class PagedList<T>: List<T>
{
	public int CurrentPage { get; private set; }
	public int TotalPages { get; private set; }
	public int PageSize { get; private set; }
	public int TotalCount { get; private set; }

	public bool HasPrevious => CurrentPage > 1;
	public bool HasNext => CurrentPage < TotalPages;

	public PagedList(List<T> items, int count, int pageNumber, int pageSize)
	{
		TotalCount = count;
		PageSize = pageSize;
		CurrentPage = pageNumber;
		TotalPages = (int) Math.Ceiling(count / (double) pageSize);
		AddRange(items);
	}

	public static async Task<PagedList<T>> CreateAsync(IQueryable<T> source, int pageNumber, int pageSize)
	{
		var count = await source.CountAsync();
		var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync();
		return new PagedList<T>(items, count, pageNumber, pageSize);
	}
}

那麼我們的查詢這樣寫:

public async Task<IEnumerable<Company>> GetCompaniesAsync(CompanyDtoParameters parameters)
{
	if (parameters == null)
	{
		throw new ArgumentNullException(nameof(parameters));
	}
   
	var queryExpression = _context.Companies as IQueryable<Company>;

	if (!string.IsNullOrWhiteSpace(parameters.CompanyName))
	{
		parameters.CompanyName = parameters.CompanyName.Trim();
		queryExpression = queryExpression.Where(x => x.Name == parameters.CompanyName);
	}

	if (!string.IsNullOrWhiteSpace(parameters.SearchTerm))
	{
		parameters.SearchTerm = parameters.SearchTerm.Trim();
		queryExpression = queryExpression.Where(x => x.Name.Contains(parameters.SearchTerm) ||
													 x.Introduction.Contains(parameters.SearchTerm));
	}

	return await PagedList<Company>.CreateAsync(queryExpression, parameters.PageNumber, parameters.PageSize);
}

這樣我們返回的就是一個PagedList,現在我們的資源還是和一個附加參數在一起,比如說頁數,那麼這時候就看我們的action如何寫了。

[HttpGet(Name = nameof(GetCompanies))]
[HttpHead]
public async Task<IActionResult> GetCompanies([FromQuery] CompanyDtoParameters parameters)
{

	var companies = await _companyRepository.GetCompaniesAsync(parameters);

	var paginationMetadata = new
	{
		totalCount = companies.TotalCount,
		pageSize = companies.PageSize,
		currentPage = companies.CurrentPage,
		totalPages = companies.TotalPages,
		previousLink = companies.HasPrevious ? CreateCompaniesResourceUri(parameters, ResourceUriType.PreviousPage) : null,
		nextLink = companies.HasNext ? CreateCompaniesResourceUri(parameters, ResourceUriType.NextPage) : null
	};

	Response.Headers.Add("X-Pagination", JsonSerializer.Serialize(paginationMetadata,
		new JsonSerializerOptions
		{
			Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
		}));
	var companyDtos = _mapper.Map<CompanyDto>(companies);
	return Ok(companyDtos);
}

action 的方式倒是好寫,就是把一些參數json序列化,放在header 中的x-pagination 中。

這裏有一個生成link的,貼一下方法。

private string CreateLinkForCompany(CompanyDtoParameters parameters, ResourceUriType resourceUri)
{
	switch (resourceUri)
	{
		case ResourceUriType.PreviousPage:
			return Url.Link(nameof(GetCompanies), new
			{
				pageNumber=parameters.PageNumber-1,
				pageSize=parameters.PageSize,
				companyName=parameters.CompanyName,
				searchTerm=parameters.SearchTerm
			});
		case ResourceUriType.NextPage:
			return Url.Link(nameof(GetCompanies), new
			{
				pageNumber = parameters.PageNumber + 1,
				pageSize = parameters.PageSize,
				companyName = parameters.CompanyName,
				searchTerm = parameters.SearchTerm
			});
		default:
			return Url.Link(nameof(GetCompanies), new
			{
				pageNumber = parameters.PageNumber - 1,
				pageSize = parameters.PageSize,
				companyName = parameters.CompanyName,
				searchTerm = parameters.SearchTerm
			});
	}
}

這樣就是一個簡單的一個分頁過程,同樣的我們看到其中有很多的問題。

比如說paginationMetadata 應該是一個固定的類,這樣統一風格。

同樣的,CreateLinkForCompany也存在很多的問題,我們在創建link的時候呢,我們寫入了很多的參數,比如說:

companyName = parameters.CompanyName,
searchTerm = parameters.SearchTerm

如果查詢的參數變化快的話,這將是一個問題,而且每次改動都需要改動這些,這也是一個問題,暫時就不改動,後面有一個小節來解決這些問題。

排序

排序問題也是我們常見的一個業務,這個業務應該屬於過於常見吧。按照restful 風格的排序呢,一般是這樣子的。/api/companies?orderby=name desc這樣子的。

這裏排序字段name和排序規則desc寫在了一起,爲什麼這麼做呢?原因就是以後可能多個擴展,如果擴展一個,那麼可能就是多兩個字段,這樣不好。

還有一個重大問題,我們一般在uri中寫的排序字段name,不是真正的數據庫字段,數據庫字段是companyName,這樣子的,那麼我們就要有一個映射關係,ok,那麼這個如何處理呢?

我們想到的就是這種:

if(orderby.startWidth('name'))
{
    dbset.company.orderby(u=>u.companyName)
}

但是呢,這樣有一個問題,如果排序字段的選擇可以很多(比如說name或者其他一些都可以作爲排序的)這樣會形成一個大的邏輯段,那麼應該怎麼辦呢?

這時候應該形成一個映射關係,使用PropertyMappingService可以做到。

這裏用emplyee的查詢舉例,在這個例子中不需要知道具體的emplyee是什麼,只需看到如何映射即可。

public class PropertyMappingService : IPropertyMappingService
{
	private readonly Dictionary<string, PropertyMappingValue> _companyPropertyMapping =
		new Dictionary<string, PropertyMappingValue>(StringComparer.OrdinalIgnoreCase)
		{
			{"Id", new PropertyMappingValue(new List<string>{"Id"}) },
			{"CompanyName", new PropertyMappingValue(new List<string>{"Name"}) },
			{"Country", new PropertyMappingValue(new List<string>{"Country"}) },
			{"Industry", new PropertyMappingValue(new List<string>{ "Industry"})},
			{"Product", new PropertyMappingValue(new List<string>{"Product"})},
			{"Introduction", new PropertyMappingValue(new List<string>{"Introduction"})}
		};

	private readonly Dictionary<string, PropertyMappingValue> _employeePropertyMapping =
		new Dictionary<string, PropertyMappingValue>(StringComparer.OrdinalIgnoreCase)
		{
			{"Id", new PropertyMappingValue(new List<string>{"Id"}) },
			{"CompanyId", new PropertyMappingValue(new List<string>{"CompanyId"}) },
			{"EmployeeNo", new PropertyMappingValue(new List<string>{"EmployeeNo"}) },
			{"Name", new PropertyMappingValue(new List<string>{"FirstName", "LastName"})},
			{"GenderDisplay", new PropertyMappingValue(new List<string>{"Gender"})},
			{"Age", new PropertyMappingValue(new List<string>{"DateOfBirth"}, true)}
		};

	private readonly IList<IPropertyMapping> _propertyMappings = new List<IPropertyMapping>();

	public PropertyMappingService()
	{
		_propertyMappings.Add(new PropertyMapping<EmployeeDto, Employee>(_employeePropertyMapping));
		_propertyMappings.Add(new PropertyMapping<CompanyDto, Company>(_companyPropertyMapping));
	}

	public Dictionary<string, PropertyMappingValue> GetPropertyMapping<TSource, TDestination>()
	{
		var matchingMapping = _propertyMappings.OfType<PropertyMapping<TSource, TDestination>>();

		var propertyMappings = matchingMapping.ToList();
		if (propertyMappings.Count == 1)
		{
			return propertyMappings.First().MappingDictionary;
		}

		throw new Exception($"無法找到唯一的映射關係:{typeof(TSource)}, {typeof(TDestination)}");
	}

	public bool ValidMappingExistsFor<TSource, TDestination>(string fields)
	{
		var propertyMapping = GetPropertyMapping<TSource, TDestination>();

		if (string.IsNullOrWhiteSpace(fields))
		{
			return true;
		}

		var fieldAfterSplit = fields.Split(",");
		foreach (var field in fieldAfterSplit)
		{
			var trimmedField = field.Trim();
			var indexOfFirstSpace = trimmedField.IndexOf(" ", StringComparison.Ordinal);
			var propertyName = indexOfFirstSpace == -1 ? trimmedField 
				: trimmedField.Remove(indexOfFirstSpace);

			if (!propertyMapping.ContainsKey(propertyName))
			{
				return false;
			}
		}

		return true;
	}
}

這樣就可以形成一個映射關係。比如說Name 表示兩個字段firstname 還有lastname這樣,這個其實自己也可以實現。

好的,來解釋一下代碼吧。

首先實例化了一個數組:private readonly IList _propertyMappings = new List();

private readonly Dictionary<string, PropertyMappingValue> _employeePropertyMapping =
	new Dictionary<string, PropertyMappingValue>(StringComparer.OrdinalIgnoreCase)
	{
		{"Id", new PropertyMappingValue(new List<string>{"Id"}) },
		{"CompanyId", new PropertyMappingValue(new List<string>{"CompanyId"}) },
		{"EmployeeNo", new PropertyMappingValue(new List<string>{"EmployeeNo"}) },
		{"Name", new PropertyMappingValue(new List<string>{"FirstName", "LastName"})},
		{"GenderDisplay", new PropertyMappingValue(new List<string>{"Gender"})},
		{"Age", new PropertyMappingValue(new List<string>{"DateOfBirth"}, true)}
	};

上面就是我們寫的映射字典。

來看下PropertyMappingValue 這個是啥,這個其實就是自己定義的一個東西:

public class PropertyMappingValue
{
	public IEnumerable<string> DestinationProperties { get; set; }

	public bool Revert { get; set; }

	public PropertyMappingValue(IEnumerable<string> destinationProperties, bool revert = false)
	{
		DestinationProperties = destinationProperties 
								?? throw new ArgumentNullException(nameof(destinationProperties));
		Revert = revert;
	}
}

接下來就是加進去:

public PropertyMappingService()
{
	_propertyMappings.Add(new PropertyMapping<EmployeeDto, Employee>(_employeePropertyMapping));
	_propertyMappings.Add(new PropertyMapping<CompanyDto, Company>(_companyPropertyMapping));
}

然後把這幾個添加到裏面。

public Dictionary<string, PropertyMappingValue> GetPropertyMapping<TSource, TDestination>()
{
	var matchingMapping = _propertyMappings.OfType<PropertyMapping<TSource, TDestination>>();

	if (matchingMapping.Count() == 1)
	{
		return matchingMapping.First().MappingDictionary;
	}

	throw new Exception($"無法找到唯一的映射關係:{typeof(TSource)}, {typeof(TDestination)}");
}

上面這個可以說是關鍵了,_propertyMappings通過OfType,找到PropertyMapping<TSource, TDestination>類型集合。

如果是PropertyMapping<EmployeeDto, Employee>也就是new PropertyMapping<EmployeeDto, Employee>的一個集合。

然後判斷是否添加進去了,如果形成一個1對1的關係,那麼就返回我們添加進去的,也就是new PropertyMapping<EmployeeDto, Employee>(_employeePropertyMapping)

看下PropertyMapping 是啥?

public class PropertyMapping<TSource, TDestination>: IPropertyMapping
{
	public Dictionary<string,PropertyMappingValue> MappingDictionary { get; private set; }

	public PropertyMapping(Dictionary<string, PropertyMappingValue> mappingDictionary)
	{
		MappingDictionary = mappingDictionary 
							 ?? throw new ArgumentNullException(nameof(mappingDictionary));
	}
}

其實就是做一層封裝而已,爲的就是OfType。在這裏非常關鍵的就是ofType,https://www.cnblogs.com/macT/p/12069362.html 可以看下這個,挺好理解的。

那麼我們拿到一個映射關係後如何處理呢?來看查詢方法。

public async Task<IEnumerable<Employee>> GetEmployeesAsync(Guid companyId,
	EmployeeDtoParameters parameters)
{
	if (companyId == Guid.Empty)
	{
		throw new ArgumentNullException(nameof(companyId));
	}

	var items = _context.Employees.Where(x => x.CompanyId == companyId);

	if (!string.IsNullOrWhiteSpace(parameters.Gender))
	{
		parameters.Gender = parameters.Gender.Trim();
		var gender = Enum.Parse<Gender>(parameters.Gender);

		items = items.Where(x => x.Gender == gender);
	}

	if (!string.IsNullOrWhiteSpace(parameters.Q))
	{
		parameters.Q = parameters.Q.Trim();

		items = items.Where(x => x.EmployeeNo.Contains(parameters.Q)
								 || x.FirstName.Contains(parameters.Q)
								 || x.LastName.Contains(parameters.Q));
	}

	var mappingDictionary = _propertyMappingService.GetPropertyMapping<EmployeeDto, Employee>();

	items = items.ApplySort(parameters.OrderBy, mappingDictionary);

	return await items.ToListAsync();
}

我們拿到一個映射關係後就可以進行排序了,看下ApplySort。

public static IQueryable<T> ApplySort<T>(
	this IQueryable<T> source,
	string orderBy,
	Dictionary<string, PropertyMappingValue> mappingDictionary)
{
	if (source == null)
	{
		throw new ArgumentNullException(nameof(source));
	}

	if (mappingDictionary == null)
	{
		throw new ArgumentNullException(nameof(mappingDictionary));
	}

	if (string.IsNullOrWhiteSpace(orderBy))
	{
		return source;
	}

	var orderByAfterSplit = orderBy.Split(",");

	foreach (var orderByClause in orderByAfterSplit.Reverse())
	{
		var trimmedOrderByClause = orderByClause.Trim();

		var orderDescending = trimmedOrderByClause.EndsWith(" desc");

		var indexOfFirstSpace = trimmedOrderByClause.IndexOf(" ", StringComparison.Ordinal);

		var propertyName = indexOfFirstSpace == -1
			? trimmedOrderByClause
			: trimmedOrderByClause.Remove(indexOfFirstSpace);

		if (!mappingDictionary.ContainsKey(propertyName))
		{
			throw new ArgumentNullException($"沒有找到Key爲{propertyName}的映射");
		}

		var propertyMappingValue = mappingDictionary[propertyName];
		if (propertyMappingValue == null)
		{
			throw new ArgumentNullException(nameof(propertyMappingValue));
		}

		foreach (var destinationProperty in propertyMappingValue.DestinationProperties.Reverse())
		{
			if (propertyMappingValue.Revert)
			{
				orderDescending = !orderDescending;
			}

			source = source.OrderBy(destinationProperty +
									(orderDescending ? " descending" : " ascending"));
		}
	}

	return source;
}

這個還是比較好理解的,思路就是用,切割orderby,然後判斷是否包含desc,然後賦值給orderDescending 表示是否降序。

同樣如果包含 desc,去除掉相應的desc,保留前面的字段。然後就去找我們的映射字典,接下來就是通過orderby的拼接來完成。

在這裏我們發現這個orderby傳入的是一個字符串,而我們ef又沒有,是的,這是通過擴展程序來的,用庫。using System.Linq.Dynamic.Core;,安裝即可。

是的至此就基本結束了,對了我們依然需要依賴注入,因爲可能以後我們有更優雅的方式,注入方式如下:

services.AddTransient<IPropertyMappingService, PropertyMappingService>();

排序的思想很簡單,值得學習的是一個映射方式,爲什麼添加一個包裝類。

下一節其他請求方式

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章