= Други развојни активности == Анализа на перформанси, додавање индекси За да направиме анализа на перформансите на нашите прашалници ќе разгледаме неколку различни употреби. Начинот на тестирање е следниот: - Додаваме голем број на нови записи во табелите (пр. 1М записи) со цел користењето на индекс да биде исплатливо, доколку истиот е искористен соодветно. Јасно е дека за табела со 20 записи нема да има голема предност доколку се користи индекс. - Пред додавање на индексот ги извршуваме прашалниците 10 пати користејќи `explain analyze`. Пресметуваме просечен `Execution Time` и query plan-от го запишуваме за да може да го споредиме со новиот план по додавање на индексот. - По додавање на индексот го извршуваме истиот прашалник 10 пати и ги споредуваме резултатите. === Сценарио 1 Ќе ја разгледуваме процедурата yesterdays_most_popular() - прва од [[AdvancedDatabaseDevelopment|Напреден развој на базата]]. Бидејќи не можеме директно да извршуваме `explain analyze` на процедурата, тоа ќе го правиме на прашалниците кои се повикуваат во неа. Додаваме 1М нови редови во `listens` табелата со случајна вредност за `timestamp` во интервал од изминатите 8 месеци. {{{ insert into listens (listener_id, song_id, timestamp) SELECT (SELECT l.user_id FROM listeners l ORDER BY random() LIMIT 1), (SELECT s.id FROM songs s ORDER BY random() LIMIT 1), NOW() - (random() * INTERVAL '8 months') FROM generate_series(1, 1000000) ON CONFLICT DO NOTHING; }}} Додаваме индекс на `timestamp` {{{ CREATE INDEX ON listens(timestamp); }}} Ќе тестираме 2 прашалници. Прашалник 1: {{{ EXPLAIN ANALYZE SELECT 1 FROM listens WHERE timestamp BETWEEN CURRENT_DATE - 1 AND CURRENT_DATE; }}} Без индекс добиваме: {{{ +------------------------------------------------------------------------------------------------------------------------------+ |QUERY PLAN | +------------------------------------------------------------------------------------------------------------------------------+ |Gather (cost=1000.00..17155.54 rows=4428 width=4) (actual time=0.342..58.768 rows=4139 loops=1) | | Workers Planned: 2 | | Workers Launched: 2 | | -> Parallel Seq Scan on listens (cost=0.00..15712.74 rows=1845 width=4) (actual time=0.080..47.575 rows=1380 loops=3) | | Filter: (("timestamp" <= CURRENT_DATE) AND ("timestamp" >= (CURRENT_DATE - 1))) | | Rows Removed by Filter: 331269 | |Planning Time: 0.116 ms | |Execution Time: 58.965 ms | +------------------------------------------------------------------------------------------------------------------------------+ }}} Просечното `Execution Time` од 10 извршувања е **67,14 ms**. Со индекс добиваме: {{{ +----------------------------------------------------------------------------------------------------------------------------------------------------+ |QUERY PLAN | +----------------------------------------------------------------------------------------------------------------------------------------------------+ |Index Only Scan using listens_timestamp_idx on listens (cost=0.43..140.59 rows=4408 width=4) (actual time=0.034..0.670 rows=4139 loops=1) | | Index Cond: (("timestamp" >= (CURRENT_DATE - 1)) AND ("timestamp" <= CURRENT_DATE)) | | Heap Fetches: 0 | |Planning Time: 0.177 ms | |Execution Time: 0.793 ms | +----------------------------------------------------------------------------------------------------------------------------------------------------+ }}} Можеме да забележиме користење на индекс преку `Index Only Scan`, сега просечното време за 10 извршувања е **0,83 ms**. Добиваме подобрување на перформансите од **~80x**. Прашалник 2: {{{ EXPLAIN ANALYZE SELECT CURRENT_DATE - 1, s.id, COUNT(*) FROM songs s JOIN listens l ON l.song_id = s.id WHERE l.timestamp BETWEEN CURRENT_DATE - 1 AND NOW() GROUP BY s.id ORDER BY COUNT(*) DESC LIMIT 1; }}} Без индекс добиваме: {{{ +--------------------------------------------------------------------------------------------------------------------------------------------------------------------+ |QUERY PLAN | +--------------------------------------------------------------------------------------------------------------------------------------------------------------------+ |Limit (cost=17116.49..17116.49 rows=1 width=20) (actual time=86.532..92.337 rows=1 loops=1) | | -> Sort (cost=17116.49..17119.16 rows=1070 width=20) (actual time=86.530..92.334 rows=1 loops=1) | | Sort Key: (count(*)) DESC | | Sort Method: quicksort Memory: 25kB | | -> Finalize GroupAggregate (cost=16834.70..17111.14 rows=1070 width=20) (actual time=86.503..92.307 rows=1 loops=1) | | Group Key: s.id | | -> Gather Merge (cost=16834.70..17084.39 rows=2140 width=16) (actual time=86.492..92.297 rows=3 loops=1) | | Workers Planned: 2 | | Workers Launched: 2 | | -> Sort (cost=15834.68..15837.35 rows=1070 width=16) (actual time=81.267..81.270 rows=1 loops=3) | | Sort Key: s.id | | Sort Method: quicksort Memory: 25kB | | Worker 0: Sort Method: quicksort Memory: 25kB | | Worker 1: Sort Method: quicksort Memory: 25kB | | -> Partial HashAggregate (cost=15770.14..15780.84 rows=1070 width=16) (actual time=81.226..81.232 rows=1 loops=3) | | Group Key: s.id | | Batches: 1 Memory Usage: 73kB | | Worker 0: Batches: 1 Memory Usage: 73kB | | Worker 1: Batches: 1 Memory Usage: 73kB | | -> Hash Join (cost=34.08..15754.87 rows=3054 width=8) (actual time=0.138..80.491 rows=2356 loops=3) | | Hash Cond: (l.song_id = s.id) | | -> Parallel Seq Scan on listens l (cost=0.00..15712.74 rows=3054 width=8) (actual time=0.061..79.620 rows=2356 loops=3) | | Filter: (("timestamp" <= now()) AND ("timestamp" >= (CURRENT_DATE - 1))) | | Rows Removed by Filter: 330293 | | -> Hash (cost=20.70..20.70 rows=1070 width=8) (actual time=0.042..0.043 rows=15 loops=3) | | Buckets: 2048 Batches: 1 Memory Usage: 17kB | | -> Seq Scan on songs s (cost=0.00..20.70 rows=1070 width=8) (actual time=0.029..0.032 rows=15 loops=3) | |Planning Time: 0.668 ms | |Execution Time: 92.596 ms | +--------------------------------------------------------------------------------------------------------------------------------------------------------------------+ }}} Просечното `Execution Time` од 10 извршувања е **88,65 ms**. Со индекс добиваме: {{{ +-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ |QUERY PLAN | +-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ |Limit (cost=13275.20..13275.21 rows=1 width=20) (actual time=10.802..15.147 rows=1 loops=1) | | -> Sort (cost=13275.20..13277.88 rows=1070 width=20) (actual time=10.801..15.145 rows=1 loops=1) | | Sort Key: (count(*)) DESC | | Sort Method: quicksort Memory: 25kB | | -> Finalize GroupAggregate (cost=12993.42..13269.85 rows=1070 width=20) (actual time=10.797..15.141 rows=1 loops=1) | | Group Key: s.id | | -> Gather Merge (cost=12993.42..13243.10 rows=2140 width=16) (actual time=10.787..15.131 rows=3 loops=1) | | Workers Planned: 2 | | Workers Launched: 2 | | -> Sort (cost=11993.40..11996.07 rows=1070 width=16) (actual time=6.038..6.041 rows=1 loops=3) | | Sort Key: s.id | | Sort Method: quicksort Memory: 25kB | | Worker 0: Sort Method: quicksort Memory: 25kB | | Worker 1: Sort Method: quicksort Memory: 25kB | | -> Partial HashAggregate (cost=11928.86..11939.56 rows=1070 width=16) (actual time=6.008..6.012 rows=1 loops=3) | | Group Key: s.id | | Batches: 1 Memory Usage: 73kB | | Worker 0: Batches: 1 Memory Usage: 73kB | | Worker 1: Batches: 1 Memory Usage: 73kB | | -> Hash Join (cost=194.78..11913.36 rows=3100 width=8) (actual time=0.937..5.574 rows=2356 loops=3) | | Hash Cond: (l.song_id = s.id) | | -> Parallel Bitmap Heap Scan on listens l (cost=160.70..11871.11 rows=3100 width=8) (actual time=0.879..4.934 rows=2356 loops=3) | | Recheck Cond: (("timestamp" >= (CURRENT_DATE - 1)) AND ("timestamp" <= now())) | | Heap Blocks: exact=3438 | | -> Bitmap Index Scan on listens_timestamp_idx (cost=0.00..158.84 rows=7441 width=0) (actual time=1.735..1.735 rows=7067 loops=1) | | Index Cond: (("timestamp" >= (CURRENT_DATE - 1)) AND ("timestamp" <= now())) | | -> Hash (cost=20.70..20.70 rows=1070 width=8) (actual time=0.039..0.040 rows=15 loops=3) | | Buckets: 2048 Batches: 1 Memory Usage: 17kB | | -> Seq Scan on songs s (cost=0.00..20.70 rows=1070 width=8) (actual time=0.028..0.030 rows=15 loops=3) | |Planning Time: 0.320 ms | |Execution Time: 15.232 ms | +-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ }}} Просечно време на извршување е **15,04ms** -> **~6x** подобри перформанси. Во планот забележуваме замена на секвенцијално скенирање со скенирање на индекс. Заклучуваме дека перформансите на процедурата ќе бидат многу подобри со овој индекс и го додаваме истиот. == Сценарио 2 Ќе го разгледуваме прашалникот кој се извршува при преземање на сите песни/албуми објавени од артистот. {{{ EXPLAIN ANALYZE SELECT me.id, me.title, me.genre, CASE WHEN s.id IS NOT NULL AND s.album_id IS NULL THEN 'SONG' WHEN a.id IS NOT NULL THEN 'ALBUM' ELSE NULL END AS type, me.cover, me.release_date FROM musical_entities me LEFT JOIN songs s ON s.id = me.id LEFT JOIN albums a ON a.id = me.id WHERE me.released_by = :artistId ORDER BY me.release_date DESC }}} Додаваме 1M нови записи во musical_entities табелата, преку извршување на следниот прашалник повеќепати: {{{ INSERT INTO musical_entities_temp (released_by) SELECT id FROM temp_users ORDER BY random() LIMIT 50000; }}} Креираме композитен индекс на `released_by` и `release_date` {{{ CREATE INDEX me_released_by_release_date_idx ON musical_entities (released_by, release_date DESC); }}} Без индекс добиваме: {{{ +--------------------------------------------------------------------------------------------------------------------------------------------------------+ |QUERY PLAN | +--------------------------------------------------------------------------------------------------------------------------------------------------------+ |Gather Merge (cost=48470.13..48470.59 rows=4 width=82) (actual time=100.621..107.725 rows=3 loops=1) | | Workers Planned: 2 | | Workers Launched: 2 | | -> Sort (cost=47470.10..47470.11 rows=2 width=82) (actual time=76.312..76.315 rows=1 loops=3) | | Sort Key: me.release_date DESC | | Sort Method: quicksort Memory: 25kB | | Worker 0: Sort Method: quicksort Memory: 25kB | | Worker 1: Sort Method: quicksort Memory: 25kB | | -> Nested Loop Left Join (cost=0.84..47470.09 rows=2 width=82) (actual time=55.582..76.259 rows=1 loops=3) | | -> Nested Loop Left Join (cost=0.42..47461.22 rows=2 width=66) (actual time=55.561..76.234 rows=1 loops=3) | | -> Parallel Seq Scan on musical_entities me (cost=0.00..47444.33 rows=2 width=50) (actual time=55.513..76.174 rows=1 loops=3) | | Filter: (released_by = 123456) | | Rows Removed by Filter: 333332 | | -> Index Scan using songs_pkey on songs s (cost=0.42..8.44 rows=1 width=16) (actual time=0.049..0.049 rows=1 loops=3) | | Index Cond: (id = me.id) | | -> Index Only Scan using albums_pkey on albums a (cost=0.42..4.44 rows=1 width=8) (actual time=0.020..0.020 rows=0 loops=3) | | Index Cond: (id = me.id) | | Heap Fetches: 0 | |Planning Time: 0.525 ms | |Execution Time: 107.796 ms | +--------------------------------------------------------------------------------------------------------------------------------------------------------+ }}} Просечното `Execution Time` од 10 извршувања е **100,59 ms** Со индекс добиваме: {{{ +----------------------------------------------------------------------------------------------------------------------------------------------------------------------+ |QUERY PLAN | +----------------------------------------------------------------------------------------------------------------------------------------------------------------------+ |Nested Loop Left Join (cost=1.27..88.91 rows=5 width=82) (actual time=0.061..0.084 rows=3 loops=1) | | -> Nested Loop Left Join (cost=0.85..66.72 rows=5 width=66) (actual time=0.026..0.041 rows=3 loops=1) | | -> Index Scan using me_released_by_release_date_idx on musical_entities me (cost=0.42..24.51 rows=5 width=50) (actual time=0.013..0.018 rows=3 loops=1) | | Index Cond: (released_by = 123456) | | -> Index Scan using songs_pkey on songs s (cost=0.42..8.44 rows=1 width=16) (actual time=0.005..0.005 rows=1 loops=3) | | Index Cond: (id = me.id) | | -> Index Only Scan using albums_pkey on albums a (cost=0.42..4.44 rows=1 width=8) (actual time=0.013..0.013 rows=0 loops=3) | | Index Cond: (id = me.id) | | Heap Fetches: 0 | |Planning Time: 0.491 ms | |Execution Time: 0.127 ms | +----------------------------------------------------------------------------------------------------------------------------------------------------------------------++ }}} Можеме да забележиме користење на новокреираниот индекс преку `Index Scan using me_released_by_index on musical_entities me`, сега просечното време за 10 извршувања е **0,105 ms**, што претставува значително подобрување на перформансите. == Безбедност и заштита === JWT На најавени корисници на нашата апликација серверот им издава access token и refresh token. Access токенот е валиден многу кратко (10 минути) со цел дури и да е превземен од малициозен корисник истиот да стане бескорисен за брзо време. Овој токен е потребен за пристап до содржини и податоци коишто не се достапни за други корисници (пр. преглед/промена на лични податоци) Refresh токенот е валиден подолго време (14 дена) и се чува во база, за кога на корисникот ќе му истече access токенот тој да може да го докаже својот идентитет на серверот преку refresh токенот, и тогаш серверот да му издаде нов access токен. {{{ public String refreshAccessToken(String refreshTokenString) { RefreshToken refreshToken = refreshTokenService.validateRefreshToken(refreshTokenString); User user = refreshToken.getUser(); return jwtService.generateToken(user.getUsername(), user.getRole().name()); } }}} Проверка за валидност на refresh токен {{{ public RefreshToken validateRefreshToken(String token){ RefreshToken refreshToken = findByToken(token) .orElseThrow(() -> new InvalidTokenException("Invalid refresh token.")); if (refreshToken.isRevoked()){ throw new InvalidTokenException("Refresh token has been revoked."); } if (refreshToken.getExpiresAt().isBefore(Instant.now())){ throw new InvalidTokenException("Refresh token has expired."); } return refreshToken; } }}} Токенот е потпишан од серверот користејќи таен клуч со цел да не може малициозен корисник сам да си издаде свој токен. Метода за генерирање токен: {{{ public String generateToken(String username, String role){ SecretKey key = Keys.hmacShaKeyFor(authProperties.getSecret().getBytes(StandardCharsets.UTF_8)); return Jwts.builder() .setSubject(username) .claim("role", "ROLE_" + role) .setExpiration(new Date(System.currentTimeMillis() + (long) authProperties.getAccessTokenMaxAge() * 1000)) .signWith(key) .compact(); } }}} Дополнително, имплементираме наш филтер кој ќе го проверува постоењето и валидноста на горенаведениот access токен: {{{ @Component @RequiredArgsConstructor public class JwtFilter extends OncePerRequestFilter { private final CustomUserDetailsService userDetailsService; private final JwtService jwtService; @Override protected void doFilterInternal( @NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException { String token = null; if (request.getCookies() != null){ for (Cookie cookie: request.getCookies()){ if ("accessToken".equals(cookie.getName())){ token = cookie.getValue(); break; } } } if (token != null && !token.isEmpty()){ try { Claims claims = jwtService.extractClaims(token); String username = claims.getSubject(); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null){ UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authToken); } } catch (ExpiredJwtException e){ System.out.println("Expired jwt token."); response.setStatus(HttpStatus.UNAUTHORIZED.value()); return; } catch (JwtException e){ System.out.println("Invalid jwt token."); response.setStatus(HttpStatus.UNAUTHORIZED.value()); return; } catch (Exception e){ System.out.println(e.getMessage()); response.setStatus(HttpStatus.UNAUTHORIZED.value()); return; } } filterChain.doFilter(request, response); } } }}} Потоа, истиот треба да го додадеме во security конфигурацијата {{{ @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth .requestMatchers("/auth/user").authenticated() .anyRequest().permitAll()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) // <---- оваа линија е битна !!!! .headers((headers) -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)); return http.build(); } }}} === Чување лозинки Бидејќи чуваме лозинки во нашата база не смееме истите да ги чуваме како plaintext. Корстиме `BCryptPasswordEncoder` за хеширање на истите: {{{ @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(10); } // понатаму кога создаваме нов корисник User.UserBuilder userBuilder = User.builder() .username(authRequestDto.username()) .password(passwordEncoder.encode(authRequestDto.password())) // ... останата логика }}} === SQL Injection За да намалиме можност за SQL injection користиме параметаризирани прашалници, наместо да ги додаваме параметрите со конкатенација. Всушност, бидејќи користиме Spring Data JPA / JPQL овие работи не треба експлицитно да ги пишуваме, туку се веќе имплементирани: пример: {{{ @Query(""" SELECT CASE WHEN COUNT (l)>0 THEN true ELSE false END FROM MusicalEntity me JOIN Like l on l.musicalEntity.id=me.id WHERE l.listener.id=:userId """) boolean isLikedByUser(@Param("userId") Long userId); }}} === CORS Конфигурација за CORS {{{ @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOriginPatterns(List.of("http://localhost:*")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); configuration.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; } }}} Во продукциска околина, секако дека ќе треба `localhost` да се смени со соодветниот домен/и, а дополнително може и да се ограничат и дозволените методи и заглавја, но за рамките на овој проект сметаме дека ова е доволно.