View Javadoc

1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  
20  package org.apache.myfaces.orchestra.conversation;
21  
22  import java.io.IOException;
23  import java.io.ObjectStreamException;
24  import java.io.Serializable;
25  import java.util.Collections;
26  import java.util.HashMap;
27  import java.util.Iterator;
28  import java.util.Map;
29  
30  import org.apache.commons.logging.Log;
31  import org.apache.commons.logging.LogFactory;
32  import org.apache.myfaces.orchestra.FactoryFinder;
33  import org.apache.myfaces.orchestra.frameworkAdapter.FrameworkAdapter;
34  import org.apache.myfaces.orchestra.lib.OrchestraException;
35  import org.apache.myfaces.orchestra.requestParameterProvider.RequestParameterProviderManager;
36  
37  /**
38   * Deals with the various conversation contexts in the current session.
39   * <p>
40   * There is expected to be one instance of this class per http-session, managing all of the
41   * data associated with all browser windows that use that http-session.
42   * <p>
43   * One particular task of this class is to return "the current" ConversationContext object for
44   * the current http request (from the set of ConversationContext objects that this manager
45   * object holds). The request url is presumed to include a query-parameter that specifies the
46   * id of the appropriate ConversationContext object to be used. If no such query-parameter is
47   * present, then a new ConversationContext object will automatically be created.
48   * <p>
49   * At the current time, this object does not serialize well. Any attempt to serialize
50   * this object (including any serialization of the user session) will just cause it
51   * to be discarded.
52   * <p>
53   * TODO: fix serialization issues.
54   */
55  public class ConversationManager implements Serializable
56  {
57      private static final long serialVersionUID = 1L;
58  
59      final static String CONVERSATION_CONTEXT_PARAM = "conversationContext";
60  
61      private final static String CONVERSATION_MANAGER_KEY = "org.apache.myfaces.ConversationManager";
62      private final static String CONVERSATION_CONTEXT_REQ = "org.apache.myfaces.ConversationManager.conversationContext";
63  
64      private static final Iterator EMPTY_ITERATOR = Collections.EMPTY_LIST.iterator();
65  
66      // See method readResolve
67      private static final Object DUMMY = new Integer(-1);
68  
69      private final Log log = LogFactory.getLog(ConversationManager.class);
70  
71      /**
72       * Used to generate a unique id for each "window" that a user has open
73       * on the same webapp within the same HttpSession. Note that this is a
74       * property of an object stored in the session, so will correctly
75       * migrate from machine to machine along with a distributed HttpSession.
76       *
77       */
78      private long nextConversationContextId = 1;
79  
80      // This member must always be accessed with a lock held on the parent ConverstationManager instance;
81      // a HashMap is not thread-safe and this class must be thread-safe.
82      private final Map conversationContexts = new HashMap();
83  
84      protected ConversationManager()
85      {
86      }
87  
88      /**
89       * Get the conversation manager for the current http session.
90       * <p>
91       * If none exists, then a new instance is allocated and stored in the current http session.
92       * Null is never returned.
93       * <p>
94       * Throws IllegalStateException if the Orchestra FrameworkAdapter has not been correctly
95       * configured.
96       */
97      public static ConversationManager getInstance()
98      {
99          return getInstance(true);
100     }
101 
102     /**
103      * Get the conversation manager for the current http session.
104      * <p>
105      * When create is true, an instance is always returned; one is created if none currently exists
106      * for the current user session.
107      * <p>
108      * When create is false, null is returned if no instance yet exists for the current user session.
109      */
110     public static ConversationManager getInstance(boolean create)
111     {
112         FrameworkAdapter frameworkAdapter = FrameworkAdapter.getCurrentInstance();
113         if (frameworkAdapter == null)
114         {
115             if (!create)
116             {
117                 // if we don't have to create a conversation manager, then it doesn't
118                 // matter if there is no FrameworkAdapter available.
119                 return null;
120             }
121             else
122             {
123                 throw new IllegalStateException("FrameworkAdapter not found");
124             }
125         }
126 
127         Object cmObj = frameworkAdapter.getSessionAttribute(CONVERSATION_MANAGER_KEY);
128         // hack: see method readResolve
129         if (DUMMY.equals(cmObj))
130         {
131             Log log = LogFactory.getLog(ConversationManager.class);
132             if (log.isDebugEnabled())
133             {
134                 log.debug("Method getInstance found dummy ConversationManager object");
135             }
136             cmObj = null;
137         }
138 
139 
140         ConversationManager conversationManager = (ConversationManager) cmObj;
141 
142         if (conversationManager == null && create)
143         {
144             Log log = LogFactory.getLog(ConversationManager.class);
145 
146             if (log.isDebugEnabled())
147             {
148                 log.debug("Register ConversationRequestParameterProvider");
149             }
150             conversationManager = FactoryFinder.getConversationManagerFactory().createConversationManager();
151 
152             // initialize environmental systems
153             RequestParameterProviderManager.getInstance().register(new ConversationRequestParameterProvider());
154 
155             // set mark
156             FrameworkAdapter.getCurrentInstance().setSessionAttribute(CONVERSATION_MANAGER_KEY, conversationManager);
157         }
158 
159         return conversationManager;
160     }
161 
162     /**
163      * Get the current conversationContextId.
164      * <p>
165      * If there is no current conversationContext, then null is returned.
166      */
167     private Long findConversationContextId()
168     {
169         FrameworkAdapter fa = FrameworkAdapter.getCurrentInstance();
170         
171         // Has it been extracted from the req params and cached as a req attr?
172         Long conversationContextId = (Long)fa.getRequestAttribute(CONVERSATION_CONTEXT_REQ);
173         if (conversationContextId == null)
174         {
175             if (fa.containsRequestParameterAttribute(CONVERSATION_CONTEXT_PARAM))
176             {
177                 String urlConversationContextId = fa.getRequestParameterAttribute(
178                         CONVERSATION_CONTEXT_PARAM).toString();
179                 conversationContextId = new Long(
180                         Long.parseLong(urlConversationContextId, Character.MAX_RADIX));
181             }
182         }
183         return conversationContextId;
184     }
185     
186     /**
187      * Get the current, or create a new unique conversationContextId.
188      * <p>
189      * The current conversationContextId will be retrieved from the request
190      * parameters. If no such parameter is present then a new id will be
191      * allocated <i>and configured as the current conversation id</i>.
192      * <p>
193      * In either case the result will be stored within the request for
194      * faster lookup.
195      * <p>
196      * Note that there is no security flaw regarding injection of fake
197      * context ids; the id must match one already in the session and there
198      * is no security problem with two windows in the same session exchanging
199      * ids.
200      * <p>
201      * This method <i>never</i> returns null.
202      */
203     private Long getOrCreateConversationContextId()
204     {
205         Long conversationContextId = findConversationContextId();
206         if (conversationContextId == null)
207         {
208             conversationContextId = createNextConversationContextId();
209             FrameworkAdapter fa = FrameworkAdapter.getCurrentInstance();
210             fa.setRequestAttribute(CONVERSATION_CONTEXT_REQ, conversationContextId);
211         }
212 
213         return conversationContextId;
214     }
215 
216     /**
217      * Get the current, or create a new unique conversationContextId.
218      * <p>
219      * This method is deprecated because, unlike all the other get methods, it
220      * actually creates the value if it does not exist. Other get methods (except
221      * getInstance) return null if the data does not exist. In addition, this
222      * method is not really useful to external code and probably should never
223      * have been exposed as a public API in the first place; external code should
224      * never need to force the creation of a ConversationContext.
225      * <p>
226      * For internal use within this class, use either findConversationContextId()
227      * or getOrCreateConversationContextId().
228      * <p>
229      * To just obtain the current ConversationContext <i>if it exists</i>, see
230      * method getCurrentConversationContext().
231      * 
232      * @deprecated This method should not be needed by external classes, and
233      * was inconsistent with other methods on this class.
234      */
235     public Long getConversationContextId()
236     {
237         return getOrCreateConversationContextId();
238     }
239 
240     /**
241      * Allocate a new Long value for use as a conversation context id.
242      * <p>
243      * The returned value must not match any conversation context id already in
244      * use within this ConversationManager instance (which is scoped to the 
245      * current http session).
246      */
247     private Long createNextConversationContextId()
248     {
249         Long conversationContextId;
250         synchronized(this)
251         {
252             conversationContextId = new Long(nextConversationContextId);
253             nextConversationContextId++;
254         }
255         return conversationContextId;
256     }
257 
258     /**
259      * Get the conversation context for the given id.
260      * <p>
261      * Null is returned if there is no ConversationContext with the specified id.
262      * <p>
263      * Param conversationContextId must not be null.
264      * <p>
265      * Public since version 1.3.
266      */
267     public ConversationContext getConversationContext(Long conversationContextId)
268     {
269         synchronized (this)
270         {
271             return (ConversationContext) conversationContexts.get(conversationContextId);
272         }
273     }
274 
275     /**
276      * Get the conversation context for the given id.
277      * <p>
278      * If there is no such conversation context a new one will be created.
279      * The new conversation context will be a "top-level" context (ie has no parent).
280      * <p>
281      * The new conversation context will <i>not</i> be the current conversation context,
282      * unless the id passed in was already configured as the current conversation context id.
283      */
284     protected ConversationContext getOrCreateConversationContext(Long conversationContextId)
285     {
286         synchronized (this)
287         {
288             ConversationContext conversationContext = (ConversationContext) conversationContexts.get(
289                     conversationContextId);
290             if (conversationContext == null)
291             {
292                 ConversationContextFactory factory = FactoryFinder.getConversationContextFactory();
293                 conversationContext = factory.createConversationContext(null, conversationContextId.longValue());
294                 conversationContexts.put(conversationContextId, conversationContext);
295 
296                 // TODO: add the "user" name here, otherwise this debugging is not very useful
297                 // except when testing a webapp with only one user.
298                 if (log.isDebugEnabled())
299                 {
300                     log.debug("Created context " + conversationContextId);
301                 }
302             }
303             return conversationContext;
304         }
305     }
306 
307     /**
308      * This will create a new conversation context using the specified context as
309      * its parent. 
310      * <p>
311      * The returned context is not selected as the "current" one; see activateConversationContext.
312      * 
313      * @since 1.3
314      */
315     public ConversationContext createConversationContext(ConversationContext parent)
316     {
317         Long ctxId = createNextConversationContextId();
318         ConversationContextFactory factory = FactoryFinder.getConversationContextFactory();
319         ConversationContext ctx = factory.createConversationContext(parent, ctxId.longValue());
320 
321         synchronized(this)
322         {
323             conversationContexts.put(ctxId, ctx);
324         }
325         
326         return ctx;
327     }
328 
329     /**
330      * Make the specific context the current context for the current HTTP session.
331      * <p>
332      * Methods like getCurrentConversationContext will then return the specified
333      * context object.
334      * 
335      * @since 1.2
336      */
337     public void activateConversationContext(ConversationContext ctx)
338     {
339         FrameworkAdapter fa = FrameworkAdapter.getCurrentInstance();
340         fa.setRequestAttribute(CONVERSATION_CONTEXT_REQ, ctx.getIdAsLong());
341     }
342 
343     /**
344      * Ends all conversations within the current context; the context itself will remain active.
345      */
346     public void clearCurrentConversationContext()
347     {
348         Long conversationContextId = findConversationContextId();
349         if (conversationContextId != null)
350         {
351             ConversationContext conversationContext = getConversationContext(conversationContextId);
352             if (conversationContext != null)
353             {
354                 conversationContext.invalidate();
355             }
356         }
357     }
358 
359     /**
360      * Removes the specified contextId from the set of known contexts,
361      * and deletes every conversation in it.
362      * <p>
363      * Objects in the conversation which implement ConversationAware
364      * will have callbacks invoked.
365      * <p>
366      * The conversation being removed must not be the currently active
367      * context. If it is, then method activateConversationContext should
368      * first be called on some other instance (perhaps the parent of the
369      * one being removed) before this method is called.
370      * 
371      * @since 1.3
372      */
373     public void removeAndInvalidateConversationContext(ConversationContext context)
374     {
375         if (context.hasChildren())
376         {
377             throw new OrchestraException("Cannot remove context with children");
378         }
379 
380         if (context.getIdAsLong().equals(findConversationContextId()))
381         {
382             throw new OrchestraException("Cannot remove current context");
383         }
384 
385         synchronized(conversationContexts)
386         {
387             conversationContexts.remove(context.getIdAsLong());
388         }
389 
390         ConversationContext parent = context.getParent();
391         if (parent != null)
392         {
393             parent.removeChild(context);
394         }
395 
396         context.invalidate();
397         
398         // TODO: add the deleted context ids to a list stored in the session,
399         // and redirect to an error page if any future request specifies this id.
400         // This catches things like going "back" into a flow that has ended, or
401         // navigating with the parent page of a popup flow (which kills the popup
402         // flow context) then trying to use the popup page.
403         //
404         // We cannot simply report an error for every case where an invalid id is
405         // used, because bookmarks will have ids in them; when the bookmark is used
406         // after the session has died we still want the bookmark url to work. Possibly
407         // we should allow GET with a bad id, but always fail a POST with one?
408     }
409 
410     /**
411      * Removes the specified contextId from the set of known contexts.
412      * <p>
413      * It does nothing else. Maybe it should be called "detachConversationContext"
414      * or similar.
415      * 
416      * @deprecated This method is not actually used by anything.
417      */
418     protected void removeConversationContext(Long conversationContextId)
419     {
420         synchronized (this)
421         {
422             conversationContexts.remove(conversationContextId);
423         }
424     }
425 
426     /**
427      * Start a conversation.
428      *
429      * @see ConversationContext#startConversation(String, ConversationFactory)
430      */
431     public Conversation startConversation(String name, ConversationFactory factory)
432     {
433         ConversationContext conversationContext = getOrCreateCurrentConversationContext();
434         return conversationContext.startConversation(name, factory);
435     }
436 
437     /**
438      * Remove a conversation
439      *
440      * Note: It is assumed that the conversation has already been invalidated
441      *
442      * @see ConversationContext#removeConversation(String)
443      */
444     protected void removeConversation(String name)
445     {
446         Long conversationContextId = findConversationContextId();
447         if (conversationContextId != null)
448         {
449             ConversationContext conversationContext = getConversationContext(conversationContextId);
450             if (conversationContext != null)
451             {
452                 conversationContext.removeConversation(name);
453             }
454         }
455     }
456 
457     /**
458      * Get the conversation with the given name
459      *
460      * @return null if no conversation context is active or if the conversation did not exist.
461      */
462     public Conversation getConversation(String name)
463     {
464         ConversationContext conversationContext = getCurrentConversationContext();
465         if (conversationContext == null)
466         {
467             return null;
468         }
469         return conversationContext.getConversation(name);
470     }
471 
472     /**
473      * check if the given conversation is active
474      */
475     public boolean hasConversation(String name)
476     {
477         ConversationContext conversationContext = getCurrentConversationContext();
478         if (conversationContext == null)
479         {
480             return false;
481         }
482         return conversationContext.hasConversation(name);
483     }
484 
485     /**
486      * Returns an iterator over all the Conversation objects in the current conversation
487      * context. Never returns null, even if no conversation context exists.
488      */
489     public Iterator iterateConversations()
490     {
491         ConversationContext conversationContext = getCurrentConversationContext();
492         if (conversationContext == null)
493         {
494             return EMPTY_ITERATOR;
495         }
496 
497         return conversationContext.iterateConversations();
498     }
499 
500     /**
501      * Get the current conversation context.
502      * <p>
503      * In a simple Orchestra application this will always be a root conversation context.
504      * When using a dialog/page-flow environment the context that is returned might have
505      * a parent context.
506      * <p>
507      * Null is returned if there is no current conversationContext.
508      */
509     public ConversationContext getCurrentConversationContext()
510     {
511         Long ccid = findConversationContextId();
512         if (ccid == null)
513         {
514             return null;
515         }
516         else
517         {
518             ConversationContext ctx = getConversationContext(ccid);
519             if (ctx == null)
520             {
521                 // Someone has perhaps used the back button to go back into a context
522                 // that has already ended. This simply will not work, so we should
523                 // throw an exception here.
524                 //
525                 // Or somebody might have just activated a bookmark. Unfortunately,
526                 // when someone bookmarks a page within an Orchestra app, the bookmark
527                 // will capture the contextId too.
528                 //
529                 // There is unfortunately no obvious way to tell these two actions apart.
530                 // So we cannot report an error here; instead, just return a null context
531                 // so that a new instance gets created - and hope that the page itself
532                 // detects the problem and reports an error if it needs conversation state
533                 // that does not exist.
534                 //
535                 // What we should do here *at least* is bump the nextConversationId value
536                 // to be greater than this value, so that we don't later try to allocate a
537                 // second conversation with the same id. Yes, evil users could pass a very
538                 // high value here and cause wraparound but that is really not a problem as
539                 // they can only screw themselves up.
540                 log.warn("ConversationContextId specified but context does not exist");
541                 synchronized(this)
542                 {
543                     if (nextConversationContextId <= ccid.longValue())
544                     {
545                         nextConversationContextId = ccid.longValue() + 1;
546                     }
547                 }
548                 return null;
549             }
550             return ctx;
551         }
552     }
553 
554     /**
555      * Return the current ConversationContext for the current http session;
556      * if none yet exists then a ConversationContext is created and configured
557      * as the current context.
558      * <p>
559      * This is currently package-scoped because it is not clear that code
560      * outside orchestra can have any use for this method. The only user
561      * outside of this class is ConversationRequestParameterProvider.
562      * 
563      * @since 1.2
564      */
565     ConversationContext getOrCreateCurrentConversationContext()
566     {
567         Long ccid = getOrCreateConversationContextId();
568         return getOrCreateConversationContext(ccid);
569     }
570 
571     /**
572      * Return true if there is a conversation context associated with the
573      * current request.
574      */
575     public boolean hasConversationContext()
576     {
577         return getCurrentConversationContext() == null;
578     }
579 
580     /**
581      * Get the current root conversation context (aka the window conversation context).
582      * <p>
583      * Null is returned if it does not exist.
584      * 
585      * @since 1.2
586      */
587     public ConversationContext getCurrentRootConversationContext()
588     {
589         Long ccid = findConversationContextId();
590         if (ccid == null)
591         {
592             return null;
593         }
594 
595         synchronized (this)
596         {
597             ConversationContext conversationContext = getConversationContext(ccid);
598             if (conversationContext == null)
599             {
600                 return null;
601             }
602             else
603             {
604                 return conversationContext.getRoot();
605             }
606         }
607     }
608 
609     /**
610      * Get the Messager used to inform the user about anomalies.
611      * <p>
612      * What instance is returned is controlled by the FrameworkAdapter. See
613      * {@link org.apache.myfaces.orchestra.frameworkAdapter.FrameworkAdapter} for details.
614      */
615     public ConversationMessager getMessager()
616     {
617         return FrameworkAdapter.getCurrentInstance().getConversationMessager();
618     }
619 
620     /**
621      * Check the timeout for each conversation context, and all conversations
622      * within those contexts.
623      * <p>
624      * If any conversation has not been accessed within its timeout period
625      * then clear the context.
626      * <p>
627      * Invoke the checkTimeout method on each context so that any conversation
628      * that has not been accessed within its timeout is invalidated.
629      */
630     protected void checkTimeouts()
631     {
632         Map.Entry[] contexts;
633         synchronized (this)
634         {
635             contexts = new Map.Entry[conversationContexts.size()];
636             conversationContexts.entrySet().toArray(contexts);
637         }
638 
639         long checkTime = System.currentTimeMillis();
640 
641         for (int i = 0; i<contexts.length; i++)
642         {
643             Map.Entry context = contexts[i];
644 
645             ConversationContext conversationContext = (ConversationContext) context.getValue();
646             if (conversationContext.hasChildren())
647             {
648                 // Never time out contexts that have children. Let the children time out first...
649                 continue;
650             }
651 
652             conversationContext.checkConversationTimeout();
653 
654             if (conversationContext.getTimeout() > -1 &&
655                 (conversationContext.getLastAccess() +
656                 conversationContext.getTimeout()) < checkTime)
657             {
658                 if (log.isDebugEnabled())
659                 {
660                     log.debug("end conversation context due to timeout: " + conversationContext.getId());
661                 }
662 
663                 removeAndInvalidateConversationContext(conversationContext);
664             }
665         }
666     }
667 
668     /**
669      * @since 1.4
670      */
671     public void removeAndInvalidateAllConversationContexts()
672     {
673         ConversationContext[] contexts;
674         synchronized (this)
675         {
676             contexts = new ConversationContext[conversationContexts.size()];
677             conversationContexts.values().toArray(contexts);
678         }
679 
680         for (int i = 0; i<contexts.length; i++)
681         {
682             ConversationContext context = contexts[i];
683             removeAndInvalidateConversationContextAndChildren(context);
684         }
685     }
686 
687     private void removeAndInvalidateConversationContextAndChildren(ConversationContext conversationContext)
688     {
689         while (conversationContext.hasChildren())
690         {
691             // Get first child
692             ConversationContext child = (ConversationContext) conversationContext.getChildren().iterator().next();
693 
694             // This call removes child from conversationContext.children
695             removeAndInvalidateConversationContextAndChildren(child);
696         }
697 
698         if (log.isDebugEnabled())
699         {
700             log.debug("end conversation context: " + conversationContext.getId());
701         }
702 
703         removeAndInvalidateConversationContext(conversationContext);
704     }
705 
706     private void writeObject(java.io.ObjectOutputStream out) throws IOException
707     {
708         // the conversation manager is not (yet) serializable, we just implement it
709         // to make it work with distributed sessions
710     }
711 
712     private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException
713     {
714         // nothing written, so nothing to read
715     }
716 
717     private Object readResolve() throws ObjectStreamException
718     {
719         // Note: Returning null here is not a good idea (for Tomcat 6.0.16 at least). Null objects are
720         // not permitted within an HttpSession; calling HttpSession.setAttribute(name, null) is defined as
721         // removing the attribute. So returning null here when deserializing an object from the session
722         // can cause problems.
723         //
724         // Note that nothing should have a reference to the ConversationManager *except* the entry
725         // in the http session; all other code should look it up "on demand" via the getInstance
726         // method rather than storing a reference to it. So we can do pretty much anything we like
727         // here as long as the getInstance() method works correctly later. Thus:
728         //  * returning null here is one option (getInstance just creates the item later) - except
729         //    that tomcat doesn't like it.
730         // * creating a new object instance that getInstance will later simply find and return will
731         //   work - except that the actual type to create can be overridden via the dependency-injection
732         //   config, and the FrameworkAdapter class that gives us access to that info is not available
733         //   at the current time.
734         //
735         // To solve this, we use a hack: a special DUMMY object is returned (and therefore will be inserted
736         // into the HTTP session under the ConversationManager key). The getInstance method then checks
737         // for this dummy object, and treats it like NULL. Conveniently, it appears that the serialization
738         // mechanism doesn't care if readResolve returns an object that is not a subclass of the one that
739         // is being deserialized, so here we can return any old object (eg an Integer).
740         //
741         // An alternative would be to just remove the ConversationManager object from the http session
742         // on passivate, so that this readResolve method is never called. However hopefully at some
743         // future time we *will* get serialization for this class working nicely and then will need
744         // to discard these serialization hacks; it is easier to do that when the hacks are all in
745         // the same class.
746 
747         Log log = LogFactory.getLog(ConversationManager.class);
748         log.debug("readResolve returning dummy ConversationManager object");
749         return DUMMY;
750     }
751 }