JSF2 静态资源管理--合并、压缩

16

有没有办法在render阶段动态组合/压缩所有h:outputStylesheet资源,然后组合/压缩所有h:outputScript资源?组合/压缩的资源可能需要使用基于组合资源字符串或其它内容的键进行缓存,以避免过度处理。

如果不存在此功能,则我想着手解决这个问题。有人对实现这样的功能有什么好的建议吗?Servlet过滤器可能会起作用,但是过滤器必须执行比必要更多的工作-- 基本上检查整个渲染输出并替换匹配项。似乎在render阶段实现某些东西会更好,因为所有静态资源都可用而不必解析整个输出。

感谢任何建议!

编辑:为了证明我不懒惰,并将真正遵循一些指导来解决这个问题,这是一个捕获脚本资源名称/库然后从视图中删除它们的存根。如您所见,我对接下来该做什么有些困惑...我应该发出http请求获取要组合的资源,然后将它们组合并保存到资源缓存中吗?

package com.davemaple.jsf.listener;

import java.util.ArrayList;
import java.util.List;

import javax.faces.component.UIComponent;
import javax.faces.component.UIOutput;
import javax.faces.component.UIViewRoot;
import javax.faces.context.FacesContext;
import javax.faces.event.AbortProcessingException;
import javax.faces.event.PhaseEvent;
import javax.faces.event.PhaseId;
import javax.faces.event.PhaseListener;
import javax.faces.event.PreRenderViewEvent;
import javax.faces.event.SystemEvent;
import javax.faces.event.SystemEventListener;

import org.apache.log4j.Logger;

/**
 * A Listener that combines CSS/Javascript Resources
 * 
 * @author David Maple<d@davemaple.com>
 *
 */
public class ResourceComboListener implements PhaseListener, SystemEventListener {

    private static final long serialVersionUID = -8430945481069344353L;
    private static final Logger LOGGER = Logger.getLogger(ResourceComboListener.class);

    @Override
    public PhaseId getPhaseId() {
        return PhaseId.RESTORE_VIEW;
    }

    /*
     * (non-Javadoc)
     * @see javax.faces.event.PhaseListener#beforePhase(javax.faces.event.PhaseEvent)
     */
    public void afterPhase(PhaseEvent event) {
        FacesContext.getCurrentInstance().getViewRoot().subscribeToViewEvent(PreRenderViewEvent.class, this);
    }

    /*
     * (non-Javadoc)
     * @see javax.faces.event.PhaseListener#afterPhase(javax.faces.event.PhaseEvent)
     */
    public void beforePhase(PhaseEvent event) {
        //nothing here
    }

    /*
     * (non-Javadoc)
     * @see javax.faces.event.SystemEventListener#isListenerForSource(java.lang.Object)
     */
    public boolean isListenerForSource(Object source) {
        return (source instanceof UIViewRoot);
    }

    /*
     * (non-Javadoc)
     * @see javax.faces.event.SystemEventListener#processEvent(javax.faces.event.SystemEvent)
     */
    public void processEvent(SystemEvent event) throws AbortProcessingException {
        FacesContext context = FacesContext.getCurrentInstance();
        UIViewRoot viewRoot = context.getViewRoot();
        List<UIComponent> scriptsToRemove = new ArrayList<UIComponent>();

        if (!context.isPostback()) {

            for (UIComponent component : viewRoot.getComponentResources(context, "head")) {
                if (component.getClass().equals(UIOutput.class)) {
                    UIOutput uiOutput = (UIOutput) component;

                    if (uiOutput.getRendererType().equals("javax.faces.resource.Script")) {
                        String library = uiOutput.getAttributes().get("library").toString();
                        String name = uiOutput.getAttributes().get("name").toString();

                        // make https requests to get the resources?
                        // combine then and save to resource cache?
                        // insert new UIOutput script?

                        scriptsToRemove.add(component);
                    }


                }
            }

            for (UIComponent component : scriptsToRemove) {
                viewRoot.getComponentResources(context, "head").remove(component);
            }

        }
    }

}

1
提供给您的信息是,我的答案的完整工作示例可在OmniFaces中找到,这是一个组件库,我和我的同事最近正在基于m4n.nl的内部组件进行开发。 - BalusC
我喜欢它。我已经有一个不同的实现方式,可以在Nginx作为反向代理运行,并将合并/压缩资源写入文件系统。这个方法有些笨重,所以我非常兴奋能够查看你的工作版本。 - Dave Maple
4个回答

12

这个答案没有涵盖压缩和缩小的内容。将每个CSS/JS资源的缩小委托给构建脚本,如YUI Compressor Ant task更好。在每个请求上手动执行此操作过于昂贵。压缩(我假设您指的是GZIP?)最好委托给您使用的servlet容器。手动执行它会过于复杂。例如,在Tomcat上,只需将compression="on"属性添加到<Connector>元素中的/conf/server.xml即可。


SystemEventListener已经是一个很好的第一步(除了一些不必要的PhaseListener)。接下来,您需要实现自定义ResourceHandlerResource。这部分并不是很简单。如果您想要独立于JSF实现,那么您需要重新发明很多东西。

首先,在您的SystemEventListener中,您需要创建一个新的UIOutput组件来表示合并资源,以便您可以使用UIViewRoot#addComponentResource()添加它。您需要将其library属性设置为一些由您的自定义资源处理程序理解的唯一内容。您需要根据资源的组合(可能是MD5哈希值)存储组合资源,并将此键设置为组件的name属性。将其存储为应用程序范围内的变量对服务器和客户端都有缓存优势。

例如:

String combinedResourceName = CombinedResourceInfo.createAndPutInCacheIfAbsent(resourceNames);
UIOutput component = new UIOutput();
component.setRendererType(rendererType);
component.getAttributes().put(ATTRIBUTE_RESOURCE_LIBRARY, CombinedResourceHandler.RESOURCE_LIBRARY);
component.getAttributes().put(ATTRIBUTE_RESOURCE_NAME, combinedResourceName + extension);
context.getViewRoot().addComponentResource(context, component, TARGET_HEAD);

然后,在您的自定义ResourceHandler实现中,您需要相应地实现createResource()方法,以便在库匹配所需值时创建自定义Resource实现:

@Override
public Resource createResource(String resourceName, String libraryName) {
    if (RESOURCE_LIBRARY.equals(libraryName)) {
        return new CombinedResource(resourceName);
    } else {
        return super.createResource(resourceName, libraryName);
    }
}

自定义Resource实现的构造函数应该根据名称获取组合资源信息:

public CombinedResource(String name) {
    setResourceName(name);
    setLibraryName(CombinedResourceHandler.RESOURCE_LIBRARY);
    setContentType(FacesContext.getCurrentInstance().getExternalContext().getMimeType(name));
    this.info = CombinedResourceInfo.getFromCache(name.split("\\.", 2)[0]);
}

这个自定义Resource实现必须提供一个正确的getRequestPath()方法,返回一个URI,然后将被包含在渲染的<script><link>元素中:

@Override
public String getRequestPath() {
    FacesContext context = FacesContext.getCurrentInstance();
    String path = ResourceHandler.RESOURCE_IDENTIFIER + "/" + getResourceName();
    String mapping = getFacesMapping();
    path = isPrefixMapping(mapping) ? (mapping + path) : (path + mapping);
    return context.getExternalContext().getRequestContextPath()
        + path + "?ln=" + CombinedResourceHandler.RESOURCE_LIBRARY;
}

现在,HTML渲染部分应该没问题了。它将看起来像这样:
<link type="text/css" rel="stylesheet" href="/playground/javax.faces.resource/dd08b105bf94e3a2b6dbbdd3ac7fc3f5.css.xhtml?ln=combined.resource" />
<script type="text/javascript" src="/playground/javax.faces.resource/2886165007ccd8fb65771b75d865f720.js.xhtml?ln=combined.resource"></script>

接下来,您需要拦截浏览器发出的组合资源请求。这是最困难的部分。首先,在您自定义的ResourceHandler实现中,您需要相应地实现handleResourceRequest()方法:

@Override
public void handleResourceRequest(FacesContext context) throws IOException {
    if (RESOURCE_LIBRARY.equals(context.getExternalContext().getRequestParameterMap().get("ln"))) {
        streamResource(context, new CombinedResource(getCombinedResourceName(context)));
    } else {
        super.handleResourceRequest(context);
    }
}

然后,您需要完成整个自定义Resource实现的其他方法的工作,例如getResponseHeaders()应返回适当的缓存标头,getInputStream()应返回单个InputStream中组合资源的InputStreams,userAgentNeedsUpdate()应正确响应与缓存相关的请求。

@Override
public Map<String, String> getResponseHeaders() {
    Map<String, String> responseHeaders = new HashMap<String, String>(3);
    SimpleDateFormat sdf = new SimpleDateFormat(PATTERN_RFC1123_DATE, Locale.US);
    sdf.setTimeZone(TIMEZONE_GMT);
    responseHeaders.put(HEADER_LAST_MODIFIED, sdf.format(new Date(info.getLastModified())));
    responseHeaders.put(HEADER_EXPIRES, sdf.format(new Date(System.currentTimeMillis() + info.getMaxAge())));
    responseHeaders.put(HEADER_ETAG, String.format(FORMAT_ETAG, info.getContentLength(), info.getLastModified()));
    return responseHeaders;
}

@Override
public InputStream getInputStream() throws IOException {
    return new CombinedResourceInputStream(info.getResources());
}

@Override
public boolean userAgentNeedsUpdate(FacesContext context) {
    String ifModifiedSince = context.getExternalContext().getRequestHeaderMap().get(HEADER_IF_MODIFIED_SINCE);

    if (ifModifiedSince != null) {
        SimpleDateFormat sdf = new SimpleDateFormat(PATTERN_RFC1123_DATE, Locale.US);

        try {
            info.reload();
            return info.getLastModified() > sdf.parse(ifModifiedSince).getTime();
        } catch (ParseException ignore) {
            return true;
        }
    }

    return true;
}

我这里有一个完整的工作概念证明,但是代码太多了,无法在SO答案中发布。上面的只是一部分,以帮助您朝正确的方向前进。我假设缺少的方法/变量/常量声明足以自行解释,否则请告诉我。

更新:根据评论,这里是如何在CombinedResourceInfo中收集资源的方法:

private synchronized void loadResources(boolean forceReload) {
    if (!forceReload && resources != null) {
        return;
    }

    FacesContext context = FacesContext.getCurrentInstance();
    ResourceHandler handler = context.getApplication().getResourceHandler();
    resources = new LinkedHashSet<Resource>();
    contentLength = 0;
    lastModified = 0;

    for (Entry<String, Set<String>> entry : resourceNames.entrySet()) {
        String libraryName = entry.getKey();

        for (String resourceName : entry.getValue()) {
            Resource resource = handler.createResource(resourceName, libraryName);
            resources.add(resource);

            try {
                URLConnection connection = resource.getURL().openConnection();
                contentLength += connection.getContentLength();
                long lastModified = connection.getLastModified();

                if (lastModified > this.lastModified) {
                    this.lastModified = lastModified;
                }
            } catch (IOException ignore) {
                // Can't and shouldn't handle it here anyway.
            }
        }
    }
}

上述方法由reload()方法和依赖于要设置的属性之一的getter方法调用

以下是CombinedResourceInputStream的样子:

final class CombinedResourceInputStream extends InputStream {

    private List<InputStream> streams;
    private Iterator<InputStream> streamIterator;
    private InputStream currentStream;

    public CombinedResourceInputStream(Set<Resource> resources) throws IOException {
        streams = new ArrayList<InputStream>();

        for (Resource resource : resources) {
            streams.add(resource.getInputStream());
        }

        streamIterator = streams.iterator();
        streamIterator.hasNext(); // We assume it to be always true; CombinedResourceInfo won't be created anyway if it's empty.
        currentStream = streamIterator.next();
    }

    @Override
    public int read() throws IOException {
        int read = -1;

        while ((read = currentStream.read()) == -1) {
            if (streamIterator.hasNext()) {
                currentStream = streamIterator.next();
            } else {
                break;
            }
        }

        return read;
    }

    @Override
    public void close() throws IOException {
        IOException caught = null;

        for (InputStream stream : streams) {
            try {
                stream.close();
            } catch (IOException e) {
                if (caught == null) {
                    caught = e; // Don't throw it yet. We have to continue closing all other streams.
                }
            }
        }

        if (caught != null) {
            throw caught;
        }
    }

}

更新2: OmniFaces 提供了一个具体且可重用的解决方案。详见CombinedResourceHandler演示页面API文档了解更多细节。


1
回到我之前关于监听器注册是多余的评论,抱歉这不是真的,无论如何,为了创建组件,监听器仍然是必需的。鸡生蛋蛋生鸡 :) 但如果JSF监听器(如阶段、系统事件和操作监听器)存在注释,那就太好了。 - BalusC
1
不,绝对不是HTTP请求。请查看我的答案中的CombinedResource#getInputStream()实现。简单来说,只需通过从Application#getResourceHandler()获取的标准ResourceHandler收集所有Resource并将它们存储在CombinedResourceInfo中。然后,在调用getInputStream()时,构造一个特殊的CombinedResourceInputStream,按顺序流式传输所有资源。我很快会更新答案,提供一些实现细节。 - BalusC
虽然 CombinedResourceHandler 是一个很好的工具,但我也想分享一下这个 maven 插件[https://github.com/primefaces-extensions/primefaces-extensions.github.com/wiki/Maven-plugin-for-web-resource-optimization]。它可以在构建期间对 js/css 文件进行缩小/压缩和/或将它们聚合成更少的资源,而不是在运行时动态执行,因此我认为它是更高性能的解决方案。 - Aklin
1
@Aklin:不,这是通过HTTP头部与底层JSF实现(通过“ResourceHandler”)与Web浏览器进行交互的。如果单独的文件已经在系统中可用,那么没有必要将不同的文件组合成副本。 - BalusC
1
@Aklin:看起来有一个误解/混淆。CombinedResourceHandler根本不会对资源进行缩小/压缩。它只是将现有的资源按顺序无修改地流式传输到单个响应输出流中。与多个HTTP请求/响应相比,绝对没有额外的CPU成本。如果CombinedResourceHandler将对它们进行缩小/压缩,则缓存显然更有意义。但这并非如此。如果您想要缩小/压缩资源,请在构建时自行使用YUI压缩器等工具进行操作。 - BalusC
显示剩余13条评论

3
Omnifaces提供的CombinedResourceHandler是一个非常好用的工具,但我还想分享这个优秀的maven插件: resources-optimizer-maven-plugin 可以在构建时将js/css文件压缩/合并成较少的资源,而不是在运行时动态进行,这使它成为更高效的解决方案。
另外还有一个优秀的库也值得一看: webutilities

是的,我们在构建时使用yuicompressor maven插件来压缩文件,然后让Omnifaces在运行时将它们组合起来。它们不是互斥的解决方案。鼓励每个人同时使用它们以获得最佳性能。 - Dave Maple
它们不是互斥的解决方案 - 是的,我意识到了... :) - Aklin

3

在实施自己的解决方案之前,您可能希望评估JAWR。我在几个项目中使用过它,取得了很大的成功。它用于JSF 1.2项目,但我认为将其扩展到与JSF 2.0一起工作将是很容易的。只需试试。


好的,我会去看一下。不过我不确定它如何捕获来自第三方组件(如richfaces和primefaces)的资源。我真的希望实现一个将视图中任何组件所需的所有静态资源合并为单个资源的东西。 - Dave Maple

0

我有一个JSF 2的其他解决方案。可能也适用于JSF 1,但我不了解JSF 1,所以无法确定。这个想法主要使用h:head组件,并且也适用于样式表。结果是每个页面都只有一个JavaScript(或样式表)文件!很难描述,但我会尝试。

我重载了标准的JSF ScriptRenderer(或StylesheetRenderer),并在faces-config.xml中配置了h:outputScript组件的渲染器。新的渲染器将不再写入script-Tag,而是将所有资源收集到列表中。因此,要呈现的第一个资源将是列表中的第一项,接下来是下一个,依此类推。在呈现最后一个h:outputScript组件之后,您必须为此页面上的JavaScript文件呈现1个script-Tag。我通过重载h:head渲染器来实现这一点。

现在开始有一个想法: 我注册一个过滤器! 过滤器将寻找此1个脚本标记请求。 当此请求到来时, 我将获取此页面的资源列表。 现在,我可以从资源列表中填充响应。 顺序将是正确的,因为JSF渲染将资源正确排序 放入列表中。 填充响应后,应清除列表。 另外,您可以进行更多优化,因为您在过滤器中编写了代码....

我有能够很好地工作的代码。 我的代码还可以处理浏览器缓存和动态脚本渲染。 如果有人感兴趣,我可以共享代码。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接