Bienvenue à Blogs CodeS-SourceS Identification | Inscription | Aide

Abonnements

4 en 1 : EF n’a pas vocation à mettre les DBA au chômage, Velocity, SQL CLR et SQL Dependency

Imaginons une base avec une table Cars avec en PK un Id (nvarchar(8)) basé sur le Regex [1-9][0-9]{1,2}[A-Z]{2,3}[0-9]{2} où les deux derniers chiffres correspondent à un numéro de région.

Dans la base l’incrémentation est faite de la manière suivante : 10AA[région], 11AA[région], …, 999AA[région], 10AB[région], …, 999ZZ[région], 10AAA[région], …

Cette table peut contenir des centaines de millions de lignes.

Dans un premier temps, nous allons écrire une requête LINQ To Entities permettant de connaître les régions étant passés sur trois lettres :

var q1 = (from c in context.Cars
          where c.Id.Contains("AAA")
          select c.Id.Substring(c.Id.Length - 2)).Distinct();

Cette requête va nous générer la requête SQL suivante :

SELECT
[Distinct1].[C1] AS [C1]
FROM ( SELECT DISTINCT

      SUBSTRING([Extent1].[Id], ((LEN([Extent1].[Id])) - 2) + 1, (LEN([Extent1].[Id])) - ((LEN([Extent1].[Id])) - 2)) AS [C1]
      FROM [dbo].[Cars] AS [Extent1]
      WHERE [Extent1].[Id] LIKE N'%AAA%'
)  AS [Distinct1]

Naturellement, j’aurais plutôt écrit cela :

SELECT DISTINCT
      RIGHT(Id, 2) AS [C1]
      FROM [Cars]
      WHERE [Id] LIKE N'%AAA%'

Mais le plan d’exécution est le même !

Imaginons que le DBA impose l’utilisation du RIGHT car il suit les requêtes exécutées avec le profiler et qu’il veut se simplifier la vie. Comme il ne faut jamais contrarié un DBA Wink, nous allons donc le faire.

Cependant, le Right n’existe pas en C#. Par contre, il existe en ESQL. Pour cela, nous allons donc utiliser une nouveauté de EF4 : les CSDL Functions.

Dans le CSDL, vous pouvez rajouter le code suivant :

<Function Name="GetRegion" ReturnType="String">
  <Parameter Name="car" Type="Self.Car" />
  <DefiningExpression>
    Right(car.Id, 2)
  </DefiningExpression>
</Function>

Ensuite, nous allons rajouter une extension method afin de pouvoir utiliser cette CSDL Function dans nos requêtes LINQ To Entities :

public static class CarExtension
{
    [EdmFunction("CarsModel", "GetRegion")] 
    public static string GetRegion(this Car car)
    {
        throw new NotImplementedException("Only used by LINQ To Entities");
    }
}

Je peux maintenant écrire ma requête LINQ comme ceci :

var q1 = (from c in context.Cars
          where c.Id.Contains("AAA")
          select c.GetRegion()).Distinct();

Cette requête sera alors traduire par la requête TSQL suivante :

SELECT
[Distinct1].[C1] AS [C1]
FROM ( SELECT DISTINCT
      RIGHT([Extent1].[Id], 2) AS [C1]
      FROM [dbo].[Cars] AS [Extent1]
      WHERE [Extent1].[Id] LIKE N'%AAA%'
)  AS [Distinct1]

Cool !

Maintenant l’idée est de récupérer le dernier id par région. A ma connaissance, il n’est pas possible de faire cela proprement en SQL. En effet, je ne pense pas que SQL Server gère les expressions régulières. Or on a besoin de cette fonctionnalité pour déterminer quel est le dernier id.

Aussi pour faire ceci, je vais utiliser une requête LINQ To Object. L’idée “naturelle” serait d’écrire quelque chose comme ça :

var qL2E = from c in context.Cars
           orderby c.GetRegion()
           select c.Id;
var qL2O = from id in qL2E.AsEnumerable()
           group id by id.Substring(id.Length - 2) into g
           select new
           {
               Region = g.Key,
               Id = (from id in g
                     let letters = Regex.Match(id, "[A-Z]{2,3}")
                     orderby letters.Length descending, letters descending, Regex.Match(id, "^[0-9]{2,3}") descending
                     select id).FirstOrDefault()
           }.ToDictionary(id => id.Region, id => id.Id);

Cependant, dans ce cas, il faut s’attendre à un OutOfMemoryException. En effet, avec cette requête, tous les enregistrements de la base seront chargés en mémoire et on peut avoir des centaines de millions de lignes en base.

Aussi, l’idée dans un premier temps va être de procéder par dichotomie sans charger en mémoire des ids inutiles.

var ids = (from c in context.Cars
           select c.GetRegion()).Distinct().AsEnumerable().ToDictionary(region => region, region => GetLastId(region, "", (new[] { "" }.Union(Enumerable.Range('A', 26).Select(i => char.ConvertFromUtf32(i)))), 0));

private static string GetLastId(string region, string letters, IEnumerable<string> possibleChars, int index)
{
    if (!possibleChars.Skip(1).Any()) // Count() == 1
    {
        letters += possibleChars.First();
        if (++index == 3)
        {
            using (var context = new CarsContainer())
            {
                return
                    (from c in context.Cars
                     where c.Id.EndsWith(letters + region)
                     orderby c.Id.IndexOf(letters) descending, c.Id descending
                     select c.Id).FirstOrDefault();
            }
        }
        return GetLastId(region, letters, Enumerable.Range('A', 26).Select(i => char.ConvertFromUtf32(i)), index);
    }
    else
    {
        var possibleCharsList = possibleChars.ToList();
        int middle = possibleCharsList.Count / 2;
        using (var context = new CarsContainer())
        {
            string idBeginning = string.Format("10{0}{1}{2}", letters, possibleCharsList[middle], "AA".Substring(index));
            if ((from c in context.Cars
                 where c.Id.StartsWith(idBeginning) && c.Id.EndsWith(region)
                 select c.Id).Any())
                return GetLastId(region, letters, possibleCharsList.Skip(middle), index);
            return GetLastId(region, letters, possibleCharsList.Take(middle), index);
        }
    }
}

L’exécution de cette requête est particulièrement longue (26 minutes et 18 secondes dans mon test avec 562 012 347 enregistrements).

Quelles sont les pistes d’améliorations ?

Le CPU utilisé par la base est à 100% et la lenteur provient uniquement de la base. Il est donc inutile d’envisager la parallélisassions du code.

On pourrait envisager de redimensionner le serveur de base de données mais ce n’est pas le but de ce post.

Dans notre cas, le DBA (je précise que je ne suis pas un DBA) va sûrement vouloir changer le schéma de la table Cars afin de séparer en 3 colonnes l’Id. Cependant, dans certains cas, il peut être intéressant de conserver la colonne Id qui restera, dans ce cas, la PK. En effet, imaginons que l’on ait déjà des applications existantes. Il faudrait pouvoir rajouter ces colonnes sans les impacter et tout en faisant en sorte que quand ces applications existantes rajoutent un enregistrement, cela renseigne ces colonnes de façon transparente.

On va donc se retrouver avec quatre colonnes (toutes not nullable) :

  • Id
  • Number
  • Letters
  • Region

Pour les renseigner, on va utiliser des fonctions CLR :

public class CarsFunction
{
    [SqlFunction]
    public static SqlInt16 GetNumber(SqlString id)
    {
        return new SqlInt16(short.Parse(Regex.Match(id.Value, "^[0-9]{2,3}").Value));
   

    [SqlFunction]
    public static SqlString GetLetters(SqlString id)
    {
        return new SqlString(Regex.Match(id.Value, "[A-Z]{2,3}").Value);
   

    [SqlFunction]
    public static SqlInt16 GetRegion(SqlString id)
    {
        return new SqlInt16(short.Parse(Regex.Match(id.Value, "[0-9]{2}$").Value));
    }
}

Ensuite, dans la base, on va enregistrer l’assembly et les fonctions :

CREATE ASSEMBLY CarsFunctionsAssembly
FROM 'D:\documents\visual studio 2010\Projects\CarsFunctions\CarsFunctions\bin\Debug\CarsFunctions.dll'
GO   

CREATE FUNCTION GetNumber(@id AS nvarchar(8)) RETURNS smallint AS EXTERNAL NAME CarsFunctionsAssembly.CarsFunction.GetNumber
GO

CREATE FUNCTION GetLetters(@id AS nvarchar(8)) RETURNS nvarchar(3) AS EXTERNAL NAME CarsFunctionsAssembly.CarsFunction.GetLetters
GO

CREATE
FUNCTION GetRegion(@id AS nvarchar(8)) RETURNS smallint AS EXTERNAL NAME CarsFunctionsAssembly.CarsFunction.GetRegion

Maintenant que nous avons nos fonctions, nous allons revenir sur la création de nos trois colonnes. A la place de créer des colonnes “normales”, nous allons en faire des colonnes calculées. Ceci peut se faire avec le designer SSMS en renseignant la Formule de Computed Column Specification à dbo.GetNumber(Id) (resp dbo.GetLetters(Id), dbo.GetRegion(Id)). Dans notre cas, l’idée n’est pas de recalculer à chaque fois ces valeurs mais de les stocker une bonne fois pour toute. Du coup, on va passer la propriété “Is Persisted” à true.

Le problème c’est que pour cela, il faut que la fonction soit déterministe. C’est le cas de notre fonction. En effet, elle retourne toujours la même valeur pour le même Id. Cependant, vu que c’est une fonction CLR, SQL Server ne peut pas le savoir. Il va donc falloir le préciser explicitement.

Pour cela, nous allons modifier notre code comme ceci :

[SqlFunction(IsDeterministic = true, IsPrecise = true)]
public static SqlInt16 GetNumber(SqlString id)
{
    return new SqlInt16(short.Parse(Regex.Match(id.Value, "^[0-9]{2,3}").Value));
}


[SqlFunction(IsDeterministic = true, IsPrecise = true)]
public static SqlString GetLetters(SqlString id)
{
    return new SqlString(Regex.Match(id.Value, "[A-Z]{2,3}").Value);
}


[SqlFunction(IsDeterministic = true, IsPrecise = true)]
public static SqlInt16 GetRegion(SqlString id)
{
    return new SqlInt16(short.Parse(Regex.Match(id.Value, "[0-9]{2}$").Value));
}

Maintenant nous pouvons persister ces colonnes.

Attention, si on importe la base dans l’edmx, il va définir StoreGeneratedPattern="Computed" sur les les colonnes Number, Letters et Region. Cela implique donc qu’à chaque update, il va recharger les propriétés Number, Letters et Region. Dans notre cas, nous savons que le calcul ne dépend que de la clé. Avec Entity Framework, il n’est pas possible de modifier la clé. Par conséquent, il est intéressant de modifier le StoreGeneratedPattern de Computed à Identity.

Cool, nous avons donc régler le problème de l’INSERT.

Concentrons nous maintenant sur ce qui nous intéresse : notre requête.

Plus besoin d’utiliser une recherche dichotomique, une simple requête suffit :

var ids = (from c in context.Cars
           group c by c.Region into g
           select new
           {
               Region = g.Key,
               LastId = (from c in g
                         orderby c.Letters.Length descending, c.Letters descending, c.Number descending
                         select c.Id).FirstOrDefault() 
           }).AsEnumerable().ToDictionary(region => region.Region, region => region.LastId);

Nous venons de passer de 26 minutes et 18 secondes à 1 minute et 42 secondes !

C’est mieux mais ce n’est pas encore fini.

Au niveau de la base, nous allons maintenant passer le cluster de la table Cars sur la colonne Region (à la place de la colonne Id).

Dans ce cas, le temps d’exécution de ma requête tombe à 12 secondes ! (plus de 26 minutes à 12 secondes, dans ces cas-là, le client ne regrette généralement pas d’avoir pris des journées de conseil Wink).

Attention cependant ! Ceci est certes très intéressant dans le cas présent mais il faudra bien étudier l’ensemble des requêtes des différentes applications utilisant la base avant de prendre la décision de changer le cluster.

Nous venons de démontrer que le DBA reste un rôle important dans la réussite d’un projet.

Comme je vous l’ai dit, je ne suis pas DBA. Cependant, mon expérience m’a permis d’acquérir quelques notions que j’ai mis en place ici. De plus, j’ai la chance d’avoir quelques DBA dans mes contacts Wink. J’en profite d’ailleurs pour les remercier pour m’avoir aider à acquérir ces notions.

 

Maintenant, imaginons que l’on veuille garder en cache le dernier id par région. Pour cela, nous allons peut-être passer notre dictionnaire ids dans une variable statique. Très bien mais il reste deux problèmes à traiter :

  • Imaginons une application N-Tiers. Si notre application est déployée sur plusieurs serveurs frontaux, il est dommage de devoir calculer ce cache pour chacun d’eux.
  • Si une application externe rajoute un enregistrement en base, comment tenir notre dictionnaire à jour ?

Pour résoudre le premier problème, nous allons utiliser Velocity. Pour le second, nous utiliserons SQLDependency.

Velocity est un cache distribué permettant beaucoup de choses très intéressantes comme le load balancing par exemple. Ce cache distribué sera partagé par l’ensemble de nos serveurs frontaux. Cela signifie donc qu’il ne sera plus utile de générer un Dictionnaire de cache par frontal.

Que devons nous faire pour utiliser Velocity ? Premier point : le télécharger et l’installer Smile

Une fois installé, il va falloir ajouter les références suivantes dans notre projet :

  • CacheBaseLibrary.dll
  • CASBase.dll
  • CASMain.dll
  • ClientLibrary.dll
  • FabricCommon.dll

Ensuite, il va falloir démarrer le cluster Velocity. Dans cette CTP, toutes les commandes d’administration se font par la console “Administration Tool - Microsoft Distributed Cache” en ligne de commande. Pour démarrer votre cluster, il faut taper la ligne de commande suivante :

start-cachecluster

Ensuite, dans le fichier de config, on va rajouter les lignes qui vont bien :

<configSections>
  <
section name="dataCacheClient" type="Microsoft.Data.Caching.DataCacheClientSection, CacheBaseLibrary" allowLocation="true" allowDefinition="Everywhere"/>
</
configSections>
<
dataCacheClient deployment="routing">
  <
localCache isEnabled="true" sync="TTLBased" ttlValue="60000"/>
  <
hosts>
    <
host name="MATTHIEU-PRO" cachePort="22233" cacheHostName="DistributedCacheService"/>
    <
host name="MATTHIEU-LAPTOP1" cachePort="22233" cacheHostName="DistributedCacheService"/>
    <
host name="MATTHIEU-LAPTOP2" cachePort="22233" cacheHostName="DistributedCacheService"/>
    <
host name="MATTHIEU-PC1" cachePort="22233" cacheHostName="DistributedCacheService"/>
    <
host name="MATTHIEU-PC2" cachePort="22233" cacheHostName="DistributedCacheService"/>
  </
hosts>
</
dataCacheClient>

Ensuite, il suffit dans notre application (sur le Tiers serveur) de requêter le cache.

return new DataCacheFactory().GetDefaultCache().GetObjectsInRegion("LastImmatPerRegion").ToDictionary(keyValuePair => short.Parse(keyValuePair.Key), keyValuePair => (string)keyValuePair.Value);

Avec ces données en cache, notre requête s’exécute en seulement une trentaine de milli-secondes.

Maintenant, il nous reste un dernier point : initialiser ce cache et le maintenir à jour.

Pour le renseigner, c’est facile, il suffit de se baser sur la requête écrite plus haut :

_cache = new DataCacheFactory().GetDefaultCache();
try
{
    _cache.RemoveRegion(LAST_IMMAT_PER_REGION);
}
catch
{
}
_cache.CreateRegion(LAST_IMMAT_PER_REGION, false);
foreach (var car in from c in context.Cars
                    group c by c.Region into g
                    select new
                    {
                        Region = g.Key,
                        LastId = (from c in g
                                  orderby c.Letters.Length descending, c.Letters descending, c.Number descending
                                  select c.Id).FirstOrDefault()
                    })
    _cache.Put(car.Region.ToString(), car.LastId, LAST_IMMAT_PER_REGION);

Maintenant pour suivre les évolutions, c’est une autre histoire. Mon idée est d’utiliser une SQLDependency. Le problème c’est qu’on ne peut pas, à priori, récupérer les modifications apportées avec les SQLDependency. Aussi mon idée est de rajouter un Trigger sur ma table Cars qui renseignera une table temporaire.

Pour commencer, nous allons créer la table CarsModificationsTmp. Elle comprendra six colonnes :

  • TmpId (int Identity(1,1), PK),
  • CarId (nvarchar(8), not nullable)
  • Number (smallint, not nullable)
  • Letters (nvarchar(3), not nullable)
  • Region (smallint, not nullable),
  • Deleted (bit, not nullable, default=0)

Ensuite, il faut définir les triggers sur la table Cars :

CREATE TRIGGER CarsInserted
ON Cars
FOR INSERT
AS
BEGIN
  DECLARE @Id AS nvarchar(8)
  DECLARE @Number AS smallint
  DECLARE @Letters AS nvarchar(3)
  DECLARE @Region AS smallint
  SELECT @Id = Id, @Number = Number, @Letters = Letters, @REGION = Region FROM Inserted
  INSERT INTO CarsModificationsTmp(CarId, Number, Letters, Region) VALUES(@Id, @Number, @Letters, @Region)
END
GO
 
CREATE TRIGGER CarsDeleted
ON Cars
FOR DELETE
AS
BEGIN
  DECLARE @Region AS smallint
  DECLARE @Id AS nvarchar(8)
  DECLARE @Number AS smallint
  DECLARE @Letters AS nvarchar(3)
  SELECT @Id = Id, @Number = Number, @Letters = Letters, @REGION = Region FROM Deleted
  INSERT INTO CarsModificationsTmp(CarId, Number, Letters, Region, Deleted) VALUES(@Id, @Number, @Letters, @Region, 1)
END

Enfin, nous allons compléter le renseignement du cache en gérant notre SQL Dependency.

private void Load()
{
    using (var context = new CarsContainer())
    {
        _cache = new DataCacheFactory().GetDefaultCache();
        try
        {
            _cache.RemoveRegion(LAST_IMMAT_PER_REGION);
        }
        catch
        {
        }
        _cache.CreateRegion(LAST_IMMAT_PER_REGION, false);
        foreach (var car in from c in context.Cars
                            group c by c.Region into g
                            select new
                            {
                                Region = g.Key,
                                LastId = (from c in g
                                          orderby c.Letters.Length descending, c.Letters descending, c.Number descending
                                          select c.Id).FirstOrDefault()
                            })
            _cache.Put(car.Region.ToString(), car.LastId, LAST_IMMAT_PER_REGION);

 

        _connectionString = ((EntityConnection)context.Connection).StoreConnection.ConnectionString;
        SqlDependency.Stop(_connectionString);
        SqlDependency.Start(_connectionString);
        DefineCarsNotification();
    }
}

 

private
void DefineCarsNotification()
{
    var connection = new SqlConnection(_connectionString);
    var command = connection.CreateCommand();
    command.CommandText = "SELECT TmpId, Region, CarId, Deleted FROM CarsModificationsTmp";
    command.CommandType = CommandType.Text;
    var sqlDependency = new SqlDependency(command);
    sqlDependency.OnChange += SqlDependency_OnChange;
    connection.Open();
    command.ExecuteNonQuery();
    connection.Close();
}

 

private
void SqlDependency_OnChange(object sender, SqlNotificationEventArgs e)
{
    using (var contextModifications = new CarsContainer())
    {
        bool any = false;
        foreach (var cm in contextModifications.CarsModificationsTmps)
        {
            any = true;
            var cacheModifications = new DataCacheFactory().GetDefaultCache();
            var cacheItem = cacheModifications.GetCacheItem(cm.Region.ToString(), LAST_IMMAT_PER_REGION);
            if (cm.Deleted)
            {
                if (cacheItem != null && cacheItem.Value.ToString() == cm.CarId)
                {
                    var newLastId = (from c in contextModifications.Cars
                                     where c.Region == cm.Region
                                     orderby c.Letters.Length descending, c.Letters descending, c.Number descending
                                     select c.Id).FirstOrDefault();
                    if (newLastId == null)
                        _cache.Remove(cm.Region.ToString(), LAST_IMMAT_PER_REGION);
                    else
                        _cache.Put(cm.Region.ToString(), newLastId, LAST_IMMAT_PER_REGION);
                }
            }
            else
            {
                string letters;
                int lettersCompare = 0;
                if (cacheItem == null || (letters = Regex.Match(cacheItem.Value.ToString(), "[A-Z]{2,3}").Value).Length < cm.Letters.Length || (letters.Length == cm.Letters.Length && ((lettersCompare = String.Compare(letters, cm.Letters)) < 0 || lettersCompare == 0 && short.Parse(Regex.Match(cacheItem.Value.ToString(), "^[0-9]{2,3}").Value) < cm.Number)))
                    _cache.Put(cm.Region.ToString(), cm.CarId, LAST_IMMAT_PER_REGION);
            }
            contextModifications.DeleteObject(cm);
        }
        if (any)
            contextModifications.SaveChanges();
    }
    DefineCarsNotification();

}

Le problème c’est que l’appel à SqlDependency_OnChange est asynchrone et il peut y avoir plusieurs appels en parallèle avec les problèmes que cela pose. Le DataCache de Velocity est thread-safe. Cependant, avec le code précédent, la concurrence ne nous permet pas d’affimer que l’on a le dernier id dans le cache. Afin de ne pas avoir de bugs liés au parallélisme, nous allons poser un lock.

if (cm.Deleted)
{
    lock (_lockObject)
    {
        if (cacheItem != null && cacheItem.Value.ToString() == cm.CarId)
        {
            var newLastId = (from c in contextModifications.Cars
                             where c.Region == cm.Region
                             orderby c.Letters.Length descending, c.Letters descending, c.Number descending
                             select c.Id).FirstOrDefault();
            if (newLastId == null)
                _cache.Remove(cm.Region.ToString(), LAST_IMMAT_PER_REGION);
            else
                _cache.Put(cm.Region.ToString(), newLastId, LAST_IMMAT_PER_REGION);
        }
    }
}
else
{
    string letters;
    int lettersCompare = 0;
    lock (_lockObject)
    {
        if (cacheItem == null || (letters = Regex.Match(cacheItem.Value.ToString(), "[A-Z]{2,3}").Value).Length < cm.Letters.Length || (letters.Length == cm.Letters.Length && ((lettersCompare = String.Compare(letters, cm.Letters)) < 0 || lettersCompare == 0 && short.Parse(Regex.Match(cacheItem.Value.ToString(), "^[0-9]{2,3}").Value) < cm.Number)))
            _cache.Put(cm.Region.ToString(), cm.CarId, LAST_IMMAT_PER_REGION);
    }
}

Il peut encore y avoir un problème lors du SaveChanges. En effet, avec la concurrence, on peut essayer de supprimer un DataRow déjà supprimé ce qui engendrera une OptimisticConcurrencyException. L’approche que nous allons adopter est la suivante. Si le SaveChanges lève cette exception, on va détacher toutes les entités qui posent problème (ie : qui ont déjà été supprimées).

if (any)
    for (; ; )
        try
        {
            contextModifications.SaveChanges();
            break;
        }
        catch (OptimisticConcurrencyException ex)
        {
            foreach (var ose in ex.StateEntries)
                contextModifications.Detach(ose.Entity);
        }

Notre solution est maintenant thread-safe et fonctionne sans problème. Cependant, nous pouvons apporter une amélioration. Si on a deux notifications de changement en parallèle qui ne portent pas sur la même région, il est inutile et dommage de devoir attendre que la première soit finie pour traiter la seconde (ce que nous faisons avec notre lock unique). Aussi, nous allons utiliser un dictionnaire de lock. Cependant, un dictionnaire n’est pas thread-safe ! Il faudrait utiliser un autre object pour locker le dictionnaire. Avec .NET 4, on a une nouvelle classe : ConcurrentDictionary :

private ConcurrentDictionary<short, object> _concurrentDictionary = new ConcurrentDictionary<short,object>();

Maintenant, nous allons modifier notre code pour utiliser notre dictionnaire afin d’avoir un lock par région :

object lockRegion = _concurrentDictionary.GetOrAdd(cm.Region, new object());
if (cm.Deleted)
{
    lock (lockRegion)
    {
        if (cacheItem != null && cacheItem.Value.ToString() == cm.CarId)
        {
            var newLastId = (from c in contextModifications.Cars
                             where c.Region == cm.Region
                             orderby c.Letters.Length descending, c.Letters descending, c.Number descending
                             select c.Id).FirstOrDefault();
            if (newLastId == null)
                _cache.Remove(cm.Region.ToString(), LAST_IMMAT_PER_REGION);
            else
                _cache.Put(cm.Region.ToString(), newLastId, LAST_IMMAT_PER_REGION);
        }
    }
}
else
{
    string letters;
    int lettersCompare = 0;
    lock (lockRegion)
    {
        if (cacheItem == null || (letters = Regex.Match(cacheItem.Value.ToString(), "[A-Z]{2,3}").Value).Length < cm.Letters.Length || (letters.Length == cm.Letters.Length && ((lettersCompare = String.Compare(letters, cm.Letters)) < 0 || lettersCompare == 0 && short.Parse(Regex.Match(cacheItem.Value.ToString(), "^[0-9]{2,3}").Value) < cm.Number)))
            _cache.Put(cm.Region.ToString(), cm.CarId, LAST_IMMAT_PER_REGION);
    }

}

Voilà, notre travail est (enfin) terminé.

Je pense qu’il illustre assez bien le fait qu’il est possible de faire beaucoup d’optimisations à condition, comme souvent voire comme toujours, de suffisamment maîtriser les différentes technos disponibles.

Ce post vous a plu ? Ajoutez le dans vos favoris pour ne pas perdre de temps à le retrouver le jour où vous en aurez besoin :

Publié vendredi 20 novembre 2009 10:16 par Matthieu MEZIL

Commentaires

# re: 4 en 1 : EF n’a pas vocation à mettre les DBA au chômage, Velocity, SQL CLR et SQL Dependency @ samedi 21 novembre 2009 02:55

Le code de mise en cache est cauchemardesque.

- Mille fois trop long.

- Une gestion de la concurrence farfelue :

-- Il y a un lock en écriture mais pas en lecture (avec un s de différence entre l'objet de synchro et le dictionnaire, bravo pour la lisibilité) -&gt; pas thread safe

-- Un objet de lock étrange (lockRegion) sur lequel tu lockes à chaque itération, chaque fois différent et unique pour chaque thread donc inutile (et je n'ai pas compris à quoi il servait. Si c'est pour rendre Velocity thread-safe, euh, j'espère qu'un cache distribué gère en interne les accès concurrents). Par ailleurs on ne fait jamais de lock autour d'une opération longue (au hasard, un accès à la base de données), sinon c'est la famine garantie.

IMHO, si l'utilisation d'un cache est aussi complexe (une centaine de lignes de code), autant lâcher l'affaire.

abolibibelot

# re: 4 en 1 : EF n’a pas vocation à mettre les DBA au chômage, Velocity, SQL CLR et SQL Dependency @ samedi 21 novembre 2009 09:18

"Maintenant l’idée est de récupérer le dernier id par région. A ma connaissance, il n’est pas possible de faire cela proprement en SQL. En effet, je ne pense pas que SQL Server gère les expressions régulières. Or on a besoin de cette fonctionnalité pour déterminer quel est le dernier id."

SELECT MAX(Id), Region OVER(PARTION BY Region)

FROM ...

..Et il y a au moins 2 ou 3 autres manières existantes...

"Mon idée est d’utiliser une SQLDependency. Le problème c’est qu’il faudrait idéalement que la commande de ma SQLDependency s’exécute très vite."

Là je ne te suis plus du tout... On utilise l'un ou l'autre pas les 2...

Le premier utilise une mécanique spécifique à SQL Server 2005 et +, il est asynchrone par définition.

L'autre s'execute de manière synchrone et permet d'executer une action dans la même transaction.

Le hic c'est que les 2 ont un coût sur la table, donc faire très attention en cas de grosses activités sur ces tables.

christian

# re: 4 en 1 : EF n’a pas vocation à mettre les DBA au chômage, Velocity, SQL CLR et SQL Dependency @ samedi 21 novembre 2009 13:25

@Yann :

"Le code de mise en cache est cauchemardesque.

 

- Mille fois trop long.

 

- Une gestion de la concurrence farfelue :

 

-- Il y a un lock en écriture mais pas en lecture (avec un s de différence entre l'objet de synchro et le dictionnaire, bravo pour la lisibilité) -&gt; pas thread safe"

Avec un s de différence ???

Je pense que tu n’as pas compris mon code. Voir ci-dessous l’explication.

 

"-- Un objet de lock étrange (lockRegion) sur lequel tu lockes à chaque itération, chaque fois différent et unique pour chaque thread donc inutile  

(et je n'ai pas compris à quoi il servait."

Effectivement tu n’as pas compris. Voir ci-dessous l’explication.

 

"Si c'est pour rendre Velocity thread-safe, euh, j'espère qu'un cache distribué gère en interne les accès concurrents)."

Bien entendu que Velocity est thread-safe. Cela n’a rien à voir.

 

" Par ailleurs on ne fait jamais de lock autour d'une opération longue (au hasard, un accès à la base de données), sinon c'est la famine garantie."

Je suis d’accord avec toi qu’il vaut mieux éviter mais dans certains cas c’est indispensable. De plus, on n’est pas dans un cas de développement temps réel. On pourrait remplacé ce code :

lock (lockRegion)

{

    var newLastId = (from c in contextModifications.Cars

                     where c.Region == cm.Region

                     orderby c.Letters.Length descending, c.Letters descending, c.Number descending

                     select c.Id).FirstOrDefault();

    if (newLastId == null)

        cache.Remove(cm.Region.ToString(), LAST_IMMAT_PER_REGION);

    else

        cache.Put(cm.Region.ToString(), newLastId, LAST_IMMAT_PER_REGION);

} 

Par celui-ci

var newLastId = (from c in contextModifications.Cars

                 where c.Region == cm.Region

                 orderby c.Letters.Length descending, c.Letters descending, c.Number descending

                 select c.Id).FirstOrDefault();

lock (lockRegion)

{

    var newCacheItem = cacheModifications.GetCacheItem(cm.Region.ToString(), LAST_IMMAT_PER_REGION);

    if ((string)newCacheItem.Value == (string)cacheItem.Value)

    {

        if (newLastId == null)

            cache.Remove(cm.Region.ToString(), LAST_IMMAT_PER_REGION);

        else

            cache.Put(cm.Region.ToString(), newLastId, LAST_IMMAT_PER_REGION);

    }

}

Mais on aurait alors le risque, lorsqu’un enregistrement supprimé est immédiatement rerajouté d’avoir une suppression non souhaitée en cache.

 

IMHO, si l'utilisation d'un cache est aussi complexe (une centaine de lignes de code), autant lâcher l'affaire."

L’utilisation de Velocity n’a rien de compliqué. Ce qui rend les choses compliqué, c’est le fait de potentiellement avoir des applications complètement indépendantes de Velocity qui peuvent modifier la base de données et que cette opération doive mettre à jour le cache.

 

@ Christian :

" "Maintenant l’idée est de récupérer le dernier id par région. A ma connaissance, il n’est pas possible de faire cela proprement en SQL. En effet, je ne pense pas que SQL Server gère les expressions régulières. Or on a besoin de cette fonctionnalité pour déterminer quel est le dernier id."

SELECT MAX(Id), Region OVER(PARTION BY Region)"

Au moment où j’écris ça, je n’ai pas la colonne Region

 

"Là je ne te suis plus du tout... On utilise l'un ou l'autre pas les 2...

 

Le premier utilise une mécanique spécifique à SQL Server 2005 et +, il est asynchrone par définition.

 

L'autre s'execute de manière synchrone et permet d'executer une action dans la même transaction.

 

Le hic c'est que les 2 ont un coût sur la table, donc faire très attention en cas de grosses activités sur ces tables."

D’accord avec toi pour la perf bien que je ne pense pas avoir une charge énorme dans mon cas. J’ai surtout une base volumineuse.

Ceci dit, pourquoi ai-je besoin des deux ?

Je ne veux pas reéxécuter ma requête à chaque modif (même si elle « ne s’exécute plus qu’en 12 secondes ». Du coup j’ai besoin de récupérer les modifications (dans mon cas, les ajouts / suppressions) d’où la nécessité de la table intermédiaire.

 

 

 

De toute évidence avec vos commentaires, mon post n’est pas assez clair. Je suis conscient qu’il aborde beaucoup de notions allant de l’accès aux données à la programmation parallèle. Je vais donc apporter quelques explications.

Comme le dit Christian, les SQL Dependency implique de l’asynchrone. Du coup, je peux envisager d’avoir plusieurs appels à l’évènement OnChange en parallèle. Le problème c’est que, comme je viens de le dire, je peux avoir des appels parallèles. Aussi il ne faudrait pas se retrouver avec deux Put permettant l’ajout de l’avant-dernier à la place du dernier. Pour cela, j’ai besoin de passer par un lock qui m’assurera du bon fonctionnement. Par contre, si j’ajoute des enregistrements qui ne sont pas relatifs à la même région, le lock est inutile et dommageable pour la perf. Du coup, j’ai un lock par région et je stocke cela dans un dictionnaire. A cause de la concurrence, il est indispensable de locker le dictionnaire avant de rajouter un élément dedans. Afin d’éviter de poser des locks inutiles, je préfère tester si le lockRegion est dans le dico et le retester après avoir posé mon lock sur le dictionnaire s’il ne l’est pas, au cas où il aurait été ajouté entre temps. J’espère que c’est plus clair maintenant.

 

Matthieu

Matthieu MEZIL

# re: 4 en 1 : EF n’a pas vocation à mettre les DBA au chômage, Velocity, SQL CLR et SQL Dependency @ samedi 21 novembre 2009 15:09

Moi un post de + de 25 lignes, sans images, c'est trop pour moi :)

azra

# re: 4 en 1 : EF n’a pas vocation à mettre les DBA au chômage, Velocity, SQL CLR et SQL Dependency @ samedi 21 novembre 2009 16:16

Le problème No1 c'est ceci "WHERE LIKE '%AAA%'". Ce code va provoquer systématiquement un SCAN TABLE (toute la table est parcourue à chaque fois), donc avec une performance **totalement pourrie**. Il faut TOUT faire pour éviter les WHERE LIKE '%AAA%' (sauf si la table est toute petite bien sûr), y compris revoir la modélisation. Le reste (complique!) ne sert pas à grand chose, et est un peu inepte :) si ce LIKE n'est pas supprimé.

smo

# re: 4 en 1 : EF n’a pas vocation à mettre les DBA au chômage, Velocity, SQL CLR et SQL Dependency @ samedi 21 novembre 2009 16:30

@ azra : le code c'est commes les images, en plus t'as la couleur Stick out tongue

@ Simon :

Justement en rajoutant les colonnes intermédiaires, j'évite le WHERE LIKE '%AAA%'.

Il n'y en a pas dans ma requête

from c in context.Cars

group c by c.Region into g

select new

{

   Region = g.Key,

   LastId = (from c in g

             orderby c.Letters.Length descending, c.Letters descending, c.Number descending

             select c.Id).FirstOrDefault()  

}

C'est vrai que je n'ai pas repris ma première requête mais c'est parce que je la trouve trop simple Wink

Matthieu MEZIL

# re: 4 en 1 : EF n’a pas vocation à mettre les DBA au chômage, Velocity, SQL CLR et SQL Dependency @ samedi 21 novembre 2009 17:22

@Simon : Oui le LIKE '%aaa%' n'est pas parfait, mais que pourrait faire d'autre le moteur EF...

En termes de perf çà dépendra de l'indexation de la table, SQL Server étant capable de ne scanner que l'index si celui-ci existe... on peut limiter les dégats.

@Matthieu : Entre SqlDependency et Trigger prend l'un ou l'autre, si tu as besoin des données sans re-requêter utilise le trigger et c'est tout ! Je ne suis pas un fanatique de l'empillement des fonctionalité... Plus c'est simple, mieux ca marche souvent. Tu vas me dire que tu ne peux pas déclencher ton code .Net depuis un triggers... Je pense que je vais écrire un article là dessus ( çà fait 3 ans que je dois l'écrire ;o))

Sinon découpe ton post en plusieurs morceaux çà passera mieux ! Pas plus d'une fonctionnalité par jour... Ne pas confondre avec les fruits et légumes :op

christian

# re: 4 en 1 : EF n’a pas vocation à mettre les DBA au chômage, Velocity, SQL CLR et SQL Dependency @ samedi 21 novembre 2009 17:35

@Matthieu

Tout d'abord, je tiens à présenter mon excuses pour le ton de mon commentaire. C'est probablement le résultat d'une saturation devant la complexité de ce que je peux lire çà (monde Alt.Net, DDD) et là (technos MS pures et dures). Tu connais ton domaine, je ne doute pas que tu sois un expert sur EF (ce qui est loin loin d'être mon cas, j'avoue même être pas particulièrement informé sur les bases de données, c'est dire).

Ma réaction épidermique vient du bloc de code lâché brutalement dans ton post. Je ne dis pas que les choses sont toujours simples, même si elles mériteraient de l'être. Mais quand on présente du code, soit on le simplifie au maximum en le découpant pour que chaque bloc soit compréhensible, soit fournit le code complet en pièces jointes. Un code complexe (une quarantaine de lignes de code, des lambdas, des if et des else dans tous les coins) est dur à lire, à modifier, à débugger et à corriger. La gestion de la concurrence est en soi un sujet suffisamment complexe pour ne pas le rendre encore plus terrible en mélangeant du code métier et du code technique.

Effectivement, le coup du dictionnaire de lock m'avait échappé. Cependant :

if (!locksDictionnary.TryGetValue(cm.Region, out lockRegion))

                           {

                               lock (lockDictionnary)

                               {

                                   if (!locksDictionnary.TryGetValue(cm.Region, out lockRegion))

                                   {

                                       lockRegion = new object();

                                       locksDictionnary.Add(cm.Region, lockRegion);

                                   }

                               }

                           }

N'est pas threadsafe, comme je le disais dans mon message odieux. En effet, le premier

!locksDictionnary.TryGetValue(cm.Region, out lockRegion) n'est pas synchronisé avec lockDictionary

Donc il est tout à fait possible que la structure interne du dictionnaire soit modifiée par

locksDictionnary.Add(cm.Region, lockRegion);

et qu'on essaye de le lire dans un autre thread. Il se passe des tas de choses dans un dictionnaire quand on ajoute un élément (liste de buckets associée à un hashcode pour la clé) qui ne sont fondamentalement pas thread safe. Autrement dit, regarder ce qui se passe dans un dictionnaire alors qu'il est en cours de modification peut planter (oui, le TryGetValue peut lever une exception si les structures internes du dictionnaires sont dans un état transitoire pendant le Add, opération qui est loin loin d'être atomique, cf. Reflector. Et ce n'est pas un pinaillage théorique, j'ai pu constater le problème en production).

Il faut éviter ce genre d'optimisations et opter pour un plus sûr :

lock (lockDictionnary)

                               {

                                   if (!locksDictionnary.TryGetValue(cm.Region, out lockRegion))

                                   {

                                       lockRegion = new object();

                                       locksDictionnary.Add(cm.Region, lockRegion);

                                   }

                               }

Sans le premier TryGetValue.

Pour le lockRegion, au temps pour moi, j'ai vite lu et trop vite répondu. Je suis un idiot et - à ma décharge - la complexité cyclomatique de la routine ne m'a pas aidé à relire le code.

Si tu veux illustrer les différents mécanismes de ta solution, et avoir un peu de pitié pour ceux qui comme moi essayent vraiment de comprendre ton code plutôt que faire PageDown PageDown, découpe tes routines en morceaux responsables d'une seule chose. Les lambdas sont sympas, et les closures évitent de devoir passer par des classes intermédiaires, mais écrire un handler d'événements inline dans une fonction à l'intérieur d'une autre routine, ça tue la lisibilité, sur un blog ou dans du code quand on essaye de comprendre pourquoi ça marche ou marche pas.

Ton code, ton exemple, gagnerait en séparant d'une part la partie synchronisation, d'autre part la partie requête, et enfin la partie gestion du cache. Le mélange des trois peut provoquer des pétages de plomb chez les âmes sensibles.

Pour ma part, je gagnerais à modérer mon ton et à boire une tisane ou à sortir couper du bois quand j'atteins la saturation. Je te présente une nouvelle fois mes excuses.

Yann

abolibibelot

# re: 4 en 1 : EF n’a pas vocation à mettre les DBA au chômage, Velocity, SQL CLR et SQL Dependency @ samedi 21 novembre 2009 18:24

@ Christian : "Tu vas me dire que tu ne peux pas déclencher ton code .Net depuis un triggers... Je pense que je vais écrire un article là dessus ( çà fait 3 ans que je dois l'écrire ;o))"

J'attend ça avec impatience. Maintenant que tu me l'as dit, je vais te faire chier jusqu'à ce que tu l'es publié Wink

@ Yann : no pb, il m'est déjà arrivé par le passé de ne pas être très "adroit" dans mes commentaires et je l'avoue mon post n'est pas super lisible (c'est ma marque de fabrique Smile).

Je note ton point sur le lock. Du coup plutôt que de bêtement gérer la concurrence sur le dictionnaire à la main, j'ai modifié mon code pour utiliser un ConcurrentDictionary. C'est bien mieux comme ça.

Pour ce qui est des lambdas, je suis d'accord avec toi quand tu dis "écrire un handler d'événements inline dans une fonction à l'intérieur d'une autre routine, ça tue la lisibilité" cependant, ça m'énerve de devoir créer une méthode qui n'est appelée qu'une fois.

Matthieu MEZIL

# re: 4 en 1 : EF n’a pas vocation à mettre les DBA au chômage, Velocity, SQL CLR et SQL Dependency @ samedi 21 novembre 2009 19:23

Autant Yann y va un peu fort sur la forme, autant il a complètement raison sur le fond.

Le problème ce n'est pas tant que le post n'est pas «super lisible», que pourquoi il ne l'est pas.

Le gros problème, c'est que le code qui est présenté est une procédure monolitique de 125 lignes, balancée comme telle, qui mélange beaucoup trop de responsabilités.

* Requêtes métier en SQL pur et dur.

* Requêtes métier avec LINQ.

* Validation des données métier.

* Gestion du cache

* Gestion de la concurrence

Personnellement, quand j'ai plus de 3 niveaux d'indentation dans mon code, je commence à me poser de sérieuses questions. Ici on monte jusqu'à 10 ! Avec des branches incroyables. Quand je donnais des cours ou formation, j'appelais ça du code en arbre de noël.

Si, il est nécessaire d'extraire du code, même si la méthode n'est appelée qu'une fois. Ceci pour que le corps entier de la méthode traite d'un problème avec un niveau d'abstraction constant. Dans un IDE moderne, on peut commencer avec «extract method». Le gros avantage c'est que quand on commence à refactorer, on voit souvent un effet tetris, où l'on peut continuer à améliorer le refactoring.

Et c'est nécessaire, ne serait-ce que pour avoir du code que l'on puisse maintenir (revenir sur du code comme ça au bout de deux semaines, et c'est la migraine assurée). Chaque méthode ne devrait avoir qu'une responsabilité. Ici rien n'est testable unitairement.

Tout ça pour dire, je trouve ça un peu dommage de balancer comme ça dans un post à but didactique, ça a complètement l'effet inverse. Je veux bien que ce soit «un petit bout de code» pour montrer une possibilité, mais encore une fois, l'effet didactique est complètement tué par la complexité du truc.

Jb Evain

# re: 4 en 1 : EF n’a pas vocation à mettre les DBA au chômage, Velocity, SQL CLR et SQL Dependency @ samedi 21 novembre 2009 21:19

Bon j'avoue je me suis fait plaisir en écrivant ce post avant de penser à mes lecteurs Smile.

Quoi comme d'hab ? Wink

Par contre je suis déçu, personne ne m'a fait remarquer la petite feinte de la lambda expression récursive (en l'initialisant à null). J'ai trouvé ça dès les premières CTP de VS 2008 mais j'adore Smile

Je suis conscient que ce post n’est pas simple et n’est probablement pas "didactique" pour reprendre le mot de JB. Cependant, l’idée ici n’était pas de vous faire une pres niveau 100 où tout est simple tout est beau mais plutôt une démo niveau 400 avec un mélange des différentes technos offertes par MS dans l’accès aux données tout en essayant de rester dans un scenario possible dans la vraie vie.

En revanche, j’en conviens, j’aurais pu faire la même démo en détaillant plus mes explications et en rajoutant des commentaires. En plus ça aurait rajouté la couleur verte, ça aurait fait plaisir à Florent. Stick out tongue

Matthieu MEZIL

# re: 4 en 1 : EF n’a pas vocation à mettre les DBA au chômage, Velocity, SQL CLR et SQL Dependency @ dimanche 22 novembre 2009 13:21

@abolibibelot :

Sisi c'est threadsafe, c'est ce qu'on appelle un double-check, (ou TATA -Try acquire Try acquire). Ca évite les contentions, dans le sens où les accès concurrents en lecture n'ont pas besoin d'être lockés pour s'executer de manière concurrentes.

Du coup on fait un premier essai de lecture non "locké", et si il échoue, on lock, on retente (dans le cas ou un autre thread aurait produit le résultat attendu entre temps) et si y'a toujours pas de valeur, on la produit.

simon ferquel

# re: 4 en 1 : EF n’a pas vocation à mettre les DBA au chômage, Velocity, SQL CLR et SQL Dependency @ dimanche 22 novembre 2009 14:10

@Simon

J'ai dû mal m'exprimer. Le double check marche si aucune exception particulière ne peut survenir. Typiquement, pour la création d'un singleton.

Ce n'est néanmoins pas le cas ici.

Soit deux threads, A et B.

Thread A : début du Add (Insert dans le Dictionary).

Thread B : début du TryGet (commence à parcourir la liste de buckets pour chercher le bucket correspondant au hashcode)

Thread A : suite du Add (création d'un nouveau bucket ou Resize() d'une structure interne)

Thread B : suite du TryGet, énumère une collection qui vient d'être modifiée (BOUM) ou accède à un index non valide (BOUM).

Le problème vient que le Add n'est pas atomique, et que faire un TryGet sur un dictionnaire en cours de modification peut exploser, du fait des structures internes. TryGet PEUT PLANTER dans ces cas-là. Ce n'est pas la même chose que savoir si une valeur est null ou non.

abolibibelot

# re: 4 en 1 : EF n’a pas vocation à mettre les DBA au chômage, Velocity, SQL CLR et SQL Dependency @ dimanche 22 novembre 2009 14:18

Le fait est qu'indépendamment de l'implémentation du Dictionary, la doc est claire sur le fait que ces structures ne sont pas thread-safe. Ca peut marcher dans une autre implémentation, ça peut ne pas marcher (implémentation actuelle), mais le contrat est explicite : aucune garantie que ça marche.

abolibibelot

# re: 4 en 1 : EF n’a pas vocation à mettre les DBA au chômage, Velocity, SQL CLR et SQL Dependency @ dimanche 22 novembre 2009 14:44

Bien vu, ReaderWriterLockSlim is the way

simon ferquel

# re: 4 en 1 : EF n’a pas vocation à mettre les DBA au chômage, Velocity, SQL CLR et SQL Dependency @ dimanche 22 novembre 2009 17:53

@Simon et Yann : Dans ces cas là, la classe ConcurrentDictionary (introduite par .NET 4) est super pratique : plus besoin de gérer le lock manuellement.

Matthieu MEZIL

# re: 4 en 1 : EF n’a pas vocation à mettre les DBA au chômage, Velocity, SQL CLR et SQL Dependency @ dimanche 22 novembre 2009 18:46

Suite à vos différentes remarques (qui je l'avoue sont un peu justifiées), j'ai mis à jour mon post pour être un peu plus pédagogique... Smile

Matthieu MEZIL

# re: 4 en 1 : EF n’a pas vocation à mettre les DBA au chômage, Velocity, SQL CLR et SQL Dependency @ lundi 23 novembre 2009 15:07

Trop long

Ca décourage à la lecture.

Un blog n'est pas un recueil de proses. Une à 2 pages maximum, si tu veux que tes lecteurs ne dlcrochent pas dès le changement de la page.

C'est encore pire dans les commentaires.

Fabrice

ROMELARD Fabrice

# re: 4 en 1 : EF n’a pas vocation à mettre les DBA au chômage, Velocity, SQL CLR et SQL Dependency @ lundi 23 novembre 2009 20:22

@Fabrice : J'aurais probablement dû en faire un article ou une série de posts. Quoi qu'il en soit, si le post est long c'est parce qu'il traite de beaucoup de notions. Si le lecteur est intéressé par les problématiques d'accès aux données, je suis relativement confiant qu'il ira jusqu'au bout, même si le post est long.

Matthieu MEZIL

Les commentaires anonymes sont désactivés

Les 10 derniers blogs postés

- Merci par Blog de Jérémy Jeanson le 10-01-2019, 20:47

- Office 365: Script PowerShell pour auditer l’usage des Office Groups de votre tenant par Blog Technique de Romelard Fabrice le 04-26-2019, 11:02

- Office 365: Script PowerShell pour auditer l’usage de Microsoft Teams de votre tenant par Blog Technique de Romelard Fabrice le 04-26-2019, 10:39

- Office 365: Script PowerShell pour auditer l’usage de OneDrive for Business de votre tenant par Blog Technique de Romelard Fabrice le 04-25-2019, 15:13

- Office 365: Script PowerShell pour auditer l’usage de SharePoint Online de votre tenant par Blog Technique de Romelard Fabrice le 02-27-2019, 13:39

- Office 365: Script PowerShell pour auditer l’usage d’Exchange Online de votre tenant par Blog Technique de Romelard Fabrice le 02-25-2019, 15:07

- Office 365: Script PowerShell pour auditer le contenu de son Office 365 Stream Portal par Blog Technique de Romelard Fabrice le 02-21-2019, 17:56

- Office 365: Script PowerShell pour auditer le contenu de son Office 365 Video Portal par Blog Technique de Romelard Fabrice le 02-18-2019, 18:56

- Office 365: Script PowerShell pour extraire les Audit Log basés sur des filtres fournis par Blog Technique de Romelard Fabrice le 01-28-2019, 16:13

- SharePoint Online: Script PowerShell pour désactiver l’Option IRM des sites SPO non autorisés par Blog Technique de Romelard Fabrice le 12-14-2018, 13:01