Niedawno opublikowałem taką oto publikację na LinkedIn. Dotyczy ona sposobu wrapowania listy encji pobieranej z repozytorium Spring Data. Kod wygląda następująco.

1
2
3
4
5
6
7
8
9
interface VehicleRepository extends CrudRepository<Vehicle, Long> {
	
    Set<Vehicle> findAllByPolicyId(Long policyId);

    default Vehicles findVehiclesBy(Long policyId) {
        return new Vehicles(findAllByPolicyId(policyId));
    }

}

Ten pomysł zrodził mi się w głowie po zapoznaniu się z zasadami zawartymi w Object Calisthenics, a dokładniej jedną z nich - First Class Collections. Korzysta się z tego naprawdę przyjemnie, jednak ma to pewną wadę, o której się niedawno dowiedziałem (pozdrowienia dla Kamila 👋).

Problem wydajnościowy

Załóżmy taki scenariusz - z powodów biznesowych musimy w dwóch miejscach pobrać kolekcję encji używając tego samego id agregującego. Oczywiście wszystko dzieje się w ramach jednego przypadku biznesowego. Dodatkowo nie chcemy, aby ta lista była przekazywana przez wiele warstw jako parametr metod.

Spójrzmy jak to może wyglądać w kodzie. Wyjątkowo będzie on dzisiaj napisany w Kotlinie z racji tego, że chciałbym z nim poobcować. 😜

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
class Inmate {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    var id: Long? = null
    var houseId: Long? = null
    var name: String? = null
    var role: String? = null

    constructor(name: String?, houseId: Long?, role: String?) {
        this.name = name
        this.houseId = houseId
        this.role = role
    }

}
1
2
3
4
5
interface InmateRepository : CrudRepository<Inmate, Long> {

    fun findAllByHouseId(houseId: Long): Set<Inmate>

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Entity
@Table(name = "house")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
class House {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    var id: Long? = null
    var name: String? = null

    constructor(name: String?) {
        this.name = name
    }

}
1
2
interface HouseRepository : CrudRepository<House, Long> {
}

Tak prezentuje się ten trywialny kod. Oczywiście już nie opakowywałem zbioru Inmate w klasę, bo to nie ma tutaj znaczenia (nawet nie wiem czy można korzystać ze słowa kluczowego default w interfejsach w Kotlinie). Napiszmy teraz kod, który będzie nam potrzebny, aby sprawdzić wcześniej opisaną sytuację.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Component
class HouseService(
    val houseRepository: HouseRepository,
    val inmateRepository: InmateRepository,
    val transactionTemplate: TransactionTemplate
) {

    val log = LoggerFactory.getLogger(HouseService::class.java)

    fun call() {
        val houseId = transactionTemplate.execute {
            log.debug("saving house")
            val house = houseRepository.save(House("Pogodny domek"))

            log.debug("saving inmates")
            inmateRepository.save(Inmate("Tadzik", house.id, "Tata"))
            inmateRepository.save(Inmate("Grażyna", house.id, "Mama"))
            inmateRepository.save(Inmate("Albert", house.id, "Syn"))
            inmateRepository.save(Inmate("Roksana", house.id, "Córka"))

            return@execute house.id
        }

        transactionTemplate.execute {
            log.debug("fetching inmates by house id")
            val first = houseId?.let { inmateRepository.findAllByHouseId(it) }.orEmpty()
            log.debug("first size {}", first.size)
            val second = houseId?.let { inmateRepository.findAllByHouseId(it) }.orEmpty()
            log.debug("second size {}", second.size)
        }
    }

}

Jeśli umożliwimy pokazywanie wygenerowanych SQLek w logach to naszym oczom ukaże się coś takiego.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Hibernate: 
    /\* <criteria> \*/ select
        i1_0.id,
        i1_0.house_id,
        i1_0.name,
        i1_0.role 
    from
        inmate i1_0 
    where
        i1_0.house_id=?
2024-05-17T12:21:08.572+02:00 DEBUG 39328 --- [jpa-list-optimization] [           main] p.c.jpalistoptimization.HouseService     : first size 4
Hibernate: 
    /\* <criteria> \*/ select
        i1_0.id,
        i1_0.house_id,
        i1_0.name,
        i1_0.role 
    from
        inmate i1_0 
    where
        i1_0.house_id=?
2024-05-17T12:21:08.573+02:00 DEBUG 39328 --- [jpa-list-optimization] [           main] p.c.jpalistoptimization.HouseService     : second size 4

Robimy dwa strzały do bazy danych, aby pobrać listę mieszkańców. Cache Hibernate nie zadziałał. Jak możemy sobie z tym poradzić? Oto jedno z możliwych rozwiązań.

Rozwiązanie problemu

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Table(name = "house")
@Entity(name = "house_inmates")
@NoArgsConstructor(access = AccessLevel.PACKAGE)
class HouseInmates {

    @Id
    var id: Long? = null

    @OneToMany(mappedBy = "houseId", cascade = [CascadeType.ALL], orphanRemoval = true, fetch = FetchType.EAGER)
    var inmates: Set<Inmate> = HashSet()

    constructor(id: Long?, inmates: Set<Inmate>) {
        this.id = id
        this.inmates = inmates
    }

    fun size(): Int {
        return inmates.size
    }

}
1
2
interface HouseInmatesRepository : CrudRepository<HouseInmates, Long> {
}

Możemy stworzyć encję, która odnosi się do tej samej tabeli co pierwotna encja. W jej ciele umieszczamy tylko zbiór encji, które chcemy wyciągać razem.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Component
class HouseService(
    val houseRepository: HouseRepository,
    val inmateRepository: InmateRepository,
    val houseInmatesRepository: HouseInmatesRepository,
    val transactionTemplate: TransactionTemplate
) {

    val log = LoggerFactory.getLogger(HouseService::class.java)

    fun call() {
        val houseId = transactionTemplate.execute {
            log.debug("saving house")
            val house = houseRepository.save(House("Pogodny domek"))

            log.debug("saving inmates")
            inmateRepository.save(Inmate("Tadzik", house.id, "Tata"))
            inmateRepository.save(Inmate("Grażyna", house.id, "Mama"))
            inmateRepository.save(Inmate("Albert", house.id, "Syn"))
            inmateRepository.save(Inmate("Roksana", house.id, "Córka"))

            return@execute house.id
        }

        transactionTemplate.execute {
            log.debug("fetching inmates by house id with aggregation")
            val third = houseId?.let { houseInmatesRepository.findById(it) } ?: Optional.empty()
            third.ifPresentOrElse(
                { log.debug("third size {}", it.size()) },
                { log.debug("third size is 0") }
            )
            val fourth = houseId?.let { houseInmatesRepository.findById(it) } ?: Optional.empty()
            fourth.ifPresentOrElse(
                { log.debug("fourth size {}", it.size()) },
                { log.debug("fourth size is 0") }
            )
        }
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Hibernate: 
    select
        hi1_0.id,
        i1_0.house_id,
        i1_0.id,
        i1_0.name,
        i1_0.role 
    from
        house hi1_0 
    left join
        inmate i1_0 
            on hi1_0.id=i1_0.house_id 
    where
        hi1_0.id=?
2024-05-17T12:21:08.592+02:00 DEBUG 39328 --- [jpa-list-optimization] [           main] p.c.jpalistoptimization.HouseService     : third size 4
2024-05-17T12:21:08.593+02:00 DEBUG 39328 --- [jpa-list-optimization] [           main] p.c.jpalistoptimization.HouseService     : fourth size 4

Mamy już tylko jeden strzał. Spory uzysk jak na tak małą ilość pracy.

Podsumowanie

Pewnie takie podejście nie każdemu się spodoba. To prawda, nie jest eleganckie, bo mamy dwie encje działające na jeden tabelce. Ale działa i poprawia driver architektoniczny jakim jest wydajność. Jeśli faktycznie zaczniemy cierpieć z powodu zbyt wielu uderzeń do bazy danych to może warto rozważyć takie rozwiązanie. Oczywiście zdaje sobie sprawę, że to nie jest jedyny sposób poprawy wydajności. Jeśli masz jakiś inny pomysł to podziel się nim w komentarzu.