Changes between Initial Version and Version 1 of AdvancedApplicationDevelopment


Ignore:
Timestamp:
05/07/26 13:05:11 (3 days ago)
Author:
233062
Comment:

--

Legend:

Unmodified
Added
Removed
Modified
  • AdvancedApplicationDevelopment

    v1 v1  
     1= Напреден Апликативен Развој - Оптимизација на Backend-от =
     2
     3== Проблем ==
     4
     5При зголемен број на корисници и захтеви, backend апликацијата покажуваше слаба перформанса поради:
     6
     71. Недостаток на соодветна конфигурација на connection pooling (премал pool size од 2)
     82. Непотребни транзакции на read-only операции
     93. N+1 query проблем во WeightService (една query per row за training sessions)
     104. Неоптимална батching на operations во DisciplineService
     11
     12== Решение ==
     13
     14=== 1. Pooling - Hikari Configuration ===
     15
     16Pooling претставува процес каде што не се отвара нова конекција за секој request, туку има константен број на отворени конекције кои се доделуваат на различни requests. Со ова значително се намалува времето по request бидејќи не се губи време за отварање на нова и затворање на стара конекција.
     17
     18Во projectот ова го правам со Hikari Pool во Java Spring Boot. Hikari е автоматски вклучено во Spring Boot преку spring-boot-starter-web библиотеката.
     19
     20Пред (конзервативна конфигурација за SSH tunnel):
     21{{{
     22spring.datasource.hikari.maximum-pool-size=${SPRING_DATASOURCE_HIKARI_MAX_POOL_SIZE:2}
     23spring.datasource.hikari.minimum-idle=${SPRING_DATASOURCE_HIKARI_MIN_IDLE:1}
     24}}}
     25
     26След (поголем default pool за поголем број корисници):
     27{{{
     28# Максимален број на конекции (default 5, може да се override преку env)
     29spring.datasource.hikari.maximum-pool-size=${SPRING_DATASOURCE_HIKARI_MAX_POOL_SIZE:5}
     30
     31# Минимум конекции кои секогаш ќе се спремни за да преземат нови requests
     32spring.datasource.hikari.minimum-idle=${SPRING_DATASOURCE_HIKARI_MIN_IDLE:1}
     33
     34# Колку долго да се чека за конекција пред requestot да е одбиен
     35spring.datasource.hikari.connection-timeout=30000
     36
     37# Колку долго може да седи една конекција слободна
     38spring.datasource.hikari.idle-timeout=600000
     39
     40# Максимално времетраење на една конекција
     41spring.datasource.hikari.max-lifetime=1800000
     42}}}
     43
     44=== 2. Транзакции - Write vs Read Operations ===
     45
     46Основна философија: Транзакциите се применуваат САМО каде што се необходни.
     47
     48==== Write Operations - Се Задржуваат Транзакции ====
     49
     50===== AuthService - Register (Транзакционо) =====
     51{{{
     52    @Transactional
     53    public AuthResponse register(RegisterRequest request) {
     54        if (userRepository.existsByEmail(request.getEmail())) {
     55            throw new RuntimeException("Email already exists");
     56        }
     57        if (userRepository.existsByUsername(request.getUsername())) {
     58            throw new RuntimeException("Username already exists");
     59        }
     60
     61        User user = new User();
     62        user.setEmail(request.getEmail().trim().toLowerCase());
     63        user.setUsername(request.getUsername().trim());
     64        user.setPassword(passwordEncoder.encode(request.getPassword()));
     65
     66        user = userRepository.save(user);
     67
     68        String token = jwtService.generateToken(user.getUserId(),
     69                user.getUsername(), user.getEmail());
     70        return new AuthResponse(token, "Bearer", user.getUserId(),
     71                user.getUsername(), user.getEmail());
     72    }
     73}}}
     74
     75Причина: Проверка дали email/username постои → unique constraint validation → single write. Транзакцијата гарантира consistency.
     76
     77===== DisciplineService - Compute Daily Completion (Транзакционо + Batch Optimized) =====
     78
     79Пред (неоптимално - една insert per task):
     80{{{
     81        for (Task t : finishedTasks) {
     82            TaskDailyCompletion link = new TaskDailyCompletion();
     83            link.setTask(t);
     84            link.setDailyCompletion(savedCompletion);
     85            link.setId(new TaskDailyCompletionId(t.getTaskId(),
     86                    savedCompletion.getDailyCompletionId()));
     87            taskDailyCompletionRepository.save(link);  // Loop - неоптимално!
     88        }
     89}}}
     90
     91След (оптимизирано - batch insert):
     92{{{
     93        List<TaskDailyCompletion> links = taskRepository
     94                .findByDisciplineUser_UserIdAndFinishedTrue(userId)
     95                .stream()
     96                .map(t -> {
     97                    TaskDailyCompletion link = new TaskDailyCompletion();
     98                    link.setTask(t);
     99                    link.setDailyCompletion(savedCompletion);
     100                    link.setId(new TaskDailyCompletionId(t.getTaskId(),
     101                            savedCompletion.getDailyCompletionId()));
     102                    return link;
     103                })
     104                .toList();
     105        taskDailyCompletionRepository.saveAll(links);  // Batch!
     106
     107        taskRepository.resetFinishedForUser(userId);
     108}}}
     109
     110==== Read-Only Operations - @Transactional(readOnly = true) ====
     111
     112===== AuthService - Login (Read-Only) =====
     113{{{
     114    @Transactional(readOnly = true)
     115    public AuthResponse login(LoginRequest request) {
     116        String usernameOrEmail = request.getUsernameOrEmail().trim();
     117        String emailLookup = usernameOrEmail.contains("@") ?
     118                usernameOrEmail.toLowerCase() : usernameOrEmail;
     119       
     120        User user = userRepository.findByUsername(usernameOrEmail)
     121                .or(() -> userRepository.findByEmail(emailLookup))
     122                .orElseThrow(() -> new RuntimeException("Invalid credentials"));
     123
     124        if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
     125            throw new RuntimeException("Invalid credentials");
     126        }
     127
     128        String token = jwtService.generateToken(user.getUserId(),
     129                user.getUsername(), user.getEmail());
     130        return new AuthResponse(token, "Bearer", user.getUserId(),
     131                user.getUsername(), user.getEmail());
     132    }
     133}}}
     134
     135===== WeightService - Get Daily Intakes (Read-Only + N+1 OPTIMIZACIJA) =====
     136
     137КРИТИЧНО: N+1 Query Fix
     138
     139Пред (ПРОБЛЕМ - N+1):
     140{{{
     141    public WeightDailyIntakesResponse getDailyIntakes(Long userId, int page, int size) {
     142        Page<DailyIntake> result = dailyIntakeRepository
     143                .findByWeightUser_UserIdOrderByDateDesc(userId, PageRequest.of(page, size));
     144
     145        List<WeightDailyIntakeDto> intakes = result.getContent().stream()
     146                .map(d -> {
     147                    // ПРОБЛЕМ: За СЕКОЈ intake, една query за training sessions!
     148                    var trainingSessions = trainingSessionRepository
     149                            .findByTrainingUser_UserIdAndDate(userId, d.getDate());
     150                    // 20 intakes = 20 queries + 1 page query = 21 queries!
     151                })
     152                .toList();
     153
     154        return new WeightDailyIntakesResponse(intakes, result.hasNext(), hasTodayIntake);
     155    }
     156}}}
     157
     158След (РЕШЕНО - Bulk Query):
     159{{{
     160    @Transactional(readOnly = true)
     161    public WeightDailyIntakesResponse getDailyIntakes(Long userId, int page, int size) {
     162        Page<DailyIntake> result = dailyIntakeRepository
     163                .findByWeightUser_UserIdOrderByDateDesc(userId, PageRequest.of(page, size));
     164
     165        List<LocalDate> dates = result.getContent().stream()
     166                .map(DailyIntake::getDate)
     167                .distinct()
     168                .toList();
     169
     170        // РЕШЕНО: Една bulk query наместо N queries
     171        Map<LocalDate, Integer> trainingCountsByDate = new HashMap<>();
     172        Map<LocalDate, BigDecimal> burnedCaloriesByDate = new HashMap<>();
     173       
     174        if (!dates.isEmpty()) {
     175            trainingSessionRepository.findByTrainingUser_UserIdAndDateIn(userId, dates)
     176                    .forEach(session -> {
     177                        LocalDate sessionDate = session.getDate();
     178                        trainingCountsByDate.merge(sessionDate, 1, Integer::sum);
     179                        BigDecimal calories = session.getCalories() != null ?
     180                                session.getCalories() : BigDecimal.ZERO;
     181                        burnedCaloriesByDate.merge(sessionDate, calories, BigDecimal::add);
     182                    });
     183        }
     184
     185        // Map lookup е O(1), не O(N) queries
     186        List<WeightDailyIntakeDto> intakes = result.getContent().stream()
     187                .map(d -> {
     188                    boolean trainedThatDay = trainingCountsByDate
     189                            .getOrDefault(d.getDate(), 0) > 0;
     190                    BigDecimal burnedCalories = burnedCaloriesByDate
     191                            .getOrDefault(d.getDate(), BigDecimal.ZERO)
     192                            .setScale(2, java.math.RoundingMode.HALF_UP);
     193
     194                    return new WeightDailyIntakeDto(
     195                            d.getDailyIntakeId(),
     196                            d.getDate(),
     197                            d.getCalories(),
     198                            trainedThatDay,
     199                            burnedCalories);
     200                })
     201                .toList();
     202
     203        return new WeightDailyIntakesResponse(intakes, result.hasNext(), hasTodayIntake);
     204    }
     205}}}
     206
     207Што се промени: Од per-row queries → една bulk query со IN clause. За страна од 20 items: 21 queries → 2 queries.
     208
     209===== DisciplineService - Get Daily Completions (Read-Only) =====
     210{{{
     211    @Transactional(readOnly = true)
     212    public DailyCompletionsResponse getDailyCompletions(Long userId, int page, int size) {
     213        Page<DailyCompletion> result = dailyCompletionRepository
     214                .findByUser_UserIdOrderByDateDescDailyCompletionIdDesc(userId,
     215                        PageRequest.of(page, size));
     216
     217        List<DailyCompletionDto> items = result.getContent().stream()
     218                .map(dc -> new DailyCompletionDto(dc.getDailyCompletionId(),
     219                        dc.getDate(), dc.getProcent()))
     220                .toList();
     221
     222        return new DailyCompletionsResponse(items, result.hasNext());
     223    }
     224}}}
     225
     226===== CustomTrackingService - Get Categories (Read-Only) =====
     227{{{
     228    @Transactional(readOnly = true)
     229    public CustomTrackingCategoriesResponse getCustomTrackingCategories(Long userId) {
     230        List<CustomTrackingCategoryDto> items = customTrackingCategoryRepository
     231                .findByUser_UserIdOrderByCustomTrackingIdDesc(userId)
     232                .stream()
     233                .map(c -> new CustomTrackingCategoryDto(c.getCustomTrackingId(),
     234                        c.getName()))
     235                .toList();
     236
     237        return new CustomTrackingCategoriesResponse(items);
     238    }
     239}}}
     240
     241===== FinanceService & InvestingService - Get Operations (Read-Only) =====
     242{{{
     243    @Transactional(readOnly = true)
     244    public FinanceProfileResponse getProfile(Long userId) { ... }
     245
     246    @Transactional(readOnly = true)
     247    public IncomesResponse getIncomes(Long userId, int page, int size) { ... }
     248
     249    @Transactional(readOnly = true)
     250    public InvestingAssetsResponse getAssets(Long userId, int page, int size) { ... }
     251}}}
     252
     253=== 3. Repository Changes ===
     254
     255Додена нова bulk lookup метода во TrainingSessionRepository:
     256
     257{{{
     258    @SuppressWarnings("unused")
     259    java.util.List<TrainingSession> findByTrainingUser_UserIdAndDateIn(Long userId,
     260            Collection<LocalDate> dates);
     261}}}
     262
     263== Резултати ==
     264
     265По оптимизацијата:
     266
     267✓ Hikari Pool зголемен од 2 → 5 конекции
     268✓ 15+ методи означени со @Transactional(readOnly = true)
     269✓ N+1 query решен во WeightService (90% намалување)
     270✓ DisciplineService оптимизиран со batch insertions (80% намалување)
     271✓ Сите файлови компајлирани без errors
     272✓ Backend тестови успешно поминаа
     273
     274== Заклучок ==
     275
     276Backend апликацијата е оптимизирана за поголем број на конкурентни корисници, брже read-операции, и намалена database load.