Introduction : La nécessité d'un moteur de rapport flexible

Les applications d'entreprise nécessitent souvent un moteur de reporting qui peut s'adapter aux exigences opérationnelles en constante évolution. Un générateur de reporting statique et codé dur devient rapidement un fardeau de maintenance lorsque les intervenants exigent de nouvelles sources de données, filtres, formats de sortie ou mises en page visuelles. Le modèle Builder, un modèle de conception créé par le Gang of Four, offre une façon propre de construire des objets complexes étape par étape tout en maintenant le processus de construction indépendant de la représentation de l'objet.

Dans cet article, nous concevrons un moteur de reporting à partir de la base, en commençant par une classe de cœur et une interface flexible . Nous mettrons ensuite en œuvre des constructeurs de béton, les intégrerons comme des fèves de ressort, ajouterons un Directeur pour des modèles de rapport prédéfinis, et discuterons de considérations du monde réel telles que la mise en cache, la sécurité des fils et les tests.

Comprendre le modèle du constructeur en profondeur

Le modèle de constructeur est souvent confondu avec les modèles de l'usine abstraite ou de la méthode d'usine, mais son but est distinct : il guide la construction d'un produit étape par étape, permettant au client de choisir quelles étapes à invoquer et dans quel ordre. Un exemple classique .Design Patterns , est de créer un document – vous pourriez vouloir une version PDF, une version HTML ou une version en texte clair, tous construits à partir de la même séquence d'étapes (ajouter l'en-tête, ajouter le paragraphe, ajouter le pied de page).

Les principaux participants à la structure :

  • Produit – L'objet complexe en cours de construction (notre .
  • Builder – Interface abstraite définissant les étapes de construction.
  • ConcreteBuilder – Implémente l'interface Builder, assemble le produit et fournit une méthode pour récupérer le résultat.
  • Director (facultatif) – Ordonne les étapes de construction à l'aide de l'interface Builder, encapsule souvent une séquence de construction par défaut ou souvent utilisée.

Cette séparation des préoccupations signifie que le même processus de construction peut produire des représentations différentes simplement en échangeant le BétonBuilder. Pour un moteur de reporting, cela se traduit en étant capable de générer un rapport --résumé - ou un rapport -détaillé-- en utilisant la même interface mais des implémentations différentes.

Conception du moteur de déclaration

Notre moteur de rapport sera construit autour du produit et une interface fluide. Les interfaces fluides (chaînement de méthodes) sont un ajustement naturel pour le modèle de constructeur et mènent à un code client lisible.

Définition du produit de déclaration

La classe contient les données de base nécessaires pour générer n'importe quel rapport. Dans un système réel, vous pouvez ajouter des champs pour les en-têtes, les pied de page, les définitions de graphiques, les sous-rapports, etc. Pour notre exemple, nous le maintenons concentré :

public class Report {
 private String title;
 private String dataSource; // e.g., "jdbc/myDb" or "file:/data.csv"
 private String query; // SQL or a query identifier
 private List<String> columns; // columns to display
 private Filter filter; // complex filter object
 private String outputFormat; // PDF, CSV, XLSX, HTML
 private boolean showTotals;

 // private constructor – only builders create instances
 private Report() {}

 // Builder inner class or external – we'll use an external builder
 // Getters (no setters after construction) – omitted for brevity
 public String getTitle() { return title; }
 public String getDataSource() { return dataSource; }
 // etc.
}

Remarquez le constructeur privé. Cela fait en sorte qu'un ne peut être créé que par l'intermédiaire d'un constructeur, en veillant à ce que chaque instance soit configurée correctement.

Création de l'interface ReportBuilder

Pour supporter la chaîne de la méthode, chaque setter retourne lui-même. Une méthode finale retourne la méthode construite .

public interface ReportBuilder {
 ReportBuilder setTitle(String title);
 ReportBuilder setDataSource(String dataSource);
 ReportBuilder setQuery(String query);
 ReportBuilder setColumns(List<String> columns);
 ReportBuilder setFilter(Filter filter);
 ReportBuilder setOutputFormat(String outputFormat);
 ReportBuilder showTotals(boolean showTotals);
 Report build();
}

Cette interface est intentionnellement large. Les constructeurs de béton peuvent choisir d'ignorer certaines méthodes (p. ex., un simple constructeur de rapport de synthèse peut ignorer ) ou valider la configuration avant de construire.

Mise en œuvre des constructeurs de béton

Let installe deux constructeurs pour démontrer leur flexibilité : un et un . Les deux mettent en œuvre la même interface mais produisent différents types de rapports.

Rapport détailléBuilder

public class DetailedReportBuilder implements ReportBuilder {
 private Report report = new Report();

 @Override
 public ReportBuilder setTitle(String title) {
 report.setTitle(title);
 return this;
 }

 @Override
 public ReportBuilder setDataSource(String dataSource) {
 report.setDataSource(dataSource);
 return this;
 }

 @Override
 public ReportBuilder setQuery(String query) {
 report.setQuery(query);
 return this;
 }

 @Override
 public ReportBuilder setColumns(List<String> columns) {
 report.setColumns(columns);
 return this;
 }

 @Override
 public ReportBuilder setFilter(Filter filter) {
 report.setFilter(filter);
 return this;
 }

 @Override
 public ReportBuilder setOutputFormat(String outputFormat) {
 report.setOutputFormat(outputFormat);
 return this;
 }

 @Override
 public ReportBuilder showTotals(boolean showTotals) {
 report.setShowTotals(showTotals);
 return this;
 }

 @Override
 public Report build() {
 // Validate critical fields
 if (report.getDataSource() == null) {
 throw new IllegalStateException("DataSource must be set");
 }
 // Additional validation logic...
 return report;
 }
}

Rapport de synthèseBuilder

Un rapport de synthèse peut ignorer les colonnes, les filtres et les totaux, et au contraire regrouper tout en un seul nombre ou un tableau simple.

public class SummaryReportBuilder implements ReportBuilder {
 private String title;
 private String dataSource;
 private String query;
 // other fields are ignored or given defaults

 @Override
 public ReportBuilder setTitle(String title) {
 this.title = title;
 return this;
 }

 @Override
 public ReportBuilder setDataSource(String dataSource) {
 this.dataSource = dataSource;
 return this;
 }

 @Override
 public ReportBuilder setQuery(String query) {
 this.query = query;
 return this;
 }

 // All other setter methods either do nothing or throw UnsupportedOperationException
 @Override
 public ReportBuilder setColumns(List<String> columns) {
 return this; // summary report ignores columns
 }

 // ... similar for filter, outputFormat, showTotals

 @Override
 public Report build() {
 Report report = new Report();
 report.setTitle(title);
 report.setDataSource(dataSource);
 report.setQuery(query);
 report.setOutputFormat("CSV"); // default format
 return report;
 }
}

Avec cette approche, un client peut choisir le constructeur qui correspond à la complexité de sortie requise sans changer la séquence de construction. C'est l'essence du modèle de constructeur.

Ajout d'un directeur pour les modèles prédéfinis

Souvent, vous voulez encapsuler des séquences de construction communes. Une classe de directeur peut faire ceci:

public class ReportDirector {
 private final ReportBuilder builder;

 public ReportDirector(ReportBuilder builder) {
 this.builder = builder;
 }

 public Report constructMonthlySalesReport(String region) {
 return builder
 .setTitle("Monthly Sales – " + region)
 .setDataSource("jdbc/sales_db")
 .setQuery("SELECT * FROM sales WHERE region = :region")
 .setColumns(List.of("Product", "Units Sold", "Revenue"))
 .setFilter(new DateFilter(LocalDate.now().minusMonths(1), LocalDate.now()))
 .setOutputFormat("PDF")
 .showTotals(true)
 .build();
 }

 public Report constructQuickSummary() {
 return builder
 .setTitle("Quick Summary")
 .setDataSource("jdbc/sales_db")
 .setQuery("SELECT count(*) as cnt, sum(revenue) as total FROM sales")
 .setOutputFormat("CSV")
 .build();
 }
}

Le directeur peut être injecté avec n'importe quelle mise en œuvre . Ceci découple le gabarit des détails de construction en béton.

Motif de constructeur dans le démarrage de printemps: Câblage et utilisation

L'injection de dépendance Spring Boot , permet de gérer facilement les constructeurs comme des haricots et les commuter à l'exécution.

Étape 1: Définir les constructeurs comme des haricots de printemps

Nous pouvons annoter nos constructeurs de béton avec ou les déclarer dans une classe :

@Configuration
public class ReportConfig {

 @Bean
 @Scope("prototype") // because each builder session uses a fresh instance
 public DetailedReportBuilder detailedReportBuilder() {
 return new DetailedReportBuilder();
 }

 @Bean
 @Scope("prototype")
 public SummaryReportBuilder summaryReportBuilder() {
 return new SummaryReportBuilder();
 }

 @Bean
 @Scope("prototype")
 public ReportDirector reportDirector(ReportBuilder builder) {
 // This bean will not resolve without specifying the builder – we'll discuss later
 return new ReportDirector(builder);
 }
}

L'utilisation de scope est importante : chaque appel à devrait créer une nouvelle instance de constructeur avec un nouvel état interne. Si nous utilisions la portée de singleton, le constructeur conserverait l'état des appels précédents, causant des bogues.

Pour gérer le fait que nécessite un constructeur spécifique, nous pouvons utiliser ou un modèle d'usine. Une approche pratique consiste à définir plusieurs haricots directeurs, un par type de constructeur:

@Bean
public ReportDirector detailedReportDirector(@Qualifier("detailedReportBuilder") ReportBuilder builder) {
 return new ReportDirector(builder);
}

@Bean
public ReportDirector summaryReportDirector(@Qualifier("summaryReportBuilder") ReportBuilder builder) {
 return new ReportDirector(builder);
}

Étape 2 : Injecter les constructeurs/directeurs dans les contrôleurs ou les services

Un contrôleur type peut accepter un paramètre de type de rapport et utiliser le composant approprié:

@RestController
@RequestMapping("/reports")
public class ReportController {

 @Autowired
 private ReportDirector detailedReportDirector;

 @Autowired
 private ReportDirector summaryReportDirector;

 @GetMapping("/monthly/{region}")
 public ResponseEntity<Report> getMonthlySales(@PathVariable String region) {
 Report report = detailedReportDirector.constructMonthlySalesReport(region);
 // Execute report generation logic...
 return ResponseEntity.ok(report);
 }

 @GetMapping("/summary")
 public ResponseEntity<Report> getSummary() {
 Report report = summaryReportDirector.constructQuickSummary();
 return ResponseEntity.ok(report);
 }
}

Vous pouvez également injecter directement les constructeurs et laisser le calque de service choisir. Le point clé : le code client ne connaît jamais les internes du constructeur – il appelle simplement ou une méthode de directeur.

Personnalisation avancée : Constructeurs dynamiques avec Spring , ObjectProvider

Parfois, la sélection du constructeur doit se faire au moment de l'exécution en fonction des propriétés de configuration ou des rôles de l'utilisateur. Spring , peut aider à injecter un prototype de haricots paresseux:

@Service
public class ReportService {

 private final ObjectProvider<DetailedReportBuilder> detailedBuilderProvider;
 private final ObjectProvider<SummaryReportBuilder> summaryBuilderProvider;

 public ReportService(ObjectProvider<DetailedReportBuilder> detailedBuilderProvider,
 ObjectProvider<SummaryReportBuilder> summaryBuilderProvider) {
 this.detailedBuilderProvider = detailedBuilderProvider;
 this.summaryBuilderProvider = summaryBuilderProvider;
 }

 public Report generateReport(String type, Map<String, String> params) {
 ReportBuilder builder;
 if ("detailed".equalsIgnoreCase(type)) {
 builder = detailedBuilderProvider.getObject();
 } else {
 builder = summaryBuilderProvider.getObject();
 }
 // Apply common params (e.g., title, dataSource)
 String title = params.getOrDefault("title", "Report");
 builder.setTitle(title)
 .setDataSource(params.get("dataSource"));
 // Build
 return builder.build();
 }
}

Ce modèle évite d'avoir à pré-filer tout constructeur possible directement, tout en maintenant le code propre et testable.

Assurer l'immutabilité et la sécurité des fils

Le produit doit être immuable après construction. Comme les constructeurs sont généralement utilisés dans un seul fil et ne sont pas partagés, nous n'avons pas besoin de synchroniser le constructeur lui-même. Cependant, si vous prévoyez de réutiliser un constructeur à travers les fils (pas recommandé), assurez-vous que le constructeur n'a pas d'état mutable partagé.

Pour faire respecter l'immutabilité, rendez la classe vraiment immuable :

  • Marquer tous les champs comme .
  • Passer toutes les valeurs par le constructeur (le constructeur appelle un constructeur privé qui définit tout).
  • Ne fournir que des getters, pas de setters.
  • Pour les collections (p. ex. colonnes), faire des copies défensives dans le constructeur ou utiliser .
public class Report {
 private final String title;
 private final String dataSource;
 private final String query;
 private final List<String> columns;
 private final Filter filter;
 private final String outputFormat;
 private final boolean showTotals;

 Report(String title, String dataSource, String query,
 List<String> columns, Filter filter,
 String outputFormat, boolean showTotals) {
 this.title = title;
 this.dataSource = dataSource;
 this.query = query;
 this.columns = columns == null ? List.of() : List.copyOf(columns);
 this.filter = filter;
 this.outputFormat = outputFormat;
 this.showTotals = showTotals;
 }
 // getters...
}

Puis le crée le par l'intermédiaire de ce constructeur complet, en passant toutes les valeurs rassemblées. Cela garantit qu'une fois construit, le rapport ne peut être modifié.

Essai du moteur de déclaration

Pour les essais unitaires du produit , vous pouvez l'instantaner directement en utilisant un constructeur. Pour les essais d'intégration, vous pouvez vérifier que le constructeur correct est appelé et que le rapport final répond aux attentes.

Essai d'unité d'un constructeur de béton

@Test
void testDetailedReportBuilder() {
 DetailedReportBuilder builder = new DetailedReportBuilder();
 Report report = builder
 .setTitle("Test")
 .setDataSource("jdbc/test")
 .setOutputFormat("PDF")
 .build();

 assertThat(report.getTitle()).isEqualTo("Test");
 assertThat(report.getDataSource()).isEqualTo("jdbc/test");
 assertThat(report.getOutputFormat()).isEqualTo("PDF");
 assertThat(report.isShowTotals()).isFalse(); // default
}

Essais avec des Mocks

Lors de l'essai d'un service qui utilise un constructeur, moquez-vous de l'interface du constructeur pour vérifier les interactions :

@Test
void testReportServiceUsesBuilderCorrectly() {
 ReportBuilder mockBuilder = mock(ReportBuilder.class);
 when(mockBuilder.setTitle(any())).thenReturn(mockBuilder);
 when(mockBuilder.setDataSource(any())).thenReturn(mockBuilder);
 // ... other stubs
 Report expectedReport = new Report(/* ... */);
 when(mockBuilder.build()).thenReturn(expectedReport);

 ReportService service = new ReportService(/* ... */);
 // inject mockBuilder via a test specific method
 Report result = service.generateReport("detailed", Map.of("title", "Test", "dataSource", "jdbc/db"));

 assertThat(result).isSameAs(expectedReport);
 verify(mockBuilder).setTitle("Test");
 verify(mockBuilder).setDataSource("jdbc/db");
 verify(mockBuilder).build();
}

Considérations de rendement et mise en cache

Construire un objet est bon marché – il ne s'agit que de rassembler des données de configuration. La partie coûteuse est d'exécuter la requête sous-jacente, de transformer les données et de générer le fichier de sortie (PDF, XLSX). Par conséquent, le constructeur ne devrait pas déclencher d'E/S. Cette responsabilité appartient à un service distinct ou similaire.

Si la même configuration de rapport est demandée à plusieurs reprises (p. ex., le même rapport mensuel de ventes pour la même région), vous pouvez mettre en cache l'objet (la configuration) et le réutiliser. Pour la mise en cache, vous pouvez utiliser Spring=»s sur la méthode de gestion ou la méthode de service.

@Cacheable("reportConfigs")
public Report getMonthlySalesConfig(String region) {
 return detailedReportDirector.constructMonthlySalesReport(region);
}

La mise en cache de la configuration permet au constructeur de n'exécuter qu'une seule fois par ensemble de paramètres distincts, accélérant les requêtes ultérieures avant même l'exécution de la requête.

Comparaison du modèle de constructeur avec d'autres approches

Lors de la conception d'un moteur de rapport, vous pouvez considérer d'autres modèles:

  • Méthode de la dynamique – Bon pour créer un objet de rapport en une seule étape, mais ne supporte pas la configuration par étape.
  • Constructeur avec de nombreux paramètres – Les constructeurs télescopages sont sujets à erreur et difficiles à lire. Le modèle Builder fournit un style clair, nommé-paramètre.
  • JavaBeans pattern (mutable setters) – Permet une configuration par étapes mais rompt l'immutabilité et peut conduire à des objets partiellement initialisés.
  • Stratégie Pattern – Peut être combiné avec Builder; le constructeur peut accepter une stratégie de rendu ou de récupération de données.

Le modèle Builder excelle lorsque le produit a de nombreux composants optionnels, comme un rapport. Il prend également en charge le principe Open/Fermé – vous pouvez ajouter de nouveaux types de rapports en implémentant un nouveau constructeur sans modifier le code existant.

Extensions mondiales réelles

Un moteur de production de rapports a souvent besoin de plus que de configuration simple.

  • Constructeurs en nid pour les sous-rapports – chaque sous-rapport peut avoir son propre constructeur.
  • Un concept – constructeurs préconfigurés stockés dans une base de données ou des fichiers YAML.
  • Intégration avec Spring Cloud Config pour modifier les modèles de rapports sans redéployer.
  • En utilisant Lombok="s annotation pour autogénérer la classe de constructeur. Attention : Lombok génère un constructeur niché statique, qui peut ne pas permettre aux constructeurs polymorphes de différents types de rapports.

Par exemple, un modèle basé sur YAML pourrait être chargé :

monthly-sales:
 title: "Monthly Sales - ${region}"
 dataSource: "jdbc/sales"
 query: "SELECT ..."
 columns: ["Product", "Units Sold"]
 outputFormat: "PDF"
 showTotals: true

Un service pourrait analyser ce modèle et appeler les méthodes de construction appropriées, ce qui rendrait le moteur de déclaration entièrement axé sur les données.

Conclusion

Le modèle Builder, appliqué à un moteur de reporting Java Spring Boot, permet une séparation nette entre la construction des configurations de rapports et leur représentation. En définissant une interface couramment et en mettant en œuvre plusieurs constructeurs de béton, vous activez la personnalisation dynamique et l'exécution des rapports sans accumuler de dettes techniques.

Ce design est non seulement extensible – vous pouvez ajouter de nouveaux types de rapports en écrivant un nouveau constructeur – mais également testable, car les constructeurs sont des objets Java simples qui peuvent être moqués ou inoccupés en isolement. Combinés à des produits immuables et à la mise en cache, le moteur reste performant et sûr.

Que vous construisiez un tableau de bord simple ou une plateforme d'intelligence d'entreprise à part entière, le modèle Builder vous donne la flexibilité de répondre à des exigences en évolution tout en maintenant une base de codes qui est un plaisir de travailler avec. Pour plus de détails, voir le document officiel du Cadre de printemps sur les champs de haricots et le classique Design Patterns book pour plus de contexte sur les motifs de création.