DDD-begrensde contexten en Java-modules

1. Overzicht

Domain-Driven Design (DDD) is een reeks principes en tools die ons helpen bij het ontwerpen van effectieve softwarearchitecturen om een ​​hogere bedrijfswaarde te leveren. Bounded Context is een van de centrale en essentiële patronen om architectuur te redden van de Big Ball Of Mud door het hele toepassingsdomein op te splitsen in meerdere semantisch consistente delen.

Tegelijkertijd kunnen we met het Java 9 Module System sterk ingekapselde modules maken.

In deze zelfstudie maken we een eenvoudige winkeltoepassing en zien we hoe we Java 9-modules kunnen gebruiken terwijl we expliciete grenzen voor begrensde contexten definiëren.

2. DDD-begrensde contexten

Tegenwoordig zijn softwaresystemen geen eenvoudige CRUD-toepassingen. Eigenlijk bestaat het typische monolithische bedrijfssysteem uit een aantal verouderde codebases en nieuw toegevoegde functies. Het wordt echter steeds moeilijker om dergelijke systemen te onderhouden met elke aangebrachte wijziging. Uiteindelijk kan het totaal onhoudbaar worden.

2.1. Begrensde context en alomtegenwoordige taal

Om het geadresseerde probleem op te lossen, biedt DDD het concept van Bounded Context. Een Bounded Context is een logische grens van een domein waar bepaalde termen en regels consistent van toepassing zijn. Binnen deze grens, alle termen, definities en concepten vormen de alomtegenwoordige taal.

In het bijzonder is het belangrijkste voordeel van alomtegenwoordige taal het groeperen van projectleden uit verschillende gebieden rond een specifiek bedrijfsdomein.

Bovendien kunnen meerdere contexten met hetzelfde werken. Het kan echter binnen elk van deze contexten verschillende betekenissen hebben.

2.2. Bestelcontext

Laten we beginnen met het implementeren van onze applicatie door de ordercontext te definiëren. Deze context bevat twee entiteiten: Bestel item en Klantenorder.

De Klantenorder entiteit is een geaggregeerde root:

openbare klasse CustomerOrder {private int orderId; private String paymentMethod; privé String-adres; privélijst orderItems; openbare float berekenTotalPrice () {retour orderItems.stream (). map (OrderItem :: getTotalPrice) .reduce (0F, Float :: som); }}

Zoals we kunnen zien, bevat deze klasse de berekenenTotaalPrijs zakelijke methode. Maar in een real-world project zal het waarschijnlijk veel gecompliceerder zijn - bijvoorbeeld inclusief kortingen en belastingen in de uiteindelijke prijs.

Laten we vervolgens het Bestel item klasse:

openbare klasse OrderItem {private int productId; private int hoeveelheid; private float unitPrijs; eigen vlottereenheidGewicht; }

We hebben entiteiten gedefinieerd, maar we moeten ook een API beschikbaar stellen voor andere delen van de applicatie. Laten we de KlantOrderService klasse:

public class CustomerOrderService implementeert OrderService {public static final String EVENT_ORDER_READY_FOR_SHIPMENT = "OrderReadyForShipmentEvent"; privé CustomerOrderRepository orderRepository; privé EventBus eventBus; @Override public void placeOrder (Order CustomerOrder) {this.orderRepository.saveCustomerOrder (order); Kaart payload = nieuwe HashMap (); payload.put ("order_id", String.valueOf (order.getOrderId ())); ApplicationEvent-gebeurtenis = nieuwe ApplicationEvent (payload) {@Override public String getType () {return EVENT_ORDER_READY_FOR_SHIPMENT; }}; this.eventBus.publish (evenement); }}

Hier hebben we enkele belangrijke punten om te benadrukken. De plaats bestelling method is verantwoordelijk voor het verwerken van klantorders. Nadat een bestelling is verwerkt, wordt het evenement gepubliceerd op de EventBus. In de volgende hoofdstukken bespreken we de gebeurtenisgestuurde communicatie. Deze service biedt de standaardimplementatie voor het Bestelservice koppel:

openbare interface OrderService breidt ApplicationService uit {void placeOrder (CustomerOrder order); void setOrderRepository (CustomerOrderRepository orderRepository); }

Bovendien vereist deze service het CustomerOrderRepository om bestellingen vol te houden:

openbare interface CustomerOrderRepository {void saveCustomerOrder (CustomerOrder-bestelling); }

Wat essentieel is, is dat deze interface is niet binnen deze context geïmplementeerd, maar wordt geleverd door de infrastructuurmodule, zoals we later zullen zien.

2.3. Verzendcontext

Laten we nu de verzendcontext definiëren. Het zal ook eenvoudig zijn en drie entiteiten bevatten: Pakket, Pakketitem, en Verzendbaar.

Laten we beginnen met de Verzendbaar entiteit:

openbare klasse ShippableOrder {private int orderId; privé String-adres; privélijst packageItems; }

In dit geval bevat de entiteit niet de betalingswijze veld. Dat komt omdat het ons in onze verzendcontext niet kan schelen welke betaalmethode wordt gebruikt. De verzendcontext is alleen verantwoordelijk voor het verwerken van verzendingen van bestellingen.

Ook de Pakket entiteit is specifiek voor de verzendcontext:

openbare klasse Parcel {private int orderId; privé String-adres; private String trackingId; privélijst packageItems; openbare float berekenTotalWeight () {retour pakketItems.stream (). kaart (PakketItem :: getWeight). verminderen (0F, Float :: som); } openbare boolean isTaxable () {return berekenEstimatedValue ()> 100; } openbare float berekenEstimatedValue () {retour pakketItems.stream (). map (PackageItem :: getWeight). verminderen (0F, Float :: som); }}

Zoals we kunnen zien, bevat het ook specifieke bedrijfsmethoden en fungeert het als een geaggregeerde root.

Laten we tot slot de ParcelShippingService:

public class ParcelShippingService implementeert ShippingService {public static final String EVENT_ORDER_READY_FOR_SHIPMENT = "OrderReadyForShipmentEvent"; privé ShippingOrderRepository orderRepository; privé EventBus eventBus; privékaart verzondenParcels = nieuwe HashMap (); @Override public void shipOrder (int orderId) {Optioneel order = this.orderRepository.findShippableOrder (orderId); order.ifPresent (completeOrder -> {Parcel parcel = new Parcel (completeOrder.getOrderId (), completeOrder.getAddress (), completeOrder.getPackageItems ()); if (parcel.isTaxable ()) {// Bereken extra belastingen} // Pakket verzenden this.shippedParcels.put (completeOrder.getOrderId (), pakket);}); } @Override public void listenToOrderEvents () {this.eventBus.subscribe (EVENT_ORDER_READY_FOR_SHIPMENT, nieuwe EventSubscriber () {@Override public void onEvent (E-evenement) {shipOrder (Integer.parseInt (event.getPayload_) "); }); } @Override public Optioneel getParcelByOrderId (int orderId) {return Optioneel.ofNullable (this.shippedParcels.get (orderId)); }}

Deze service maakt op dezelfde manier gebruik van de VerzendingOrderRepository voor het ophalen van bestellingen op ID. Wat nog belangrijker is, het is geabonneerd op de OrderReadyForShipmentEvent event, dat wordt gepubliceerd door een andere context. Wanneer deze gebeurtenis zich voordoet, past de service enkele regels toe en verzendt de bestelling. Gemakshalve slaan we verzonden bestellingen op in een Hash kaart.

3. Contextkaarten

Tot dusver hebben we twee contexten gedefinieerd. We hebben echter geen expliciete relaties tussen hen ingesteld. DDD heeft hiervoor het concept van Context Mapping. Een contextkaart is een visuele beschrijving van relaties tussen verschillende contexten van het systeem. Deze kaart laat zien hoe verschillende onderdelen naast elkaar bestaan ​​om het domein te vormen.

Er zijn vijf hoofdtypen relaties tussen begrensde contexten:

  • Vennootschap - een relatie tussen twee contexten die samenwerken om de twee teams op één lijn te brengen met afhankelijke doelen
  • Gedeelde kernel - een soort relatie wanneer gemeenschappelijke delen van verschillende contexten worden geëxtraheerd naar een andere context / module om codeduplicatie te verminderen
  • Klantleverancier - een verbinding tussen twee contexten, waarbij de ene context (stroomopwaarts) gegevens produceert en de andere (stroomafwaarts) gegevens consumeert. In deze relatie zijn beide partijen geïnteresseerd in het tot stand brengen van de best mogelijke communicatie
  • Conformist - deze relatie heeft ook stroomopwaarts en stroomafwaarts, maar stroomafwaarts voldoet altijd aan de upstream-API's
  • Anticorruptielaag - dit type relatie wordt veel gebruikt voor legacy-systemen om ze aan te passen aan een nieuwe architectuur en geleidelijk te migreren van de legacy-codebase. De anticorruptielaag fungeert als een adapter om gegevens stroomopwaarts te vertalen en te beschermen tegen ongewenste wijzigingen

In ons specifieke voorbeeld gebruiken we de Shared Kernel-relatie. We zullen het niet in zijn zuivere vorm definiëren, maar het zal vooral optreden als een bemiddelaar van gebeurtenissen in het systeem.

De SharedKernel-module bevat dus geen concrete implementaties, alleen interfaces.

Laten we beginnen met de EventBus koppel:

openbare interface EventBus {ongeldig publiceren (E-evenement); ongeldig abonneren (String eventType, EventSubscriber-abonnee); ongeldig uitschrijven (String eventType, EventSubscriber-abonnee); }

Deze interface wordt later geïmplementeerd in onze Infrastructuurmodule.

Vervolgens maken we een basisservice-interface met standaardmethoden om gebeurtenisgestuurde communicatie te ondersteunen:

openbare interface ApplicationService {standaard ongeldig publishEvent (E-evenement) {EventBus eventBus = getEventBus (); if (eventBus! = null) {eventBus.publish (evenement); }} standaard ongeldig abonneren (String eventType, EventSubscriber abonnee) {EventBus eventBus = getEventBus (); if (eventBus! = null) {eventBus.subscribe (eventType, abonnee); }} standaard ongeldig afmelden (String eventType, EventSubscriber abonnee) {EventBus eventBus = getEventBus (); if (eventBus! = null) {eventBus.unsubscribe (eventType, abonnee); }} EventBus getEventBus (); void setEventBus (EventBus eventBus); }

Service-interfaces in begrensde contexten breiden deze interface dus uit met algemene gebeurtenisgerelateerde functionaliteit.

4. Java 9-modulariteit

Nu is het tijd om te onderzoeken hoe het Java 9-modulesysteem de gedefinieerde applicatiestructuur kan ondersteunen.

Het Java Platform Module System (JPMS) moedigt aan om betrouwbaardere en sterk ingekapselde modules te bouwen. Als gevolg hiervan kunnen deze functies helpen onze contexten te isoleren en duidelijke grenzen te stellen.

Laten we ons laatste modulediagram bekijken:

4.1. SharedKernel-module

Laten we beginnen met de SharedKernel-module, die niet afhankelijk is van andere modules. Dus de module-info.java lijkt op:

module com.baeldung.dddmodules.sharedkernel {exporteert com.baeldung.dddmodules.sharedkernel.events; exporteert com.baeldung.dddmodules.sharedkernel.service; }

We exporteren module-interfaces, zodat ze beschikbaar zijn voor andere modules.

4.2. OrderContext Module

Laten we vervolgens onze focus verleggen naar de OrderContext-module. Het vereist alleen interfaces die zijn gedefinieerd in de SharedKernel-module:

module com.baeldung.dddmodules.ordercontext {vereist com.baeldung.dddmodules.sharedkernel; exporteert com.baeldung.dddmodules.ordercontext.service; exporteert com.baeldung.dddmodules.ordercontext.model; exporteert com.baeldung.dddmodules.ordercontext.repository; biedt com.baeldung.dddmodules.ordercontext.service.OrderService com.baeldung.dddmodules.ordercontext.service.CustomerOrderService; }

We kunnen ook zien dat deze module de standaardimplementatie voor het Bestelservice koppel.

4.3. ShippingContext Module

Laten we, net als bij de vorige module, het definitiebestand van de ShippingContext-module maken:

module com.baeldung.dddmodules.shippingcontext {vereist com.baeldung.dddmodules.sharedkernel; exporteert com.baeldung.dddmodules.shippingcontext.service; exporteert com.baeldung.dddmodules.shippingcontext.model; exporteert com.baeldung.dddmodules.shippingcontext.repository; biedt com.baeldung.dddmodules.shippingcontext.service.ShippingService com.baeldung.dddmodules.shippingcontext.service.ParcelShippingService; }

Op dezelfde manier exporteren we de standaardimplementatie voor het Verzendservice koppel.

4.4. Infrastructuurmodule

Nu is het tijd om de module Infrastructuur te beschrijven. Deze module bevat de implementatiedetails voor de gedefinieerde interfaces. We beginnen met het maken van een eenvoudige implementatie voor de EventBus koppel:

public class SimpleEventBus implementeert EventBus {private final Map subscribers = nieuwe ConcurrentHashMap (); @Override public void publish (E event) {if (subscribers.containsKey (event.getType ())) {subscribers.get (event.getType ()) .forEach (subscriber -> subscriber.onEvent (event)); }} @Override public void subscribe (String eventType, EventSubscriber subscriber) {Set eventSubscribers = subscribers.get (eventType); if (eventSubscribers == null) {eventSubscribers = nieuwe CopyOnWriteArraySet (); subscribers.put (eventType, eventSubscribers); } eventSubscribers.add (abonnee); } @Override public void unsubscribe (String eventType, EventSubscriber subscriber) {if (subscribers.containsKey (eventType)) {subscribers.get (eventType) .remove (subscriber); }}}

Vervolgens moeten we het CustomerOrderRepository en VerzendingOrderRepository interfaces. In de meeste gevallen is het Bestellen entiteit wordt opgeslagen in dezelfde tabel maar gebruikt als een ander entiteitsmodel in begrensde contexten.

Het is heel gebruikelijk om een ​​enkele entiteit te zien die gemengde code bevat uit verschillende delen van het bedrijfsdomein of databasetoewijzingen op laag niveau. Voor onze implementatie hebben we onze entiteiten opgesplitst volgens de begrensde contexten: Klantenorder en Verzendbaar.

Laten we eerst een klasse maken die een heel persistent model vertegenwoordigt:

openbare statische klasse PersistenceOrder {public int orderId; openbare String paymentMethod; openbaar String-adres; openbare lijst orderItems; openbare statische klasse OrderItem {public int productId; public float unitPrijs; openbaar float itemWeight; publieke int hoeveelheid; }}

We kunnen zien dat deze klasse alle velden van beide bevat Klantenorder en Verzendbaar entiteiten.

Laten we, om het simpel te houden, een in-memory database simuleren:

openbare klasse InMemoryOrderStore implementeert CustomerOrderRepository, ShippingOrderRepository {privé Map ordersDb = nieuwe HashMap (); @Override public void saveCustomerOrder (CustomerOrder-bestelling) {this.ordersDb.put (order.getOrderId (), nieuwe PersistenceOrder (order.getOrderId (), order.getPaymentMethod (), order.getAddress (), order .getOrderItems () .stream () .map (orderItem -> nieuwe PersistenceOrder.OrderItem (orderItem.getProductId (), orderItem.getQuantity (), orderItem.getUnitWeight (), orderItem.getUnitPrice ())) .collect (Collectors.toList ()))); } @Override public Optioneel findShippableOrder (int orderId) {if (! This.ordersDb.containsKey (orderId)) return Optional.empty (); PersistenceOrder orderRecord = this.ordersDb.get (orderId); return Optioneel.of (nieuwe ShippableOrder (orderRecord.orderId, orderRecord.orderItems .stream (). map (orderItem -> nieuw PackageItem (orderItem.productId, orderItem.itemWeight, orderItem.quantity * orderItem.unitPrice)) .collect (Collectors. toList ()))); }}

Hier houden we stand en halen we verschillende soorten entiteiten op door persistente modellen van of naar een geschikt type te converteren.

Laten we tot slot de moduledefinitie maken:

module com.baeldung.dddmodules.infrastructure {vereist transitieve com.baeldung.dddmodules.sharedkernel; vereist transitieve com.baeldung.dddmodules.ordercontext; vereist transitieve com.baeldung.dddmodules.shippingcontext; biedt com.baeldung.dddmodules.sharedkernel.events.EventBus com.baeldung.dddmodules.infrastructure.events.SimpleEventBus; biedt com.baeldung.dddmodules.ordercontext.repository.CustomerOrderRepository com.baeldung.dddmodules.infrastructure.db.InMemoryOrderStore; biedt com.baeldung.dddmodules.shippingcontext.repository.ShippingOrderRepository com.baeldung.dddmodules.infrastructure.db.InMemoryOrderStore; }

De ... gebruiken voorziet clausule, bieden we de implementatie van een paar interfaces die zijn gedefinieerd in andere modules.

Bovendien fungeert deze module als een aggregator van afhankelijkheden, dus gebruiken we de vereist transitief trefwoord. Als gevolg hiervan krijgt een module die de infrastructuurmodule vereist, al deze afhankelijkheden tijdelijk.

4.5. Hoofdmodule

Laten we tot slot een module definiëren die het toegangspunt tot onze applicatie zal zijn:

module com.baeldung.dddmodules.mainapp {gebruikt com.baeldung.dddmodules.sharedkernel.events.EventBus; gebruikt com.baeldung.dddmodules.ordercontext.service.OrderService; gebruikt com.baeldung.dddmodules.ordercontext.repository.CustomerOrderRepository; gebruikt com.baeldung.dddmodules.shippingcontext.repository.ShippingOrderRepository; gebruikt com.baeldung.dddmodules.shippingcontext.service.ShippingService; vereist transitieve com.baeldung.dddmodules.infrastructure; }

Aangezien we zojuist transitieve afhankelijkheden hebben ingesteld op de infrastructuurmodule, hoeven we deze hier niet expliciet te vereisen.

Aan de andere kant vermelden we deze afhankelijkheden met de toepassingen trefwoord. De toepassingen clausule instrueert ServiceLoader, die we in het volgende hoofdstuk zullen ontdekken, dat deze module deze interfaces wil gebruiken. Echter, het vereist geen implementaties om beschikbaar te zijn tijdens het compileren.

5. De toepassing uitvoeren

Eindelijk zijn we bijna klaar om onze applicatie te bouwen. We zullen Maven gebruiken voor het bouwen van ons project. Dit maakt het veel gemakkelijker om met modules te werken.

5.1. Projectstructuur

Ons project bevat vijf modules en de oudermodule. Laten we eens kijken naar onze projectstructuur:

ddd-modules (de hoofdmap) pom.xml | - infrastructuur | - src | - hoofd | - java module-info.java | - com.baeldung.dddmodules.infrastructuur pom.xml | - mainapp | - src | - hoofd | - java module-info.java | - com.baeldung.dddmodules.mainapp pom.xml | - ordercontext | - src | - hoofd | - java module-info.java | --com.baeldung.dddmodules.ordercontext pom.xml | - sharedkernel | - src | - hoofd | - java module-info.java | - com.baeldung.dddmodules.sharedkernel pom.xml | - shippingcontext | - src | - main | - java module-info.java | - com.baeldung.dddmodules.shippingcontext pom.xml

5.2. Hoofdtoepassing

Inmiddels hebben we alles behalve de hoofdtoepassing, dus laten we onze hoofd methode:

public static void main (String args []) {Map container = createContainer (); OrderService orderService = (OrderService) container.get (OrderService.class); ShippingService shippingService = (ShippingService) container.get (ShippingService.class); shippingService.listenToOrderEvents (); CustomerOrder customerOrder = nieuwe CustomerOrder (); int orderId = 1; customerOrder.setOrderId (orderId); Lijst orderItems = nieuwe ArrayList (); orderItems.add (nieuwe OrderItem (1, 2, 3, 1)); orderItems.add (nieuwe OrderItem (2, 1, 1, 1)); orderItems.add (nieuwe OrderItem (3, 4, 11, 21)); customerOrder.setOrderItems (orderItems); customerOrder.setPaymentMethod ("PayPal"); customerOrder.setAddress ("Volledig adres hier"); orderService.placeOrder (customerOrder); if (orderId == shippingService.getParcelByOrderId (orderId) .get (). getOrderId ()) {System.out.println ("Bestelling is succesvol verwerkt en verzonden"); }}

Laten we kort onze belangrijkste methode bespreken. Bij deze methode simuleren we een eenvoudige klantorderstroom door gebruik te maken van eerder gedefinieerde services. In eerste instantie hebben we de bestelling gemaakt met drie items en de nodige verzend- en betalingsinformatie verstrekt. Vervolgens hebben we de bestelling ingediend en ten slotte gecontroleerd of deze met succes is verzonden en verwerkt.

Maar hoe hebben we alle afhankelijkheden gekregen en waarom komt het createContainer methode terugkeer Kaart<> Object>? Laten we deze methode eens nader bekijken.

5.3. Afhankelijkheidsinjectie met behulp van ServiceLoader

In dit project hebben we geen Spring IoC-afhankelijkheden, dus als alternatief gebruiken we de ServiceLoader API voor het ontdekken van implementaties van services. Dit is geen nieuwe functie - de ServiceLoader API zelf bestaat al sinds Java 6.

We kunnen een loader-instantie verkrijgen door een van de static laden methoden van de ServiceLoader klasse. De laden methode retourneert de Herhaalbaar type zodat we de ontdekte implementaties kunnen herhalen.

Laten we nu de lader toepassen om onze afhankelijkheden op te lossen:

openbare statische kaart createContainer () {EventBus eventBus = ServiceLoader.load (EventBus.class) .findFirst (). get (); CustomerOrderRepository customerOrderRepository = ServiceLoader.load (CustomerOrderRepository.class) .findFirst (). Get (); ShippingOrderRepository shippingOrderRepository = ServiceLoader.load (ShippingOrderRepository.class) .findFirst (). Get (); ShippingService shippingService = ServiceLoader.load (ShippingService.class) .findFirst (). Get (); shippingService.setEventBus (eventBus); shippingService.setOrderRepository (shippingOrderRepository); OrderService orderService = ServiceLoader.load (OrderService.class) .findFirst (). Get (); orderService.setEventBus (eventBus); orderService.setOrderRepository (customerOrderRepository); Hash kaart container = nieuwe HashMap (); container.put (OrderService.class, orderService); container.put (ShippingService.class, shippingService); retourcontainer; }

Hier, we noemen de statische laden methode voor elke interface die we nodig hebben, die elke keer een nieuwe loader-instantie maakt. Als gevolg hiervan worden reeds opgeloste afhankelijkheden niet in het cachegeheugen opgeslagen, maar worden er elke keer nieuwe instanties aangemaakt.

Over het algemeen kunnen service-instances op twee manieren worden gemaakt. De service-implementatieklasse moet een openbare no-arg-constructor hebben, of deze moet een statische code gebruiken provider methode.

Als gevolg hiervan hebben de meeste van onze services no-arg constructors en setter-methoden voor afhankelijkheden. Maar, zoals we al hebben gezien, de InMemoryOrderStore class implementeert twee interfaces: CustomerOrderRepository en VerzendingOrderRepository.

Als we echter elk van deze interfaces aanvragen met behulp van de laden methode, krijgen we verschillende exemplaren van de InMemoryOrderStore. Dat is niet wenselijk gedrag, dus laten we de provider methode techniek om de instantie te cachen:

openbare klasse InMemoryOrderStore implementeert CustomerOrderRepository, ShippingOrderRepository {privé vluchtige statische InMemoryOrderStore-instantie = nieuwe InMemoryOrderStore (); openbare statische InMemoryOrderStore-provider () {retourinstantie; }}

We hebben het Singleton-patroon toegepast om een ​​enkele instantie van het InMemoryOrderStore klasse en stuur het terug van de provider methode.

Als de serviceprovider een provider methode, vervolgens de ServiceLoader roept deze methode aan om een ​​instantie van een service te verkrijgen. Anders zal het proberen een instantie te maken met behulp van de constructor no-arguments via Reflection. Als gevolg hiervan kunnen we het serviceprovidermechanisme wijzigen zonder onze createContainer methode.

En tot slot bieden we opgeloste afhankelijkheden aan services via setters en retourneren we de geconfigureerde services.

Eindelijk kunnen we de applicatie uitvoeren.

6. Conclusie

In dit artikel hebben we enkele kritische DDD-concepten besproken: Bounded Context, Ubiquitous Language en Context Mapping. Hoewel het verdelen van een systeem in begrensde contexten veel voordelen heeft, is het niet nodig om deze benadering overal toe te passen.

Vervolgens hebben we gezien hoe we het Java 9-modulesysteem samen met Bounded Context kunnen gebruiken om sterk ingekapselde modules te maken.

Verder hebben we de standaard behandeld ServiceLoader mechanisme voor het ontdekken van afhankelijkheden.

De volledige broncode van het project is beschikbaar op GitHub.


$config[zx-auto] not found$config[zx-overlay] not found