Artículo técnico

Diagnósticos estructurados en lugar de resultados booleanos en HotXLS

Ejecutad una conversión por lotes sobre diez mil hojas de cálculo durante la noche, y por la mañana tres de ellas devuelven False. Eso es todo el post mortem que os da un resultado booleano de guardado: un recuento de fallos, sin nada sobre qué archivo, qué hoja o cuál de una docena de causas posibles fue la responsable. HotXLS, el componente nativo de losLab para Delphi y C++Builder para archivos Excel, sustituye ese único bit por diagnósticos estructurados. La interfaz IXLSWorkbookProgress expone una lista Diagnostics y un evento OnDiagnostic que informan de un código numérico estable, un nivel de severidad, la operación que falló y la hoja donde ocurrió, para cada llamada a Open, SaveAs y Recalculate

¿Por qué falla a gran escala un resultado booleano de guardado?

Un único archivo fallido no es el problema que crea un resultado booleano; mil de ellos sí lo son. Cuando SaveAs devuelve algo distinto de éxito para tres archivos de cada diez mil, la siguiente pregunta es siempre la misma: ¿son estos tres reintentables, o necesitan a una persona? Un error de permisos en una unidad de red no es el mismo incidente que una fórmula que el motor de cálculo no puede evaluar, y ninguno de los dos es lo mismo que una hoja de cálculo que superó silenciosamente un límite de formato. Con solo un resultado de éxito/fallo con el que trabajar, cada uno de esos casos se convierte en un ticket de soporte idéntico, y alguien tiene que abrir cada archivo a mano, en Excel, y mirarlo fijamente hasta que la causa se vuelva evidente. Ese triaje manual es el coste real de una API booleana, y escala linealmente con el tamaño del lote, que es exactamente la propiedad que no queréis del manejo de errores

Dentro de IXLSWorkbookProgress: qué lleva un TXLSDiagnostic

IXLSWorkbookProgress es la interfaz que usa HotXLS para informar tanto de cómo va progresando una operación como de qué ha fallado dentro de ella, y las dos mitades comparten un contrato por una razón: ambas son cosas que una llamada larga a Open, SaveAs o Recalculate necesita comunicar sin lanzar una excepción a mitad de la operación. La mitad de progreso son OnProgress y OnProgressEx, que se disparan con una fase, un estado y un par actual/total. La mitad de diagnósticos es de la que trata este artículo: una propiedad Diagnostics que devuelve una lista TXLSDiagnostics, un atajo LastDiagnostic para la entrada más reciente, y un evento OnDiagnostic que se dispara en el momento en que se crea cada registro TXLSDiagnostic. Cada registro lleva un Code numérico, una TXLSDiagnosticSeverity, la TXLSDiagnosticOperation que lo produjo, un Message legible por humanos, un SheetIndex y un SheetName, y un NativeCode que conserva cualquiera que sea el valor de retorno de nivel inferior que disparó la entrada

var
  Book: TXLSXWorkbook;
  Diag: TXLSDiagnostic;
  I: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.SaveAs('quarterly-report.xlsx') <> 1 then
      for I := 0 to Book.Diagnostics.Count - 1 do
      begin
        Diag := Book.Diagnostics[I];
        Writeln(Format('[%d] severity=%d sheet="%s": %s',
          [Diag.Code, Ord(Diag.Severity), Diag.SheetName, Diag.Message]));
      end;
  finally
    Book.Free;
  end;
end;

Leer Diagnostics de este modo ya supera por sí solo a un resultado booleano, porque Code y SheetName convierten un misterio en un hecho concreto y filtrable. El registro TXLSDiagnostic llega más lejos que lo que imprime este ejemplo: RecordId y StreamOffset existen para forense a nivel de byte dentro de un flujo BIFF, y PartName contiene la entrada zip de OOXML, como xl/worksheets/sheet3.xml, de la que procede un problema. Merece la pena saber, antes de construir herramientas en torno a ellos: en la versión actual, ningún punto de llamada de diagnóstico integrado rellena RecordId ni StreamOffset, así que ambos se quedan en su valor por defecto del constructor, -1, que significa «no aplicable» y no «cero». Tratad su ausencia como algo normal, no como un fallo en vuestro gestor

Dos motores, una forma, una diferencia discreta

HotXLS distribuye dos motores detrás de este mismo modelo de informes, una fachada BIFF8 para archivos .xls heredados y una fachada OOXML para .xlsx, y no exponen IXLSWorkbookProgress de forma idéntica. TXLSWorkbook, el motor .xls, implementa formalmente IXLSWorkbookProgress, así que se puede pasar a cualquier sitio donde se espere ese tipo de interfaz. TXLSXWorkbook, el motor .xlsx, expone los mismos miembros Diagnostics, LastDiagnostic, OnDiagnostic, OnProgress y OnProgressEx con nombres y tipos idénticos, pero como una clase normal en lugar de como una implementación formal de esa interfaz, así que no satisfará por sí sola un parámetro IXLSWorkbookProgress. En la práctica esto rara vez importa, porque la mayoría del código trabaja contra una clase de libro concreta a la vez, pero sí significa que no podéis escribir un único ayudante tipado como IXLSWorkbookProgress y entregarle indistintamente el objeto de libro de cualquiera de los dos motores. La única diferencia de campo que se deriva directamente de la división de formatos es PartName: solo la rellena el motor XLSX, porque solo OOXML tiene partes zip que nombrar

¿Qué hace que un código de diagnóstico sea algo sobre lo que podáis ramificar con seguridad?

El campo Code es la única parte de un diagnóstico que merece la pena codificar de forma fija en una comparación; Message no lo es, porque el texto libre es exactamente el tipo de cosa que se reformula, se retraduce o se amplía con más detalle en una versión posterior sin que nadie lo trate como un cambio incompatible. Los códigos de diagnóstico integrados de HotXLS ya se leen como si se hubieran diseñado con esa distinción en mente: los códigos relacionados con guardado van del 1000 al 1005, los relacionados con apertura están en el 1100 y el 1101, los relacionados con cálculo en el 1200 y el 1201, y un código de formato no admitido en el 1300, con huecos dejados dentro de cada banda en lugar de que los códigos se sucedan consecutivamente a lo largo de todas ellas. Ese espaciado es lo que permite a un proveedor añadir un nuevo modo de fallo en tiempo de guardado en, digamos, el 1006, sin tener que renumerar los códigos de los que ya depende vuestra instrucción switch, y merece la pena comprobarlo en cualquier API de diagnósticos antes de comprometeros a comparar por código en producción, no solo en esta. Mantened una rama por defecto en vuestra propia lógica de despacho independientemente de lo estable que parezca la numeración, porque nuevos modos de fallo son exactamente lo que un analizador o escritor en evolución sigue descubriendo. NativeCode y ExceptionClass se sitúan un nivel por debajo de Code para cuando necesitéis escalar: NativeCode conserva el valor de retorno subyacente, un HRESULT de una llamada a Structured Storage entre ellos, y ExceptionClass registra el tipo de excepción de Delphi cuando hubo una implicada, lo que suele bastar para abrir una solicitud de soporte precisa sin adjuntar una traza de pila completa

La severidad y la operación deciden qué hace después vuestro código

La severidad y la operación son lo que convierten un diagnóstico de una línea de registro en una decisión de enrutamiento. TXLSDiagnosticSeverity recorre Info, Warning, Error y Fatal, y TXLSDiagnosticOperation etiqueta cada entrada con la llamada que la produjo: Open, Save, Calculate o Export. Los dos ejes son independientes por diseño: xlsDiagnosticUnhandledException es un único código fijo que se dispara con Operation puesta a cualquiera que sea la llamada que realmente lo provocó, así que Code responde a qué salió mal mientras Operation responde por separado a dónde, en lugar de necesitar un código distinto para una excepción durante la apertura frente a una durante el guardado. Esa composabilidad es también lo que hace mecánico el enrutamiento: registrar una advertencia y continuar, un guardado cancelado mediante el indicador Aborted es un ejemplo típico; contar un error y mantener el lote en marcha, una hoja de cálculo que no logró serializarse es un ejemplo típico; detener el lote ante una severidad fatal, porque ese nivel significa que una excepción no gestionada ya deshizo la llamada y continuar arriesga trabajar desde un estado a medio actualizar. Una advertencia honesta: Info existe en la enumeración como el valor por defecto con el que empieza un TXLSDiagnostic recién creado, pero cada punto de llamada de diagnóstico integrado en la versión actual de HotXLS solo llega a lanzar Warning, Error o Fatal; Info queda reservado para uso futuro, no es algo que el motor emita hoy

// same Diagnostics loop as above, routed by severity instead of printed flat:
for I := 0 to Book.Diagnostics.Count - 1 do
begin
  Diag := Book.Diagnostics[I];
  case Diag.Severity of
    xlsDiagnosticWarning:
      Writeln(Format('WARN  [%d] %s', [Diag.Code, Diag.Message]));
    xlsDiagnosticError:
      begin
        Writeln(Format('ERROR [%d] %s (sheet %s, native %d)',
          [Diag.Code, Diag.Message, Diag.SheetName, Diag.NativeCode]));
        Inc(FailedSheetCount);
      end;
    xlsDiagnosticFatal:
      raise Exception.CreateFmt('Fatal HotXLS diagnostic %d: %s', [Diag.Code, Diag.Message]);
  end;
end;

Conectar OnDiagnostic a un pipeline por lotes

Sondear Diagnostics después de cada llamada funciona para un único archivo; deja de funcionar en cuanto volvéis a ese lote nocturno de diez mil, porque Diagnostics se vacía al principio de cada llamada a Open, SaveAs y Recalculate. Leedla después del tercer archivo en un bucle y solo veréis los diagnósticos del tercer archivo; lo que hayan reportado los dos primeros ya ha desaparecido. OnDiagnostic resuelve esto convirtiendo la colección en un flujo: suscribíos una vez antes de que empiece el bucle, y el mismo gestor se dispara para cada archivo, en orden, con el nombre del archivo todavía disponible mediante un campo de instancia

type
  TBatchConverter = class
  private
    FCurrentFile: string;
    FFailedFiles: TStringList;
    procedure HandleDiagnostic(Sender: TObject; Diagnostic: TXLSDiagnostic);
  end;

procedure TBatchConverter.HandleDiagnostic(Sender: TObject; Diagnostic: TXLSDiagnostic);
begin
  if Diagnostic.Severity >= xlsDiagnosticError then
    FFailedFiles.Add(Format('%s: [%d] %s (sheet %s)',
      [FCurrentFile, Diagnostic.Code, Diagnostic.Message, Diagnostic.SheetName]));
end;

// inside the batch loop:
Book.OnDiagnostic := HandleDiagnostic;
for I := 0 to FileNames.Count - 1 do
begin
  FCurrentFile := FileNames[I];
  if Book.Open(FCurrentFile) = 1 then
    Book.SaveAs(ChangeFileExt(FCurrentFile, '.xlsx'));
end;

Qué cuesta realmente el callback

OnDiagnostic sale barato por una razón estructural: solo se dispara cuando ya hay algo mal, y estar mal es poco frecuente comparado con el número de celdas, filas u hojas que contiene un libro. Contrastad esto con OnProgress y OnProgressEx, que informan del progreso rutinario y tuvieron que diseñarse desde el principio en torno a la frecuencia de llamada. HotXLS dispara el progreso a nivel de hoja una vez por hoja durante Open y SaveAs, no una vez por celda o fila, que es lo que mantiene pequeña la sobrecarga por llamada incluso en libros con millones de celdas; Recalculate va más allá y limita su propio evento de progreso a aproximadamente cada cuatro por ciento del grafo de dependencias, así que un recálculo completo os da una señal de vida en lugar de inundar vuestro hilo de interfaz de eventos. Los diagnósticos no necesitaron nada de esa limitación, porque el número de eventos está acotado por el número de problemas reales, no por el tamaño del archivo

El único lugar donde el rendimiento sigue dependiendo de vosotros es dentro del propio gestor. OnDiagnostic se dispara de forma síncrona, en el hilo que ejecuta Open, SaveAs o Recalculate, así que un gestor que bloquee, una escritura síncrona a un servicio de registro remoto, por ejemplo, pasa a formar parte del tiempo de reloj de esa llamada. Para un único archivo, eso es invisible. Multiplicado por un lote de diez mil archivos, es la diferencia entre un trabajo que termina durante la noche y uno que sigue ejecutándose a la hora de comer, así que almacenad en búfer lo que el gestor necesite hacer y volcadlo de forma asíncrona en lugar de hacer la parte lenta en línea

Los diagnósticos estructurados son más valiosos exactamente donde un resultado booleano es más débil, en flujos de trabajo que tocan muchos archivos en lugar de uno. Un pipeline de auditoría y conversión de libros es el ejemplo más claro: en lugar de registrar un simple éxito/fallo por archivo, adjuntad la lista Diagnostics de cada archivo a su registro de auditoría, y el informe os dirá no solo qué falló sino por qué, que es la mayor parte de lo que nuestro artículo sobre la construcción de un banco de auditoría y conversión de libros intenta hacer bien en primer lugar. El mismo emparejamiento de progreso y diagnósticos también pertenece a cualquier flujo de trabajo que ya necesite informes de progreso por sí mismo, que es exactamente el terreno cubierto en nuestra guía sobre el rendimiento de libros grandes en HotXLS, donde una llamada larga a Open o SaveAs es lo bastante habitual como para que OnProgress ya esté conectado y OnDiagnostic sea un añadido natural y casi gratuito junto a él

Nada de esto requiere tener Excel instalado en ningún punto del pipeline, y nada de esto requiere capturar una excepción genérica e intentar adivinar qué significaba. IXLSWorkbookProgress y sus miembros Diagnostics, LastDiagnostic y OnDiagnostic forman parte del componente HotXLS estándar para Delphi y C++Builder, junto con la referencia completa de códigos de diagnóstico y el resto de la superficie de Open, SaveAs y Recalculate que ha ido recorriendo este artículo