Exportar objetos en varios formatos mientras informa el progreso

Aug 18 2020

Descripción

Una aplicación WinForms tiene una función para exportar objetos del siguiente tipo, en varios formatos:

class Item
{
    public int id { get; set; }
    public string description { get; set; }
}

Con el clic de un botón en una ventana, SaveFileDialogse muestra a, y actualmente brinda la opción de guardar los datos en formato .txt, .csv o .xlsx. Dado que a veces hay cientos o miles de objetos y la interfaz de usuario no debería congelarse, Taskse utiliza a para ejecutar esta operación. Esta implementación funciona, pero podría mejorarse.

Código

public partial class ExportWindow : Form
{
    // objects to be exported
    List<Item> items;

    // event handler for the "Export" button click
    private async void exportButton_click(object sender, System.EventArgs e)
    {
        SaveFileDialog exportDialog = new SaveFileDialog();
        exportDialog.Filter = "Text File (*.txt)|*.txt|Comma-separated values file (*.csv)|*.csv|Excel spreadsheet (*.xlsx)|*.xlsx";
        exportDialog.CheckPathExists = true;
        DialogResult result = exportDialog.ShowDialog();
        if (result == DialogResult.OK)
        {
            var ext = System.IO.Path.GetExtension(saveExportFileDlg.FileName);

            try
            { 
                // update status bar
                // (it is a custom control)
                statusBar.text("Exporting");

                // now export it
                await Task.Run(() =>
                {
                    switch (ext.ToLower())
                    {
                        case ".txt":
                            saveAsTxt(exportDialog.FileName);
                            break;

                        case ".csv":
                            saveAsCsv(exportDialog.FileName);
                            break;
                    
                        case ".xlsx":
                            saveAsExcel(exportDialog.FileName);
                            break;

                        default:
                            // shouldn't happen
                            throw new Exception("Specified export format not supported.");
                    }
                });
            }
            catch (System.IO.IOException ex)
            {
                 statusBar.text("Export failed");
                 logger.logError("Export failed" + ex.Message + "\n" + ex.StackTrace);

                 return;
            }
        }
    }

    private delegate void updateProgressDelegate(int percentage);

    public void updateProgress(int percentage)
    {
        if (statusBar.InvokeRequired)
        {
            var d = updateProgressDelegate(updateProgress);
            statusBar.Invoke(d, percentage);
        }
        else
        {
            _updateProgress(percentage);
        }
    }

    private void saveAsTxt(string filename)
    {
        IProgress<int> progress = new Progress<int>(updateProgress);
        
        // save the text file, while reporting progress....
    }

    private void saveAsCsv(string filename)
    {
        IProgress<int> progress = new Progress<int>(updateProgress);
        
        using (StreamWriter writer = StreamWriter(filename))
        {
            // write the headers and the data, while reporting progres...
        }
    }

    private void saveAsExcel(string filename)
    {
        IProgress<int> progress = Progress<int>(updateProgress);

        // EPPlus magic to write the data, while reporting progress...
    }
}

Preguntas

¿Cómo se puede refactorizar esto para que sea más extensible? Es decir, si quisiera agregar soporte para más tipos de archivos, hacerlo más fácil y rápido de modificar. La declaración de cambio podría ser muy larga. Esencialmente, ¿cómo cumplir con el principio Abierto / Cerrado?

Respuestas

5 CharlesNRice Aug 18 2020 at 20:04

Sugeriría mover las exportaciones reales a su propia clase. Podemos crear una interfaz para exportaciones. Algo parecido a

public interface IExport<T>
{
    Task SaveAsync(string fileName, IEnumerable<T> items, IProgress<int> progress = null);
    string ExportType { get; }
}

Entonces, cada tipo de exportación puede implementar esta interfaz.

public class ExportItemsToText : IExport<Item>
{
    public Task SaveAsync(string fileName, IEnumerable<Item> items, IProgress<int> progress = null)
    {
        throw new NotImplementedException();
    }

    public string ExportType => "txt";
}

Luego, en su constructor de ExportWindow

public ExportWindow(IEnumerable<IExport<Item>> exports)
{
    // if using DI otherwise could just fill in dictionary here
    ExportStrategy = exports.ToDictionary(x => x.ExportType, x => x);
}

En lugar de una declaración de cambio, ahora puede buscar la clave en el diccionario para encontrar qué exportación debe ejecutarse y, si no se encuentra, sería la misma que su caso predeterminado.

IExport<Item> exporter;
if (ExportStrategy.TryGetValue(ext.ToLower(), out exporter))
{
    await exporter.SaveAsync(exportDialog.FileName, items, new Progress<int>(updateProgress))
}
else
{
    throw new Exception("Specified export format not supported.");
}

Ahora, en el futuro, si agrega soporte para más tipos, simplemente implemente la interfaz y actualice su contenedor DI. O si no usa DI, entonces necesitaría agregarlo al constructor de su ExportWindow.

No creo que esta sea una gran idea, pero si realmente no desea crear una clase por exportación, lo que creo que debería hacer, puede crear el diccionario y IDictionary<string, Action<string>>luego poner sus métodos allí y al agregar un nuevo tipo, cree el método y actualice el diccionario.

2 iSR5 Aug 21 2020 at 06:22

Solo quiero compartir lo que tengo, ya que ya implementé esto (más o menos) en uno de mis proyectos anteriores (estaba en ASP.NET), pero se puede aplicar en cualquier otro entorno. La implementación fue similar a la sugerencia de CharlesNRice. Sin embargo, el requisito era tener solo opciones para exportar los informes del sistema (que se usa solo una plantilla de informe) a Pdf, Excel y Word con la negociación de tener más opciones de exportación en el futuro. Así que así es como lo hice:

Primero la interfaz:

public interface IExportTo<T>
{
    IExportTo<T> Generate();

    void Download(string fileName);

    void SaveAs(string fileFullPath);
}

luego la clase de contenedor:

public class ExportTo : IDisposable
{
    private readonly IList<T> _source;

    public ExportTo(IList<T> source)
    {
        _source = source;
    }

    public ExportExcel Excel()
    {
        return new ExportExcel(_source);
    }

    public ExportPdf Pdf()
    {
        return new ExportPdf(_source);
    }
    
    public ExportWord Word()
    {
        return new ExportPdf(_source);
    }
    

    #region IDisposable

    private bool _disposed = false;

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    private void Dispose(bool disposing)
    {
        if (!_disposed)
        {
            if (disposing)
            {
                Dispose();
            }

            _disposed = true;
        }
    }


    ~ExportTo()
    {
        Dispose(false);
    }

    #endregion
}

Implementé una clase para cada tipo de exportación, como podemos ver en la clase anterior. Compartiré una clase (aunque la simplificaré de la clase real).

public sealed class ExportPdf : IExportTo<T>, IDisposable
{
    private readonly IList<T> _source;

    private ExportPdf() { }

    public ExportPdf(IList<T> source) : this() => _source = source ?? throw new ArgumentNullException(nameof(source));

    public IExportTo<T> Generate()
    {
        // some implementation 
        return this;
    }

    // another overload to generate by Id 
    public IExportTo<T> Generate(long reportId)
    {
        // do some work 
        return this;
    }

    // Download report as file 
    public void Download(string fileName)
    {
       // do some work 
    }

    public void SaveAs(string fileFullPath)
    {
        throw new NotImplementedException("This function has not been implemented yet. Only download is available for now.");
    }


    #region IDisposable

    private bool _disposed = false;

    public void Dispose()
    {   
        Dispose(true);
        GC.SuppressFinalize(this);
    }


    private void Dispose(bool disposing)
    {
        if (!_disposed)
        {
            if (disposing)
            {
                Dispose();
            }

            _disposed = true;
        }
    }


    ~ExportPdf()
    {
        Dispose(false);
    }

    #endregion
}

Downloady SaveAsson diferentes (no iguales). Downloaddescargaría el archivo exportado, mientras que SaveAsguardaría la instancia del objeto. Pero esto se implementó así porque las dependencias utilizadas.

Ahora al uso le gustaría esto:

new ExportTo(someList)
.Pdf()
.Generate()
.Download(fileName);

Así es como lo he implementado en ese proyecto, se podría mejorar, pero para los requerimientos del negocio es suficiente.

Siempre que necesite agregar un nuevo tipo de exportación, simplemente cree una nueva sealedclase y luego impleméntela IExportTo<T>, IDisposableen esa clase. Finalmente, actualice la clase contenedora con el nuevo tipo (agregue un método para abrir una nueva instancia de este método) y estará listo.