WebAPI: данни за други програми
Проблем
Приложение, а не браузър
Магазинът пуска приложение за телефон, а партньорско кафене иска да поръчва от каталога автоматично. Страниците от предишните уроци са HTML за хора: менюта, таблици, бутони. Програмата на кафенето трябва да рови в този HTML, за да намери цената. Щом дизайнерът премести колоната, програмата спира да работи.
Програмите искат само данните, в предвидим вид. Затова сървърът има и втора врата: WebAPI, адреси, които връщат JSON вместо страници. Едно API служи на приложението за телефон, на сайта, на десктоп програмата на касата и на партньорите.
Идеята
Ресурси, методи и статуси
API-тата днес обикновено следват REST. Правилата са малко:
- Адресът назовава ресурс, нещо:
/api/productsе всички продукти,/api/products/2е продукт 2. В адреса няма глаголи катоgetProductилиdelete. - Какво правиш с ресурса, казва HTTP методът:
GETчете,POSTсъздава,PUTзаменя,DELETEизтрива. - Как е минало, казва статусът:
| Статус | Кога |
|---|---|
| 200 OK | GET е намерил ресурса, тялото е JSON |
| 201 Created | POST е създал нов ресурс; Location е адресът му |
| 204 No Content | PUT или DELETE са успели, тяло няма |
| 400 Bad Request | данните не са валидни, тялото казва защо |
| 404 Not Found | такъв ресурс няма |
| 409 Conflict | заявката е правилна, но не може да се изпълни сега (например няма наличност) |
API по тези правила се казва REST API. Клиентът може да го ползва, без да чете кода на сървъра: адресът и методът стигат.
Пет заявки към един и същ ресурс и статусите, които връщат. Как се пишат контролерът и клиентът, е в следващите карти:
var http = new HttpClient { BaseAddress = new Uri("http://localhost:5292") };var matcha = new Product { Name = "Матча", Price = 18.00m, Category = "чай" };var cheaper = new Product { Name = "Колумбия Супремо", Price = 11.90m, Category = "кафе" };
Show(await http.GetAsync("/api/products/3"));Show(await http.PostAsJsonAsync("/api/products", matcha));Show(await http.PutAsJsonAsync("/api/products/2", cheaper));Show(await http.DeleteAsync("/api/products/4"));Show(await http.GetAsync("/api/products/4"));
static void Show(HttpResponseMessage r) => Console.WriteLine(r.RequestMessage.Method + " " + r.RequestMessage.RequestUri.AbsolutePath + " -> " + (int)r.StatusCode);Стъпка по стъпка, с думи
- Ресурсът е /api/products: 4 продукта на сървъра. Клиентът знае само адреса.
- GET чете продукт 3 (Сенча). Има го: 200 OK и продуктът като JSON.
- POST към колекцията създава нов продукт. Сървърът му дава id 5: 201 Created.
- PUT заменя продукт 2: цената става 11.90 €. Тяло в отговора няма: 204 No Content.
- DELETE изтрива продукт 4: 204 No Content.
- GET на продукт 4: вече го няма, затова 404 Not Found.
- Адресът казва кой ресурс, методът какво правиш с него, статусът как е минало.
Виж го
Контролер за данни
Контролерът на API наследява ControllerBase: това е Controller без изгледите. Двата атрибута отгоре го правят API:
[ApiController][Route("api/[controller]")]public class ProductsController : ControllerBase{ private readonly IProductService products;
public ProductsController(IProductService products) => this.products = products;
[HttpGet] public List<Product> GetAll() => products.All();
[HttpGet("{id:int}")] public ActionResult<Product> Get(int id) { Product product = products.Find(id); if (product == null) return NotFound(); return product; }}[Route("api/[controller]")]слага името на контролера в адреса:api/Products. Големината на буквите в адреса няма значение.[ApiController]включва поведението на API: параметрите идват от JSON тялото, а невалидните данни връщат 400 сами (след малко).GetAllвръща списък и той става JSON.GetвръщаActionResult<Product>: или продукт (200 с JSON), или друг резултат, напримерNotFound().return product;е позволено, защото C# превръща продукта вActionResult<Product>сам.
В Program.cs контролерите се добавят с AddControllers (без изгледи) и се свързват с MapControllers (маршрутите са в атрибутите). AddJsonOptions е настройката за четим JSON от урока за MVC:
var builder = WebApplication.CreateBuilder();builder.Logging.ClearProviders();builder.Services.AddControllers() .AddJsonOptions(o => o.JsonSerializerOptions.Encoder = JavaScriptEncoder.Create(UnicodeRanges.All));builder.Services.AddSingleton<IProductService, ProductService>();
var app = builder.Build();app.Urls.Add("http://localhost:5261");app.MapControllers();await app.StartAsync();Клиентът задава общото начало на адресите с BaseAddress и после пише само пътя:
var http = new HttpClient { BaseAddress = new Uri("http://localhost:5261") };
Console.WriteLine(await http.GetStringAsync("/api/products"));
Product tea = await http.GetFromJsonAsync<Product>("/api/products/3");Console.WriteLine(tea.Name + ", " + tea.Price + " €");
HttpResponseMessage missing = await http.GetAsync("/api/products/99");Console.WriteLine("GET /api/products/99 -> " + (int)missing.StatusCode);[{"id":1,"name":"Етиопия Йиргачефе","price":14.90,"category":"кафе"},{"id":2,"name":"Колумбия Супремо","price":12.50,"category":"кафе"},{"id":3,"name":"Сенча","price":8.40,"category":"чай"},{"id":4,"name":"Ерл Грей","price":6.90,"category":"чай"}]Сенча, 8.40 €GET /api/products/99 -> 404GetFromJsonAsync<Product> чете JSON и прави от него обект Product. Свойствата в JSON са с малка буква (name), а в C# с главна (Name). Четенето не гледа големината на буквите.
Виж го
POST: нов продукт
Product е със същите свойства, а атрибутите казват какво е валидно:
public class Product{ public int Id { get; set; }
[Required(ErrorMessage = "Името е задължително.")] public string Name { get; set; }
[Range(0.01, 1000, ErrorMessage = "Цената е от 0.01 до 1000 €.")] public decimal Price { get; set; }
public string Category { get; set; }}Действието за създаване:
[HttpPost] public ActionResult<Product> Create(Product product) { products.Add(product); return CreatedAtAction(nameof(Get), new { id = product.Id }, product); }CreatedAtAction връща 201, слага в Location адреса на новия продукт (сглобен от действието Get и id) и праща продукта в тялото. nameof(Get) е името на метода като текст, "Get", но компилаторът го проверява. Клиентът праща един верен и един празен продукт:
var http = new HttpClient { BaseAddress = new Uri("http://localhost:5263") };
var matcha = new Product { Name = "Матча", Price = 18.00m, Category = "чай" };HttpResponseMessage created = await http.PostAsJsonAsync("/api/products", matcha);Product saved = await created.Content.ReadFromJsonAsync<Product>();Console.WriteLine((int)created.StatusCode + ", Location: " + created.Headers.Location);Console.WriteLine("Новият продукт е с id " + saved.Id);
HttpResponseMessage rejected = await http.PostAsJsonAsync("/api/products", new Product { Price = 0 });Console.WriteLine((int)rejected.StatusCode);ValidationProblemDetails problem = await rejected.Content.ReadFromJsonAsync<ValidationProblemDetails>();foreach (KeyValuePair<string, string[]> error in problem.Errors.OrderBy(e => e.Key)){ Console.WriteLine(error.Key + ": " + string.Join(" ", error.Value));}201, Location: http://localhost:5263/api/Products/5Новият продукт е с id 5400Name: Името е задължително.Price: Цената е от 0.01 до 1000 €.Вторият продукт няма име и цената му е 0. Create изобщо не се вика: [ApiController] проверява атрибутите и връща 400 сам. Тялото е ValidationProblemDetails: стандартен JSON с полето errors, в което за всяко свойство има списък грешки.
По-точно, за любопитните
- Без
[ApiController]трябва сам да пишешif (!ModelState.IsValid) return ValidationProblem();в началото на всяко действие и[FromBody]пред параметъра, за да се чете от JSON. - В тялото на 400 има и
traceId, номер на заявката. По него намираш грешката в логовете на сървъра. Той е различен при всяка заявка, затова програмата печата самоerrors. NotFound()в API контролер също връща малко JSON (ProblemDetailsсъс статус 404), не празно тяло.
Виж го
PUT и DELETE
[HttpPut("{id:int}")] public IActionResult Update(int id, Product product) { if (!products.Update(id, product)) return NotFound(); return NoContent(); }
[HttpDelete("{id:int}")] public IActionResult Delete(int id) { if (!products.Remove(id)) return NotFound(); return NoContent(); }Услугата е тази от урока за MVC, с три метода повече: Add, Update и Remove. Update и Remove връщат false, ако продукт с този id няма, а действието го превръща в 404:
public interface IProductService{ Product Find(int id); List<Product> All(); void Add(Product product); bool Update(int id, Product product); bool Remove(int id);}var http = new HttpClient { BaseAddress = new Uri("http://localhost:5264") };
var cheaper = new Product { Name = "Колумбия Супремо", Price = 11.90m, Category = "кафе" };HttpResponseMessage updated = await http.PutAsJsonAsync("/api/products/2", cheaper);Console.WriteLine("PUT /api/products/2 -> " + (int)updated.StatusCode);Console.WriteLine((await http.GetFromJsonAsync<Product>("/api/products/2")).Price + " €");
HttpResponseMessage deleted = await http.DeleteAsync("/api/products/4");Console.WriteLine("DELETE /api/products/4 -> " + (int)deleted.StatusCode);HttpResponseMessage again = await http.DeleteAsync("/api/products/4");Console.WriteLine("DELETE /api/products/4 -> " + (int)again.StatusCode);HttpResponseMessage nowhere = await http.PutAsJsonAsync("/api/products/4", cheaper);Console.WriteLine("PUT /api/products/4 -> " + (int)nowhere.StatusCode);PUT /api/products/2 -> 20411.90 €DELETE /api/products/4 -> 204DELETE /api/products/4 -> 404PUT /api/products/4 -> 404PUT праща целия продукт и той заменя стария. Второто изтриване на продукт 4 е 404: вече го няма. Същото е и при PUT към него.
По-точно, за любопитните
- За промяна само на част от ресурса (например само цената) има метод
PATCH. Той е по-рядък, защото трябва да се договориш как изглежда „част“. GET,PUTиDELETEса идемпотентни: да ги повториш е безопасно, резултатът на сървъра е същият.POSTне е: два POST правят два продукта. Затова браузърът пита „Да изпратя ли формата отново?“ само при POST.
Пробвай
Четири заявки
Сървърът е от предишните карти, с четирите продукта. Какво печата клиентът?
var http = new HttpClient { BaseAddress = new Uri("http://localhost:5262") };
var sencha = new Product { Name = "Сенча", Price = 9.20m, Category = "чай" };HttpResponseMessage a = await http.PutAsJsonAsync("/api/products/3", sencha);HttpResponseMessage b = await http.DeleteAsync("/api/products/3");HttpResponseMessage c = await http.GetAsync("/api/products/3");HttpResponseMessage d = await http.PostAsJsonAsync("/api/products", new Product { Name = "", Price = 5 });Console.WriteLine((int)a.StatusCode + " " + (int)b.StatusCode + " " + (int)c.StatusCode + " " + (int)d.StatusCode);PUT към продукт 3 е успешен: 204. DELETE също: 204. След това GET не го намира: 404. Последният POST е с празно име, а [Required] не приема празен текст, затова [ApiController] връща 400, преди да се стигне до Create.
Пробвай
Продуктът не се намира
GET /api/products/2 връща 405, а GET /api/products/id връща 404. Продукт 2 се показва само на странния адрес /api/products/id?id=2. На кой ред е грешката?
Избери реда с грешката.
Без къдрави скоби "id" е буквален текст в адреса: действието отговаря само на /api/products/id. Тогава параметърът int id не е част от пътя и [ApiController] го търси в заявката след ?. Ако го няма, той е 0 и продуктът не се намира. Адресът /api/products/2 съвпада само с PUT и DELETE, затова GET към него е 405. Поправката е [HttpGet("{id:int}")].
Проблем
Последните десет пакета
Сървърът не обслужва заявките една по една. Ако сто души натиснат „Купи“ в една и съща секунда, ASP.NET Core ги обработва едновременно. Всички те ползват един и същ обект: услугата е singleton.
Складът има 10 пакета от промоционалното кафе. Резервацията проверява и намалява:
public class Stock{ private readonly Dictionary<int, int> left = new Dictionary<int, int> { [1] = 10 };
public bool TryTake(int productId) { if (!left.TryGetValue(productId, out int count) || count == 0) return false; left[productId] = count - 1; return true; }
public int Left(int productId) => left[productId];}Една по една заявките минават правилно, 10 пъти 201 и после 409:
201 201 201 201 201 201 201 201 201 201 409 409Едновременно обаче две заявки могат да прочетат count = 1 в един и същ миг. И двете решават, че има, и двете пишат 0 и връщат 201. Така продаваш повече, отколкото имаш. Колко повече, зависи от случая: при всяко пускане е различно. Същото е с последната стая в хотел и с последното място в залата.
Идеята
lock: един по един в опасното място
Проверката и промяната трябва да са неделими: никой да не влиза между тях. В C# това прави lock:
public class Stock{ private readonly Dictionary<int, int> left = new Dictionary<int, int> { [1] = 10 }; private readonly object sync = new();
public bool TryTake(int productId) { lock (sync) { if (!left.TryGetValue(productId, out int count) || count == 0) return false; left[productId] = count - 1; return true; } }
public int Left(int productId) { lock (sync) { return left[productId]; } }}syncе обикновен обект, който служи само за ключ.new()е кратко заnew object(): типът се взима от полето отляво.lock (sync) { ... }пуска в блока само една заявка наведнъж. Другите чакат на входа, докато първата излезе. Така проверката и намаляването стават заедно.- Четенето също е в
lock: иначе можеш да прочетеш речника точно докато друга заявка го променя.
Контролерът е обикновен: 201 при успех, 409 Conflict, ако няма наличност:
public class Reservation{ public int ProductId { get; set; }}
[ApiController][Route("api/[controller]")]public class ReservationsController : ControllerBase{ private readonly Stock stock;
public ReservationsController(Stock stock) => this.stock = stock;
[HttpPost] public IActionResult Reserve(Reservation reservation) { if (!stock.TryTake(reservation.ProductId)) return Conflict("Няма наличност."); return StatusCode(201, reservation); }}Доказателството: сто заявки наведнъж с Task.WhenAll срещу 10 пакета:
var http = new HttpClient { BaseAddress = new Uri("http://localhost:5266") };
var requests = new List<Task<HttpResponseMessage>>();for (int i = 0; i < 100; i++){ requests.Add(http.PostAsJsonAsync("/api/reservations", new Reservation { ProductId = 1 }));}HttpResponseMessage[] responses = await Task.WhenAll(requests);
Console.WriteLine("201 Created: " + responses.Count(r => (int)r.StatusCode == 201));Console.WriteLine("409 Conflict: " + responses.Count(r => (int)r.StatusCode == 409));Console.WriteLine("Остават: " + app.Services.GetRequiredService<Stock>().Left(1));201 Created: 10409 Conflict: 90Остават: 0С lock резултатът е един и същ при всяко пускане. Същото важи за всяка услуга, регистрирана с AddSingleton, която пази данни в паметта: ProductService от горните карти също трябва да заключва в Add, Update и Remove, ако с него работят много клиенти.
По-точно, за любопитните
- Вътре в
lockне може да имаawait, и компилаторът не го позволява.lockдържи ключа за нишката, която е влязла, а следawaitпродължението може да е на друга нишка. Ако трябва да чакаш нещо асинхронно, докато пазиш данните, ползва сеSemaphoreSlimсawait semaphore.WaitAsync(). - Блокът в
lockтрябва да е кратък: всички останали чакат. Не прави в него заявки към други сървъри или към диска. - За речник, който се ползва от много места едновременно, има готов
ConcurrentDictionary<K, V>: методи катоTryUpdateиAddOrUpdateправят проверката и промяната наведнъж, без твойlock. - За брояч стига
Interlocked:Interlocked.Increment(ref count)увеличава числото неделимо.count++не е неделимо: то е „прочети, събери, запиши“. - В истинско приложение складът е в база данни. Там същата задача решават транзакциите и условие в самата заявка: „намали, само ако е над 0“.
Виж го
Програмата от другата страна
Партньорското кафене не знае нищо за контролерите. То знае адресите и вида на JSON. Удобно е заявките да са в един клас:
public class CatalogClient{ private readonly HttpClient http;
public CatalogClient(string address) { http = new HttpClient { BaseAddress = new Uri(address) }; }
public async Task<List<Product>> AllAsync() => await http.GetFromJsonAsync<List<Product>>("/api/products");
public async Task<int> AddAsync(Product product) { HttpResponseMessage response = await http.PostAsJsonAsync("/api/products", product); response.EnsureSuccessStatusCode(); Product saved = await response.Content.ReadFromJsonAsync<Product>(); return saved.Id; }}EnsureSuccessStatusCode() хвърля HttpRequestException, ако статусът не е 2xx. Така грешка от сървъра не минава незабелязано. Кафенето добавя продукт, взима кафетата, подредени по цена, и опитва невалиден продукт:
var catalog = new CatalogClient("http://localhost:5267");
int id = await catalog.AddAsync(new Product { Name = "Гватемала Антигуа", Price = 13.20m, Category = "кафе" });Console.WriteLine("Добавен с id " + id);
List<Product> coffee = (await catalog.AllAsync()).Where(p => p.Category == "кафе").OrderBy(p => p.Price).ToList();foreach (Product p in coffee){ Console.WriteLine(p.Name + " – " + p.Price + " €");}
try{ await catalog.AddAsync(new Product { Name = "Без цена" });}catch (HttpRequestException e){ Console.WriteLine("Отказано: " + (int)e.StatusCode);}Добавен с id 5Колумбия Супремо – 12.50 €Гватемала Антигуа – 13.20 €Етиопия Йиргачефе – 14.90 €Отказано: 400По-точно, за любопитните
- Swagger / OpenAPI описва API-то в стандартен JSON файл: адреси, методи, вид на телата, статуси. От него се прави страница, на която пробваш API-то от браузъра, и се генерират клиенти на други езици. В .NET 10 описанието е вградено (
AddOpenApiиMapOpenApi), а страницата за пробване идва от отделен пакет, напримерScalar.AspNetCoreилиSwashbuckle.AspNetCore. - CORS: браузърът по подразбиране не дава на JavaScript от
shop.example.bgда чете отговори отapi.example.bg. Сървърът разрешава изрично кои сайтове могат, сbuilder.Services.AddCors(...)иapp.UseCors(...). За програми извън браузъра, като тази, CORS няма значение. - ASP.NET Core може да отговаря и с XML, ако клиентът поиска с заглавка
Accept: application/xmlи сървърът е сAddXmlSerializerFormatters(). Изборът на формат по заглавката се казва content negotiation. - Един
HttpClientсе ползва за много заявки. Нов за всяка заявка изчерпва връзките на компютъра при голямо натоварване.
Упражнение
API за задачи
Реши задачата в редактора си, в проект от dotnet new webapi --use-controllers или в един файл като в урока.
API за задачи (todo). TodoItem има Id, Title (задължително, с [Required]) и Done. TodosController на api/todos има петте действия: всички, една по id (или 404), създаване (201 с CreatedAtAction), замяна с PUT (204 или 404) и изтриване (204 или 404). Задачите са в singleton услуга TodoList, която заключва с lock при всяко четене и писане. Новата задача взима следващия номер, дори ако по-стари са изтрити.
Клиентът вика поред: две задачи, задача без заглавие, отбелязва първата като свършена, чете я, изтрива втората, търси я и накрая чете всички. Печата метода, статуса и тялото, ако заявката е успешна и има тяло:
POST -> 201 {"id":1,"title":"Поръчай филтри за кафе машината","done":false}POST -> 201 {"id":2,"title":"Обади се на доставчика на чай","done":false}POST без заглавие -> 400PUT 1 -> 204GET 1 -> 200 {"id":1,"title":"Поръчай филтри за кафе машината","done":true}DELETE 2 -> 204GET 2 -> 404GET -> 200 [{"id":1,"title":"Поръчай филтри за кафе машината","done":true}]После добави GET /api/todos?done=false, което връща само несвършените: параметър bool? done на GetAll и Where.
Тест