Extending OSGi Components

A few months ago, in the Community Chat, one of our community members raised the question, "Why does Liferay prefer public pages over private pages?" For example, if you select the "Go to Site" option, if there are both private pages and public pages, Liferay sends you to the public pages.

Unfortunately, I don't have an answer to that question. However, through some experimentation, I am able to help answer a closely related question: "Is it possible to get Liferay to prefer private pages over public pages?"

Find an Extension Point

Before you can actually do any customization, you need to find out what is responsible for the behavior that you are seeing. Once you find that, it'll then become apparent what extension points are available to bring about the change you want to see in the product.

So to start off, you need to determine what is responsible for generating the URL for the "Go to Site" option.

Usually, the first thing to do is to run a search against your favorite search engine to see if anyone has explained how it works, or at least tried a similar customization before. If you're extremely fortunate, you'll find a proof of concept, or at least a vague outline of what you need to do, which will cut down on the amount of time it takes for you to implement your customization. If you're very fortunate, you'll find someone talking about how the overall design of the feature, which will give you some hints on where you should look.

Sadly, in most cases, your search will probably come up empty. That's what would have happened in this case.

If you have no idea what to do, the next thing you should try is to ask someone if they have any ideas on how to implement your customization. For example, you might post on the Liferay community forums or you might try asking a question on Stackoverflow. If you have a Liferay expert nearby, you can ask them to see if they have any ideas on where to look.

If there are no Liferay experts, or if you believe yourself to be one, the next step is to go ahead and find it yourself. To do so, you will need a copy of the Liferay Portal source (shallow clones are also adequate for this purpose, which is beneficial because a full clone of Liferay's repository is on the order of 10 GB now), and then search for what's responsible for the behavior that you are seeing within that source code.

Find the Language Key

Since what we're changing has a GUI element with text, it necessarily has a language key responsible for displaying that text. This means that if you search through all the Language.properties files in Liferay, you should be able to find something that reads, "Go to Site". If you're using a different language, you'll need to search through Language_xx_YY.properties files instead.

git ls-files | grep -F Language.properties | xargs grep -F "Go to Site"

In this case, searching for "Go to Site" will lead you to the com.liferay.product.navigation.site.administration module's language files, which tell us that the language key that reads "Go to Site" corresponds to the key go-to-site.

Find the Frontend Code

It's usually safe to assume that the language file where the key is declared is also the module that uses it, which means we can restrict our search to just the module where the Language.properties lives.

git ls-files modules/apps/web-experience/product-navigation/product-navigation-site-administration | \
    xargs grep -Fl go-to-site

This will give us exactly one result.

Replace the Original JSP

If you were to choose to modify this JSP, a natural approach would be to follow the tutorial on JSP Overrides Using OSGi Fragments, and then call it a day.

With that in mind, a simple way to get the behavior we want is to let Liferay generate the URL, and then do a straight string replacement changing /web to /group (or /user if it's a user personal site) if we know that the site has private pages.

<%@page import="com.liferay.portal.kernel.util.StringUtil" %>
<%@page import="com.liferay.portal.util.PropsValues" %>

<%
Group goToSiteGroup = siteAdministrationPanelCategoryDisplayContext.getGroup();
String goToSiteURL = siteAdministrationPanelCategoryDisplayContext.getGroupURL();

if (goToSiteGroup.getPrivateLayoutsPageCount() > 0) {
    goToSiteURL = StringUtil.replaceFirst(
        goToSiteURL, PropsValues.LAYOUT_FRIENDLY_URL_PUBLIC_SERVLET_MAPPING,
        goToSiteGroup.isUser() ?
            PropsValues.LAYOUT_FRIENDLY_URL_PRIVATE_USER_SERVLET_MAPPING :
                PropsValues.LAYOUT_FRIENDLY_URL_PRIVATE_GROUP_SERVLET_MAPPING);
}
%>

<aui:a
    cssClass="goto-link list-group-heading"
    href="<%= goToSiteURL %>"
    label="go-to-site"
/>

Copy More Original Code

Now, let's imagine that we want to also worry about the go-to-other-site site selector and update it to provide the URLs we wanted. Investigating the site selector investigation would lead you to item selectors, which would lead you to the MySitesItemSelectorView and RecentSitesItemSelectorView, which would take you to view_sites.jsp.

We can see that there are three instances where it generates the URL by calling GroupURLProvider.getGroup directly: line 83, line 111, and line 207. We would simply follow the same pattern as before in each of these three instances, and we'd be finished with our customization.

If additional JSP changes would be needed, this process of adding JSP overrides and replacement bundles would continue.

Extend the Original Component

While we were lucky in this case and found that we could fix everything just by modifying just two JSPs, we won't always be this lucky. Therefore, let's take the opportunity to understand if there's a different way to solve the problem.

Following the path down to all the different things that this JSP calls leads us to a few options for which extension point we can potentially override in order to get the behavior we want.

First, we'll want to ask: is the package the class lives in exported? If it is not, we'll need to either rebuild the manifest of the original module to provide the package in Export-Package, or we will need to add our classes directly to an updated version of the original module. The latter is far less complicated, but the module would live in osgi/marketplace/override, which is not monitored for changes by default (see the module.framework.auto.deploy.dirs portal property).

From there, you'll want to ask the question: what kind of Java class is it? In particular, you would ask dependency management questions. Is an instance of it managed via Spring? Is it retrieved via a static method? Is there a factory we can replace? Is it directly instantiated each time it's needed?

Once you know how it's instantiated, the next question is how you can change its value where it's used. If there's a dependency management framework involved, we make the framework aware of our new class. For a direct instantiation, then depending on the Liferay team that maintains the component, you might see these types of variables injected as request attributes (which you would handle by injecting your own class for the request attribute), or you might see this instantiated directly in the JSP.

Extend a Component in a Public Package

Let's start with SiteAdministrationPanelCategoryDisplayContext. Digging around in the JSP, you discover that, unfortunately, it's just a regular Java object and the constructor is called in site_administration_body.jsp. Since this is just a plain old Java object that we instantiate from the JSP (it's not a managed dependency), which makes it a bad choice for an extension point unless you want to replace the class definition.

What about GroupURLProvider? Well, it turns out that GroupURLProvider is an OSGi component, which means its lifecycle is managed by OSGi. This means that we need to make OSGi aware of our customization, and then replace the existing component with our component, which will provide a different implementation of the getGroupURL method which prefers private URLs over public URLs.

From a "can I extend this in a different bundle, or will I need to replace the existing bundle" perspective, we're fortunate (the class is inside of a -api module, where everything is exported), and we can simply extend and override the class from a different bundle. The steps are otherwise identical, but it's nice knowing that you're modifying a known extension point.

Next, we declare our extension as an OSGi component.

@Component(
    immediate = true,
    service = GroupURLProvider.class
)
public class PreferPrivatePagesGroupURLProvider extends GroupURLProvider {
}

If you're the type of person who likes to sanity check after each small change by deploying your update, there's a wrinkle you will run into right here.

If you deploy this component and then blacklist the original component by following the instructions on Blacklisting OSGi Modules and Components, you'll run into a NullPointerException. This is because OSGi doesn't fulfill any of the references on the parent class, so when it calls methods on the original GroupURLProvider, none of the references that the code thought would be satisfied actually are satisfied, and it just calls methods on null objects.

You can address part of the problem by using bnd to analyze the parent class for protected methods and protected fields by adding -dsannotations-options: inherit.

-dsannotations-options: inherit

Of course, setting field values using methods is uncommon, and the things Liferay likes to attach @Reference to are generally private variables, and so almost everything will still be null even after this is added. In order to work around that limitation, you'll need to use Java reflection. In a bit of an ironic twist, the convenience utility for replacing the private fields via reflection will also be a private method.

@Reference(unbind = "unsetHttp")
protected void setHttp(Http http)
    throws Exception {

    _setSuperClassField("_http", http);
}

@Reference(unbind = "unsetPortal")
protected void setPortal(Portal portal)
    throws Exception {

    _setSuperClassField("_portal", portal);
}

protected void unsetHttp(Http http)
    throws Exception {

    _setSuperClassField("_http", null);
}

protected void unsetPortal(Portal portal)
    throws Exception {

    _setSuperClassField("_portal", null);
}

private void _setSuperClassField(String name, Object value)
    throws Exception {

    Field field = ReflectionUtil.getDeclaredField(
        GroupURLProvider.class, name);

    field.set(this, value);
}

Implement the New Business Logic

Now that we've extended the logic, what we'll want to do is steal all of the logic from the original method (GroupURLProvider.getGroupURL), and then flip the order on public pages and private pages, so that private pages are checked first.

@Override
protected String getGroupURL(
    Group group, PortletRequest portletRequest,
    boolean includeStagingGroup) {

    ThemeDisplay themeDisplay = (ThemeDisplay)portletRequest.getAttribute(
        WebKeys.THEME_DISPLAY);

    // Customization START
    // Usually Liferay passes false and then true. We'll change that to
    // instead pass true and then false, which will result in the Go to Site
    // preferring private pages over public pages whenever both are present.

    String groupDisplayURL = group.getDisplayURL(themeDisplay, true);

    if (Validator.isNotNull(groupDisplayURL)) {
        return _http.removeParameter(groupDisplayURL, "p_p_id");
    }

    groupDisplayURL = group.getDisplayURL(themeDisplay, false);

    if (Validator.isNotNull(groupDisplayURL)) {
        return _http.removeParameter(groupDisplayURL, "p_p_id");
    }

    // Customization END

    if (includeStagingGroup && group.hasStagingGroup()) {
        try {
            if (GroupPermissionUtil.contains(
                    themeDisplay.getPermissionChecker(), group,
                    ActionKeys.VIEW_STAGING)) {

                return getGroupURL(group.getStagingGroup(), portletRequest);
            }
        }
        catch (PortalException pe) {
            _log.error(
                "Unable to check permission on group " +
                    group.getGroupId(),
                pe);
        }
    }

    return getGroupAdministrationURL(group, portletRequest);
}

Notice that in copying the original logic, we need a reference to _http. We can either replace that with HttpUtil, or we can store our own private copy of _http. So that the code looks as close to the original as possible, we'll store our own private copy of _http.

@Reference(unbind = "unsetHttp")
protected void setHttp(Http http)
    throws Exception {

    _http = http;

    _setSuperClassField("_http", http);
}

protected void unsetHttp(Http http)
    throws Exception {

    _http = null;

    _setSuperClassField("_http", null);
}

Manage a Component's Lifecycle

At this point, all we have to do is disable the old component, which we can do by following the instructions on Blacklisting OSGi Modules and Components.

However, what if we wanted to do that at the code level rather than at the configuration level? Maybe we want to simply deploy our bundle and have everything just work without requiring any manual setup.

Disable the Original Component on Activate

First, you need to know that the component exists. If you attempt to disable the component before it exists, that's not going to do anything for you. We know it exists, once a @Reference is satisfied. However, because we're going to disable it immediately upon realizing it exists, we want to make it optional. This leads us to the following rough outline, where we call _deactivateExistingComponent once we have our reference satisfied.

@Reference(
    cardinality = ReferenceCardinality.OPTIONAL,
    policy = ReferencePolicy.DYNAMIC,
    policyOption = ReferencePolicyOption.GREEDY,
    target = "(component.name=com.liferay.site.util.GroupURLProvider)",
    unbind = "unsetGroupURLProvider"
)
protected void setGroupURLProvider(GroupURLProvider groupURLProvider)
    throws Exception {

    _deactivateExistingComponent();
}

protected void unsetGroupURLProvider(GroupURLProvider groupURLProvider) {
}

Next, you need to be able to access a ServiceComponentRuntime, which provides a disableComponent method. We can get access to this with another @Reference. If we exported this package, we would probably want this to be set using a method for reasons we experienced earlier that required us to implement our _setSuperClassField method, but for now, we'll be content with leaving it as private.

@Reference
private ServiceComponentRuntime _serviceComponentRuntime;

Finally, in order to call ServiceComponentRuntime.disableComponent, you need to generate a ComponentDescriptionDTO, which coincidentally needs just a name and the bundle that holds the component. In order to get the Bundle, you need to have the BundleContext.

@Activate
public void activate(
        ComponentContext componentContext, BundleContext bundleContext,
        Map<String, Object> config)
    throws Exception {

    _bundleContext = bundleContext;

    _deactivateExistingComponent();
}

private void _deactivateExistingComponent()
    throws Exception {

    if (_bundleContext == null) {
        return;
    }

    String componentName = GroupURLProvider.class.getName();

    Collection<ServiceReference<GroupURLProvider>>
        serviceReferences = _bundleContext.getServiceReferences(
            GroupURLProvider.class,
            "(component.name=" + componentName + ")");

    for (ServiceReference serviceReference : serviceReferences) {
        Bundle bundle = serviceReference.getBundle();

        ComponentDescriptionDTO description =
            _serviceComponentRuntime.getComponentDescriptionDTO(
                bundle, componentName);

        _serviceComponentRuntime.disableComponent(description);
    }
}

Enable the Original Component on Deactivate

While I'd originally thought the code sample below was working, re-testing the theory behind it reveals that it doesn't work. Rather, if you want to re-enable the component, you should keep track of the ComponentDescriptionDTO objects that are disabled in the step shown above, rather than look up service references via the BundleContext.

If we want to be a good OSGi citizen, we also want to make sure that the original component is still available whenever we stop or undeploy our module. This is really the same thing in reverse.

@Deactivate
public void deactivate()
    throws Exception {

    _activateExistingComponent();

    _bundleContext = null;
}

private void _activateExistingComponent()
    throws Exception {

    if (_bundleContext == null) {
        return;
    }

    String componentName = GroupURLProvider.class.getName();

    Collection<ServiceReference<GroupURLProvider>>
        serviceReferences = _bundleContext.getServiceReferences(
            GroupURLProvider.class,
            "(component.name=" + componentName + ")");

    for (ServiceReference serviceReference : serviceReferences) {
        Bundle bundle = serviceReference.getBundle();

        ComponentDescriptionDTO description =
            _serviceComponentRuntime.getComponentDescriptionDTO(
                bundle, componentName);

        _serviceComponentRuntime.enableComponent(description);
    }
}

Once we deploy our change, we find that a few other components in Liferay are using the GroupURLProvider provided by OSGi. Among these is the go-to-other-site site selector, which would have required another bundle replacement with the previous approach.

Blogs

Interesting; but do you know that all the git code you reference does not exist in the 7.1.x branch?

As far as I know, the code still exists, but people with a lot of control over the repository decided to move everything up one directory. The first step, for example, is a grep command which would reveal that the following has changed:

 

7.0.x: modules/apps/web-experience/product-navigation/product-navigation-site-administration/src/main/resources/content/Language.properties

7.1.x: modules/apps/product-navigation/product-navigation-site-administration/src/main/resources/content/Language.properties

 

You would then update the later commands accordingly. That's why I provided the commands I used in order to locate them at each step, so that if you needed to do an investigation for 7.1.x, you wouldn't be completely blind.