Changes between Version 1 and Version 2 of AdvancedApplicationDevelopment


Ignore:
Timestamp:
09/23/26 13:58:12 (5 days ago)
Author:
233149
Comment:

--

Legend:

Unmodified
Added
Removed
Modified
  • AdvancedApplicationDevelopment

    v1 v2  
    1 == Напреден апликативен развој
     1Само двата блока кои даваа грешка се менуваат. Останатиот текст на страницата
     2останува непроменет.
    23
    3 Backend делот од апликацијата е .NET 9 Web API кој до базата пристапува преку
    4 Entity Framework Core и драјверот Npgsql
    5 ([https://www.nuget.org/packages/Npgsql.EntityFrameworkCore.PostgreSQL Npgsql.EntityFrameworkCore.PostgreSQL 9.0.4]).
    6 Базата е зад SSH тунел, па секоја физичка конекција е уште еден канал низ тунелот -
    7 тоа е причината зошто во оваа фаза и трансакциите и pool-от се разгледуваат заедно.
     4================================================================================
     5БЛОК 1 - во делот "2.1 Pool на конекции (Npgsql)"
     6================================================================================
    87
    9 Оваа фаза опфаќа:
     8ЗАМЕНИ ГО ОВА:
    109
    11  * '''Трансакции''' - кои сценарија пишуваат во повеќе табели и како тие се водат како една целина
    12  * '''Ниво на изолација''' - што се случува кога два студента запишуваат ист семестар истовремено
    13  * '''Pooling''' - како се менаџираат конекциите и колку од нив навистина се отвораат
    14 
    15 == 1. Трансакции
    16 
    17 === Како EF Core води трансакција
    18 
    19 EF Core не бара анотација како {{{@Transactional}}}. Секој повик на
    20 {{{SaveChangesAsync()}}} сам по себе е една трансакција - сите промени натрупани во
    21 контекстот се испраќаат во еден BEGIN/COMMIT. Затоа операција која пишува во повеќе
    22 табели '''еднаш''' не бара ништо дополнително.
    23 
    24 Експлицитна трансакција е потребна кога операцијата мора да повика
    25 {{{SaveChangesAsync()}}} '''повеќе пати''', најчесто затоа што вториот запис го
    26 употребува идентификаторот доделен при првиот. Тогаш двата повика се затвораат во
    27 {{{BeginTransactionAsync()}}} ... {{{CommitAsync()}}}, за да не остане запис од
    28 првиот чекор ако вториот падне.
    29 
    30 Во апликацијата тоа се три сценарија: регистрација на студент, запишување на
    31 семестар и бришење на предмет.
    32 
    33 === 1.1 Регистрација на студент (UC002)
    34 
    35 Корисникот, неговите контакт податоци и средношколските податоци се три табели.
    36 Contact и high_school имаат надворешен клуч кон users, па идентификаторот на
    37 корисникот мора прво да постои - оттука и двата повика на {{{SaveChangesAsync}}}.
    38 
    39 {{{#!csharp
    40 public async Task<bool> RegisterAsync(RegisterDto registerDto)
    41 {
    42     if (await _userRepository.UserExistsAsync(registerDto.Email))
    43         throw new InvalidOperationException("User with this email already exists");
    44 
    45     // Use a transaction to ensure all entities are saved together
    46     using var transaction = await _context.Database.BeginTransactionAsync();
    47     try
    48     {
    49         // quota and enrollment_year are attributes of Users in the ER
    50         // model, so they are set here rather than on a separate entity.
    51         var user = new User
    52         {
    53             Name = registerDto.Name,
    54             Surname = registerDto.Surname,
    55             Index = registerDto.Index,
    56             Email = registerDto.Email,
    57             PasswordHash = BCrypt.Net.BCrypt.HashPassword(registerDto.Password),
    58             Bday = DateTime.SpecifyKind(registerDto.Bday, DateTimeKind.Unspecified),
    59             CreatedAt = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified),
    60             Role = (Models.UserRole)registerDto.Role,
    61             EMBG = registerDto.EMBG,
    62             Quota = (Models.Quota)registerDto.quotaType,
    63             EnrollmentYear = registerDto.enrollmentYear
    64         };
    65 
    66         _context.User.Add(user);
    67         await _context.SaveChangesAsync();   // <- тука user.Id добива вредност
    68 
    69         int userId = user.Id;
    70 
    71         var contactInfo = new ContactInfo
    72         {
    73             UserId = userId,
    74             City = registerDto.city,
    75             Address = registerDto.address,
    76             Municipality = registerDto.municipality,
    77             PhoneNumber = registerDto.phoneNumber,
    78             MicrosoftEmail = registerDto.microsoftEmail
    79         };
    80 
    81         var highSchool = new HighSchool
    82         {
    83             UserId = userId,
    84             GPA = registerDto.gpa,
    85             HighSchoolType = (Models.HighSchoolType)registerDto.tip
    86         };
    87 
    88         _context.ContactInfo.Add(contactInfo);
    89         _context.HighSchool.Add(highSchool);
    90 
    91         // Save all related entities in a single transaction
    92         await _context.SaveChangesAsync();
    93         await transaction.CommitAsync();
    94 
    95         return true;
    96     }
    97     catch
    98     {
    99         await transaction.RollbackAsync();
    100         throw;
    101     }
     10{{{#!json
     11"ConnectionStrings": {
     12  "DefaultConnection": "Host=localhost;..."
    10213}
    10314}}}
    10415
    105 Без трансакцијата, пад при вториот {{{SaveChangesAsync}}} би оставил корисник без
    106 контакт и без средношколски податоци - запис кој ниту може да се употреби, ниту
    107 може да се регистрира повторно, бидејќи е-поштата е UNIQUE.
    108 
    109 === 1.2 Запишување на семестар (UC007)
    110 
    111 Запишувањето создава еден ред во enrolled_semesters и '''точно пет''' реда во
    112 semesters_subjects. Редовите за предметите го бараат идентификаторот на
    113 запишувањето, па повторно се работи за два чекора.
    114 
    115 {{{#!csharp
    116 await using var transaction = await _context.Database.BeginTransactionAsync();
    117 try
    118 {
    119     var enrolment = new EnrolledSemesters
    120     {
    121         UserId = studentId,
    122         SemesterId = request.SemesterId,
    123         MajorId = request.MajorId,
    124         // enrolled_semesters.quota is NOT NULL; fall back to the
    125         // state quota when the student record has none.
    126         QuotaType = student.Quota ?? Models.Quota.drzavna,
    127         CratedAt = now,
    128         LastChange = now,
    129         Verified = null
    130     };
    131 
    132     _context.EnrolledSemesters.Add(enrolment);
    133     await _context.SaveChangesAsync();
    134 
    135     foreach (var subjectId in subjectIds)
    136     {
    137         _context.SemesterSubjects.Add(new SemesterSubject
    138         {
    139             EnrolledSemesterId = enrolment.Id,
    140             SubjectId = subjectId,
    141             ProfessorId = professorBySubject[subjectId],
    142             Signature = false
    143         });
    144     }
    145 
    146     await _context.SaveChangesAsync();
    147     await transaction.CommitAsync();
    148 
    149     return new EnrollSemesterResultDto
    150     {
    151         Ok = true,
    152         Message = $"Enrolled with {subjectIds.Count} subjects.",
    153         EnrolledSemesterId = enrolment.Id
    154     };
    155 }
    156 }}}
    157 
    158 Овде трансакцијата не е само заштита од полузапишан семестар. Ограничувањето за
    159 големина на запишувањето од Фаза 7 е '''одложен''' тригер:
    160 
    161 {{{#!sql
    162 CREATE CONSTRAINT TRIGGER trg_enrolment_size
    163     AFTER INSERT OR UPDATE OR DELETE ON semesters_subjects
    164     DEFERRABLE INITIALLY DEFERRED
    165     FOR EACH ROW EXECUTE FUNCTION check_enrolment_size();
    166 }}}
    167 
    168 Одложен тригер се проверува при COMMIT, а не по секој ред. Токму затоа петте
    169 предмети се внесуваат во иста трансакција: ако се внесуваа секој во своја
    170 трансакција, првиот ред ќе беше видлив во база како запишување со еден предмет, а
    171 правилото „најмногу 5 предмети и најмногу 30 кредити“ ќе се проверуваше пет пати
    172 наместо еднаш, на крајот.
    173 
    174 === 1.3 Бришење на предмет (UC012)
    175 
    176 Предметот е референциран од три врзни табели. Тие мора да се избришат пред него, а
    177 ако некоја од нив падне, предметот мора да остане недопрен.
    178 
    179 {{{#!csharp
    180 // semesters_subjects references subjects, so a taken subject cannot go.
    181 var taken = await _context.SemesterSubjects.CountAsync(ss => ss.SubjectId == subjectId);
    182 if (taken > 0)
    183 {
    184     return Fail($"Cannot delete: {taken} enrolment(s) already contain this subject.");
    185 }
    186 
    187 await using var transaction = await _context.Database.BeginTransactionAsync();
    188 try
    189 {
    190     // The mapping rows must go first; their foreign keys would block the delete.
    191     var prereqs = await _context.DependencySubjects
    192         .Where(d => d.SubjectId == subjectId || d.DependencyId == subjectId)
    193         .ToListAsync();
    194     _context.DependencySubjects.RemoveRange(prereqs);
    195 
    196     var majors = await _context.MajorSubjects
    197         .Where(ms => ms.SubjectId == subjectId).ToListAsync();
    198     _context.MajorSubjects.RemoveRange(majors);
    199 
    200     var teaching = await _context.ProfessorSubjects
    201         .Where(ps => ps.SubjectId == subjectId).ToListAsync();
    202     _context.ProfessorSubjects.RemoveRange(teaching);
    203 
    204     _context.Subjects.Remove(subject);
    205     await _context.SaveChangesAsync();
    206     await transaction.CommitAsync();
    207 
    208     return Ok($"Subject '{subject.Name}' deleted.");
    209 }
    210 catch
    211 {
    212     await transaction.RollbackAsync();
    213     throw;
    214 }
    215 }}}
    216 
    217 === 1.4 Оценување (UC010) - кога трансакција не е потребна
    218 
    219 Внесувањето на оценка пишува во една табела, со еден
    220 {{{SaveChangesAsync}}}, па EF Core сам го обвиткува во трансакција. Додавање на
    221 експлицитна трансакција овде не би променило ништо, освен уште една размена со
    222 серверот.
    223 
    224 {{{#!csharp
    225 case GradeAction.Add:
    226     if (existing is not null)
    227     {
    228         return Fail("This subject is already graded. Use edit to change the grade.");
    229     }
    230     await _profRepository.AddPassedSubjectAsync(new PassedSubject
    231     {
    232         SemesterSubjectId = semesterSubject.Id,
    233         Grade = (Grade)request.Grade,
    234         // date_passed is `timestamp without time zone`.
    235         DatePassed = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified)
    236     });
    237     return Ok($"Grade {request.Grade} added.");
    238 }}}
    239 
    240 Вреди да се забележи дека и овде има повеќе од еден запис во базата, но вториот го
    241 прави '''базата''', не апликацијата: тригерот trg_grade_rules од Фаза 7 го
    242 поставува потписот и создава известување за студентот. Бидејќи тие се извршуваат во
    243 истата трансакција како и внесувањето, оценка без потпис и без известување не може
    244 да постои.
    245 
    246 === 1.5 Ниво на изолација и конкурентни запишувања
    247 
    248 Ниту EF Core ниту Npgsql не го менуваат нивото на изолација, па важи
    249 стандардното ниво на PostgreSQL - '''READ COMMITTED'''. Тоа значи дека секое
    250 барање во трансакцијата ја гледа состојбата потврдена пред тоа барање, но не ги
    251 гледа незавршените трансакции на другите.
    252 
    253 Тоа е доволно за сите сценарија освен за едно. Пред да запише, апликацијата
    254 проверува дали студентот веќе го запишал тој семестар:
    255 
    256 {{{#!csharp
    257 // enrolled_semesters has UNIQUE (user_id, semester_id); check first so
    258 // the student gets a sentence rather than a constraint violation.
    259 if (await _context.EnrolledSemesters.AnyAsync(
    260         es => es.UserId == studentId && es.SemesterId == request.SemesterId))
    261 {
    262     return Fail("You are already enrolled in that semester.");
    263 }
    264 }}}
    265 
    266 Ако студентот два пати кликне на копчето, двете барања ја прават оваа проверка пред
    267 било кое од нив да потврди, па двете ја поминуваат. Проверката во апликација не
    268 може да го спречи тоа - таа чита состојба која во меѓувреме се менува.
    269 
    270 Ограничувањето во базата може. Проверено со две истовремени трансакции врз шемата
    271 project:
     16СО ОВА:
    27217
    27318{{{
    274 -- сесија А                          -- сесија Б (0.5s подоцна)
    275 BEGIN;                               BEGIN;
    276 INSERT INTO enrolled_semesters       INSERT INTO enrolled_semesters
    277   (user_id, quota, major_id,           (user_id, quota, major_id,
    278    semester_id)                         semester_id)
    279 VALUES (1, 'drzavna', 1, 3);         VALUES (1, 'drzavna', 1, 3);
    280 SELECT pg_sleep(2);                  -- чека А да заврши
    281 COMMIT;                              ERROR: duplicate key value violates unique
    282                                      constraint "enrolled_semesters_user_id_semester_id_key"
    283                                      DETAIL: Key (user_id, semester_id)=(1, 3) already exists.
    284                                      ROLLBACK
    285 }}}
    286 
    287 Втората трансакција не добива грешка веднаш - таа '''чека''' на редот вметнат од
    288 првата, бидејќи PostgreSQL мора прво да види дали првата ќе потврди или ќе се
    289 врати. Дури по COMMIT-от на првата, втората паѓа.
    290 
    291 Апликацијата затоа ја фаќа таа грешка и ја претвора во истата реченица која ја враќа
    292 и проверката погоре, наместо студентот да добие 500:
    293 
    294 {{{#!csharp
    295 catch (DbUpdateException ex) when (IsUniqueViolation(ex))
    296 {
    297     // Two enrolments for the same semester sent at the same time both
    298     // pass the check above, because each transaction reads the state
    299     // from before the other one wrote. UNIQUE (user_id, semester_id)
    300     // is what actually settles it, so the loser gets the same
    301     // sentence as if the check had caught it.
    302     await transaction.RollbackAsync();
    303     return Fail("You are already enrolled in that semester.");
    304 }
    305 catch
    306 {
    307     await transaction.RollbackAsync();
    308     throw;
    309 }
    310 
    311 /// <summary>23505 is unique_violation.</summary>
    312 private static bool IsUniqueViolation(DbUpdateException ex) =>
    313     ex.InnerException is PostgresException { SqlState: "23505" };
    314 }}}
    315 
    316 Значи проверката во апликацијата постои заради пораката, а ограничувањето во базата
    317 заради точноста. Истиот принцип важи и за правилата од Фаза 7: апликацијата ги
    318 проверува за да даде разбирлива порака, а базата ги чува за да важат и кога некој
    319 пишува директно во неа.
    320 
    321 == 2. Pooling
    322 
    323 === 2.1 Pool на конекции (Npgsql)
    324 
    325 Како и кај Spring Boot, конекциите не се отвораат рачно. Npgsql има вграден pool
    326 кој е вклучен стандардно, па {{{UseNpgsql(connectionString)}}} е доволно.
    327 
    328 Стандардните вредности на Npgsql 9.0.4 (отчитани од
    329 {{{NpgsqlConnectionStringBuilder}}}):
    330 
    331 {{{
    332 Pooling                     = True
    333 Minimum Pool Size           = 0
    334 Maximum Pool Size           = 100
    335 Connection Idle Lifetime    = 300     (секунди)
    336 Connection Pruning Interval = 10      (секунди)
    337 Timeout                     = 15      (секунди, чекање на слободна конекција)
    338 Command Timeout             = 30      (секунди)
    339 Multiplexing                = False
    340 }}}
    341 
    342 Стандардните 100 конекции се премногу за оваа поставеност: серверот е споделен меѓу
    343 сите проекти, а секоја конекција е уште еден канал низ SSH тунелот. Затоа pool-от е
    344 ограничен во самиот connection string:
    345 
    346 {{{#!json
    34719"ConnectionStrings": {
    34820  "DefaultConnection": "Host=localhost;Port=9999;Database=db_202526z_va_prj_know2026;Username=db_202526z_va_prj_iknow2026_owner;Password=***;Search Path=project;Include Error Detail=true;Maximum Pool Size=10;Minimum Pool Size=1;Connection Idle Lifetime=300;Timeout=15"
    … …  
    35022}}}
    35123
    352  * '''Maximum Pool Size=10''' - горна граница на отворени конекции, исто како default-от на HikariCP
    353  * '''Minimum Pool Size=1''' - една конекција останува отворена, за тунелот да не мора да отвора нов канал за секое прво барање
    354  * '''Connection Idle Lifetime=300''' - неупотребените конекции над минимумот се затвораат по 5 минути
    355  * '''Timeout=15''' - ако сите 10 се зафатени, барањето чека најмногу 15 секунди пред да падне
     24================================================================================
     25БЛОК 2 - во делот "2.4 Проверка дека апликацијата е поврзана"
     26================================================================================
    35627
    357 === 2.2 Pool на DbContext (EF Core)
     28ЗАМЕНИ ГО ОВА:
    35829
    359 Покрај конекциите, EF Core може да ги рециклира и самите DbContext објекти. Тоа е
    360 одделен pool: {{{AddDbContextPool}}} ги чува инстанците на контекстот (со нивните
    361 поставки и мапирања), додека Npgsql ги чува физичките конекции под нив.
    362 
    363 {{{#!csharp
    364 // AddDbContextPool reuses the DbContext instances themselves; the connections
    365 // underneath them are pooled separately by Npgsql, sized in the connection
    366 // string. Both matter here because every physical connection is one more
    367 // channel through the SSH tunnel.
    368 builder.Services.AddDbContextPool<AppDbContext>(options =>
    369     options.UseNpgsql(
    370         builder.Configuration.GetConnectionString("DefaultConnection"),
    371         npgsql =>
    372         {
    373             npgsql.MapEnum<UserRole>("user_role", AppDbContext.ProjectSchema, PgEnumLabels.UserRole);
    374             npgsql.MapEnum<HighSchoolType>("hs_type", AppDbContext.ProjectSchema, PgEnumLabels.Lowercase);
    375             npgsql.MapEnum<Quota>("quota_type", AppDbContext.ProjectSchema, PgEnumLabels.Lowercase);
    376             npgsql.MapEnum<sType>("semester_type", AppDbContext.ProjectSchema, PgEnumLabels.Lowercase);
    377             npgsql.MapEnum<Grade>("grade_type", AppDbContext.ProjectSchema, PgEnumLabels.Grade);
    378         }));
     30{{{#!json
     31{
     32  "connected": true,
     33  ...
     34}
    37935}}}
    38036
    381 Ова е применливо бидејќи AppDbContext има само еден конструктор, оној кој прима
    382 {{{DbContextOptions}}}. Контекст кој би примал сопствени зависности (на пример
    383 тековниот корисник) не смее да се рециклира, бидејќи состојбата од едно барање би
    384 протекла во следното.
    385 
    386 === 2.3 Мерење на pool-от
    387 
    388 Мерено со апликацијата пуштена врз локална база со истата шема, со броење на
    389 {{{pg_stat_activity}}}:
    390 
    391 {{{#!sql
    392 SELECT count(*) FROM pg_stat_activity WHERE datname = 'iknow';
    393 }}}
     37СО ОВА:
    39438
    39539{{{
    396 состојба                                          отворени конекции
    397 --------------------------------------------------------------------
    398 по стартување и едно барање                              1
    399 за време на 50 истовремени барања                       10
    400 по завршување на барањата                               10
    401 }}}
    402 
    403 Првиот ред е Minimum Pool Size=1 - една конекција останува отворена. Вториот ред
    404 покажува дека pool-от расте со оптоварувањето, но застанува точно на Maximum Pool
    405 Size=10; останатите барања чекаат слободна конекција наместо да отворат нова.
    406 Третиот ред покажува дека отворените конекции не се затвораат веднаш - тие остануваат
    407 во pool-от и се затвораат дури по Connection Idle Lifetime, бидејќи повторното
    408 отворање низ тунел е поскапо од држењето отворена конекција.
    409 
    410 Истото може да се провери и од страната на тунелот - секоја нова конекција се гледа
    411 како нов канал во логовите на тунел скриптата:
    412 
    413 {{{
    414 debug1: Connection to port 9999 forwarding to localhost port 5432 requested.
    415 debug1: channel 2: new direct-tcpip [direct-tcpip] (inactive timeout: 0)
    416 }}}
    417 
    418 === 2.4 Проверка дека апликацијата е поврзана
    419 
    420 Контролерот DbHealth го враќа одговорот кој покажува дека тунелот, конекцијата и
    421 мапирањето на ентитетите работат:
    422 
    423 {{{#!json
    42440{
    42541  "connected": true,
    … …  
    43551}}}
    43652
    437 == Историјат
     53================================================================================
     54ОБЈАСНУВАЊЕ
     55================================================================================
    43856
    439  '''Верзија 1''' - Прва верзија: трансакции во трите сценарија кои пишуваат во
    440  повеќе табели, обработка на конкурентни запишувања преку ограничувањето
    441  UNIQUE (user_id, semester_id), и конфигурација и мерење на pool-от на конекции и
    442  на DbContext.
     57Trac ги обработува блоковите {{{#!ime }}} преку процесор со тоа име. Процесор
     58'json' не постои во оваа инсталација, па наместо содржината се прикажува
     59пораката:
    44360
    444 == Статус
     61    Error: Failed to load processor json
     62    No macro or processor named 'json' found
    44563
    446  ''' [[span(style=color: #FF8000, Во тек )]] '''
     64Обичен блок {{{ ... }}} без име на процесор прикажува претформатиран текст и
     65секогаш работи. Затоа е доволно само да се избрише "#!json" од првата линија на
     66двата блока.
     67
     68Ако сакате боење на синтаксата, {{{#!js }}} обично работи и за JSON, бидејќи
     69JSON е подмножество на JavaScript. Но сигурниот избор е обичниот блок.