Publishers of technology books, eBooks, and videos for creative people

Home > Articles

A User's Look at the Cocoon Architecture

  • Print
  • + Share This
Examine Cocoon components and learn concepts that you can use to build more advanced applications. Matthew Langham and Carsten Ziegeler discuss how to manage your site map, and give practical tips on how to tune an installation for maximum performance.
This chapter is from the book

This chapter is from the book

In Chapter 4, "Putting Cocoon to Work," you saw a simplified view of the Cocoon architecture. You built a first version of a news portal in Chapter 5, "Cocoon News Portal: Entry Version." Now that we have gone over the basics, it is time to fill in the missing pieces from a user perspective. This chapter presents additional Cocoon components and concepts you can use to build more advanced applications than the ones you have seen so far.

We will start by describing the architecture and further features of the sitemap in detail. A Cocoon-based application can become quite large. The sitemap becomes more complicated to manage as you add new pipelines. We will show you how to organize an application's structure so that it is easier to maintain. New components allow you to connect your Cocoon-based application to a database and diagnose what might be going wrong if something does not work as planned. We will also explain how Cocoon can be used without running it in a servlet engine and give some practical tips on how to tune an installation for maximum performance.

The Cocoon Architecture in Detail

Before we begin, let's look at a figure that gives an overview of the Cocoon architecture. It might help you to refer to Figure 6.1 when reading about the individual building blocks that make up Cocoon in the following sections. This figure is actually a simplified view of the architecture, because the dependencies of the components contained in Cocoon are more complicated than this figure shows. We will get into more detail as we progress through this book. Imagine that each chapter is a layer of Cocoon that you are slowly peeling away to see more and more of what is inside.

Cocoon is made up of several blocks of functionality. Starting at the top of Figure 6.1, you see Cocoon integrated into a servlet engine. This can be a standalone servlet engine, such as Apache Tomcat, or part of an application server, such as IBM WebSphere.

The Cocoon framework forms the envelope around the component-based architecture, including the different Cocoon components, such as generators and transformers, that can be used to build document pipelines, the XML and XSLT components, and any custom components built for a specific application.

As you can seen from the figure, each block in the Cocoon architecture has its own configuration file. Until now, we have only talked about the central Cocoon configuration file—the sitemap. The additional configuration files we will look at in this chapter are also important, because they allow you to define and configure various aspects of a Cocoon-based application, such as how a running Cocoon should react to changes in the sitemap or whether Cocoon should cache pipelines. In general, you will need to alter something in these configuration files only when development of the application is finished and you are ready to put it into a production environment.

Figure 6.1Figure 6.1 The big picture of Cocoon.

Cocoon is a component-based system. As such, it uses parts of Avalon, a major Apache project for component-based Java architectures. Apart from Avalon component management, Cocoon also integrates the Avalon logging architecture, as shown at the bottom of Figure 6.1.

Avalon Integrated into Cocoon

In addition to including actual software components that can be used in an application, Avalon provides a set of rules and Java interfaces that are used in Cocoon to configure components. For example, Avalon allows components to be reused via a pooling mechanism. Therefore, Avalon provides components to manage these pools and also defines how a component should be written so that it can be pooled. Cocoon components then implement these interfaces.

The Avalon project is divided into several subprojects. However, not all the subprojects are used in Cocoon. The following is a list of subprojects that are used:

  • The Avalon LogKit. A Java-based logging API. This logging functionality is used throughout all the Avalon-based projects and inside Cocoon. The logging configuration is very flexible, as you will see.

  • The Avalon Framework. The base of Avalon. It defines several concepts and interfaces for component development in Java. It defines the basics of defining, configuring, and managing software components and how to use them.

  • The Avalon Excalibur project. Layered on top of the Avalon Framework. It implements common reusable components and offers some component management facilities to fine-tune your installation.

This chapter looks at the possibilities Avalon provides in the context of how they are actually used inside Cocoon. For example, when we talk about logging, we give tips on how to optimize the performance of a Cocoon application. Also, for a more detailed overview of Avalon, see Chapter 8, "A Developer's Look at the Cocoon Architecture."

First, however, we'll start our configuration tour of Cocoon with the configuration file read by the servlet engine when Cocoon is started.

The Web Application Configuration

When Cocoon runs as a servlet, the servlet engine processes a configuration file during the startup phase. The servlet engine reads the web application deployment descriptor (which is located at WEB-INF/web.xml in your Cocoon context directory) and uses the parameters in this file to perform the initial configuration of Cocoon.

The web.xml file contains the startup configuration that is required to get the system running. The most important piece of information is the location of the configuration file for the Avalon-based Cocoon components. In Listing 6.1, which is a snippet from a web.xml file, the name and location of the configuration file are entered as parameters inside the init-param tag.

Listing 6.1 The Avalon Configuration Location in web.xml

 This parameter points to the main configuration file for Cocoon.
 Note that the path is specified in absolute notation but it will be
 resolved relative to the servlets webapp context path

In a default installation of Cocoon, this file is called cocoon.xconf and is located in the Cocoon context directory. You have probably already seen it when looking for the sitemap, which is also located there by default. The cocoon.xconf file is an XML file that contains a description of the used Avalon components for Cocoon and their configuration. Configuring the name and location of this file inside web.xml allows you to choose your own name and location for the file if you wish. However, we recommend that you leave the defaults as is. From now on we will refer to this file simply as cocoon.xconf, regardless of where you place it and what name you choose.

Although the sitemap components, such as transformers and generators, are also Avalon-based components, they are not listed inside cocoon.xconf. They are listed inside the sitemap, as you saw in Chapter 4. This means that a site administrator building a Cocoon-based application does not need to know about cocoon.xconf. When designing an application, it is easier to reference only one file instead of having to view several files at once. cocoon.xconf will become important when you want to fine-tune the installation or replace any of the default components, such as the XML parser.

Configuring Components in cocoon.xconf

One of Cocoon's advantages is that it forms a flexible framework around other components that come from different projects, such as those hosted by Apache. For example, instead of being able to use only a specific XML parser, Cocoon allows you to choose which actual implementation you might want to use by allowing these components to be configured via cocoon.xconf. In addition, cocoon.xconf can be used to pass parameters to the components so that different aspects can be configured. Listing 6.2 is a brief excerpt from cocoon.xconf that shows the basics of this configuration.

Listing 6.2  An Excerpt from cocoon.xconf

<?xml version="1.0"?>
<cocoon version="2.0">

 <parser class="org.apache.cocoon.components.parser.XercesParser"/>

 <hsqldb-server class="org.apache.cocoon.components.hsqldb.ServerImpl"
         pool-max="1" pool-min="1">
   <parameter name="port" value="9002"/>
   <parameter name="silent" value="true"/>
   <parameter name="trace" value="false"/>

Unlike the sitemap, cocoon.xconf does not use a namespace. Each component you want to configure is defined inside the root element called cocoon using its own specific element. Listing 6.2 has two configured components: parser and hsqldb-server. These are the logical names under which Cocoon looks for a concrete implementation. The actual Java class that then implements the expected functionality is configured via the class attribute. As you can see from Listing 6.2, the default parser is the Xerces Parser from Apache. Apart from allowing different implementations to be used, cocoon.xconf allows the components to be configured using individual parameter tags. Each parameter tag consists of a name and value attribute. This lets you pass information such as the port number to the configured database. HSQLDB is an open-source database that is included in the Cocoon distribution. It is used in the practical database examples later in this chapter. We will also discuss the attributes pool-max and pool-min when we look at ways to optimize Cocoon's performance.

If you change something inside cocoon.xconf, these changes are not reflected automatically. To apply the changes, you have to reinstantiate Cocoon. One way of doing this is by restarting your servlet engine. However, this is not always an ideal solution, because you will affect other servlets also currently running in the same servlet engine. It might also take some time for the engine to restart.

Fortunately, Cocoon provides another way to force the reload of cocoon.xconf. You can directly request the root node where Cocoon is mounted (such as http://localhost:8080/cocoon) and then add the request parameter cocoon-reload with the value true. The whole URL looks like this:


This restarts Cocoon with the changed cocoon.xconf.

Because restarting can be a time-consuming process, you should avoid it in a production environment. You can turn off this feature by setting the parameter allow-reload in the web application deployment descriptor (web.xml) to no. The default for this setting is yes, as shown in Listing 6.3.

Listing 6.3 Allowing Cocoon Reloading in web.xml

   Allow reinstantiating (reloading) of the cocoon instance. If this is
   set to "yes" or "true", a new cocoon instance can be created using
   the request parameter "cocoon-reload"

Remember, this parameter is not in cocoon.xconf. It is in the web.xml file used to control certain settings for a servlet. This parameter should be set to no in a production environment, because the default allows anyone to start the reloading of your Cocoon installation by accessing the URL just listed. If someone were to abuse this, Cocoon would spend all its time reloading the configuration files, which would prevent any other activity.

In addition to component configuration, another important piece of information contained in cocoon.xconf is the location of the sitemap. The last line of cocoon.xconf looks like this:

<sitemap file="sitemap.xmap" reload-method="asynchron" check-reload="yes"/>

This definition tells Cocoon where to look for the main sitemap and how to handle its reloading. Although you can change the file attribute by entering a different location and name, we have never needed to change this setting. So we recommend that you do not change it either.

Sitemap Reloading

As you might have noticed during your first steps with Cocoon, changes made to the sitemap are automatically reflected after some time without a restart of your servlet engine being necessary.

When configured appropriately, Cocoon occasionally checks the sitemap for changes. Each time a change is detected, the old sitemap is discarded and the new one is used. Cocoon detects this change using the last modification date, which is automatically set by the operating system for a file when it is saved. So even if you do not change the sitemap but save it unchanged, Cocoon assumes that it has changed and reloads it.

As explained in Chapter 4, a servlet can act only on an incoming request. So Cocoon can check for changes only when a request for a document is received. The automatic reloading can be done in a synchronous or asynchronous manner. You can set this reload method by specifying either synchron or asynchron in the attribute reload-method in cocoon.xconf for the sitemap location. The default is asynchron. (Note that this is the correct way to write these parameters—without ous on the end.)

In synchronous mode, the new sitemap is generated in memory from the configuration file. After this process is finished, it is used and the request is served with this new sitemap.

In asynchronous mode, the new sitemap is generated in the background, and the incoming request is served by the old sitemap. All further requests are then processed by the old sitemap until the generation is finished. From that time on, all documents are generated using the new sitemap.

Synchronous mode is very useful when you develop your application, because each change to the sitemap is reflected immediately. Asynchronous mode is more useful for a production environment in which the sitemap changes very rarely.

Although the automatic reloading of the sitemap seems to be a very useful feature, it has potential dangers. Assume that you change the sitemap to an invalid state, either by creating invalid XML or by making some other mistake that prevents Cocoon from being able to create the sitemap. The next request enters Cocoon, and the sitemap generation process is triggered.

In synchronous mode, the sitemap is generated immediately, but it fails due to the error you made beforehand. So you get a Cocoon error page, because Cocoon cannot process your request. The whole Cocoon installation is "dead" until you correct the error.

In asynchronous mode, the situation is even worse. When the request comes in, the sitemap generation process is started in the background. The current request and all further requests are processed by the old sitemap. The generation of the new sitemap fails because of the error. All further requests are then served using the old sitemap. If the changes made to the sitemap were only slight, it might take a while before anyone realizes that the old sitemap is still being used.

Cocoon provides a parameter that allows you to control whether the sitemap should be checked and reloaded. You can prevent Cocoon from reloading the sitemap by setting the attribute check-reload in cocoon.xconf to false. If you use the default, the sitemap is checked for reloading.

But what if you really changed the sitemap and you made a mistake? The first thing to do is check if your sitemap still contains well-formed XML, so load it into your favorite XML editor and check this. If it is well-formed but still does not work, you should use the logging facilities in Cocoon to find any error you perhaps made.

LogKit Configuration

Cocoon is based on the Avalon logging facilities, which are very flexible and powerful. You can configure details about what should be logged and what should be done with the log messages.

Cocoon has five log levels:


  • INFO




Each component sends out log messages at one of these five levels. The LogKit then decides what should be done with this message.

Using the configuration, you can decide that only certain levels should really be logged to a file. For production sites, you will usually want to log only messages with a level of ERROR or FATAL_ERROR. In contrast, when developing your application, you will always want to see all levels. Because of the ordering of the different levels, each level contains all the following levels. Therefore, setting the level to DEBUG results in all messages being logged. Setting the level to WARNING results in all messages with a level of WARNING, ERROR, or FATAL_ERROR being logged.

The first thing you have to configure, however, is where Cocoon can find the LogKit configuration. This is done by another parameter in the web application deployment descriptor (web.xml), as shown in Listing 6.4.

Listing 6.4 The Location of the LogKit Configuration in the Web Application Deployment Descriptor

 This parameter indicates the configuration file of the LogKit management

The standard place for the LogKit configuration is WEB-INF/logkit.xconf inside your Cocoon context directory. This configuration file is an XML document that describes the LogKit configuration. Listing 6.5 is a simple example.

Listing 6.5 An Excerpt from the LogKit Configuration

  <factory type="priority-filter" class=   
"org.apache.avalon.excalibur.logger.factory.PriorityFilterTargetFactory"/> <factory type="servlet" class=
"org.apache.avalon.excalibur.logger.factory.ServletTargetFactory"/> <factory type="cocoon" class=
"org.apache.cocoon.util.log.CocoonTargetFactory"/> </factories> <targets> <cocoon id="cocoon"> <filename>${context-root}/WEB-INF/logs/cocoon.log</filename> <format type="cocoon"> %7.7{priority} %{time} [%8.8{category}] (%{uri}) %{thread}/%{class:short}: %{message}\n%{throwable} </format> <append>true</append> <rotation type="revolving" init="1" max="4"> <or> <size>100m</size> <time>01:00:00</time> </or> </rotation> </cocoon> <priority-filter id="filter" log-level="ERROR"> <servlet> <format type="extended">%7.7{priority} %5.5{time}: %{message}\n%{throwable}</format> </servlet> </priority-filter> </targets> <categories> <category name="cocoon" log-level="DEBUG"> <log-target id-ref="cocoon"/> <log-target id-ref="filter"/> </category> </categories> </logkit>

The first part of the configuration file deals with factories for the logging targets. Factories are used inside component-based architectures to allow the flexible creation of components. They remove the need to "hard-wire" specific implementations into the system. You can compare this part of the configuration file with the components section of the sitemap, where you define the available generators, transformers, and so on.

These factories define components that are to receive the log events. In this example, the cocoon factory writes log events to a file. The servlet factory logs into the servlet log, and the priority-filter filters events.

These factories are then used in the targets section to instantiate real targets. When the cocoon target is instantiated, it receives the location of the log file (the filename tag) and in what format (the format tag) the log messages should be written.

The third part of the configuration is the categories section. Each component inside Cocoon can log into different categories. Usually they all log into the root category, which is also called cocoon.

So the LogKit configuration defines this category. A category gets a log level and a set of targets. All log events with this log level (or above) are sent to all the targets. So, in this example, all log events with DEBUG or higher are sent to a target called cocoon (logging into a file) and a target called filter.

This "filter" uses the priority filter to filter the log events. In this configuration, the filter discards all messages that do not have the level ERROR or FATAL_ERROR. Messages with one of these two levels are sent to the servlet target. So they are logged into the servlet log as well.

As you can see from this example, even a simple LogKit configuration can get very complex (and therefore complicated). But in most cases, it is sufficient to change the used log level. You can do this simply by changing the log-level attribute of the cocoon category. When you use a file-based configuration like this, you also can add new targets and categories without changing the code.

In case of a problem, you should have a look at the log file and see if you can find any description of the problem in the file. If the log level is not DEBUG, you should switch it. But be careful: A change to the log level (or any other change in the LogKit configuration) is not reflected immediately. You need to reinstantiate Cocoon in order for this to happen. You can force this by specifying the parameter cocoon-reload or by changing cocoon.xconf.

Changing the level to DEBUG causes the log file to become very large. Logging is also quite a time-consuming process, so you will want to set the level as low as possible (such as to ERROR) in a production environment.

How Requests Are Processed Inside Cocoon

Whenever a request for a document is sent to Cocoon, the root sitemap is taken to respond to the request. The pipelines section of the root sitemap is then processed top-down. All map:pipeline sections marked as internal-only using the attribute internal-only are skipped. The process follows the steps described next. For the moment, we will neglect the views (they are explained in a separate section), because they would only confuse this description:

  • If a match directive is found, the matcher tests a value against a given pattern. If the value matches, the directives inside the matcher are executed next, and possible values from the matcher can be used by specific keys. If the value does not match, the next directive on the same level is executed next.

  • If an action directive is found, the action is executed immediately. If the action returns keys for value substitution, the directives inside the action are executed next. If no keys are provided, the directive on the same level is next.

  • If a selector directive is found, the selector performs the various test cases from top to bottom. When the value is equivalent to the first test case, the directives inside this case are executed next, and all others are ignored. If no test case matches, the default case (if it's available) defines the next directives to execute.

  • If a generator directive is found, it builds the starting point for the XML processing pipeline. The next directive on the same level is executed. The generator is not yet started.

  • If a transformer directive is found, the transformer is added at the end of the XML processing pipeline, but it is not executed yet. Then the next directive on the same level is executed.

  • If a serializer directive is found, the serializer builds the end of the XML processing pipeline, and the buildup pipeline is executed. The generator feeds its XML through the various transformers. The serializer produces the document, and the processing is finished.

  • If a reader directive is found, the reader delivers the document, and the processing is finished.

  • If a redirect occurs, the processing is stopped. If the redirect points to a sitemap resource, it is processed. If the redirect is an external link, the client is redirected to it. If the link is internal, a new request is processed by Cocoon, starting at the main sitemap.

  • If a mount for a subsitemap is found, the processing is passed on to the subsitemap. When the subsitemap processing is finished, the document is processed.

  • If a content aggregation directive is found, this special generator is added as the starting point of the XML processing pipeline.

  • If an error occurs, the error handler of the current map:pipeline is called.

As you can see from this flow description, actions, matchers, and selectors are executed immediately when the sitemap is processed. The same applies for a reader.

But generators, transformers, and serializers are not executed immediately. They are chained to build the processing pipeline. Only when this pipeline is complete (when a serializer is added) is the whole pipeline executed.

Because the XML is processed in this created pipeline, all other sitemap components not chained in this pipeline have no access to the XML. Thus, an action, matcher, or selector cannot be influenced by this XML, nor can they influence it.

Cocoon distinguishes between two pipeline types: the event pipeline and the stream pipeline. As the name implies, the event pipeline deals with SAX events. It consists of the usual XML processing pipeline (generator and transformers) without the serializer. A stream pipeline streams the final document to the client. It consists of only a reader or of an event pipeline in combination with a serializer.

For a Cocoon user, this information is important to know in order to understand caching (which we will explain later) and the cocoon protocol.

The cocoon protocol invokes an internal request to the sitemap. The resulting document can be used, for example, as the input for a generator or transformer or for content aggregation. All these components require XML. The generator reads produced XML, the xslt transformer uses stylesheets, and the content aggregation aggregates XML documents and generates from these documents one XML document.

But the cocoon protocol calls an arbitrary pipeline, which has a serializer at the end. It could, in the best case, return XML as a stream of characters or, even worse, HTML or any other format. How does this work? As you might guess, the answer is the event pipeline.

Whenever the cocoon protocol is used, only the event pipeline is built. Remember, the event pipeline is the XML processing pipeline without the serializer. So the event pipeline directly outputs XML as SAX events. Therefore, all components requiring XML can very easily use the cocoon protocol. Obviously, the cocoon protocol must not point to a pipeline using a reader.

Now let's get on with explaining these mysterious SAX events in detail.

SAX Event Handling

XML pipelines also work internally with the SAX model. Therefore, a generator sends SAX events to the following component in the pipeline. This component sends SAX events to the next one, and so on until the final serializer gets the final SAX events, serializes them, and creates the output document.

It might seem unimportant to a Cocoon user that the SAX model is used, but it has an impact on how pipelines must be built. SAX events have only one direction: from top to bottom, if you think about how they are written in the sitemap. It is not possible to send SAX events back up the pipeline.

A transformer transforms the incoming XML stream. There are two possible categories of transformers. In the first one, a transformer transforms the document as a whole, like the xslt transformer does. The stylesheet for the xslt transformer contains all the information for each node in the XML document.

The other category is a transformer that listens for specific XML elements that it will transform. For example, the sql transformer waits for special elements that set the SQL connection and the SQL query. All other elements surrounding the SQL statements are ignored. By ignored, we mean that they are passed unchanged from the sql transformer to the next component in the pipeline, as shown in Figure 6.2.

In order to get the sql transformer working, the incoming SAX events of the previous component in the pipeline (perhaps the generator) must contain those special elements for the sql transformer. So this is the first simple rule: If a component is listening for specific information, that information must be provided by a previous component in the pipeline.

There are more transformers that act like the sql transformer. The ldap transformer is another example of a transformer that reacts to special tags. It listens for some elements and then queries an LDAP system. If you want to build complex pipelines that have more than one transformer of this category, you have to think carefully about what you really want to do.

Imagine that you want to read an XML document from the local hard drive. This XML document contains information for the sql transformer. The sql transformer fetches data from the database that is then feed into the ldap transformer.

From these requirements, you should be able to build up your XML document. It should look like Listing 6.6.

Figure 6.2Figure 6.2 SAX event handling.

Listing 6.6 An Example of Dependent Components

<?xml version="1.0"?>

The information for the sql transformer is surrounded by the elements for the ldap transformer. Because the fetched data is the input for the LDAP query, it must be contained inside the LDAP elements.

In order to make the example work, you have to define your pipeline according to your XML document. As the ldap transformer waits for information from the sql transformer, the pipeline should look like Listing 6.7.

Listing 6.7 A Pipeline of Dependent Components

<map:generate src="document.xml"/>
<map:transform type="sql"/>
<map:transform type="ldap"/>

The sql transformer needs to come before the ldap transformer. Why is this so? The answer lies in the SAX events. As mentioned, SAX events are sent in only one direction. The ldap transformer needs information from the sql transformer, so the SQL query must be done first.

If you put the sql transformer after the ldap transformer, the statements and elements for the sql transformer would be directly used as the information for the ldap transformer. This LDAP query would then fail, and the sql transformer would never get its information.

So the second important rule is this: When building pipelines, you need to be aware of the events or data flow. In other words, you need to know the dependencies between your transformation steps. For example, if transformer A needs information from transformer B, you have to put transformer B before transformer A in the pipeline, and the elements for transformer B must be nested inside those for transformer A.

Of course, you need not stick to this simple rule. In some cases, the information delivered from one transformer cannot be used directly by another transformer. Then you should use intermediate stylesheet transformation, which converts the data of the first transformer to usable input for the second transformer.

In the preceding example, the order of the components in the pipeline would still be the same, but you could then add a stylesheet transformation between the sql transformer and the ldap transformer stage. This stylesheet would convert the response from the sql transformer into a suitable request for the ldap transformer.

Using an intermediate stylesheet is very important if you have circular dependencies. Imagine a pipeline in which you first have a SQL query, and then a dependent LDAP query, and after that a second SQL query that needs information from the LDAP transformation.

The simple approach shown in Figure 6.3 will not work. If you follow the rule we set up, you would build the structure of the commands as set out in the first block at the beginning of the chain in the figure—first the outer tags for the last sql transformer, and then the tags for the ldap transformer, and inside them the tags for the first sql transformer. However, because a sql transformer is in front of the ldap transformer, the last sql transformer never receives any of its commands, because the first sql transformer will have already processed them. There is no way to tell each sql transformer which SQL tags are for the first transformer and which are for the second.

The only solution that works in a case like this is to use an intermediate stylesheet, as shown in Figure 6.4.

The starting document containing the commands must then contain only the LDAP query with the nested SQL query for the first sql transformer. After the ldap transformer in the pipeline, you need a stylesheet transformation, which adds the SQL statement for the last sql transformer around the data fetched from the LDAP query. This can then be processed by the following sql transformer.

Figure 6.3Figure 6.3 Incorrect chaining of dependent transformers.

Figure 6.4Figure 6.4 Using an intermediate stylesheet.

As you can see from the example that uses transformers and intermediate stylesheets, pipelines can get quite complicated. You need to be aware of how things work in order to build your pipeline. However, in our experience with Cocoon, we have very rarely had such complex dependencies. It is more often the case that you need more than two transformers, but they are not dependent, so you do not need an intermediate stylesheet transformation.

This section introduced the additional files that control how Cocoon is configured. It also showed you how components in Cocoon can receive parameters through these configuration files. Cocoon components are based on design principles set out by the Apache project Avalon. Cocoon also uses the Avalon logging mechanism. We also looked at how a request is processed inside Cocoon and how the XML tags are sent through a pipeline as SAX events. After taking a user's look at the various configuration files, we can now return to the sitemap, which is the most important configuration file from a user perspective. We will look at the features not already explained in Chapter 4.

  • + Share This
  • 🔖 Save To Your Account