Péntek este van. A mai fejlesztés kicsit nagyobbra nőtt, mint eredetileg terveztem. Menet közben találtam valamit, aminek majd egyszer örülni fogok, de ma még csak a gép előtt töltött órákat növelte.

Builderek! Builderek mindenhol! Link to heading

Nézzük hát, mit hoz a jövőben ez a sok száz sor kód, amit ma fejlesztettem bele a korábbi modulokba.

Adott a TenantsModule stuktúra és annak a jelenleg még kicsit körülményes inicializációja

 1pub struct TenantsModule {
 2    pub pool_manager: Arc<dyn PgPoolManagerTrait>,
 3    pub config: Arc<AppConfig>,
 4    pub repo_factory: Box<dyn Fn() -> Box<dyn TenantsRepository + Send + Sync> + Send + Sync>,
 5    pub migrator_factory: Box<dyn Fn() -> Box<dyn DatabaseMigrator + Send + Sync> + Send + Sync>,
 6    pub connection_tester_factory:
 7        Box<dyn Fn() -> Box<dyn ConnectionTester + Send + Sync> + Send + Sync>,
 8}
 9
10#[test]
11fn some_important_test() {
12    // ...
13    let mut pool_manager_mock = Arc::new(MockPgPoolManagerTrait::new());
14    // some long pool_manager_mock init...
15
16    let mut migrator_factory = MockDatabaseMigrator::new();
17    // some long migrator_factory init...
18
19    let mut connection_tester_factory = MockConnectionTester::new();
20    // some long connection_tester_factory init...
21
22    let config = Arc::new(AppConfigBuilder::default().build().unwrap());
23    // let's say I want to use the default test config
24    
25    let repo_factory = Box::new(|| {
26        let mut repo = MockTenantsRepository::new();
27        repo.expect_setup_self_hosted()
28            .times(1)
29            .withf(|name, _, _| name == "test")
30            .returning(|_, _, _| {
31                Ok(Tenant {
32                    id: Uuid::new_v4(),
33                    name: "test".to_string(),
34                    db_host: "example.com".to_string(),
35                    db_port: 5432,
36                    db_name: "tenant_1234567890".to_string(),
37                    db_user: "tenant_1234567890".to_string(),
38                    db_password: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(),
39                    db_max_pool_size: 5,
40                    db_ssl_mode: "verify-full".to_string(),
41                    created_at: Local::now(),
42                    updated_at: Local::now(),
43                    deleted_at: None,
44                })
45            });
46        Box::new(repo) as Box<dyn TenantsRepository + Send + Sync>
47    });
48    let tenants_module = TenantsModule {
49        pool_manager: pool_manager_mock.clone(),
50        config: config.clone(),
51        repo_factory,
52        migrator_factory,
53    };
54    // ...
55}

A probléma ezzel az, hogy abban a pillanatban, amikor létre hozom ezt a struktúrát az összes értékkel rendelkeznem kell, amit bele szeretnék tenni, de ez a megkötés gyakran elég kényelmetlen helyzeteket teremt

Erre a legegyszerűbb megoldás, amit sok más nyelvben is használnak: A builder pattern. Ennek lényege, hogy a Builder struktúrában engedem, hogy anélkül létezzenek az értékek, hogy teljesen inicializálva lenne az egész. Ha minden érték a helyén, akkor a végén egy build függvénnyel létre tudom hozni a TenantsModule stuktúrát.

Hogy néz ki ez a builder a gyakorlatban?

 1pub struct TenantsModuleBuilder {
 2    pub pool_manager: Option<Arc<dyn PgPoolManagerTrait>>, // Option!
 3    pub config: Option<Arc<AppConfig>>, // Option!
 4    pub repo_factory:
 5        Option<Box<dyn Fn() -> Box<dyn TenantsRepository + Send + Sync> + Send + Sync>>, // Option!
 6    pub migrator_factory:
 7        Option<Box<dyn Fn() -> Box<dyn DatabaseMigrator + Send + Sync> + Send + Sync>>, // Option!
 8    pub connection_tester_factory:
 9        Option<Box<dyn Fn() -> Box<dyn ConnectionTester + Send + Sync> + Send + Sync>>, // Option!
10}
11
12impl TenantsModuleBuilder {
13    pub fn new() -> Self {
14        Self {
15            pool_manager: None,
16            config: None,
17            repo_factory: None,
18            migrator_factory: None,
19            connection_tester_factory: None,
20        }
21    }
22    pub fn pool_manager(mut self, pool_manager: Arc<dyn PgPoolManagerTrait>) -> Self {
23        self.pool_manager = Some(pool_manager);
24        self
25    }
26    pub fn config(mut self, config: Arc<AppConfig>) -> Self {
27        self.config = Some(config);
28        self
29    }
30    pub fn repo_factory(
31        mut self,
32        repo_factory: Box<dyn Fn() -> Box<dyn TenantsRepository + Send + Sync> + Send + Sync>,
33    ) -> Self {
34        self.repo_factory = Some(repo_factory);
35        self
36    }
37    pub fn migrator_factory(
38        mut self,
39        migrator_factory: Box<dyn Fn() -> Box<dyn DatabaseMigrator + Send + Sync> + Send + Sync>,
40    ) -> Self {
41        self.migrator_factory = Some(migrator_factory);
42        self
43    }
44    pub fn connection_tester_factory(
45        mut self,
46        connection_tester_factory: Box<
47            dyn Fn() -> Box<dyn ConnectionTester + Send + Sync> + Send + Sync,
48        >,
49    ) -> Self {
50        self.connection_tester_factory = Some(connection_tester_factory);
51        self
52    }
53
54    pub fn build(self) -> Result<TenantsModule, String> {
55        Ok(TenantsModule {
56            pool_manager: self
57                .pool_manager
58                .ok_or("pool_manager is required".to_string())?,
59            config: self.config.ok_or("config is required".to_string())?,
60            repo_factory: self
61                .repo_factory
62                .ok_or("repo_factory is required".to_string())?,
63            migrator_factory: self
64                .migrator_factory
65                .ok_or("migrator_factory is required".to_string())?,
66            connection_tester_factory: self
67                .connection_tester_factory
68                .ok_or("connection_tester is required".to_string())?,
69        })
70    }
71}

És mire jó ez? Link to heading

Tegyük fel, hogy sok tesztet kell megírnom, ahol a pool_manager, a config, a migrator_factory és a connection_tester_factory mindig ugyan az, de repo_factory-t tesztről tesztre változtatnom kell. Egy builderrel sokkal rugalmasabban tudom ezt megtenni olyan struktúrák esetében is, amiken privát mezők vannak. (A TenantsModule nem ilyen egyelőre)

A végeredmény builderrel valami ilyesmi lesz:

 1#[cfg(test)]
 2pub(crate) mod tests {
 3    use super::*;
 4    use crate::app::config::AppConfigBuilder;
 5
 6    use crate::app::database::{
 7        MockConnectionTester, MockDatabaseMigrator, MockPgPoolManagerTrait,
 8    };
 9    use crate::tenants::repository::MockTenantsRepository;
10
11    fn default_tenants_module_for_self_hosted_tests() -> TenantsModuleBuilder {
12        let mut pool_manager_mock = Arc::new(MockPgPoolManagerTrait::new());
13        // some long pool_manager_mock init...
14
15        let mut migrator_factory = MockDatabaseMigrator::new();
16        // some long migrator_factory init...
17
18        let mut connection_tester_factory = MockConnectionTester::new();
19        // some long connection_tester_factory init...
20
21        let config = Arc::new(AppConfigBuilder::default().build().unwrap());
22        // let's say I want to use the default test config
23
24        // notice: I expect the caller to finish the repo_factory
25        TenantsModuleBuilder::default()
26            .pool_manager(pool_manager_mock.clone())
27            .config(config)
28            .migrator_factory(migrator_factory)
29            .connection_tester_factory(connection_tester_factory)
30    }
31
32    #[test]
33    fn some_important_test1() {
34        let repo_factory = Box::new(|| {
35            let mut repo = MockTenantsRepository::new();
36            repo.expect_setup_self_hosted()
37                .times(1)
38                .withf(|name, _, _| name == "test")
39                .returning(|_, _, _| {
40                    Ok(Tenant {
41                        id: Uuid::new_v4(),
42                        name: "test".to_string(),
43                        db_host: "example.com".to_string(),
44                        db_port: 5432,
45                        db_name: "tenant_1234567890".to_string(),
46                        db_user: "tenant_1234567890".to_string(),
47                        db_password: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(),
48                        db_max_pool_size: 5,
49                        db_ssl_mode: "verify-full".to_string(),
50                        created_at: Local::now(),
51                        updated_at: Local::now(),
52                        deleted_at: None,
53                    })
54                });
55            Box::new(repo) as Box<dyn TenantsRepository + Send + Sync>
56        });
57
58        let tenants_module = default_tenants_module_for_self_hosted_tests()
59            .repo_factory(repo_factory)
60            .build()
61            .unwrap();
62    }
63
64    #[test]
65    fn some_important_test1() {
66        let repo_factory = Box::new(|| {
67            let mut repo = MockTenantsRepository::new();
68            repo.expect_setup_managed()
69                .times(0)
70                .withf(|_, name, _, _, _| name == "test")
71                .returning(|uuid: Uuid, _, _, _, _| {
72                    Ok(Tenant {
73                        id: uuid,
74                        name: "test".to_string(),
75                        db_host: "localhost".to_string(),
76                        db_port: 5432,
77                        db_name: "database".to_string(),
78                        db_user: "user".to_string(),
79                        db_password: "password".to_string(),
80                        db_max_pool_size: 5,
81                        db_ssl_mode: "disable".to_string(),
82                        created_at: Local::now(),
83                        updated_at: Local::now(),
84                        deleted_at: None,
85                    })
86                });
87            Box::new(repo) as Box<dyn TenantsRepository + Send + Sync>
88        });
89
90        let tenants_module = default_tenants_module_for_self_hosted_tests()
91            .repo_factory(repo_factory)
92            .build()
93            .unwrap();
94    }
95}

Ezzel a megoldással el tudom kerülni, hogy feleslegesen újra inicializáljam a tenants_module azon részeit, amiknek minden tesztben egyezniük kell. Ha javítanom kell az alapértelmezett részeken valamit, akkor csak egy helyen kell megtennem és utána az összes teszben javítva lesz.

Ezt a bejegyzés tegnap terveztem közzétenni, csak késő volt már. :)

Ha találsz hibát, írj egy e-mail!