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      private final Log log = LogFactory.getLog(ConversationManager.class);
67  
68      /**
69       * Used to generate a unique id for each "window" that a user has open
70       * on the same webapp within the same HttpSession. Note that this is a
71       * property of an object stored in the session, so will correctly
72       * migrate from machine to machine along with a distributed HttpSession.
73       *
74       */
75      private long nextConversationContextId = 1;
76  
77      // This member must always be accessed with a lock held on the parent ConverstationManager instance;
78      // a HashMap is not thread-safe and this class must be thread-safe.
79      private final Map conversationContexts = new HashMap();
80  
81      protected ConversationManager()
82      {
83      }
84  
85      /**
86       * Get the conversation manager for the current http session.
87       * <p>
88       * If none exists, then a new instance is allocated and stored in the current http session.
89       * Null is never returned.
90       * <p>
91       * Throws IllegalStateException if the Orchestra FrameworkAdapter has not been correctly
92       * configured.
93       */
94      public static ConversationManager getInstance()
95      {
96          return getInstance(true);
97      }
98  
99      /**
100      * Get the conversation manager for the current http session.
101      * <p>
102      * When create is true, an instance is always returned; one is created if none currently exists
103      * for the current user session.
104      * <p>
105      * When create is false, null is returned if no instance yet exists for the current user session.
106      */
107     public static ConversationManager getInstance(boolean create)
108     {
109         FrameworkAdapter frameworkAdapter = FrameworkAdapter.getCurrentInstance();
110         if (frameworkAdapter == null)
111         {
112             if (!create)
113             {
114                 // if we don't have to create a conversation manager, then it doesn't
115                 // matter if there is no FrameworkAdapter available.
116                 return null;
117             }
118             else
119             {
120                 throw new IllegalStateException("FrameworkAdapter not found");
121             }
122         }
123 
124         ConversationManager conversationManager = (ConversationManager) frameworkAdapter.getSessionAttribute(
125                 CONVERSATION_MANAGER_KEY);
126         if (conversationManager == null && create)
127         {
128             conversationManager = FactoryFinder.getConversationManagerFactory().createConversationManager();
129 
130             // initialize environmental systems
131             RequestParameterProviderManager.getInstance().register(new ConversationRequestParameterProvider());
132 
133             // set mark
134             FrameworkAdapter.getCurrentInstance().setSessionAttribute(CONVERSATION_MANAGER_KEY, conversationManager);
135         }
136 
137         return conversationManager;
138     }
139 
140     /**
141      * Get the current conversationContextId.
142      * <p>
143      * If there is no current conversationContext, then null is returned.
144      */
145     private Long findConversationContextId()
146     {
147         FrameworkAdapter fa = FrameworkAdapter.getCurrentInstance();
148         
149         // Has it been extracted from the req params and cached as a req attr?
150         Long conversationContextId = (Long)fa.getRequestAttribute(CONVERSATION_CONTEXT_REQ);
151         if (conversationContextId == null)
152         {
153             if (fa.containsRequestParameterAttribute(CONVERSATION_CONTEXT_PARAM))
154             {
155                 String urlConversationContextId = fa.getRequestParameterAttribute(
156                         CONVERSATION_CONTEXT_PARAM).toString();
157                 conversationContextId = new Long(
158                         Long.parseLong(urlConversationContextId, Character.MAX_RADIX));
159             }
160         }
161         return conversationContextId;
162     }
163     
164     /**
165      * Get the current, or create a new unique conversationContextId.
166      * <p>
167      * The current conversationContextId will be retrieved from the request
168      * parameters. If no such parameter is present then a new id will be
169      * allocated <i>and configured as the current conversation id</i>.
170      * <p>
171      * In either case the result will be stored within the request for
172      * faster lookup.
173      * <p>
174      * Note that there is no security flaw regarding injection of fake
175      * context ids; the id must match one already in the session and there
176      * is no security problem with two windows in the same session exchanging
177      * ids.
178      * <p>
179      * This method <i>never</i> returns null.
180      */
181     private Long getOrCreateConversationContextId()
182     {
183         Long conversationContextId = findConversationContextId();
184         if (conversationContextId == null)
185         {
186             conversationContextId = createNextConversationContextId();
187             FrameworkAdapter fa = FrameworkAdapter.getCurrentInstance();
188             fa.setRequestAttribute(CONVERSATION_CONTEXT_REQ, conversationContextId);
189         }
190 
191         return conversationContextId;
192     }
193 
194     /**
195      * Get the current, or create a new unique conversationContextId.
196      * <p>
197      * This method is deprecated because, unlike all the other get methods, it
198      * actually creates the value if it does not exist. Other get methods (except
199      * getInstance) return null if the data does not exist. In addition, this
200      * method is not really useful to external code and probably should never
201      * have been exposed as a public API in the first place; external code should
202      * never need to force the creation of a ConversationContext.
203      * <p>
204      * For internal use within this class, use either findConversationContextId()
205      * or getOrCreateConversationContextId().
206      * <p>
207      * To just obtain the current ConversationContext <i>if it exists</i>, see
208      * method getCurrentConversationContext().
209      * 
210      * @deprecated This method should not be needed by external classes, and
211      * was inconsistent with other methods on this class.
212      */
213     public Long getConversationContextId()
214     {
215         return getOrCreateConversationContextId();
216     }
217 
218     /**
219      * Allocate a new Long value for use as a conversation context id.
220      * <p>
221      * The returned value must not match any conversation context id already in
222      * use within this ConversationManager instance (which is scoped to the 
223      * current http session).
224      */
225     private Long createNextConversationContextId()
226     {
227         Long conversationContextId;
228         synchronized(this)
229         {
230             conversationContextId = new Long(nextConversationContextId);
231             nextConversationContextId++;
232         }
233         return conversationContextId;
234     }
235 
236     /**
237      * Get the conversation context for the given id.
238      * <p>
239      * Null is returned if there is no ConversationContext with the specified id.
240      * <p>
241      * Param conversationContextId must not be null.
242      * <p>
243      * Public since version 1.3.
244      */
245     public ConversationContext getConversationContext(Long conversationContextId)
246     {
247         synchronized (this)
248         {
249             return (ConversationContext) conversationContexts.get(conversationContextId);
250         }
251     }
252 
253     /**
254      * Get the conversation context for the given id.
255      * <p>
256      * If there is no such conversation context a new one will be created.
257      * The new conversation context will be a "top-level" context (ie has no parent).
258      * <p>
259      * The new conversation context will <i>not</i> be the current conversation context,
260      * unless the id passed in was already configured as the current conversation context id.
261      */
262     protected ConversationContext getOrCreateConversationContext(Long conversationContextId)
263     {
264         synchronized (this)
265         {
266             ConversationContext conversationContext = (ConversationContext) conversationContexts.get(
267                     conversationContextId);
268             if (conversationContext == null)
269             {
270                 ConversationContextFactory factory = FactoryFinder.getConversationContextFactory();
271                 conversationContext = factory.createConversationContext(null, conversationContextId.longValue());
272                 conversationContexts.put(conversationContextId, conversationContext);
273 
274                 // TODO: add the "user" name here, otherwise this debugging is not very useful
275                 // except when testing a webapp with only one user.
276                 log.debug("Created context " + conversationContextId);
277             }
278             return conversationContext;
279         }
280     }
281 
282     /**
283      * This will create a new conversation context using the specified context as
284      * its parent. 
285      * <p>
286      * The returned context is not selected as the "current" one; see activateConversationContext.
287      * 
288      * @since 1.3
289      */
290     public ConversationContext createConversationContext(ConversationContext parent)
291     {
292         Long ctxId = createNextConversationContextId();
293         ConversationContextFactory factory = FactoryFinder.getConversationContextFactory();
294         ConversationContext ctx = factory.createConversationContext(parent, ctxId.longValue());
295 
296         synchronized(this)
297         {
298             conversationContexts.put(ctxId, ctx);
299         }
300         
301         return ctx;
302     }
303 
304     /**
305      * Make the specific context the current context for the current HTTP session.
306      * <p>
307      * Methods like getCurrentConversationContext will then return the specified
308      * context object.
309      * 
310      * @since 1.2
311      */
312     public void activateConversationContext(ConversationContext ctx)
313     {
314         FrameworkAdapter fa = FrameworkAdapter.getCurrentInstance();
315         fa.setRequestAttribute(CONVERSATION_CONTEXT_REQ, ctx.getIdAsLong());
316     }
317 
318     /**
319      * Ends all conversations within the current context; the context itself will remain active.
320      */
321     public void clearCurrentConversationContext()
322     {
323         Long conversationContextId = findConversationContextId();
324         if (conversationContextId != null)
325         {
326             ConversationContext conversationContext = getConversationContext(conversationContextId);
327             if (conversationContext != null)
328             {
329                 conversationContext.invalidate();
330             }
331         }
332     }
333 
334     /**
335      * Removes the specified contextId from the set of known contexts,
336      * and deletes every conversation in it.
337      * <p>
338      * Objects in the conversation which implement ConversationAware
339      * will have callbacks invoked.
340      * <p>
341      * The conversation being removed must not be the currently active
342      * context. If it is, then method activateConversationContext should
343      * first be called on some other instance (perhaps the parent of the
344      * one being removed) before this method is called.
345      * 
346      * @since 1.3
347      */
348     public void removeAndInvalidateConversationContext(ConversationContext context)
349     {
350         if (context.hasChildren())
351         {
352             throw new OrchestraException("Cannot remove context with children");
353         }
354 
355         if (context.getIdAsLong().equals(findConversationContextId()))
356         {
357             throw new OrchestraException("Cannot remove current context");
358         }
359 
360         synchronized(conversationContexts)
361         {
362             conversationContexts.remove(context.getIdAsLong());
363         }
364 
365         ConversationContext parent = context.getParent();
366         if (parent != null)
367         {
368             parent.removeChild(context);
369         }
370 
371         context.invalidate();
372         
373         // TODO: add the deleted context ids to a list stored in the session,
374         // and redirect to an error page if any future request specifies this id.
375         // This catches things like going "back" into a flow that has ended, or
376         // navigating with the parent page of a popup flow (which kills the popup
377         // flow context) then trying to use the popup page.
378         //
379         // We cannot simply report an error for every case where an invalid id is
380         // used, because bookmarks will have ids in them; when the bookmark is used
381         // after the session has died we still want the bookmark url to work. Possibly
382         // we should allow GET with a bad id, but always fail a POST with one?
383     }
384 
385     /**
386      * Removes the specified contextId from the set of known contexts.
387      * <p>
388      * It does nothing else. Maybe it should be called "detachConversationContext"
389      * or similar.
390      * 
391      * @deprecated This method is not actually used by anything.
392      */
393     protected void removeConversationContext(Long conversationContextId)
394     {
395         synchronized (this)
396         {
397             conversationContexts.remove(conversationContextId);
398         }
399     }
400 
401     /**
402      * Start a conversation.
403      *
404      * @see ConversationContext#startConversation(String, ConversationFactory)
405      */
406     public Conversation startConversation(String name, ConversationFactory factory)
407     {
408         ConversationContext conversationContext = getOrCreateCurrentConversationContext();
409         return conversationContext.startConversation(name, factory);
410     }
411 
412     /**
413      * Remove a conversation
414      *
415      * Note: It is assumed that the conversation has already been invalidated
416      *
417      * @see ConversationContext#removeConversation(String)
418      */
419     protected void removeConversation(String name)
420     {
421         Long conversationContextId = findConversationContextId();
422         if (conversationContextId != null)
423         {
424             ConversationContext conversationContext = getConversationContext(conversationContextId);
425             if (conversationContext != null)
426             {
427                 conversationContext.removeConversation(name);
428             }
429         }
430     }
431 
432     /**
433      * Get the conversation with the given name
434      *
435      * @return null if no conversation context is active or if the conversation did not exist.
436      */
437     public Conversation getConversation(String name)
438     {
439         ConversationContext conversationContext = getCurrentConversationContext();
440         if (conversationContext == null)
441         {
442             return null;
443         }
444         return conversationContext.getConversation(name);
445     }
446 
447     /**
448      * check if the given conversation is active
449      */
450     public boolean hasConversation(String name)
451     {
452         ConversationContext conversationContext = getCurrentConversationContext();
453         if (conversationContext == null)
454         {
455             return false;
456         }
457         return conversationContext.hasConversation(name);
458     }
459 
460     /**
461      * Returns an iterator over all the Conversation objects in the current conversation
462      * context. Never returns null, even if no conversation context exists.
463      */
464     public Iterator iterateConversations()
465     {
466         ConversationContext conversationContext = getCurrentConversationContext();
467         if (conversationContext == null)
468         {
469             return EMPTY_ITERATOR;
470         }
471 
472         return conversationContext.iterateConversations();
473     }
474 
475     /**
476      * Get the current conversation context.
477      * <p>
478      * In a simple Orchestra application this will always be a root conversation context.
479      * When using a dialog/page-flow environment the context that is returned might have
480      * a parent context.
481      * <p>
482      * Null is returned if there is no current conversationContext.
483      */
484     public ConversationContext getCurrentConversationContext()
485     {
486         Long ccid = findConversationContextId();
487         if (ccid == null)
488         {
489             return null;
490         }
491         else
492         {
493             ConversationContext ctx = getConversationContext(ccid);
494             if (ctx == null)
495             {
496                 // Someone has perhaps used the back button to go back into a context
497                 // that has already ended. This simply will not work, so we should
498                 // throw an exception here.
499                 //
500                 // Or somebody might have just activated a bookmark. Unfortunately,
501                 // when someone bookmarks a page within an Orchestra app, the bookmark
502                 // will capture the contextId too.
503                 //
504                 // There is unfortunately no obvious way to tell these two actions apart.
505                 // So we cannot report an error here; instead, just return a null context
506                 // so that a new instance gets created - and hope that the page itself
507                 // detects the problem and reports an error if it needs conversation state
508                 // that does not exist.
509                 //
510                 // What we should do here *at least* is bump the nextConversationId value
511                 // to be greater than this value, so that we don't later try to allocate a
512                 // second conversation with the same id. Yes, evil users could pass a very
513                 // high value here and cause wraparound but that is really not a problem as
514                 // they can only screw themselves up.
515                 log.warn("ConversationContextId specified but context does not exist");
516                 synchronized(this)
517                 {
518                     if (nextConversationContextId <= ccid.longValue())
519                     {
520                         nextConversationContextId = ccid.longValue() + 1;
521                     }
522                 }
523                 return null;
524             }
525             return ctx;
526         }
527     }
528 
529     /**
530      * Return the current ConversationContext for the current http session;
531      * if none yet exists then a ConversationContext is created and configured
532      * as the current context.
533      * <p>
534      * This is currently package-scoped because it is not clear that code
535      * outside orchestra can have any use for this method. The only user
536      * outside of this class is ConversationRequestParameterProvider.
537      * 
538      * @since 1.2
539      */
540     ConversationContext getOrCreateCurrentConversationContext()
541     {
542         Long ccid = getOrCreateConversationContextId();
543         return getOrCreateConversationContext(ccid);
544     }
545 
546     /**
547      * Return true if there is a conversation context associated with the
548      * current request.
549      */
550     public boolean hasConversationContext()
551     {
552         return getCurrentConversationContext() == null;
553     }
554 
555     /**
556      * Get the current root conversation context (aka the window conversation context).
557      * <p>
558      * Null is returned if it does not exist.
559      * 
560      * @since 1.2
561      */
562     public ConversationContext getCurrentRootConversationContext()
563     {
564         Long ccid = findConversationContextId();
565         if (ccid == null)
566         {
567             return null;
568         }
569 
570         synchronized (this)
571         {
572             ConversationContext conversationContext = getConversationContext(ccid);
573             if (conversationContext == null)
574             {
575                 return null;
576             }
577             else
578             {
579                 return conversationContext.getRoot();
580             }
581         }
582     }
583 
584     /**
585      * Get the Messager used to inform the user about anomalies.
586      * <p>
587      * What instance is returned is controlled by the FrameworkAdapter. See
588      * {@link org.apache.myfaces.orchestra.frameworkAdapter.FrameworkAdapter} for details.
589      */
590     public ConversationMessager getMessager()
591     {
592         return FrameworkAdapter.getCurrentInstance().getConversationMessager();
593     }
594 
595     /**
596      * Check the timeout for each conversation context, and all conversations
597      * within those contexts.
598      * <p>
599      * If any conversation has not been accessed within its timeout period
600      * then clear the context.
601      * <p>
602      * Invoke the checkTimeout method on each context so that any conversation
603      * that has not been accessed within its timeout is invalidated.
604      */
605     protected void checkTimeouts()
606     {
607         Map.Entry[] contexts;
608         synchronized (this)
609         {
610             contexts = new Map.Entry[conversationContexts.size()];
611             conversationContexts.entrySet().toArray(contexts);
612         }
613 
614         long checkTime = System.currentTimeMillis();
615 
616         for (int i = 0; i<contexts.length; i++)
617         {
618             Map.Entry context = contexts[i];
619 
620             ConversationContext conversationContext = (ConversationContext) context.getValue();
621             if (conversationContext.hasChildren())
622             {
623                 // Never time out contexts that have children. Let the children time out first...
624                 continue;
625             }
626 
627             conversationContext.checkConversationTimeout();
628 
629             if (conversationContext.getTimeout() > -1 &&
630                 (conversationContext.getLastAccess() +
631                 conversationContext.getTimeout()) < checkTime)
632             {
633                 if (log.isDebugEnabled())
634                 {
635                     log.debug("end conversation context due to timeout: " + conversationContext.getId());
636                 }
637 
638                 removeAndInvalidateConversationContext(conversationContext);
639             }
640         }
641     }
642 
643     private void writeObject(java.io.ObjectOutputStream out) throws IOException
644     {
645         // the conversation manager is not (yet) serializable, we just implement it
646         // to make it work with distributed sessions
647     }
648 
649     private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException
650     {
651         // nothing written, so nothing to read
652     }
653 
654     private Object readResolve() throws ObjectStreamException
655     {
656         // Note: Returning null here is not a good idea (for Tomcat 6.0.16 at least). Null objects are
657         // not permitted within an HttpSession; calling HttpSession.setAttribute(name, null) is defined as
658         // removing the attribute. So returning null here when deserializing an object from the session
659         // can cause problems.
660         return FactoryFinder.getConversationManagerFactory().createConversationManager();
661     }
662 }