How detect and remove (during a session) unused @ViewScoped beans that can't be garbage collected

一笑奈何 提交于 2019-11-27 20:17:13

Basically, you want the JSF view state and all view scoped beans to be destroyed during a window unload. The solution has been implemented in OmniFaces @ViewScoped annotation which is fleshed out in its documentation as below:

There may be cases when it's desirable to immediately destroy a view scoped bean as well when the browser unload event is invoked. I.e. when the user navigates away by GET, or closes the browser tab/window. None of the both JSF 2.2 view scope annotations support this. Since OmniFaces 2.2, this CDI view scope annotation will guarantee that the @PreDestroy annotated method is also invoked on browser unload. This trick is done by a synchronous XHR request via an automatically included helper script omnifaces:unload.js. There's however a small caveat: on slow network and/or poor server hardware, there may be a noticeable lag between the enduser action of unloading the page and the desired result. If this is undesireable, then better stick to JSF 2.2's own view scope annotations and accept the postponed destroy.

Since OmniFaces 2.3, the unload has been further improved to also physically remove the associated JSF view state from JSF implementation's internal LRU map in case of server side state saving, hereby further decreasing the risk at ViewExpiredException on the other views which were created/opened earlier. As side effect of this change, the @PreDestroy annotated method of any standard JSF view scoped beans referenced in the same view as the OmniFaces CDI view scoped bean will also guaranteed be invoked on browser unload.

You can find the relevant source code here:

The unload script will run during window's beforeunload event, unless it's caused by any JSF based (ajax) form submit. As to commandlink and/or ajax submits, this is implementation specific. Currently Mojarra, MyFaces and PrimeFaces are recognized.

The unload script will trigger navigator.sendBeacon on modern browsers and fall back to synchronous XHR (asynchronous would fail as page might be unloaded sooner than the request actually hits the server).

var url = form.action;
var query = "omnifaces.event=unload&id=" + id + "&" + VIEW_STATE_PARAM + "=" + encodeURIComponent(form[VIEW_STATE_PARAM].value);
var contentType = "application/x-www-form-urlencoded";

if (navigator.sendBeacon) {
    // Synchronous XHR is deprecated during unload event, modern browsers offer Beacon API for this which will basically fire-and-forget the request.
    navigator.sendBeacon(url, new Blob([query], {type: contentType}));
}
else {
    var xhr = new XMLHttpRequest();
    xhr.open("POST", url, false);
    xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
    xhr.setRequestHeader("Content-Type", contentType);
    xhr.send(query);
}

The unload view handler will explicitly destroy all @ViewScoped beans, including standard JSF ones (do note that the unload script is only initialized when the view references at least one OmniFaces @ViewScoped bean).

context.getApplication().publishEvent(context, PreDestroyViewMapEvent.class, UIViewRoot.class, createdView);

This however doesn't destroy the physical JSF view state in the HTTP session and thus the below use case would fail:

  1. Set number of physical views to 3 (in Mojarra, use com.sun.faces.numberOfLogicalViews context param and in MyFaces use org.apache.myfaces.NUMBER_OF_VIEWS_IN_SESSION context param).
  2. Create a page which references a standard JSF @ViewScoped bean.
  3. Open this page in a tab and keep it open all time.
  4. Open the same page in another tab and then immediately close this tab.
  5. Open the same page in another tab and then immediately close this tab.
  6. Open the same page in another tab and then immediately close this tab.
  7. Submit a form in the first tab.

This would fail with a ViewExpiredException because the JSF view states of previously closed tabs aren't physically destroyed during PreDestroyViewMapEvent. They still stick around in the session. OmniFaces @ViewScoped will actually destroy them. Destroying the JSF view state is however implementation specific. That explains at least the quite hacky code in Hacks class which should achieve that.

The integration test for this specific case can be found in ViewScopedIT#destroyViewState() on ViewScopedIT.xhtml which is currently run against WildFly 10.0.0, TomEE 7.0.1 and Payara 4.1.1.163.


In a nutshell: just replace javax.faces.view.ViewScoped by org.omnifaces.cdi.ViewScoped. The rest is transparent.

import javax.inject.Named;
import org.omnifaces.cdi.ViewScoped;

@Named
@ViewScoped
public class Bean implements Serializable {}

I have at least made an effort to propose a public API method to physically destroy the JSF view state. Perhaps it will come in JSF 2.3 and then I should be able to eliminate the boilerplate in OmniFaces Hacks class. Once the thing is polished in OmniFaces, it will perhaps ultimately come in JSF, but not before 2.4.

kolossus

Okay, so I cobbled something together.

The Principle

The now-irrelevant viewscoped beans sit there, wasting everyone's time and space because in a GET navigation case, using any of the controls that you've highlighted, the server is not involved. If the server is not involved, it has no way of knowing the viewscoped beans are now redundant (that is until the session has died). So what's needed here is a way to tell the server-side that the view from which you're navigating, needs to terminate its view-scoped beans

The Constraints

The server-side should be notified as soon as the navigation is happening

  1. beforeunload or unload in an <h:body/> would have been ideal but for the following problems

  2. You can't send an ajax request in onclick of a control, and also navigate in the same control. Not without a dirty popup anyway. So navigating onclick in a h:button or h:link is out of it

The dirty compromise

Trigger an ajax request onclick, and have a PhaseListener do the actual navigation and the viewscope cleanup

The Recipe

  1. 1 PhaseListener (a ViewHandler would also work here; I'm going with the former because it's easier to setup)

  2. 1 wrapper around the JSF js API

  3. A medium helping of shame

Let's see:

  1. The PhaseListener

    public ViewScopedCleaner implements PhaseListener{
    
        public void afterPhase(PhaseEvent evt){
             FacesContext ctxt = event.getFacesContext();
             NavigationHandler navHandler = ctxt.getApplication().getNavigationHanler();
             boolean isAjax =  ctx.getPartialViewContext().isAjaxRequest(); //determine that it's an ajax request
             Object target = ctxt.getExternalContext().getRequestParameterMap().get("target"); //get the destination URL
    
                    if(target !=null && !target.toString().equals("")&&isAjax ){
                         ctxt.getViewRoot().getViewMap().clear(); //clear the map
                         navHandler.handleNavigation(ctxt,null,target);//navigate
                     }
    
        }
    
        public PhaseId getPhaseId(){
            return PhaseId.APPLY_REQUEST_VALUES;
        }
    
    }
    
  2. The JS wrapper

     function cleanViewScope(){
      jsf.ajax.request(this, null, {execute: 'someButton', target: this.href});
       return false;
      }
    
  3. Putting it together

      <script>
         function cleanViewScope(){
             jsf.ajax.request(this, null, {execute: 'someButton', target: this.href}); return false;
          }
      </script>  
    
     <f:phaseListener type="com.you.test.ViewScopedCleaner" />
     <h:link onclick="cleanViewScope();" value="h:link: GET: done" outcome="done?faces-redirect=true"/>
    

To Do

  1. Extend the h:link, possibly add an attribute to configure the clearing behaviour

  2. The way the target url is being passed is suspect; might open up a hole

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!