ЛР1: UserService — gRPC, PostgreSQL и слоистая архитектура на троих
Доменный сервис на CRUD пользователей, реализованный через gRPC и PostgreSQL. Три человека в команде делали три слоя параллельно — ниже разбор всей работы: что каждый слой делает, зачем именно так, и на какие решения стоит быть готовым ответить на защите.
Задача и разбивка на слои
Сущность User: id, login, password, name, surname, age. Пять методов — CreateUser (с проверкой уникальности login), GetUserById, GetUserByName, UpdateUser (всё кроме id/login), DeleteUser.
Работу разложили на три блока, которые вызывают друг друга по цепочке:
gRPC-контроллер → доменный сервис → Repository → Postgres-функция
(Request/Response) (Domain: User) (DbModel)
Правило простое: каждый слой видит только модель соседнего уровня. Контроллер не знает про DbModel, репозиторий не знает про gRPC-Request. Это и есть требуемые лабой "три уровня моделей" — они физически разнесены по разным проектам/папкам, а не просто разные классы в одном месте.
Общий проект UserService.Contracts — это "язык", на котором слои договариваются: доменная модель User, интерфейсы IUserRepository/IUserService, доменные исключения UserNotFoundException/LoginAlreadyExistsException. Каждый слой реализует свой интерфейс и вызывает чужой только через контракт, никогда не заглядывая внутрь чужой реализации.
Таблица: пункт ТЗ → где это лежит в коде
Если на защите попросят "покажите, где у вас X" — вот прямой указатель, без поиска по проекту.
| Пункт ТЗ | Где реализовано |
|---|---|
Entity User (id, login, password, name, surname, age) |
Contracts/Users/User.cs (домен), Persistence/DbModels/UserDbModel.cs (БД) |
CreateUser + уникальность login |
UserGrpcService.CreateUser → UserService.CreateUserAsync → CreateUserValidator (ExistsByLoginAsync) → UserRepository.CreateAsync → fn_create_user + ux_users_login_lower |
GetUserById |
UserGrpcService.GetUserById → UserService.GetUserByIdAsync → UserRepository.GetByIdAsync → fn_get_user_by_id |
GetUserByName |
UserGrpcService.GetUserByName → UserService.GetUserByNameAsync → UserRepository.GetByNameAsync → fn_get_user_by_name + ix_users_name_surname_lower |
UpdateUser (без id/login) |
UserMapper.ToDomain(UpdateUserRequest) (Login = "") → UserService.UpdateUserAsync → UserRepository.UpdateAsync → fn_update_user (без p_login вообще) |
DeleteUser |
UserGrpcService.DeleteUser → UserService.DeleteUserAsync → UserRepository.DeleteAsync → fn_delete_user |
| СУБД PostgreSQL | docker-compose.yml, образ postgres:16-alpine |
| Индексы под запросы | Database/002_create_indexes.sql |
Функция/процедура на метод, в Database/ |
Database/001_…008_*.sql |
| Вызов функций по имени из репозитория | UserRepository.cs, SELECT * FROM fn_...(...) в каждом методе |
Dapper + DynamicParameters |
UserRepository.cs, аргументы каждого вызова |
Repository → массив, не IEnumerable |
IUserRepository.GetByNameAsync возвращает User[] |
FluentValidation, Validate/ValidateAsync |
Validation/*RequestValidator.cs (структурная), Services/Users/Validators/* (бизнес) |
| Mapperly, мапперы в отдельных классах | Persistence/Mapping/UserDbModelMapper.cs, Mapping/UserMapper.cs |
CancellationToken |
сквозной параметр во всех async-методах всех слоёв, от gRPC до репозитория |
| 3 уровня моделей | Request/Response (.proto) ↔ Domain (Contracts/Users/User.cs) ↔ DbModel (Persistence/DbModels/UserDbModel.cs) |
| DI, по умолчанию Singleton | AddUserRepository, AddUserService, AddUserGrpcLayer — везде AddSingleton |
Блок 1 — PostgreSQL и Repository
Что вообще такое индекс. Без индекса Postgres ищет строку по условию WHERE login = ... единственным способом — прочитать все строки таблицы подряд и проверить каждую (full table scan, O(n) от числа строк).
Индекс — это отдельная структура рядом с таблицей, заранее отсортированная (в Postgres по умолчанию B-tree), которая хранит значения колонки вместе со ссылкой на физическое место строки. Поиск по индексу — это спуск по дереву, а не перебор таблицы, поэтому и получается O(log n) вместо O(n).
Плата не бесплатная: каждый INSERT/UPDATE по проиндексированной колонке обязан ещё и перестроить индекс — индексировать "на всякий случай" каждую колонку значит платить за это на каждой записи, а не только выигрывать на чтении.
Таблица и индексы в этом проекте. users с SERIAL PRIMARY KEY и CHECK (age BETWEEN 1 AND 120). Два индекса под два реальных паттерна запросов: UNIQUE INDEX ON users (lower(login)) — источник истины для уникальности логина и опора для ExistsByLoginAsync, и обычный INDEX ON users (lower(name), lower(surname)) — под GetUserByName. Оба регистронезависимы через lower(...), потому что Ivan и ivan должны считаться одним и тем же логином.
По функции на метод. Каждая CRUD-операция — отдельная plpgsql-функция (fn_create_user, fn_get_user_by_id, fn_get_user_by_name, fn_update_user, fn_delete_user) плюс шестая, fn_user_exists_by_login, под быструю проверку логина. fn_update_user/fn_delete_user не возвращают строку — они возвращают INT через GET DIAGNOSTICS v_affected_rows = ROW_COUNT, то есть число реально изменённых строк.
Почему вызов через SELECT, а не CommandType.StoredProcedure. Это неочевидное место, проверено прямо в исходниках Npgsql — стоит быть готовым объяснить на защите:
// По умолчанию Npgsql строит "CALL fn_name(...)".
// CALL в Postgres работает только с PROCEDURE,
// а fn_create_user — это FUNCTION. Вызов упадёт
// с ошибкой "fn_create_user(...) is not a procedure".
var command = new CommandDefinition(
"fn_create_user",
parameters,
commandType: CommandType.StoredProcedure);// FUNCTION умеет то, что PROCEDURE не умеет так же
// просто — вернуть TABLE. Вызываем честным SELECT,
// это тоже вызов "по имени", просто без магии
// CommandType — и работает предсказуемо.
var command = new CommandDefinition(
"SELECT * FROM fn_create_user(@p_login, @p_password, @p_name, @p_surname, @p_age)",
parameters);Repository. UserRepository использует NpgsqlDataSource (не голый NpgsqlConnection) — это пул соединений, живущий как Singleton, каждый метод берёт соединение через OpenConnectionAsync(). Аргументы передаются через DynamicParameters, как требует лаба. CreateAsync дополнительно ловит
SQLSTATE 23505
и превращает его в LoginAlreadyExistsException — это защита от race condition поверх предварительной проверки в сервисе (два параллельных запроса с одним логином не проскочат оба).
DbModel (плоский класс под колонки таблицы) в домен превращается через Mapperly-маппер: [Mapper] public sealed partial class UserDbModelMapper { public partial User ToDomain(UserDbModel dbModel); }. Тело ToDomain не написано руками — Mapperly видит совпадающие по имени свойства и генерирует код маппинга на этапе компиляции (можно посмотреть сгенерированный .g.cs файл в obj/), в отличие от AutoMapper, который делает это рефлексией в рантайме и медленнее.
Всё регистрируется одним расширением AddUserRepository(configuration), все зависимости — Singleton (у NpgsqlDataSource, репозитория и маппера нет состояния между запросами, шарить один экземпляр безопасно и дешевле, чем создавать заново на каждый вызов).
Блок 2 — доменный сервис
UserService : IUserService (в Services/Users/) сидит между репозиторием и контроллером и отвечает за бизнес-правила, а не за форму данных.
Два валидатора, две модели вызова. UserValidator проверяет поля синхронно (Password/Name/Surname/Age — длины и диапазон) и вызывается через _validator.Validate(user), не ValidateAsync. Это прямое следование условию лабы:
синхронный валидатор — синхронный вызов, лишняя async-машина без асинхронных правил внутри — просто накладные расходы. CreateUserValidator, наоборот, содержит ровно одно асинхронное правило — обращение к ExistsByLoginAsync — и поэтому вызывается через ValidateAsync.
Not-found через affected rows. UpdateUserAsync/DeleteUserAsync не спрашивают репозиторий "нашёл или нет" отдельным запросом — они смотрят на число, которое вернул UpdateAsync/DeleteAsync (int, а не bool, специально ради этого). count == 0 значит "строки с таким id не было" → UserNotFoundException. После успешного UpdateAsync сервис ещё раз перечитывает пользователя через GetByIdAsync — не потому что не доверяет предыдущему шагу, а потому что UpdateAsync возвращает только число, а не обновлённую строку, и её реально больше неоткуда взять.
Слабое место, к которому стоит быть готовым. UserValidator принимает IUserRepository в конструктор, но нигде его не использует — мёртвый параметр, остался, судя по всему, от более раннего варианта дизайна. Если спросят "зачем тут репозиторий" — по коду сейчас ответа нет, это на удаление.
Блок 3 — gRPC-контроллер
.proto описывает 5 rpc-методов один в один по названиям из условия. UserResponse намеренно не содержит password — [MapperIgnoreSource(nameof(User.Password))] на Mapperly-мапперe гарантирует, что пароль физически не попадёт в ответ, это не забытое поле, а явное решение.
Двухуровневая валидация. UserGrpcService сначала прогоняет Request через структурный IValidator<TRequest> (login/password не пустые и ≤ 64, name/surname ≤ 128, age в 1..120 — ровно то, что решено в контракте команды), и только потом мапит в User и идёт в сервис. Бизнес-валидатор (UserValidator) в сервисе проверяет те же диапазоны второй раз — это осознанное дублирование "на всякий случай", а не ошибка: домен не должен слепо доверять вызывающему слою.
Исключения → gRPC-статусы. ExceptionHandlingInterceptor — единая точка перехвата: ValidationException → InvalidArgument, UserNotFoundException → NotFound, LoginAlreadyExistsException → AlreadyExists, всё прочее — залогировать и вернуть Internal, не отдавая наружу детали (не палим стектрейс клиенту).
Почему исключения, а не Result<T>. Result<T> — рабочая альтернатива, но у неё есть цена именно на трёх слоях подряд:
// Repository
public async Task<Result<User>> GetByIdAsync(int id, CancellationToken ct)
{
var dbModel = await connection.QuerySingleOrDefaultAsync<UserDbModel>(cmd);
return dbModel is null
? Result<User>.Failure("not found")
: Result<User>.Success(mapper.ToDomain(dbModel));
}
// Service — обязан явно проверить чужой
// Result и вручную протащить его дальше
public async Task<Result<User>> GetUserByIdAsync(int id, CancellationToken ct)
{
var result = await repository.GetByIdAsync(id, ct);
if (!result.IsSuccess) return result;
return Result<User>.Success(result.Value);
}
// Controller — снова проверка, и только
// здесь наконец решаем gRPC-статус
public override async Task<UserResponse> GetUserById(...)
{
var result = await service.GetUserByIdAsync(id, ct);
if (!result.IsSuccess)
throw new RpcException(new Status(StatusCode.NotFound, result.Error));
return mapper.ToResponse(result.Value);
}// Repository
public async Task<User?> GetByIdAsync(int id, CancellationToken ct)
{
var dbModel = await connection.QuerySingleOrDefaultAsync<UserDbModel>(cmd);
return dbModel is null ? null : mapper.ToDomain(dbModel);
}
// Service — просто кидает и забывает
public async Task<User> GetUserByIdAsync(int id, CancellationToken ct)
{
var result = await repository.GetByIdAsync(id, ct);
if (result is null) throw new UserNotFoundException(id);
return result;
}
// Controller вообще не думает про ошибки —
// Interceptor разберётся сам, один раз,
// сразу для всех пяти методов
public override async Task<UserResponse> GetUserById(...)
{
var user = await service.GetUserByIdAsync(id, ct);
return mapper.ToResponse(user);
}С Result<T> каждый промежуточный слой обязан явно проверить IsSuccess и вручную протащить ошибку выше — на пять методов это 15 мест, где проверку легко забыть или перепутать код ошибки. С исключениями логика "как превратить ошибку в gRPC-статус" живёт ровно в одном месте, ExceptionHandlingInterceptor, а не размазана по слоям. Плюс сам gRPC и так устроен вокруг похожей модели: любой ответ либо успешен, либо несёт RpcException со StatusCode — граница системы всё равно exception-based, Result<T> пришлось бы превращать в исключение на этой границе в любом случае, просто позже и вручную.
Честная оговорка на случай встречного вопроса: исключения дороже в момент throw (разворачивание стека) и превращаются в антипаттерн, если ими подменяют частый, ожидаемый control flow — кидать исключение на каждый невалидный email в форме на высоконагруженном эндпоинте было бы плохой идеей. Здесь не тот случай: "пользователь не найден" и "логин уже занят" — это по-настоящему исключительные для клиента бизнес-исходы, а не ветка, которая срабатывает на каждый запрос.
Именной коллапс, который стоит понимать. В proto стоит option csharp_namespace = "UserService"; и service UserService, а сам проект тоже называется UserService (совпадает с корневым неймспейсом), и вдобавок доменный сервис — тоже класс UserService. Итого три разных вещи с одним именем в разных неймспейсах. В UserGrpcService.cs это решено явным алиасом:
using GeneratedUserService = global::UserService.UserService;
а в DependencyInjection.cs — явной квалификацией Users.UserService, а не голым UserService. C# резолвит неквалифицированное имя, поднимаясь по родительским неймспейсам — без этих уточнений компилятор в некоторых местах молча схватил бы не тот класс. Команда обошла это корректно, но название компонентов "как у самого проекта" — это риск для любого нового кода, который допишут позже без этой осторожности.
Путь одного запроса: CreateUser от клиента до ответа
Клиент шлёт CreateUserRequest{login, password, name, surname, age}. Дальше по шагам, без пропусков:
UserGrpcService.CreateUserвызывает_createValidator.ValidateAndThrow(request)— этоCreateUserRequestValidator, структурная проверка (login/passwordне пустые и ≤ 64,name/surname≤ 128,ageв 1..120). Провал →ValidationException.UserMapper.ToDomain(request)превращаетCreateUserRequestвUser— Mapperly копирует совпадающие по имени поля,Idявно проигнорирован ([MapperIgnoreTarget]), его ещё не существует.UserService.CreateUserAsync(user, ct)— вход в бизнес-слой: -_validator.Validate(user)— синхронно, ещё разPassword/Name/Surname/Age(домен не доверяет вызывающему слою вслепую); -_createUserValidator.ValidateAsync(user, ct)— единственное правило внутри дёргаетIUserRepository.ExistsByLoginAsync(login, ct). Еслиtrue→ правило падает → сервис кидаетLoginAlreadyExistsExceptionдо похода вINSERT.UserRepository.CreateAsync(user, ct)— собираетDynamicParameters(p_login,p_password,p_name,p_surname,p_age), берёт соединение изNpgsqlDataSource, выполняетSELECT * FROM fn_create_user(...).fn_create_userв Postgres делаетINSERT ... RETURNING— еслиUNIQUE INDEX ux_users_login_lowerуже занят (два одновременных запроса проскочили шаг 3 до того, как первый успел записаться) — Postgres бросает ошибку сSQLSTATE 23505, репозиторий её ловит и превращает в тот жеLoginAlreadyExistsException, только на уровень позже — это и есть защита от race condition.- Строка из БД возвращается как
UserDbModel,UserDbModelMapper.ToDomainпревращает её обратно вUser— теперь уже с реальнымIdизSERIAL. UserService.CreateUserAsyncвозвращаетUserконтроллеру,UserMapper.ToResponse(user)строитUserResponse—[MapperIgnoreSource(nameof(User.Password))]гарантирует, что пароль в ответ не попадёт.- Если где-то по пути вылетело исключение —
ExceptionHandlingInterceptorперехватывает его один раз, в одном месте, превращает вRpcExceptionс нужнымStatusCode, и только тогда это долетает до клиента.
GetUserById/GetUserByName/UpdateUser/DeleteUser идут по такой же схеме, отличаются только тем, какой валидатор на входе и какая функция в конце цепочки.
CancellationToken: что реально происходит при отмене
В шаге 3 и 4 выше ct (CancellationToken) прошёл через все три слоя не для галочки — это единственное, что делает отмену запроса настоящей, а не притворной.
Откуда он берётся. У каждого gRPC-вызова есть ServerCallContext, а у него — CancellationToken, который срабатывает в двух случаях: клиент сам отменил вызов (закрыл соединение, истёк deadline) или сам ASP.NET Core оборвал вызов. UserGrpcService берёт его из context.CancellationToken и передаёт вниз — в UserService, оттуда в UserRepository, оттуда в CommandDefinition для Dapper, а Dapper — в Npgsql.
Почему важно донести токен до самого низа, а не остановиться на полпути:
var command = new CommandDefinition(
"SELECT * FROM fn_get_user_by_id(@p_id)",
parameters);
// Клиент отменил вызов — await в C# просто
// перестаёт ждать, метод возвращает
// управление раньше. Но Postgres об этом
// ничего не знает: запрос на сервере БД
// продолжает выполняться до конца, просто
// его результат уже никому не нужен.var command = new CommandDefinition(
"SELECT * FROM fn_get_user_by_id(@p_id)",
parameters,
cancellationToken: cancellationToken);
// Npgsql отправляет в Postgres настоящий
// протокольный Cancel-запрос. Работающий
// запрос на сервере БД реально прерывается,
// а не просто перестаёт быть кому-то нужным.Без прокидывания токена вниз отмена — это иллюзия только на уровне C#-await, а реальная работа (чтение с диска, блокировки строк в Postgres) продолжает выполняться впустую. Именно поэтому CancellationToken — обязательный параметр каждого async-метода на каждом уровне здесь, без значения по умолчанию: забыть прокинуть его хотя бы в одном месте цепочки значит сделать отмену настоящей только до этого места.
Program.cs, appsettings и docker-compose — как всё собирается
Program.cs — composition root, он не содержит логики, только сборку DI-контейнера:
builder.Services.AddGrpc(options =>
{
options.Interceptors.Add<ExceptionHandlingInterceptor>();
});
builder.Services.AddUserGrpcLayer(); // Илья: мапперы, request-валидаторы, интерцептор
builder.Services.AddUserRepository(builder.Configuration); // Руслан: БД
builder.Services.AddUserService(); // Оля: бизнес-логика
var app = builder.Build();
app.MapGrpcService<UserGrpcService>(); // после Build() — раньше сервис-провайдера ещё нет
Порядок трёх Add* между собой не важен — это просто регистрация типов в контейнере, ничего ещё не выполняется. А MapGrpcService обязан идти строго после builder.Build(), потому что до этого момента app (и его IServiceProvider) ещё не существует.
AddUserRepository внутри читает builder.Configuration.GetConnectionString("UserServiceDb") — эта строка живёт в appsettings.json, и она обязана совпадать с POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB из docker-compose.yml (сейчас везде user_service/user_service/user_service) — если поменяли одно, надо поменять и второе, никакой синхронизации между файлами нет.
Практическая ловушка с docker-entrypoint-initdb.d: Postgres выполняет .sql-скрипты из этой папки только при первом старте контейнера на пустом volume. Если контейнер уже когда-то поднимался и в user_service_db_data уже есть данные — правка любого файла в Database/ (новая функция, новый индекс) молча не применится, пока не сделать docker compose down -v (или -v явно удалить volume) и поднять заново. Частая причина "я поправил SQL, а ничего не изменилось" на этой лабе.
Как поднять и проверить
docker compose up -d
dotnet build
dotnet run --project UserService
docker-compose.yml монтирует UserService/Database как /docker-entrypoint-initdb.d — Postgres накатывает таблицу, индексы и все функции сам при первом старте контейнера (файлы пронумерованы 001_…008_, порядок применения = алфавитный = числовой).
Шпаргалка: кто что спросит
- "Почему Singleton, а не Scoped?" — у репозитория, сервиса и мапперов нет состояния между запросами;
NpgsqlDataSourceсам по себе пул соединений, ему незачем плодиться на каждый запрос. - "Что будет, если два запроса одновременно создадут одного и того же пользователя?" — оба пройдут
ExistsByLoginAsync(там ещё нет строки), оба пойдут вINSERT, ноUNIQUE INDEXпропустит только первый — второй получитSQLSTATE 23505, репозиторий превратит это вLoginAlreadyExistsException, Interceptor — вAlreadyExists. - "Почему
GetUserByNameс пустым результатом — не ошибка?" — так решено в контракте команды: отсутствие совпадений это валидный ответ (пустой массив), а не исключительная ситуация, в отличие отGetById/Update/Deleteпо несуществующемуid.