Changes between Version 2 and Version 3 of AdvancedApplicationDevelopment


Ignore:
Timestamp:
09/23/26 14:03:15 (5 days ago)
Author:
233149
Comment:

--

Legend:

Unmodified
Added
Removed
Modified
  • AdvancedApplicationDevelopment

    v2 v3  
    1 Само двата блока кои даваа грешка се менуваат. Останатиот текст на страницата
    2 останува непроменет.
    3 
    4 ================================================================================
    5 БЛОК 1 - во делот "2.1 Pool на конекции (Npgsql)"
    6 ================================================================================
    7 
    8 ЗАМЕНИ ГО ОВА:
    9 
    10 {{{#!json
    11 "ConnectionStrings": {
    12   "DefaultConnection": "Host=localhost;..."
    13 }
    14 }}}
    15 
    16 СО ОВА:
     1== Напреден апликативен развој
     2
     3Backend делот од апликацијата е .NET 9 Web API кој до базата пристапува преку
     4Entity Framework Core и драјверот Npgsql
     5([https://www.nuget.org/packages/Npgsql.EntityFrameworkCore.PostgreSQL Npgsql.!EntityFrameworkCore.PostgreSQL 9.0.4]).
     6Базата е зад SSH тунел, па секоја физичка конекција е уште еден канал низ тунелот -
     7тоа е причината зошто во оваа фаза и трансакциите и pool-от се разгледуваат заедно.
     8
     9Оваа фаза опфаќа:
     10
     11 * '''Трансакции''' - кои сценарија пишуваат во повеќе табели и како тие се водат како една целина
     12 * '''Ниво на изолација''' - што се случува кога два студента запишуваат ист семестар истовремено
     13 * '''Pooling''' - како се менаџираат конекциите и колку од нив навистина се отвораат
     14
     15== 1. Трансакции
     16
     17=== Како EF Core води трансакција
     18
     19EF 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Корисникот, неговите контакт податоци и средношколските податоци се три табели.
     36Contact и high_school имаат надворешен клуч кон users, па идентификаторот на
     37корисникот мора прво да постои - оттука и двата повика на {{{SaveChangesAsync}}}.
     38
     39{{{#!csharp
     40public 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    }
     102}
     103}}}
     104
     105Без трансакцијата, пад при вториот {{{SaveChangesAsync}}} би оставил корисник без
     106контакт и без средношколски податоци - запис кој ниту може да се употреби, ниту
     107може да се регистрира повторно, бидејќи е-поштата е UNIQUE.
     108
     109=== 1.2 Запишување на семестар (UC007)
     110
     111Запишувањето создава еден ред во enrolled_semesters и '''точно пет''' реда во
     112semesters_subjects. Редовите за предметите го бараат идентификаторот на
     113запишувањето, па повторно се работи за два чекора.
     114
     115{{{#!csharp
     116await using var transaction = await _context.Database.BeginTransactionAsync();
     117try
     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
     162CREATE 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.
     181var taken = await _context.SemesterSubjects.CountAsync(ss => ss.SubjectId == subjectId);
     182if (taken > 0)
     183{
     184    return Fail($"Cannot delete: {taken} enrolment(s) already contain this subject.");
     185}
     186
     187await using var transaction = await _context.Database.BeginTransactionAsync();
     188try
     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}
     210catch
     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
     225case 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.
     259if (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Ограничувањето во базата може. Проверено со две истовремени трансакции врз шемата
     271project:
     272
     273{{{
     274-- сесија А                          -- сесија Б (0.5s подоцна)
     275BEGIN;                               BEGIN;
     276INSERT INTO enrolled_semesters       INSERT INTO enrolled_semesters
     277  (user_id, quota, major_id,           (user_id, quota, major_id,
     278   semester_id)                         semester_id)
     279VALUES (1, 'drzavna', 1, 3);         VALUES (1, 'drzavna', 1, 3);
     280SELECT pg_sleep(2);                  -- чека А да заврши
     281COMMIT;                              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
     295catch (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}
     305catch
     306{
     307    await transaction.RollbackAsync();
     308    throw;
     309}
     310
     311/// <summary>23505 is unique_violation.</summary>
     312private 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{{{
     332Pooling                     = True
     333Minimum Pool Size           = 0
     334Maximum Pool Size           = 100
     335Connection Idle Lifetime    = 300     (секунди)
     336Connection Pruning Interval = 10      (секунди)
     337Timeout                     = 15      (секунди, чекање на слободна конекција)
     338Command Timeout             = 30      (секунди)
     339Multiplexing                = False
     340}}}
     341
     342Стандардните 100 конекции се премногу за оваа поставеност: серверот е споделен меѓу
     343сите проекти, а секоја конекција е уште еден канал низ SSH тунелот. Затоа pool-от е
     344ограничен во самиот connection string:
    17345
    18346{{{
    … …  
    22350}}}
    23351
    24 ================================================================================
    25 БЛОК 2 - во делот "2.4 Проверка дека апликацијата е поврзана"
    26 ================================================================================
    27 
    28 ЗАМЕНИ ГО ОВА:
    29 
    30 {{{#!json
    31 {
    32   "connected": true,
    33   ...
    34 }
    35 }}}
    36 
    37 СО ОВА:
     352 * '''Maximum Pool Size=10''' - горна граница на отворени конекции, исто како default-от на !HikariCP
     353 * '''Minimum Pool Size=1''' - една конекција останува отворена, за тунелот да не мора да отвора нов канал за секое прво барање
     354 * '''Connection Idle Lifetime=300''' - неупотребените конекции над минимумот се затвораат по 5 минути
     355 * '''Timeout=15''' - ако сите 10 се зафатени, барањето чека најмногу 15 секунди пред да падне
     356
     357=== 2.2 Pool на !DbContext (EF Core)
     358
     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.
     368builder.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        }));
     379}}}
     380
     381Ова е применливо бидејќи !AppDbContext има само еден конструктор, оној кој прима
     382{{{DbContextOptions}}}. Контекст кој би примал сопствени зависности (на пример
     383тековниот корисник) не смее да се рециклира, бидејќи состојбата од едно барање би
     384протекла во следното.
     385
     386=== 2.3 Мерење на pool-от
     387
     388Мерено со апликацијата пуштена врз локална база со истата шема, со броење на
     389{{{pg_stat_activity}}}:
     390
     391{{{#!sql
     392SELECT count(*) FROM pg_stat_activity WHERE datname = 'iknow';
     393}}}
     394
     395{{{
     396состојба                                          отворени конекции
     397--------------------------------------------------------------------
     398по стартување и едно барање                              1
     399за време на 50 истовремени барања                       10
     400по завршување на барањата                               10
     401}}}
     402
     403Првиот ред е Minimum Pool Size=1 - една конекција останува отворена. Вториот ред
     404покажува дека pool-от расте со оптоварувањето, но застанува точно на Maximum Pool
     405Size=10; останатите барања чекаат слободна конекција наместо да отворат нова.
     406Третиот ред покажува дека отворените конекции не се затвораат веднаш - тие остануваат
     407во pool-от и се затвораат дури по Connection Idle Lifetime, бидејќи повторното
     408отворање низ тунел е поскапо од држењето отворена конекција.
     409
     410Истото може да се провери и од страната на тунелот - секоја нова конекција се гледа
     411како нов канал во логовите на тунел скриптата:
     412
     413{{{
     414debug1: Connection to port 9999 forwarding to localhost port 5432 requested.
     415debug1: channel 2: new direct-tcpip [direct-tcpip] (inactive timeout: 0)
     416}}}
     417
     418=== 2.4 Проверка дека апликацијата е поврзана
     419
     420Контролерот !DbHealth го враќа одговорот кој покажува дека тунелот, конекцијата и
     421мапирањето на ентитетите работат:
    38422
    39423{{{
    … …  
    51435}}}
    52436
    53 ================================================================================
    54 ОБЈАСНУВАЊЕ
    55 ================================================================================
    56 
    57 Trac ги обработува блоковите {{{#!ime }}} преку процесор со тоа име. Процесор
    58 'json' не постои во оваа инсталација, па наместо содржината се прикажува
    59 пораката:
    60 
    61     Error: Failed to load processor json
    62     No macro or processor named 'json' found
    63 
    64 Обичен блок {{{ ... }}} без име на процесор прикажува претформатиран текст и
    65 секогаш работи. Затоа е доволно само да се избрише "#!json" од првата линија на
    66 двата блока.
    67 
    68 Ако сакате боење на синтаксата, {{{#!js }}} обично работи и за JSON, бидејќи
    69 JSON е подмножество на JavaScript. Но сигурниот избор е обичниот блок.
     437== Користење на вештачка интелигенција
     438
     439 [wiki:AdvancedApplicationDevelopmentAIUsage AdvancedApplicationDevelopmentAIUsage]
     440
     441== Историјат
     442
     443 '''Верзија 1''' - Прва верзија: трансакции во трите сценарија кои пишуваат во
     444 повеќе табели, обработка на конкурентни запишувања преку ограничувањето
     445 UNIQUE (user_id, semester_id), и конфигурација и мерење на pool-от на конекции и
     446 на !DbContext.
     447
     448== Статус
     449
     450 ''' [[span(style=color: #FF8000, Во тек )]] '''