SPContext et SPServiceContext dans le mauvais Contexte ou le piège du singleton masqué

Sous couvert de ce titre en jeux de mots je souhaite vous parler d’un sujet inspiré de deux problèmes rencontrés avec du code pour SharePoint.
(EDIT 18/11/2011 : J’ai modifié mon code car j’utilisais le Thread.CurrentPrincipal pour assigner le User du HttpContext. Dans le cas d’une application console, ce CurrentPrincipal est “vide”. J’ai donc corrigé pour utiliser “new WindowsPrincipal(WindowsIdentity.GetCurrent())” à la place)

1 – L’erreur de départ

Concrètement ces deux “erreurs” apparaissent dans un contexte particulier qui est celui d’une application Windows (batch, powershell, client lourd) utilisant le SDK de SharePoint :

1er cas : c’est la cas “facile”. En tentant de construire une instance de UserProfileManager vous utilisez la ligne de code suivante :

UserProfileManager mana = new UserProfileManager(SPServiceContext.Current);

et vous obtenez l’exception suivante :

System.ArgumentNullException : Value cannot be null. Parameter name: serviceContext

at Microsoft.Office.Server.UserProfiles.ProfileManagerBase..ctor(SPServiceContext serviceContext)

at Microsoft.Office.Server.UserProfiles.ProfileManagerBase..ctor(SPServiceContext serviceContext, Boolean ignorePrivacy)

at Microsoft.Office.Server.UserProfiles.UserProfileManager..ctor(SPServiceContext serviceContext, Boolean IgnoreUserPrivacy, Boolean backwardCompatible)

at Microsoft.Office.Server.UserProfiles.UserProfileManager..ctor(SPServiceContext serviceContext, Boolean IgnoreUserPrivacy)

2nd cas : c’est la cas “vicieux”. En utilisant un SPLimitedWebPartManager pour modifier ou consulter les propriétés de webparts, en particulier des ContentByQueryWebPart ayant des XSLT customs attachés (ItemXslLink et/ou MainXslLink) vous récupérez uniquement des:

Microsoft.SharePoint.WebPartPages.ErrorWebPart

avec comme erreur par exemple :

An error occurred while setting the value of this property: Microsoft.SharePoint.Publishing.WebControls.ContentByQueryWebPart:ItemXslLink – Exception has been thrown by the target of an invocation.

2 – L’analyse

Alors bien sûr vous vous en doutez, la raison est globalement la même, il s’agit du context qui est mal initialisé car nous sommes dans un contexte client lourd et pas application web, c’est logique…

Mais quel contexte ? SPServiceContext, SPContext, HttpContext ?

Et puis d’abord comme ça se fait que le singleton SPContext.Current ne se débrouille pas tout seul pour être correctement construit  ?!

Instinctivement j’aurais tendance à dire que le SPServiceContext.Current utilise le SPContext.Current qui à son tour utilise le HttpContext.Current. Vérifions avec une bonne vielle décompilation :

SPServiceContexT.Current:

public static SPServiceContext Current

{

    get

    {

        SPServiceContextScope currentScope = SPServiceContextScope.CurrentScope;

        if (currentScope != null)

        {

            return currentScope.Context;

        }

        return GetContext(HttpContext.Current);

    }

}

et SPContext.Current :

public static SPContext Current

{

    get

    {

        SPContext context = null;

        if (HttpContext.Current != null)

        {

            try

            {

                if (SPControl.GetContextWeb(HttpContext.Current) == null)

                {

                    return null;

                }

            }

            catch (InvalidOperationException)

            {

                return null;

            }

            try

            {

                context = GetContext(HttpContext.Current);

            }

            catch (FileNotFoundException)

            {

            }

            catch (InvalidOperationException)

            {

            }

        }

        return context;

    }

}

On constate que dans les deux cas il faut que le HttpContext.Current soit initialisé. Sur la cas du SPContext c’est d’autant plus flagrant qu’un premier test vérifie que le HttpContext fourni n’est pas null.

Du coup cette imbrication de propriétés, toutes statiques, masquent un peu un besoin nécessaire au bon fonctionnement des classes du SDK : un contexte d’application web, donc une requête http sur une url. Quelque part cela ne me “choque pas” sur le principe que SharePoint est avant tout utilisé dans un contexte web. Donc l’utiliser dans un autre contexte peut nécessiter des actions supplémentaires. Ce qui me gène c’est que dans certains cas, l’utilisation du SPContext est faite en interne par le biais d’autres classes et méthodes et dans mon cas la remontée d’erreur n’est pas forcement très explicite.

Par exemple ma seconde erreur est due au fait que j’utilise un ContentByQueryWebPart qui lui même hérite d’un CmsDataFormWebPart qui notamment dans le cas du setter de certaines propriétés (comme celle concernant le Xsl) appelle une méthode nommée MakeSiteRelativeUrl.

Que fait cette méthode ? Je vous le donne en mille :

private static string MakeSiteRelativeUrl(string xslServerRelativeUrl)

{

   string serverRelativeUrl = SPContext.Current.Site.ServerRelativeUrl;

   ...

}

Et évidement si je n’ai pas de HttpContext, je n’ai pas non plus de SPContext, et donc pas de Site et encore moins de ServerRelativeUrl.

Et pour finir, lors de l’analyse de la page par le SPLimitedWebPartManager, le webpart est chargé comme si il était rendu dans la page web, donc le XslLink est fixé lors du CreateChildControl et donc en cascade la fameuse méthode est appelée, elle plante, SharePoint prends ça en charge et remplace le webpart par un ErrorWebPart…

3 – La résolution

La solution est simple me diriez vous ! Il suffit d’instancier un HttpContext dans le cas d’une application qui ne tournera pas en application web ! Ok très bien allons y. Pour instancier un HttpContext il nous faut une HttpRequest qui a besoin d’une url et d’une HttpResponse.

Concernant l’url il me semble judicieux de fournir celle du SPWeb que l’on souhaite ouvrir ou du SPFile par exemple, je rajoute donc la ligne suivante juste après l’ouverture d’un SPWeb nommé web :

HttpContext.Current = new HttpContext(new HttpRequest("", web.Url, ""), new HttpResponse(new StringWriter()));

Et malheureusement ça ne suffit pas !

Et pour comprendre pourquoi le mieux et de retourner voir le code de la propriété SPContext.Current, en particulier la méthode SPControl.GetContextWeb :

private static SPWeb SPWebEnsureSPControl(HttpContext context)

{

    SPWeb web = (SPWeb)context.Items["HttpHandlerSPWeb"];

    if ((web == null) && (context.Items["HttpHandlerSPSiteNotFound"] == null))

    {

        if (context.User == null)

        {

            context.Items["HttpHandlerSPSiteNotFound"] = "1";

            throw new InvalidOperationException();

        }

        if (SPSecurity.ImpersonatingSelf || SPSecurity.RunAsUserInProgress)

        {

            throw new InvalidOperationException();

        }

        try

        {

            SPSite site;

            context.Items["HttpHandlerSPSite"] = site = SPSiteFromContextNoCache();

            if (site == null)

            {

                context.Items["HttpHandlerSPSiteNotFound"] = "1";

                return null;

            }

            web = site.OpenWeb();

            context.Items["HttpHandlerSPWeb"] = web;

            if (!SPRequestUsageMonitoredScope.s_FirstBrowseHasOccured)

            {

                SPRequestUsageMonitoredScope.s_FirstBrowseHasOccured = true;

            }

            if (SPSecurity.ApplicationPrincipal == null)

            {

                site.InitUserToken(EnsureSPWebRequest(web));

            }

            else

            {

                site.InitUserToken(null);

            }

            SPRequestModule.InitContextWeb(context, web);

            if (SPContext.GetShouldInitThreadCultureWhenContextWebIsInited(context))

            {

                web.SetThreadCultureAfterInit();

            }

            ULS.CorrelationAdd("Site", site.ServerRelativeUrl);

        }

        catch (FileNotFoundException)

        {

            context.Items["HttpHandlerSPSiteNotFound"] = "1";

        }

    }

    return web;

}

A mon sens il y a dans cette méthode deux choses importantes à ne pas louper :

1- on essai d’abord de récupérer un SPWeb à partir d’un Item du HttpContext nommé “HttpHandlerSPWeb”

2- si cet SPWeb n’est pas présent, on tente de le créer, mais à condition notamment d’avoir un User sur le HttpContext courant. A défaut on recevra une InvalidOperationException.

le SPWeb quand à lui sera crée à partir du SPSite crée lui même à partir d’une méthode SPSiteFromContextNoCache qui au final va chercher une url dans le HttpContext sous l’Item “Microsoft.SharePoint.Administrtion.ContextUri", qui dans notre cas n’est très probablement pas renseignée.

Je vais donc rajouter deux lignes de codes supplémentaire juste après l’ouverture de mon SPWeb pour initialiser correctement le HttpContext:

Pour être sûr que le SPWeb puisse être construit dans le SPContext :

HttpContext.Current.User =  new WindowsPrincipal(WindowsIdentity.GetCurrent());

Tant qu’à faire, ayant déjà un SPWeb sous la main, pour gagner du temps :

HttpContext.Current.Items["HttpHandlerSPWeb"] = web;

Bien sûr cela n’est pas du tout conseillé dans un contexte web, donc à ne PAS faire systématiquement, mais bien dans le cas précis d’une application qui est exécutée sur le serveur, avec une identité “choisie”.

Avec ces 3 lignes de codes, mon application console fonctionne à merveille !

Pour me simplifier un peu la vie, j’ai crée quelques extensions de méthodes :

public static class SPSiteExtensions

{

    public static void EnsureContext(this SPSite site)

    {

        if (HttpContext.Current == null)

        {

            HttpContext.Current = new HttpContext(new HttpRequest("", site.Url, ""), new HttpResponse(new StringWriter()));

            //en principe suffisant

            HttpContext.Current.User = new WindowsPrincipal(WindowsIdentity.GetCurrent());

        }

    }

}

public static class SPWebExtensions

{

    public static void EnsureContext(this SPWeb web)

    {

        if (HttpContext.Current == null)

        {

            HttpContext.Current = new HttpContext(new HttpRequest("", web.Url, ""), new HttpResponse(new StringWriter()));

            //en principe suffisant

            HttpContext.Current.User = new WindowsPrincipal(WindowsIdentity.GetCurrent());

            //pour gagner du temps

            HttpContext.Current.Items["HttpHandlerSPWeb"] = web;

        }

    }

    public static SPWeb OpenWeb(this SPSite site, bool loadHttpContext)

    {

        SPWeb w = site.OpenWeb();

        if (w == null) return null;

        w.EnsureContext();

        return w;

    }

}

Et l’utilisation est du coup assez simple :

static void Main(string[] args)

{

    string url = "http://root.paslatek.com/SitePages/testCQWP.aspx";

    using (SPSite site = new SPSite(url))

    {

        //première façon de faire si on va pas plus loin que le SPSite

        site.EnsureContext();

        //ouverture SPWeb avec la metode d'extension

        using (SPWeb web = site.OpenWeb(true))

        {

 

            //autre manière d'assurer le context avec le SPWeb

            web.EnsureContext();

            ////test 1

            UserProfileManager mana = new UserProfileManager(SPServiceContext.Current, true);

            foreach (UserProfile u in mana)

                Console.WriteLine(u.DisplayName);

            //test 2

            SPFile f = web.GetFile(url);

            var wpMana = f.GetLimitedWebPartManager(System.Web.UI.WebControls.WebParts.PersonalizationScope.Shared);

            foreach (System.Web.UI.WebControls.WebParts.WebPart wp in wpMana.WebParts)

                Console.WriteLine(wp.GetType().ToString());

        }

    }

}

4 – La conclusion

Le SPContext.Current c’est le mal ? Il ne faut pas l’utiliser ?

De manière générale j’ai envie de dire oui, pour vous éviter ce genre de cas tordu où par un enchainement d’appels, à travers un tas de classes, vous arriviez sur un cas identique au mien. En effet dans mon cas, les codes utilisant le UserProfileManager et du SPLimitedWebPartManager étaient dans le un FeatureReceiver, à l’activation de la feature. Ce code à très bien fonctionné tant qu’on activait la feature par l’interface web. Jusqu’au jour où j’ai eu besoin d’utiliser une application console qui parcourais un bon millier de sites et activait la feature en question de manière automatique. E là c’est le drame !

Mais au final de toute façon tout cela ne dépends pas que de vous, puisque comme nous l’avons vu, certains composants de ce, riche, Framework qu’est SharePoint utilisent eux mêmes le SPContext. De plus j’ai rencontré ici deux cas particuliers, mais rien ne dit que vous ne tomberez pas sur un autre problème dont la source est la même sur d’autres webparts par exemple. Donc vous n’y pourrez pas grand chose d’autre que d’utiliser un contournement comme celui que je vous ai proposé dans cet article.

Ce qui est marrant c’est que la documentation MSDN est assez claire sur le sujet, entre autre parce qu’elle dit bien que le SPContext n’a pas de sens dans le cas d’une application console…

Merci aussi à SanDeep pour son article sur le SPLimitedWebPartManager et la création du HttpContext

Publié dans SharePoint Tagués avec : , , , , , , ,

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

*

Verifions que vous êtes un humain * Time limit is exhausted. Please reload CAPTCHA.

Archives

Social

  • Twitter
  • LinkedIn
  • Flux RSS
  • mvp
  • technet
  • Google+