Към съдържанието
wannacode

Първият ти сървър

Проблем

Каталогът трябва да се вижда отвсякъде

Каталогът на магазина вече е списък от продукти в програмата ти и можеш да го филтрираш с LINQ. Но той е в паметта на твоя компютър. Клиентът отваря магазина в браузъра, друг го отваря в приложението на телефона си. И двете програми говорят HTTP: пращат заявка GET /products/3 и чакат отговор със статус и тяло.

Трябва ти програма, която слуша на порт, получава HTTP заявки и отговаря. Това е уеб сървър.

Виж го

Сървър и клиент в една програма

В C# уеб сървърът е ASP.NET Core. Най-простият начин да го ползваш е Minimal API: сървърът и адресите му са описани в няколко реда. Тази програма пуска сървър и сама го пита като клиент:

Program.cs
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:
Program.cs
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());
Стъпка по стъпка, с думи
  1. Сървърът записва маршрут: адрес /products/<число> води до този метод.
  2. StartAsync: сървърът започва да слуша на порт 5219.
  3. Клиентът праща заявка: метод GET, път /products/2.
  4. Пътят пасва на маршрута. 2 е число, затова id = 2.
  5. Продуктът е в каталога, затова методът връща Results.Ok с него. Сървърът го превръща в JSON.
  6. Отговорът се връща: статус 200 и тяло с JSON.
  7. Клиентът печата статуса и тялото.

Ламбдата може да върне не само стойност, а цял отговор. 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);
Отговор

Виж го

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. На кой ред е грешката?

Избери реда с грешката.

Упражнение

Сървърът на магазина

Реши задачите в редактора си. Всяка задача е една програма: сървър, клиент, който го вика, и спиране. Ако пускаш няколко наведнъж, дай на всяка свой порт.

1. Колко е часът. GET /time връща часа като текст 14:05 (DateTime.Now.ToString("HH:mm")). Часът се мени, затова клиентът проверява само вида му: дължина 5 и : на позиция 2:

Изход
Часът има вид ЧЧ:ММ: True

2. Каталог с търсене. 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/5
GET: 5 Ройбос 6.90 €
POST без име: 400 "Името и цената са задължителни"

Тест

Провери се

1. Какво прави app.MapGet("/products/{id:int}", (int id) => ...)?
2. Ламбдата връща Results.NotFound(). Какво получава клиентът?
3. Ламбдата връща обект Product. Как стига той до клиента?
4. Защо в програмите от урока има await app.StartAsync() и await app.StopAsync()?
5. Ламбдата намира продукта с catalog.First(p => p.Id == id), а такъв продукт няма. Какво получава клиентът?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·