Първият ти сървър
Проблем
Каталогът трябва да се вижда отвсякъде
Каталогът на магазина вече е списък от продукти в програмата ти и можеш да го филтрираш с LINQ. Но той е в паметта на твоя компютър. Клиентът отваря магазина в браузъра, друг го отваря в приложението на телефона си. И двете програми говорят HTTP: пращат заявка GET /products/3 и чакат отговор със статус и тяло.
Трябва ти програма, която слуша на порт, получава HTTP заявки и отговаря. Това е уеб сървър.
Виж го
Сървър и клиент в една програма
В C# уеб сървърът е ASP.NET Core. Най-простият начин да го ползваш е Minimal API: сървърът и адресите му са описани в няколко реда. Тази програма пуска сървър и сама го пита като клиент:
var builder = WebApplication.CreateBuilder();builder.Logging.ClearProviders();var app = builder.Build();app.Urls.Add("http://localhost:5201");
app.MapGet("/", () => "Кафе и чай: отворено");await app.StartAsync();
using var http = new HttpClient();string text = await http.GetStringAsync("http://localhost:5201/");Console.WriteLine(text);
await app.StopAsync();Кафе и чай: отвореноWebApplication.CreateBuilder()иBuild()правят приложението.app.Urls.Addказва на кой адрес и порт да слуша.MapGet("/", ламбда)казва: наGETзаявка към пътя/отговори с това, което върне ламбдата. Текстът отива в тялото на отговора.await app.StartAsync()пуска сървъра и не чака: продължава към следващия ред.await app.StopAsync()го спира накрая.HttpClientе клиентът.GetStringAsync(адрес)пращаGETзаявка и дава тялото на отговора като текст.
var е, защото типовете тук са дълги (WebApplicationBuilder, WebApplication). using var http значи, че клиентът се затваря сам, когато програмата свърши.
По-точно, за любопитните
builder.Logging.ClearProviders()маха съобщенията, които ASP.NET пише в конзолата, например „Now listening on: http://localhost:5201“. В истински сървър те са полезни. Тук ги махаме, за да е в изхода само нашият текст.- Истинският сървър завършва с
app.Run(): той слуша, докато не го спреш с Ctrl+C. Тогава клиентът е друга програма: браузърът (отвориhttp://localhost:5201/) или приложението на телефона. Тук сървърът и клиентът са в една програма, за да може кодът да се провери без втори компютър. - Проект се прави с
dotnet new web. Програмата е вProgram.cs, аusing-ите за уеб са включени по подразбиране. - Един
HttpClientе за много заявки. Не прави нов за всяка: той пази връзките към сървъра отворени и ги ползва пак.
Виж го
Маршрути: кой адрес какво връща
Сървърът сравнява пътя на всяка заявка с маршрутите си и вика ламбдата на този, който пасва. Част от пътя може да е параметър:
app.MapGet("/products/{id:int}", (int id) =>{ Product product = catalog.FirstOrDefault(p => p.Id == id); return product == null ? "Няма такъв продукт" : product.Name + ": " + product.Price + " €";});app.MapGet("/search", (string q) => string.Join(", ", catalog.Where(p => p.Name.Contains(q)).Select(p => p.Name)));using var http = new HttpClient();Console.WriteLine(await http.GetStringAsync("http://localhost:5202/products/3"));Console.WriteLine(await http.GetStringAsync("http://localhost:5202/products/9"));Console.WriteLine(await http.GetStringAsync("http://localhost:5202/search?q=зърна"));Колумбия на зърна: 31.50 €Няма такъв продуктЕспресо на зърна, Колумбия на зърна{id:int}в маршрута е параметър в пътя:/products/3пасва иidе 3.:intе ограничение: пасват само числа.- Ламбдата има параметър със същото име,
int id. ASP.NET сам взима стойността от пътя и я превръща вint. (string q)няма място в пътя, затова се взима от заявката след?:/search?q=зърнадаваq = "зърна".catalogе списъкът с продукти от урока за LINQ. Ламбдите го виждат, защото са в същия код, след него.
По-точно, за любопитните
- Целият файл. Класът
Productе след кода, както в урока за LINQ:
var builder = WebApplication.CreateBuilder();builder.Logging.ClearProviders();var app = builder.Build();app.Urls.Add("http://localhost:5202");
List<Product> catalog = new List<Product>{ new Product { Id = 1, Name = "Еспресо на зърна", Price = 24.90m, Category = "кафе" }, new Product { Id = 2, Name = "Зелен чай сенча", Price = 9.80m, Category = "чай" }, new Product { Id = 3, Name = "Колумбия на зърна", Price = 31.50m, Category = "кафе" }, new Product { Id = 4, Name = "Мента", Price = 4.20m, Category = "чай" },};
app.MapGet("/products/{id:int}", (int id) =>{ Product product = catalog.FirstOrDefault(p => p.Id == id); return product == null ? "Няма такъв продукт" : product.Name + ": " + product.Price + " €";});app.MapGet("/search", (string q) => string.Join(", ", catalog.Where(p => p.Name.Contains(q)).Select(p => p.Name)));await app.StartAsync();
using var http = new HttpClient();Console.WriteLine(await http.GetStringAsync("http://localhost:5202/products/3"));Console.WriteLine(await http.GetStringAsync("http://localhost:5202/products/9"));Console.WriteLine(await http.GetStringAsync("http://localhost:5202/search?q=зърна"));
await app.StopAsync();
public class Product{ public int Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } public string Category { get; set; }}- Ако адресът е без
?q=, тукqеnull,Contains(null)хвърля изключение и сървърът отговаря с 500. В проект отdotnet new webе включена проверката заnull(<Nullable>enable</Nullable>) и тогава ASP.NET сам отговаря с 400, ако задължителен параметър липсва. - Други ограничения:
{price:decimal},{code:alpha}(само букви),{id:int:min(1)}. Параметър с?е незадължителен:/products/{page:int?}.
Виж го
Заявка и отговор
app.MapGet("/products/{id:int}", (int id) =>{ Product product = catalog.FirstOrDefault(p => p.Id == id); return product == null ? Results.NotFound() : Results.Ok(product);});await app.StartAsync();
using var http = new HttpClient();HttpResponseMessage response = await http.GetAsync("http://localhost:5219/products/2");Console.WriteLine((int)response.StatusCode);Console.WriteLine(await response.Content.ReadAsStringAsync());Стъпка по стъпка, с думи
- Сървърът записва маршрут: адрес /products/<число> води до този метод.
- StartAsync: сървърът започва да слуша на порт 5219.
- Клиентът праща заявка: метод GET, път /products/2.
- Пътят пасва на маршрута. 2 е число, затова id = 2.
- Продуктът е в каталога, затова методът връща Results.Ok с него. Сървърът го превръща в JSON.
- Отговорът се връща: статус 200 и тяло с JSON.
- Клиентът печата статуса и тялото.
Ламбдата може да върне не само стойност, а цял отговор. Results.Ok(обект) е статус 200 с обекта в тялото, а Results.NotFound() е 404 без тяло. Сървърът сам превръща продукта в JSON, текст, който описва обекта.
От страна на клиента GetAsync дава целия отговор като HttpResponseMessage. StatusCode е статусът ((int) го показва като число), а Content.ReadAsStringAsync() чете тялото. GetStringAsync от предишните карти хвърля изключение при статус, различен от 2xx. GetAsync не хвърля, затова с него виждаш и 404.
Същото за каталога, с продукт, който го има, и такъв, който го няма:
app.MapGet("/products/{id:int}", (int id) =>{ Product product = catalog.FirstOrDefault(p => p.Id == id); return product == null ? Results.NotFound() : Results.Ok(product);});using var http = new HttpClient();foreach (string path in new string[] { "/products/2", "/products/9" }){ HttpResponseMessage response = await http.GetAsync("http://localhost:5203" + path); Console.WriteLine(path + " -> " + (int)response.StatusCode + " " + response.StatusCode); Console.WriteLine(await response.Content.ReadAsStringAsync());}/products/2 -> 200 OK{"id":2,"name":"Зелен чай сенча","price":9.80,"category":"чай"}/products/9 -> 404 NotFoundПробвай
Текст вместо число
Маршрутът е същият, /products/{id:int}. Клиентът пита за продукт по име:
Какво ще отпечата?
HttpResponseMessage response = await http.GetAsync("http://localhost:5204/products/espresso");Console.WriteLine((int)response.StatusCode);espresso не е число, затова {id:int} не пасва. Друг маршрут за този път няма и сървърът отговаря с 404: „няма такъв адрес“. Ламбдата изобщо не се вика.
Виж го
JSON: обекти по мрежата
По мрежата отива текст, не обекти. JSON (JavaScript Object Notation) е най-честият начин обект да се запише като текст: {"id":2,"name":"Зелен чай сенча","price":9.80,"category":"чай"}. Свойствата са двойки "име":стойност в къдрави скоби, а списъкът е в квадратни скоби: [{…},{…}]. Разбират го всички езици и браузърите.
Ако ламбдата върне списък, сървърът го прави на JSON масив. В клиента GetFromJsonAsync<List<Product>> чете JSON-а и го превръща обратно в списък от продукти:
app.MapGet("/products", () => catalog);using var http = new HttpClient();string json = await http.GetStringAsync("http://localhost:5205/products");Console.WriteLine(json.Substring(0, 70) + "...");
List<Product> products = await http.GetFromJsonAsync<List<Product>>("http://localhost:5205/products");foreach (Product p in products.Where(p => p.Category == "чай")){ Console.WriteLine(p.Name + ": " + p.Price + " €");}[{"id":1,"name":"Еспресо на зърна","price":24.90,"category":"кафе"},{"...Зелен чай сенча: 9.80 €Мента: 4.20 €- В JSON-а имената са с малка буква (
name), а в C# с главна (Name). Сървърът иHttpClientсе разбират за това сами. decimalе число в JSON, със знаците след точката:24.90.
Програмите в урока, които печатат JSON с кирилица, имат два using-а най-отгоре:
using System.Text.Encodings.Web;using System.Text.Unicode;и още един ред след ClearProviders(). Защо, е в „По-точно“:
builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.Encoder = JavaScriptEncoder.Create(UnicodeRanges.All));По-точно, за любопитните
- Кирилицата в JSON. Библиотеката за JSON в .NET (
System.Text.Json) по подразбиране пише всичко извън латиницата като\uXXXX. Пак е верен JSON и всяка програма го чете правилно, но човек не може да го прочете. ЕтоJsonSerializer.Serializeбез уеб сървъра. Имената на свойствата тук остават с главна буква, защото малката е настройка на уеба:
using System.Text.Json;
Product mint = new Product { Id = 4, Name = "Мента", Price = 4.20m, Category = "чай" };Console.WriteLine(JsonSerializer.Serialize(mint));
public class Product{ public int Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } public string Category { get; set; }}{"Id":4,"Name":"\u041C\u0435\u043D\u0442\u0430","Price":4.20,"Category":"\u0447\u0430\u0439"}- Уеб сървърът в .NET 10 по подразбиране кодира по-малко: кирилицата излиза, както е, но не се кодират и знаците
<,>,&и'. Това е удобно, но е опасно, ако JSON-ът попадне в HTML страница. Затова името на тази настройка еUnsafeRelaxedJsonEscaping. Редът сConfigureHttpJsonOptionsзадава изрично какво става: всички букви от Unicode (UnicodeRanges.All) остават, както са, а опасните за HTML знаци се кодират (<става\u003C). Така изходът е четим и не зависи от настройката по подразбиране. - Обратното превръщане, от JSON към обект, не се интересува от тези настройки:
\u041CиМсе четат еднакво.
Виж го
POST: добави продукт
GET чете. За да добавиш продукт, клиентът праща POST с продукта като JSON в тялото. На сървъра това е MapPost:
app.MapPost("/products", (Product product) =>{ product.Id = catalog.Max(p => p.Id) + 1; catalog.Add(product); return Results.Created($"/products/{product.Id}", product);});using var http = new HttpClient();Product tea = new Product { Name = "Черен чай асам", Price = 7.40m, Category = "чай" };HttpResponseMessage response = await http.PostAsJsonAsync("http://localhost:5207/products", tea);Console.WriteLine((int)response.StatusCode + " " + response.Headers.Location);
Product created = await response.Content.ReadFromJsonAsync<Product>();Console.WriteLine("Нов продукт " + created.Id + ": " + created.Name);List<Product> all = await http.GetFromJsonAsync<List<Product>>("http://localhost:5207/products");Console.WriteLine("В каталога: " + all.Count);201 /products/5Нов продукт 5: Черен чай асамВ каталога: 5- Ламбдата взима
Product product. Той не е в пътя, затова ASP.NET го чете от JSON-а в тялото на заявката. Max(p => p.Id)от LINQ дава най-големия номер, затова новият е с едно повече.Results.Created(адрес, обект)е статус 201 Created: създадено е. ЗаглавкатаLocationказва адреса на новия ресурс, а тялото е самият продукт с новия му номер.- В клиента
PostAsJsonAsyncправи обекта на JSON и пращаPOST.ReadFromJsonAsync<Product>чете тялото на отговора като продукт.
По-точно, за любопитните
- Каталогът е в паметта на сървъра: при рестарт новите продукти изчезват. Истинският магазин ги пази в база данни.
- Истинският сървър обслужва много заявки наведнъж, а
Listне е направен за промени от няколко заявки едновременно. Това е още една причина данните да са в база данни, която се грижи за това. - Има и
MapPut(замени) иMapDelete(изтрий). Те са в урока за WebAPI.
Пробвай
500 вместо 404
Клиентът пита за /products/9, а такъв продукт няма. Очаква 404, но получава 500. На кой ред е грешката?
Избери реда с грешката.
First хвърля InvalidOperationException, когато не намери нищо. Сървърът не спира, но превръща изключението в отговор 500: „грешка на сървъра“. А грешката е в заявката: няма такъв продукт. Поправката е FirstOrDefault, проверка за null и Results.NotFound(), както на картата „Заявка и отговор“.
Упражнение
Сървърът на магазина
Реши задачите в редактора си. Всяка задача е една програма: сървър, клиент, който го вика, и спиране. Ако пускаш няколко наведнъж, дай на всяка свой порт.
1. Колко е часът. GET /time връща часа като текст 14:05 (DateTime.Now.ToString("HH:mm")). Часът се мени, затова клиентът проверява само вида му: дължина 5 и : на позиция 2:
Часът има вид ЧЧ:ММ: True2. Каталог с търсене. GET /products?category=чай връща продуктите от категорията, подредени по цена, а GET /search?q=… търси в имената без значение от главни и малки букви (ToLower). Клиентът чете първите две с GetFromJsonAsync, а третото печата като JSON:
Чай: Мента 4.20 €, Зелен чай сенча 9.80 €Намерени: 2[{"id":4,"name":"Мента","price":4.20,"category":"чай"}]3. Липсващ продукт. GET /products/{id:int} връща продукта или 404 със съобщение в тялото: Results.NotFound("Няма продукт с номер 42"). Клиентът пита за 1 и 42 и печата статуса и тялото:
1: 200 {"id":1,"name":"Еспресо на зърна","price":24.90,"category":"кафе"}42: 404 "Няма продукт с номер 42"4. Добави продукт. POST /products добавя продукта и връща 201 с адреса му. Ако името е празно (string.IsNullOrWhiteSpace) или цената не е положителна, връща Results.BadRequest("Името и цената са задължителни") и не добавя нищо. Клиентът добавя ройбос за 6,90 €, чете го по адреса от Location и пробва продукт без име:
POST: 201 /products/5GET: 5 Ройбос 6.90 €POST без име: 400 "Името и цената са задължителни"Тест