[HowTo] – LINQ to SQL: Mostrando el progreso de Context.SumbitChanges()

Hola de nuevo,

 

En el proyecto que me ocupa actualmente he tenido que pelearme con algo curioso: Resulta que hay que realizar un proceso que lea una serie de ficheros de texto (si, si, de texto) que contienen una serie de información que debo analizar y posteriormente volcar a la base de datos. Hasta aquí ningún problema, salvo el de tener que lidiar con unos ficheros de texto cuyos formatos parecen haber sido diseñados por un loco (parece mentira que hoy en día hayan tantas organizaciones / empresas / bancos que sigan utilizando este método para intercambiar información).

SubmitChanges

El problema

Una vez desarrolladas las clases necesarias para leer y desglosar toda la información, me he encontrado con que se generan en el menor de los casos del orden de medio millón de filas a insertar en la BD.

Como os podéis imaginar el tiempo para realizar el volcado es considerable, del orden de entre 5 y 15 minutos. Así que he pensado en al menos optimizar la experiéncia del usuario, y me he marcado estos objetivos:

  1. Que el usuario pueda seguir trabajando mientras se insertan los registros
  2. Poder mostrar el progreso de la operación

Partimos de que tenemos un contexto de LINQ to SQL (por favor, no me preguntéis porque estamos usando esto en lugar de EF o NHibernate). En dicho contexto tenemos unas 495.000 filas pendientes de insertarse en cuanto hagamos la llamada a SubmitChanges(), y lo malo es que este método es síncrono y además no genera ningún evento ni permite usar callbacks ni ningún mecanismo de notificación del progreso.

Solución al primer problema (asincronía)

Es el más sencillo de los dos con diferencia. En versiones anteriores debíamos crear un thread manualmente, pero desde la aparición de la clase Task en .NET 4.0 esto se ha simplificado enormemente. Basta con crear un objeto Task que apunte al método que deseamos lanzar de forma asíncrona (mediante paralelismo).

importEngineInsertTask = new Task(() => insertPendingRows(), cancellationTokenSource.Token); 

importEngineInsertTask .Start();

Una de las ventajas respecto a los diferentes métodos de versiones anteriores es la posibilidad de pasar un token de cancelación, para por ejemplo poder cancelar la tarea si el usuario así lo decide. Tal vez sea interesante un post sobre esto más adelante, aunque es bastante sencillo.

Solución al segundo problema (mostrar progreso)

Este ha sido un poco más peliagudo, ya que en principio no hay modo de interactuar con el DataContext de LINQ to SQL para reportar el progreso de las inserciones. Para poder mostrar el progreso necesitamos saber por un lado el número total de filas a insertar y por otro el elemento actual. Para conocer el número total de filas a insertar basta con preguntarle al DataContext:

numInsertLines = context.GetChangeSet().Inserts.Count;

Pero para saber el número de insert actual no disponemos de nada. Sin embargo, el Contexto dispone de una propiedad Log de tipo TextWriter, que permite la monitorización de cada operación. Bien, a partir de esto, podemos generar una clase propia que herede de TextWriter:

class ActionTextWriter : TextWriter 

{ 

    private readonly Action<string> action;

 

    public ActionTextWriter(Action<string> action) 

    { 

        this.action = action; 

    }

 

    public override void Write(char[] buffer, int index, int count) 

    { 

        Write(new string(buffer, index, count)); 

    }

 

    public override void Write(string value) 

    { 

        action.Invoke(value); 

    }

 

    public override Encoding Encoding 

    { 

        get { return System.Text.Encoding.Default; } 

    } 

}

 

Y posteriormente usarla para llamar a un método que se encargue de lanzar un evento que será monitorizado para mostrar el progreso

private void configureSubmitInsertProgress() 

{ 

    context.Log = new ActionTextWriter(s => 

    { 

        if (s.StartsWith("INSERT INTO")) 

            ReportInsertProgress(s); 

    }); 

} 

Conclusión

El resultado es que el usuario puede realizar un proceso relativamente pesado sin tener que dejar de trabajar, ya que el proceso se ejecuta en segundo plano, y además puede ver un proceso del progrso de la operación. Ya sabemos lo impacientes que pueden ser los usuarios, verdad? :-)

ReportProgress

Saludos,

SharePoint 2007 + Outlook 2007: Guardar correos de forma masiva (V)

Entradas anteriores de la serie:

SharePoint 2007 + Outlook 2007: Guardar correos de forma masiva (I)

SharePoint 2007 + Outlook 2007: Guardar correos de forma masiva (II)

SharePoint 2007 + Outlook 2007: Guardar correos de forma masiva (III)

SharePoint 2007 + Outlook 2007: Guardar correos de forma masiva (IV)


bender2

Después de algunos días sin poder seguir con la serie porque han detenido a Bender hay que sacar adelante otros proyectos, volvemos a la carga con lo que promete ser la serie de artículos más larga que he publicado hasta ahora en este blog.

 

Antes de nada vamos a ponernos al día:

Que pretendemos?

Crear un add-in de Outlook que permita guardar nuestros correos en una librería de documentos de SharePoint (tanto MOSS como WSS).

Que necesitamos?

  • Cómo crear un complemento para Office (VSTO). visto
  • Cómo registrar acciones en los menús de la aplicación host (Outlook). visto
  • Cómo conectar con un servidor MOSS/WSS con diferentes credenciales de usuario (System.Net.NetworkCredential) visto
  • Cómo acceder a los sitios y listas de un site de MOSS/WSS (servicios Web de SharePoint). visto
  • Cómo interpretar la información XML devuelta por los servicios (LINQ to XML). visto
  • Cómo extender el modelo de objetos de SharePoint para acceder los valores devueltos por los servicios Web.
  • Cómo crear las columnas necesarias en una lista para guardar los metadatos del elementos de correo (From, To, Subject).
  • Cómo guardar un fichero en una biblioteca de documentos de SharePoint con control de versiones (DocLibHelper).
  • Cómo ejecutar distintos threads y mostrar el progreso (Callbacks).
  • Cómo distribuir nuestro complemento, creando un archivo MSI (Windows Installer packages).

Que vamos a ver hoy?

En este post vamos a centrarnos en cómo obtener datos de los servicios Web de SharePoint. En nuestro caso necesitamos obtener las colecciones de sitios, las librerías de cada uno de los sitios, y las carpetas de cada librería para mostrarlos en una ventana que permita al usuario seleccionar la ubicación en la que desea guardar los elementos de coreo:

AddIn_Step2

Del mismo modo, vamos a necesitar obtener las columnas de la librería seleccionada, para comprobar si ya existen las columnas para almacenar los metadatos de los elementos de correo, y en caso contrario proceder a crearlas (esto va a ir muy bien para posteriormente poder clasificar los elementos).

Correos1

 

Extendiendo el modelo de objetos de SharePoint

En el post anterior ya vimos que habíamos declarado algunas clases de apoyo (como la clase ‘SPSiteInfo’) para que nuestros métodos (como el método ‘getSiteSubSites’ que también vimos en el anterior post) pudiesen devolver colecciones genéricas de éestos tipos de datos, lo cual es mucho más manejable que pelearnos con el XML que devuelven los servicios Web de SharePoint.

Los métodos que vamos a crear son:

  • getSiteSubSites: Devuelve una lista genérica de objetos ‘SPSiteInfo’, a partir de un servicio Web websProxy.Webs
  • getSiteLists: Devuelve una lista genérica de objetos ‘SPListInfo’, a partir de un servicio Web listsProxy.Lists
  • getListFolders: Devuelve una lista genérica de objetos ‘SPFolderInfo’, a partir de un servicio Web listsProxy.Lists
  • getListColumns: Devuelve una lista genérica de objetos ‘SPFolderInfo’, a partir de un servicio Web listsProxy.Lists y de un listID (GUID)
  • createMetadataColumns: Crea las columnas de metadatos definidas en los settings del add-in en una lista, a partir de un servicio Web listsProxy.Lists y de un listID (GUID).

Las clases de apoyo son:

public class SPSiteInfo
{
    public string Title { get; set; }
    public string URL { get; set; }
 
    public SPSiteInfo()
    {
        //
    }
 
    public SPSiteInfo(string title, string url)
    {
        Title = title;
        URL = url;
    }
}
 
public class SPListInfo
{
    public Guid ID { get; set; }
    public string Title { get; set; }
    public string URL { get; set; }
 
    public SPListInfo()
    {
        //
    }
 
    public SPListInfo(Guid id, string title, string url)
    {
        ID = id;
        Title = title;
        URL = url;
    }
}
 
public class SPFolderInfo
{
    public string Title { get; set; }
    public string URL { get; set; }
 
    public SPFolderInfo()
    {
        //
    }
 
    public SPFolderInfo(string title, string url)
    {
        Title = title;
        URL = url;
    }
}
 
public class SPColumnInfo
{
    public string ID { get; set; }
    public string Type { get; set; }
    public string DisplayName { get; set; }
    public string Name { get; set; }
    public bool Hidden { get; set; }
    public bool Sealed { get; set; }
    public bool ReadOnly { get; set; }
 
    public SPColumnInfo()
    {
        //
    }
 
    public SPColumnInfo(string id, string type,
        string displayName, string name, bool hidden, 
        bool issealed, bool isreadonly)
    {
        ID = id;
        Type = type;
        DisplayName = displayName;
        Name = name;
        Hidden = hidden;
        Sealed = issealed;
        ReadOnly = isreadonly;
    }
}

Y el código de los métodos será el siguiente:

public class SharePointExtensions
{
    public static List<SPSiteInfo> getSiteSubSites(websProxy.Webs sharePointWebs)
    {
        try
        {
            XmlNode websResult = sharePointWebs.GetWebCollection();
            XDocument results = XDocument.Parse(websResult.OuterXml);
            XName name = XName.Get("Web", "http://schemas.microsoft.com/sharepoint/soap/");
            var webs = from item in results.Descendants(name)
                       select new SPSiteInfo
                           (
                           item.Attribute("Title").Value,
                           item.Attribute("Url").Value
                           );
            return webs.ToList();
        }
        catch (Exception ex)
        {
            throw ex;
        }
    }
 
    public static List<SPListInfo> getSiteLists(listsProxy.Lists sharePointLists)
    {
        try
        {
            XmlNode listsResult = sharePointLists.GetListCollection();
            XDocument results = XDocument.Parse(listsResult.OuterXml);
            XName name = XName.Get("List", "http://schemas.microsoft.com/sharepoint/soap/");
            var lists = from item in results.Descendants(name)
                        where item.Attribute("ServerTemplate").Value.ToString() ==
                            Properties.Settings.Default.SERVER_TEMPLATE
                        select new SPListInfo
                            (
                            new Guid(item.Attribute("ID").Value),
                            item.Attribute("Title").Value,
                            item.Attribute("DefaultViewUrl").Value
                            );
            return lists.ToList();
        }
        catch (Exception ex)
        {
            throw ex;
        }
    }
 
    public static List<SPFolderInfo> getListFolders(listsProxy.Lists sharePointLists, 
        string listName, string listText, string folderURL)
    {
        try
        {
            XmlDocument xmlDoc = new System.Xml.XmlDocument();
            XmlNode ndQuery = xmlDoc.CreateNode(XmlNodeType.Element, "Query", "");
            XmlNode ndViewFields =
              xmlDoc.CreateNode(XmlNodeType.Element, "ViewFields", "");
 
            XmlNode ndQueryOptions = null;
            if (listName != folderURL)
            {
                ndQueryOptions = xmlDoc.CreateNode(
                    XmlNodeType.Element, "QueryOptions", "");
                ndQueryOptions.InnerXml = string.Format(
                    "<Folder>{0}/{1}</Folder>", listName, folderURL);
            }
 
            XName name = XName.Get("data", "urn:schemas-microsoft-com:rowset");
            XmlNode ndListItems =
                    sharePointLists.GetListItems(listText, null, ndQuery,
                    ndViewFields, null, ndQueryOptions, null);
            XDocument results = XDocument.Parse(ndListItems.OuterXml);
 
            var folders = from item in results.Descendants(name).Elements()
                          where item.Attribute("ows_ContentType").Value == "Carpeta"
                          select new SPFolderInfo
                             (
                             item.Attribute("ows_BaseName").Value,
                             item.Attribute("ows_ServerUrl").Value
                             );
            return folders.ToList();
        }
        catch (Exception ex)
        {
            throw ex;
        }
    }
    
    public static List<SPColumnInfo> getListColumns(listsProxy.Lists sharePointLists, string siteID)
    {
        try
        {
            List<SPColumnInfo> columns =new List<SPColumnInfo>();
            XmlNode listsResult = sharePointLists.GetList(siteID);
            XDocument results = XDocument.Parse(listsResult.OuterXml);
            XName name = XName.Get("Field", "http://schemas.microsoft.com/sharepoint/soap/");
            var lists = from item in results.Descendants(name)
                        select item;
            foreach (var item in lists.ToList())
            {
                XAttribute aID = item.Attribute("ID");
                XAttribute aType = item.Attribute("Type");
                XAttribute aDisplayName = item.Attribute("DisplayName");
                XAttribute aName = item.Attribute("Name");
                XAttribute aHidden = item.Attribute("Hidden");
                XAttribute aSealed = item.Attribute("Sealed");
                XAttribute aReadOnly = item.Attribute("ReadOnly");
 
                if (aID != null && aType != null && aDisplayName != null &&
                    aName != null)
                {
                    bool ishidden = false;
                    bool issealed = false;
                    bool isreadonly = false;
                    if (aHidden != null && aHidden.Value.ToString() == "TRUE") ishidden = true;
                    if (aSealed != null && aSealed.Value.ToString() == "TRUE") issealed = true;
                    if (aReadOnly != null && aReadOnly.Value.ToString() == "TRUE") isreadonly = true;
                    columns.Add(new SPColumnInfo(
                        aID.Value.ToString(),
                        aType.Value.ToString(),
                        aDisplayName.Value.ToString(),
                        aName.Value.ToString(),
                        ishidden, issealed, isreadonly));
                }
            }
            return columns;
        }
        catch (Exception ex)
        {                
            throw ex;
        }
    }
 
    public static void createMetadataColumns(listsProxy.Lists sharePointLists, string listID)
    {
        int createColumnsCount = 0;
        if (!Properties.Settings.Default.OPTION_CREATE_METADATA_COLUMNS) return;
        var cols = from c in SharePointExtensions.getListColumns(
                   sharePointLists, listID)
                   where c.Hidden == false && c.Sealed == false && c.ReadOnly == false
                   select c;
 
        Dictionary<string, SPColumnInfo> columns = cols.ToDictionary(c => c.Name);
        string newFieldsList = "";
 
        if (!columns.ContainsKey(Properties.Settings.Default.COL_SUBJECT))
        {
            string subjectfield =
                @"<Method ID='1'><Field Type='Text' DisplayName='{0}' MaxLength='255'/></Method>";
            newFieldsList += string.Format(subjectfield, Properties.Settings.Default.COL_SUBJECT);
            createColumnsCount++;
        }
        if (!columns.ContainsKey(Properties.Settings.Default.COL_TO))
        {
            string subjectfield =
                @"<Method ID='2'><Field Type='Text' DisplayName='{0}' MaxLength='255'/></Method>";
            newFieldsList += string.Format(subjectfield, Properties.Settings.Default.COL_TO);
            createColumnsCount++;
        }
        if (!columns.ContainsKey(Properties.Settings.Default.COL_CC))
        {
            string subjectfield =
                @"<Method ID='3'><Field Type='Text' DisplayName='{0}' MaxLength='255'/></Method>";
            newFieldsList += string.Format(subjectfield, Properties.Settings.Default.COL_CC);
            createColumnsCount++;
        }
        if (!columns.ContainsKey(Properties.Settings.Default.COL_BCC))
        {
            string subjectfield =
                @"<Method ID='4'><Field Type='Text' DisplayName='{0}' MaxLength='255'/></Method>";
            newFieldsList += string.Format(subjectfield, Properties.Settings.Default.COL_BCC);
            createColumnsCount++;
        }
        if (!columns.ContainsKey(Properties.Settings.Default.COL_FROM))
        {
            string subjectfield =
                @"<Method ID='5'><Field Type='Text' DisplayName='{0}' MaxLength='255'/></Method>";
            newFieldsList += string.Format(subjectfield, Properties.Settings.Default.COL_FROM);
            createColumnsCount++;
        }
        if (!columns.ContainsKey(Properties.Settings.Default.COL_SENT))
        {
            string subjectfield =
                @"<Method ID='6'><Field Type='DateTime' DateOnly='FALSE' DisplayName='{0}'/></Method>";
            newFieldsList += string.Format(subjectfield, Properties.Settings.Default.COL_SENT);
            createColumnsCount++;
        }
        if (!columns.ContainsKey(Properties.Settings.Default.COL_SIZE))
        {
            string subjectfield =
                @"<Method ID='7'><Field Type='Number' DisplayName='{0}'/></Method>";
            newFieldsList += string.Format(subjectfield, Properties.Settings.Default.COL_SIZE);
            createColumnsCount++;
        }
        if (!columns.ContainsKey(Properties.Settings.Default.COL_IMPORTANCE))
        {
            string subjectfield =
                @"<Method ID='8'><Field Type='Choice' DisplayName='{0}'>;
                  <Default>{1}</Default>
                  <CHOICES>
                    <CHOICE>{2}</CHOICE>
                    <CHOICE>{3}</CHOICE>
                    <CHOICE>{4}</CHOICE>
                  </CHOICES>
                </Field></Method>"
            newFieldsList += string.Format(subjectfield,
                Properties.Settings.Default.COL_IMPORTANCE,
                Properties.Settings.Default.COL_IMPORTANCE_MEDIUM,
                Properties.Settings.Default.COL_IMPORTANCE_HIGH,
                Properties.Settings.Default.COL_IMPORTANCE_MEDIUM,
                Properties.Settings.Default.COL_IMPORTANCE_LOW);
            createColumnsCount++;
        }
        if (createColumnsCount > 0)
        {
            XmlNode ndList = sharePointLists.GetList(listID);
            XmlNode ndVersion = ndList.Attributes["Version"];
            XmlDocument xmlDoc = new System.Xml.XmlDocument();
            XmlNode ndNewFields = xmlDoc.CreateNode(XmlNodeType.Element, "Fields", "");
            ndNewFields.InnerXml = newFieldsList;
            try
            {
                XmlNode ndReturn =
                   sharePointLists.UpdateList(listID,
                   null, ndNewFields, null, null,
                   ndVersion.Value);
            }
            catch (Exception ex)
            {
 
                throw ex;
            }
        }
    }
}

Y con esto ya estamos en disposición de mostrar un cuadro de diálogo al usuario, para que seleccione la ubicación en el servidor de SharePoint dónde quiere guardar los elementos de correo.

En el próximo post veremos cómo guardar los elementos de corro en la ubicación seleccionada, creando las columnas de metadatos (si así lo hemos definido), y actualizando el valor de éstas columnas para guardar los valores de los campos de correo (Asunto, De, Para, Fecha, Tamaño, etc.).

Os recuerdo que al terminar la serie publicaré la solución con el código completo, por si a alguien le sirve.

Espero no demorarme tanto en escribir el próximo post!

Nos vemos, un saludo desde Andorra!

LINQ to SQL (SQL compaq también existe!)

sqlserver2008logo

Esta mañana mi colega de penurias Toni Sala me preguntaba acerca de las posibilidades de usar LINQ to SQL con bases de datos SQL Compaq. Efectivamente, se puede hacer, es algo que probé en su momento y no había utilizado más. Sin embargo, lo que si que recordaba era que no es tan evidente como con sus hermanos mayores.

Bueno, al lío. Para este ejemplo vamos a usar la BD de ejemplo Northwind.sdf que instala Visual Studio 2008 (en mi caso la tengo ubicada en ‘C:Archivos de programaMicrosoft SQL Server Compact Editionv3.5Samples’).

Una vez creado el proyecto de ejemplo (en mi caso lo llamaré ‘SqlCompaqLINQ’), creamos una carpeta ‘Data’ dentro de nuestro proyecto y agregamos la BD a nuestro proyecto mediante ‘Project/Add existing Item’:

SqlCompaqLINQPE SqlCompaqLINQAddDB

Hasta aquí todo normal, pero la diferencia de SQlCompaq respecto a sus hermanos mayores está en que si ahora creamos un DataContext e intentamos arrastrar una tabla sobre él, aparecerá un mensaje de error un poco descorazonador:

SqlCompaqLINQError

No pasa nada, para estos casos podemos ir directamente a la herramienta SqlMetal (ya sabéis lo del poder del metal :-P), y generar el correspondiente fichero DBML mediante la línea de comandos. Para ello abrimos la consola de Visual Studio y ejecutamos el siguiente comando (desde nuestra carpeta ‘Data’):

SqlMetal.exe Northwind.sdf /dbml:NorthwindCE.dbml

Esto generará el diseñador con las clases correspondientes a las tablas de la BD de SQL Compaq, que a continuación agegaremos a nuestro proyecto también mediante ‘Project/Add existing Item’:

SqlCompaqLINQDataContext

Ahora basta con crear una instancia del diseñador, generar nuestra instrucción LINQ to SQL y ¡a jugar!:

using SqlCompaqLINQ.Data;
...
string path = "Data Source=|DataDirectory|\Data\Northwind.sdf";
using(Northwind dc = new Northwind(path))
{
    var customers = from c in dc.Customers
                    orderby c.CompanyName
                    select c;
    listBox1.DataSource = customers.ToList();
    listBox1.DisplayMember = "CompanyName";
    listBox1.ValueMember = "CustomerID";
}

Un saludo desde Andorra,

SharePoint 2007 + Outlook 2007: Guardar correos de forma masiva (IV)

Entradas anteriores de la serie:

SharePoint 2007 + Outlook 2007: Guardar correos de forma masiva (I)

SharePoint 2007 + Outlook 2007: Guardar correos de forma masiva (II)

SharePoint 2007 + Outlook 2007: Guardar correos de forma masiva (III)


bendervitruvio

He estado un par de días sin poder continuar con la serie, pero es que el trabajo aprieta y no siempre nos podemos dedicar a lo que más nos gusta…

Recapitulando: Cosas que necesitamos para nuestro add-in:

  • Cómo crear un complemento para Office (VSTO). visto en el post anterior.
  • Cómo registrar acciones en los menús de la aplicación host (Outlook). Lo veremos ahora.
  • Cómo conectar con un servidor MOSS/WSS con diferentes credenciales de usuario (System.Net.NetworkCredential) Esto también lo veremos ahora.
  • Cómo acceder a los sitios y listas de un site de MOSS/WSS (servicios Web de SharePoint). visto en el post anterior.
  • Cómo interpretar la información XML devuelta por los servicios (LINQ to XML). Y esto también lo veremos ahora.
  • El resto de cosas las veremos en los próximos días…

Hoy nos centraremos en mostrar cómo agregar opciones a los menús de Outlook (para lanzar nuestras acciones personalizadas), ver cómo conectar con un sitio de SharePoint con las credenciales predeterminadas, o bien proporcionando un usuario y password. Y además veremos cómo interpretar el resultado devuelto por los servicios Web, ya que el usar LINQ to XML va a facilitarnos en mucho la tarea de lidiar con XML puro y duro.

Demos un vistazo al futuro, a lo que va a ser este Add-In (solo Bender puede viajar al futuro):

1 – Una vez registradas nuestras propias acciones en el menú de Outlook, éstas permitirán guardar los elementos selecionados:

AddIn_Step1

2 – Aparecerá una ventana en la que podremos se leccionar la biblioteca de documentos y la carpeta destino:

 

 

AddIn_Step2

3 – Una vez seleccionada la carpeta, mostraremos una ventana de progreso en un hilo separado para que el usuario pueda seguir interactuando con Outlook:

AddIn_Step3

4 – Una vez terminado, si vamos a la biblioteca de documentos aparecerán los documentos (y más adelante también los metadatos):

AddIn_Step4

Vayamos por partes:

Registrar acciones en un menú de Outlook:

Registrar el menú es sencillo, sólo debemos modificar el código del evento ‘ItemContextMenuDisplay’ (como ya vimos en el post anterior), y llamar a un método ‘RegisterMenu’ que se encarga de agregar nuestras acciones al menú contextual de Outlook.

void Application_ItemContextMenuDisplay(
    Microsoft.Office.Core.CommandBar CommandBar, 
    Microsoft.Office.Interop.Outlook.Selection Selection)
{
    RegisterMenu(CommandBar, false);
}

 

 

 

void RegisterMenu(Microsoft.Office.Core.CommandBar CommandBar, bool IsFolder)
{
    Office.CommandBarPopup cmdSaveToMOSS = 
        (Office.CommandBarPopup)CommandBar.FindControl(
        missing, missing, "STSMOSS", missing, missing);
    if (cmdSaveToMOSS == null)
    {
        cmdSaveToMOSS = (Office.CommandBarPopup)CommandBar.Controls.Add(
                Office.MsoControlType.msoControlPopup, missing,
                missing, missing, false);
        if (cmdSaveToMOSS != null)
        {
            cmdSaveToMOSS.Caption = Properties.Resources.CONTEXT_MENU_CAPTION;
            cmdSaveToMOSS.Tag = "STMOSS";
            cmdSaveToMOSS.BeginGroup = true;
 
            Office.CommandBarButton btnSaveToMOSS =
                (Office.CommandBarButton)cmdSaveToMOSS.Controls.Add(
                Office.MsoControlType.msoControlButton, missing, missing, missing, missing);
 
            btnSaveToMOSS.Caption = Properties.Resources.CONTEXT_MENU_SENDBUTTON;
            btnSaveToMOSS.FaceId = 65;
            btnSaveToMOSS.Tag = IsFolder.ToString();
            btnSaveToMOSS.Style = MsoButtonStyle.msoButtonIconAndCaption;
            btnSaveToMOSS.Picture = getImage();
            btnSaveToMOSS.Click += new Microsoft.Office.Core.
                _CommandBarButtonEvents_ClickEventHandler(saveToMOSS_Click);  
            
            Office.CommandBarButton btnConfigurationMOSS =
                (Office.CommandBarButton)cmdSaveToMOSS.Controls.Add(
                Office.MsoControlType.msoControlButton, missing, missing, missing, missing);
 
            btnConfigurationMOSS.Caption = Properties.Resources.CONTEXT_MENU_CONFIGURATIONBUTTON;
            btnConfigurationMOSS.BeginGroup = true;
            btnConfigurationMOSS.Style = MsoButtonStyle.msoButtonIconAndCaption;
            btnConfigurationMOSS.Click += new Microsoft.Office.Core.
                _CommandBarButtonEvents_ClickEventHandler(configurationMOSS_Click);  
            
            cmdSaveToMOSS.Visible = true;
        }
    }
}

 

Este código crea un elemento menú y le agrega dos opciones (‘btnSaveToMOSS’ y ‘btnConfigurationMOSS’). A continuación establece sus propiedades y asigna los event handlers a sendas rutinas que se ejecutarán al pulsar sobre ellas.

void saveToMOSS_Click(Microsoft.Office.Core.CommandBarButton Ctrl, ref bool CancelDefault)
{     
    CancelDefault = true;
    MessageBox.Show("Save");
}
 
void configurationMOSS_Click(Microsoft.Office.Core.CommandBarButton Ctrl, ref bool CancelDefault)
{
    CancelDefault = true;
    MessageBox.Show("Config");
}

Conectar con un servidor MOSS/WSS con diferentes credenciales de usuario:

En algunas ocasiones tal vez nos interese conectar con un sitio de SharePoint y no hacer uso de las credenciales predeterminadas (por defecto el token del usuario de Windows). En estos casos, debemos hacer uso de la clase ‘System.Net.NetworkCredential’, pasando los valores de usuario y password (y opcionalmente dominio) para acceder al sitio. En este caso he creado unas sencillas funciones que devuelven un objeto de tipo ‘ICredentials’ que posteriormente usaremos en la propiedad ‘Credentials’ de los proxies de los servicios Web:

public static System.Net.ICredentials getCredentials()
{
    return System.Net.CredentialCache.DefaultCredentials;
}
 
public static System.Net.ICredentials getCredentials(string user, string password, string domain)
{
    return new System.Net.NetworkCredential(user, password, domain);
}

Interpretar el resultado devuelto por los servicios Web (LINQ to XML):

En el post anterior vimos que el resultado devuelto por los métodos de un servicio Web dista mucho de ser fácilmente interpretable, ya que nos devuelve un objeto de tipo XmlNode, de modo que debemos analizar el DOM para obtener el resultado deseado. En nuestro caso, vamos a realizar una llamada al método ‘GetWebCollection()’, que devuelve un XmlNode con los nombres de las webs (sites) de un sitio de SharePoint. Pero vamos a utilizar LINQ to XML para obtener sólo los elementos de un tipo determinado, y además vamos a obtener aquellos atributos que nos interesan. Y lo mejor es que todo se hace una sola sentencia:

public static List<SPSiteInfo> getSiteSubSites(websProxy.Webs sharePointWebs)
{
    try
    {
        XmlNode websResult = sharePointWebs.GetWebCollection();
        XDocument results = XDocument.Parse(websResult.OuterXml);
        XName name = XName.Get("Web", "http://schemas.microsoft.com/sharepoint/soap/");
        var webs = from item in results.Descendants(name)
                   select new SPSiteInfo
                       (
                       item.Attribute("Title").Value,
                       item.Attribute("Url").Value
                       );
        return webs.ToList();
    }
    catch (Exception ex)
    {
        throw ex;
    }
}

A destacar en el código anterior el uso de un elemento XName para obtener sólo los elementos de tipo Web:

XName_Web

A continuación basta con seleccionar los atributos deseados de cada elemento XML (en nuestro caso título y url) e ir creando objetos que almacenaremos en una colección. Aquí utilizo una sencilla clase llamada ‘SPSiteInfo’ para devolver una lista genérica. Esta clase tiene sólo dos propiedades de tipo string (Title y URL) y un constructor en el que le pasamos ambos valores:

public class SPSiteInfo
{
    public string Title { get; set; }
    public string URL { get; set; }
 
    public SPSiteInfo()
    {
        //
    }
 
    public SPSiteInfo(string title, string url)
    {
        Title = title;
        URL = url;
    }
}

De este modo, ya tenemos una función que nos devuelve una colección de objetos ‘SPSiteInfo’. Emplearemos este mismo procedimiento para recuperar las bibliotecas de documentos de un sitio, las carpetas de una biblioteca de documentos, e incluso las columnas de una biblioteca de documentos.

Pero ello lo veremos en el próximo post, que hay que volver a las trincheras… :-)

PD – Os recuerdo que al final de la serie publicaré el código fuente del proyecto de ejemplo.

Material del evento: Asentando las bases

Ayer tuvimos el último evento del año en AndorraDotNet. Mientras llegan las fotos que hicieron algunos de los asistentes, aprovecho para publicar el material del evento, tanto la presentación como los proyectos de ejemplo.

 image

La presentación la encontraréis aquí:

Y la solución de Visual Studio aquí:

Gracias por todo y nos vemos pronto!

¿Goodbye Dictionary?

Corrección al artículo (16/07/2008):

Todas las mediciones de tiempo efectuadas en las compartivas entre listas y diccionarios han sido mal efectuadas y no son válidas. Porqué? Porque a un servidor se le olvidó ‘resetear’ el cronómetro del StopWatch entre una medición y otra (ay, ay, ay…), de modo que los tiempos tomados para el objeto dictionary incluyen también los de la lista, y por eso son mucho mayores de lo esperado.

Un ‘pequeño’ olvido pero que afecta totalmente al sentido del post, ya que la conclusión del post era que acceder a un elemento de un diccionario NO era más rápido que acceder a un elemento de una lista, cuando la realidad es que SI lo es… y mucho. Siento haber publicado lo contrario y aquí me retracto.

De este error puedo sacar varias conclusiones:

  • Revisar: Acostumbrarme a revisar más en profundidad lo que publico. He usado infinidad de veces el objeto StopWatch y no es nuevo para mi, así que todavía me duele más haberme olvidado de reiniciar el crono… Muchas gracias por el comentario Steven.
  • No hacer tantas cosas al mismo tiempo: La verdad es que cuando escribí el post estaba haciendo varias cosas al mismo tiempo, entre las cuales me encontraba realizando unas pruebas para una aplicación, y al medir los tiempos me quedé muy sorprendido y empecé a escribir el post sin detectar el error en mi código.
  • Todos nos equivocamos: Eso es evidente, y cualquiera puede cometer un error como este. Lo malo es que en lugar de escribir el post rápidamente y publicarlo, debería haberlo dejado para revisarlo hoy… pero lo hecho hecho está.
  • Hay que aprender de los errores: Prefiero hacer algo y equivocarme a no hacer nada y no equivocarme. Con todo, espero aprender algo de todo esto, porque todos seguimos aprendiendo día a día (y malo del día que no lo hagamos).

Pero vamos con la corrección. Los nuevos tiempos si agregamos un watch.Reset(); antes de empezar a tomar los tiempos del diccionario son los siguientes:

Compare load 100000 items in List vs. Dict. (ms.)
List: 678,4068
Dict: 759,5071

Compare get one item by key in List vs. Dict. (ms.)
List: 0,3645
Dict: 0,0058

Compare verify one item by key in List vs. Dict. (ms.)
List: 0,2748
Dict: 0,0058

La conclusión es que cargar datos en un diccionario es sólamente un poco más lento que cargarlos en una lista. mientras que es mucho más rápido acceder a los elementos que contiene (del orden 50 veces más rápido aproximadamente). Evidentemente esto justifica sobradamente el uso de diccionarios.

Bueno… Tal vez el título del artículo ahora debería quedar como “¿Goodbye Dictionary? ¡Hola melón!” 😛

Saludos desde Andorra,

Fin de la corrección al artículo

El artículo original (15/07/2008):

Generics

La aparición del framework 2.0 nos trajo una grata sorpresa: La aparición de Generics, que nos proveía por fin de un conjunto de colecciones fuertemente tipadas, que mejoraban mucho el rendimiento al evitar el uso de boxing y unboxing, y permitían un código mucho más legible, elegante, así como detectar y prevenir muchos errores en tiempo de compilación.

GenericsError1

Desde ,entonces, de todos los distintos tipos de colecciones genéricas, en el 90% de los casos he usado estos dos: List y Dictionary. El primero de ellos es obviamente una lista de objetos <T>, algo de uso cotidiano, y el segundo es el equivalente genérico del viejo diccionario, que permite guardar una colección de elementos y una clave para acceder a ellos <TKey>, <TValue>:

Dictionary<int, CPItem> dict = new Dictionary<int, CPItem>();

Este tipo diccionario es ideal para almacenar una serie de objetos (por ejemplo clientes que provienen de una BD) y poder acceder a uno de ellos a través de su campo clave o identificador, sea éste del tipo que sea (por ejemplo en el caso anterior la clave es de tipo int):

CPItem item2 = dict[24];

De este modo, es muy sencillo acceder a un elemento de la colección a través del valor de su identificador (no confundir con su posición dentro de la lista). Sin embargo y a pesar de su innegable potencia, hay un par de cosas que no me gustan de los diccionarios:

  • Siempre es más lento cargar los objetos en un diccionario que en una lista (del orden de 2 a 1 aproximadamente)
  • Un diccionario no es serializable ‘per se’ (al menos sin hacer trucos), y esto SI puede ser un problema.

Sin embargo, tiene la gran ventaja frente a la lista de que permite acceder de forma casi instantánea a un elemento de la misma, y además contiene métodos que permiten verificar si la colección contiene un elemento, ya sea por clave o por valor.

List vs. Dictionary

Veamos un ejemplo rápido, declaramos una clase con algunos miembros de diversos tipos de datos (para tener de todo un poco) y usamos esta clase para cargar varios objetos en una lista y un diccionario respectivamente:

class CPItem
{
    const int max = 1000000;
 
    public int propInt { get; set; }
    public string propString { get; set; }
    public DateTime propDateTime { get; set; }
    public bool propBool { get; set; }
    public double propDouble { get; set; }
 
    public CPItem(int propint)
    {
        Random r = new Random(max);
        propInt = propint;
        propString = string.Format("Item {0}", propint.ToString("000000"));
        propDateTime.AddDays(propint);
        propBool = propint <= 0.5 ? false : true;
        propDouble = r.NextDouble() * max;
    }
}

Declaramos los dos objetos genéricos:

List<CPItem> list = new List<CPItem>();
Dictionary<int, CPItem> dict = new Dictionary<int, CPItem>();

Y a continuación usamos un StopWatch para medir los tiempos de carga de 100.000 elementos… y podremos observar una gran diferencia:

private void LoadListDict()
{
    const int max = 100000;
    Stopwatch watch = new Stopwatch();
 
    list.Clear();
    watch.Start();
    for (int i = 1; i <= max; i++)
    {
        list.Add(new CPItem(i));
    }
    watch.Stop();
    TimeSpan ts1 = watch.Elapsed;
 
    dict.Clear();
    watch.Start();
    for (int i = 1; i <= max; i++)
    {
        dict.Add(i, new CPItem(i));
    }
    watch.Stop();
    TimeSpan ts2 = watch.Elapsed;
 
    this.ResultsTextBox.Text = string.Format(
        "Compare load {0} items in List vs. Dict. (ms.)rnList: {1}rnDict: {2}",
        max.ToString(),
        ts1.TotalMilliseconds.ToString(),
        ts2.TotalMilliseconds.ToString());
}

En mi estación estos son los tiempos promedio de carga después de ejecutar 5 veces:

Compare load 100000 items in List vs. Dict. (ms.)
List: 737,1392
Dict: 1479,4486

Bien, está claro que si hay que elegir entre ambos hay que tener un buen motivo para escoger el diccionario… ¡ya que la lista es el doble de rápida!

LINQ to objects

Con la aparición de VS2008 y del framework 3.5, aparecen las expresiones de consulta. Éstas nos permiten manipular colecciones de objetos en memoria, para filtrarlos, ordenarlos y agruparlos según nuestras necesidades.

Así que es posible que nos preguntemos ¿es necesario el uso de diccionarios, si ahora disponemos de LINQ para manejar listas? Es decir, si puedo cargar los datos en una lista genérica y acceder mediante LINQ to objects a un elemento, o a un subconjunto de éstos… ¿qué sentido tiene usar un dictionary?

Uhm… Pues supongo que el rendimiento podría ser un factor, ya que según la documentación de este objeto: Retrieving a value by using its key is very fast, close to O(1), because the Dictionary<(Of <(TKey, TValue>)>) class is implemented as a hash table. O dicho de otro modo: Recuperar un valor utilizando su clave es muy rápido, porque la clase Dictionary se implementa como una tabla hash.

Ya sabía yo que había un buen motivo para usar un diccionario: Debe ser más mucho más rápido al acceder a un objeto a través de su clave que extraerlo de una lista mediante LINQ… seguro! …. ¿o tal vez no?

Hagamos un par de pruebas:

A) Cuanto tardamos en recuperar a un elemento a través de su clave:

private void GetOneItemByKey(int keyvalue)
{
    if (list.Count == 0 || dict.Count == 0) LoadListDict();
    Stopwatch watch = new Stopwatch();
    watch.Start();
    CPItem item1 = (from l in list where l.propInt == keyvalue select l).Single();
    watch.Stop();
    TimeSpan ts1 = watch.Elapsed;
 
    watch.Start();
    CPItem item2 = dict[keyvalue];
    watch.Stop();           
    TimeSpan ts2 = watch.Elapsed;
 
    this.ResultsTextBox.Text = string.Format(
        "Compare get one item by key in List vs. Dict. (ms.)rnList: {0}rnDict: {1}",
        ts1.TotalMilliseconds.ToString(),
        ts2.TotalMilliseconds.ToString());
}

En mi estación estos son los tiempos promedio de carga después de ejecutar 5 veces:

Compare get one item by key in List vs. Dict. (ms.)
List: 3,8518
Dict: 3,8566

Pues parece que ambos tiempos son iguales. Es más, acceder a la colección a través de LINQ ¡es incluso un poquito más rápido! Uhm, vaya… bueno, veamos el segundo ejemplo antes de sacar conclusiones:

B) Cuanto tardamos en verificar si existe un elemento a través de su clave:

private void VerifyOneItemByKey(int keyvalue)
{
    if (list.Count == 0 || dict.Count == 0) LoadListDict();
    Stopwatch watch = new Stopwatch();
    watch.Start();
    var items = from l in list where l.propInt == keyvalue select l;
    bool v1 = (items.Count() == 0);
    watch.Stop();
    TimeSpan ts1 = watch.Elapsed;
 
    watch.Start();
    bool v2 = dict.ContainsKey(keyvalue);
    watch.Stop();
    TimeSpan ts2 = watch.Elapsed;
 
    this.ResultsTextBox.Text = string.Format(
        "Compare verify one item by key in List vs. Dict. (ms.)rnList: {0}rnDict: {1}",
        ts1.TotalMilliseconds.ToString(),
        ts2.TotalMilliseconds.ToString());
}

En mi estación estos son los tiempos promedio de carga después de ejecutar 5 veces:

Compare verify one item by key in List vs. Dict. (ms.)
List: 3,906
Dict: 3,9111

Pues parece que en este caso ambos tiempos son también iguales. Entonces definitivamente NO ES MÁS RÁPIDO UTILIZAR UN DICCIONARIO PARA ACCEDER A LOS ELEMENTOS QUE CONTIENE A TRAVÉS DE SU CLAVE:

Carga Recuperar elemento Existe elemento
image image image

Conclusión 

Pues sinceramente, si no es más rápido el acceso a un diccionario, pero es más lento en su carga y encima no es serializable… ¿en qué casos podemos seguir usando diccionarios en lugar de listas?

Espero vuestras opiniones,