4Max/shutterstock.com

25. April 2016 / von David Gran

max(#)

String

FormatProvider

per TDD

Im Kontext einer Anforderung überkam mich das dringende Bedürfnis mit der Methode string.Format() Zeichenketten mit einer maximalen Länge auszugeben. Die Vorgeschichte zu diesem Bedürfnis ist, dass das string.Format()-Pattern über die App.config Datei angegeben wird. Dies hat den Vorteil, dass die Anwendung nicht neu gebaut und deployed werden muss, wenn in der Produktion die auszugebende Zeichenkette leicht verändert werden muss.

Damit ich nun mein Bedürfnis stillen kann, kam mir die Erleuchtung doch mein eigenes Format-Pattern für string.Format() zu implementieren, welches die übergebenen Zeichenketten auf die gewünschte Länge kürzt, welch grandiose Eingebung…! 😉

Eine kurze Einführung in string.Format()

String.Format bietet die Möglichkeit über Formate für bestimmte Datentypen (DateTime, Zahlentypen wie int und double) die Ausgabe der Typen als Zeichenketten zu formatieren. Die Anwendung von string.Format ist denkbar einfach. In der schlankesten Variante der Methode [string.Format(string Format, object arg0)] ist die zu formatierende Zeichenkette und die einzusetzenden Werte als Parameter anzugeben, wie in Listing 1 dargestellt:

string.Format(
"Das ist der Ausgabestring: {0}. Er wurde am {1:dd.MM.yyyy} um {1:HH:mm} erstellt. Und hat einen Umfang von {3:N2} mit {2:#.00 Zeichen}.", "Mein AusgabeString!", DateTime.Now, "Mein AusgabeString!".Length, 2 * "Mein AusgabeString!".Length * Math.PI)
Ausgabe: Das ist der Ausgabestring: Mein AusgabeString!. Er wurde am 15.02.2016 um 09:51 erstellt. Und hat einen Umfang von 119,38 mit 19,00 Zeichen.

Als Platzhalter im Format-String dienen die geschweiften Klammern für die zu formatierenden Objekte. Die Reihenfolge der zu übergebenden Werte muss nicht der Reihenfolge der Platzhalter entsprechen. In Listing 1 wird „Mein AusgabeString!“.Length erst nach dem „Umfang“ der Zeichenkette verwendet und ist in der Parameterliste aber in umgekehrter Reihenfolge angegeben. Ebenso kann ein Wert mehrfach verwendet werden. Das Datum in Listing 1 wird zweimal verwendet. Einmal um die Uhrzeit auszugeben und dann wieder um den Tag zu erhalten.

Spannender wird es, wenn man nun den Ziel String passend zu einer bestimmten Kultur formatieren möchte. Damit im obigen Beispiel bei den Zahlenwerten aus dem Punkt ein Komma wird ist es möglich der Methode string.Format noch eine „kulturspezifische Formatierungsinformation“ mitzugeben. Dazu ist eine Implementierung des Interface IFormatProvider erforderlich. Die Klasse System.Globalization.CultureInfo stellt eine solche Implementierung bereit. In Listing 2 wird gezeigt wie der Aufruf aus Listing 1 aussehen kann, wenn die Zahlenwerte im englischen Format ausgegeben werden sollen:

string.Format(new System.Globalization.CultureInfo("en-GB"), "Das ist der Ausgabestring: {0}. Er wurde am {1} um {1:HH:mm} erstellt. Und enthält {2:#.00 Zeichen} die einen von Umfang von {3:N2} haben.",
"Mein AusgabeString!",
DateTime.Now,
"Mein AusgabeString!".Length,
2 * "Mein AusgabeString!".Length * Math.PI)
Ausgabe: Das ist der Ausgabestring: Mein AusgabeString!. Er wurde am 15/02/2016 10:10:54 um 10:10 erstellt. Und enthält 19.00 Zeichen die einen von Umfang von 119.38 haben.

Listing 2 zeigt wie durch angeben der CultureInfo (und weglassen der Formatierung für das Datum) das Datum und die Zahlen entsprechend der verwendeten Kultur formatiert wird.

Das neue max(#) Pattern

Wie bereits erwähnt, gibt es für DateTime und Zahlentypen je eine IFormatProvider Implementierung. Die einzige weitere Implementierung von IFormatProvider ist CultureInfo, welche wir auch bereits kennengelernt haben. Damit wir nun ein eigenes Format-Pattern nutzen können müssen wir auch das Interface IFormatProvider implementieren. Es enthält die Methode GetFormat, die eine Instanz der IFormatProvider Implementierung zurückgibt. (Mehr dazu gibt es hier zu lesen: https://msdn.microsoft.com/de-de/library/system.iformatprovider(v=vs.110).aspx.) Des Weiteren ist auch noch das Interface ICustomFormatter notwendig. Es gibt die Methode Format vor, welche die eigentliche Formatierungsarbeit ausführen wird.

Als Grundgerüst ergibt sich nun die folgende Klasse:

public class MaxLenStringFormat : IFormatProvider, ICustomFormatter
{
   public string Format(string format, object arg, IFormatProvider formatProvider)
      { ... }
   public object GetFormat(Type formatType)
      { ... }
}

Mit dieser Klasse soll nun das folgende Pattern „max(#)“ erkannt und interpretiert werden:

string.Format("Das ist der Ausgabestring: {0:max(10)}. Er hat maximal {1} Zeichen.", "Mein AusgabeString!", 10)

Die erwartete Ausgabe ist:
Das ist der Ausgabestring: Mein Ausga. Er hat maximal 10 Zeichen.

Implementierung à la TDD

Im vorherigen Abschnitt haben wir die ideale Basis geschaffen damit wir nun mit der Implementierung der MaxLenStringFormat Klasse im Test Driven Development Stil fortfahren können. Wir haben die Grundlagen gelegt in dem wir gelernt haben, wie eine Klasse beschaffen sein muss um ein Format-Pattern zu interpretieren und wir kennen unser Ergebnis, die erwartete Ausgabe. Unsere nächsten Schritte in der Implementierung beginnen wir durch das Implementieren von Unittests. nUnit ist hierbei unser Testframework der Wahl, einen besonderen Grund gibt es für diese Auswahl nicht.

1. Test

Der erste Test sieht wie folgt aus:

[Test]
public void TestMaxLengthFormat()
{
   string langerString = "Mein AusgabeString!";
   string erwartetesErgebnis = "Das ist der Ausgabestring: Mein Ausga. Er hat maximal 10 Zeichen.";
   Assert.AreEqual(erwartetesErgebnis,
   string.Format(
      new MaxLenStringFormat(),
      "Das ist der Ausgabestring: {0:max(10)}. Er hat maximal {1} Zeichen.", langerString, 10)
   );
}

Er wird zunächst fehlschlagen, da wir noch keinerlei Logik haben, die uns hierbei hilft das Ergebnis passend zu formatieren. Wenn wir uns jetzt etwas doof stellen ist die Implementierung der Logik relativ einfach. Wir müssten in der Methode Format unserer Klasse MaxLenStringFormat einfach den erwarteten String zurückgeben. Diese kleinteiligen TDD-Schritte erspare ich uns an dieser Stelle und zeige dir gleich mal eine Implementierung die für jede beliebige Zeichenkette die zu formatierende Zeichenkette richtig kürzt.

public string Format(string format, object arg, IFormatProvider formatProvider)
{
   if (format != null && arg is string)
   {
      var formatPattern = format.Trim().ToLowerInvariant();
      if (formatPattern.StartsWith("max(") && formatPattern.EndsWith(")"))
      {
         return ((string) arg).Substring(0, ExtractMaxLength(formatPattern));
      }
   }
   return arg != null ? arg.ToString() : null;
}
private static int ExtractMaxLength(string pattern)
{
   var maxIndex = pattern.LastIndexOf(")", StringComparison.Ordinal);
   var numIndex = pattern.IndexOf("(", StringComparison.Ordinal) + 1;
   StringBuilder stringBuilder = null;
   while (numIndex < maxIndex)
   {
      var num = pattern[numIndex];
      if (num <= 57 && num >= 47)
      {
         if (stringBuilder == null)
         {
            stringBuilder = new StringBuilder();
         }
         stringBuilder.Append(num);
      }
      else
      {
         throw new FormatException("Der Format String für max(#) ist ungültig.");
      }
      numIndex++;
   }
   if (stringBuilder == null)
   {
      throw new FormatException("Der Format String für max(#) ist ungültig.");
   }

   var maxStringLength = Convert.ToInt32(stringBuilder.ToString());
   stringBuilder.Clear();

   return maxStringLength;
}

Mit dieser Implementierung bestehen wir immerhin unseren Test. Aber freu dich nicht zu früh.

2. Test

Wie sieht es denn mit diesem Test aus?

[Test]
public void TestMaxLengthShortStringFormat()
{
   string langerString = "Mein Aus!";
   string erwartetesErgebnis = "Das ist der Ausgabestring: Mein Aus!. Er hat maximal 10 Zeichen.";

   Assert.AreEqual(erwartetesErgebnis,
   string.Format(
   new MaxLenStringFormat(),
   "Das ist der Ausgabestring: {0:max(10)}. Er hat maximal {1} Zeichen.", langerString, 10)
   );
}

Richtig er schlägt fehl, weil der zu kürzende String kürzer ist als die maximale Länge. Die Methode Substring fällt dabei auf die Nase. Also müssen wir unsere Implementierung robuster gestalten. Hier mein Vorschlag:

public string Format(string format, object arg, IFormatProvider formatProvider)
{
   if (format != null && arg is string)
   {
      var formatPattern = format.Trim().ToLowerInvariant();
      if (formatPattern.StartsWith("max(") && formatPattern.EndsWith(")"))
      {
         return ShortenString((string) arg, ExtractMaxLength(formatPattern));
      }
   }
   return arg != null ? arg.ToString() : null;
}

private static string ShortenString(string origString, int maxStringLength)
{
   var retString = origString;
   if (retString.Length > maxStringLength)
   {
      retString = retString.Substring(0, maxStringLength);
   }

   return retString;
}

Damit klappt es nun.

Test Nr. 3

Doch was ist wenn unser max() Pattern gar nicht angegeben wurde?

[Test]
public void TestMaxLengthNotUsedFormat()
{
   string langerString = "Mein Aus!";
   string erwartetesErgebnis = "Das ist der Ausgabestring: Mein Aus!. Er hat maximal 10 Zeichen.";

   Assert.AreEqual(erwartetesErgebnis,
   string.Format(
      new MaxLenStringFormat(),
      "Das ist der Ausgabestring: {0}. Er hat maximal {1:## 'Zeichen'}.", langerString, 10)
   );
}

Und auch dann werden wir ein Problem haben. Es liegt daran, dass unsere Format Methode einfach den Inhalt von arg über die ToString Methode zurückgibt, wenn das Pattern nicht gefunden wurde. So kommt es, dass nicht der String „10 Zeichen‘“ ausgegeben wird, sondern lediglich „10“. Hier kommt nun der dritte Parameter von Format ins Spiel. Wenn unsere eigene Implementierung nicht greift so können wir den Inhalt von „arg“ vielleicht über diesen FormatProvider formatieren. Nach der Anpassung unserer Format Methode erhalten wir nun folgenden Code:

public string Format(string format, object arg, IFormatProvider formatProvider)
{
   if (format != null && arg is string)
   {
      var formatPattern = format.Trim().ToLowerInvariant();

      if (formatPattern.StartsWith("max(") && formatPattern.EndsWith(")"))
      {
         return ShortenString((string) arg, ExtractMaxLength(formatPattern));
      }
   }

   var formattable = arg as IFormattable;

   return formattable != null
      ? formattable.ToString(format, formatProvider)
      : arg != null ? arg.ToString() : null;
}

4. Test

Als letzte Variation stelle ich mir nun die Frage was passiert, wenn ich vielleicht eine eigene Kultur angeben möchte und nicht auf die aktuell eingestellte Kultur zurückgegriffen werden soll? Tja, dann machen wir erstmal ein Test dazu oder? Wie wäre es denn damit:

[Test]
public void TestMaxLengthFormatMitMehrerenVerschiedenenArgsUndAndererCulture()
{
   string langerString = "Mein Aus!";
   string erwartetesErgebnis = "Das ist der Ausgabestring: Mein Aus!. Er hat maximal 10.94 Zeichen.";

   Assert.AreEqual(
      erwartetesErgebnis,
      string.Format(
         new MaxLenStringFormat(new CultureInfo("en-GB")),
         "Das ist der Ausgabestring: {0}. Er hat maximal {1:N} Zeichen.", langerString, 10.93823
      )
   );
}

Nun ja, der Test verrät jetzt wirklich schon super schnell, was wir machen müssen. Daher ohne große Worte hier eine mögliche Implementierung:

...

private readonly IFormatProvider m_defaultFormatProvider;

public ExtendedStringFormatInfo(IFormatProvider defaultFormatProvider)
{
   m_defaultFormatProvider = defaultFormatProvider;
}

public string Format(string format, object arg, IFormatProvider formatProvider)
{
   if (format != null && arg is string)
   {
      var formatPattern = format.Trim().ToLowerInvariant();

      if (formatPattern.StartsWith("max(") && formatPattern.EndsWith(")"))
      {
         return ShortenString((string) arg, ExtractMaxLength(formatPattern));
      }
   }

   var formattable = arg as IFormattable;

   return formattable != null
      ? formattable.ToString(format, m_defaultFormatProvider ?? formatProvider)
      : arg != null ? arg.ToString() : null;
}

...

Per Konstruktor kann eine Standard FormatProvider angegeben werden. In der Format Methode wird der Provider verwendet, falls gesetzt und die eigene Implementierung nicht gegriffen hat. Andernfalls wird der Format Provider aus dem Übergabeparameter verwendet.

Weitere Tests?

Für eine hinreichend getesteten Code sollten wir eigentlich auch die Grenzbereiche testen. Was passiert denn, wenn das „max(#)“ Pattern falsch verwendet wird oder das zu formatierende Objekt null bzw. string.Empty ist? Diese Fragen beantwortet bereits die obige Implementierung von ExtractMaxLength. Die Methode wirft bei einem nicht interpretierbaren max(#) Pattern eine FormatException. An dieser Stelle habe ich mir das testgetriebene Vorgehen erspart. Sonst findet das hier ja nie ein Ende.

Finale

Die komplette Klasse MaxLenStringFormat.

 

Autor

David Gran David arbeitet seit April 2013 bei der Accso GmbH. Seine technischen Interessensschwerpunkte sind der Entwurf und die Entwicklung von klassischen OLTP-Systeme und OLAP-Systemen. Darüber hinaus interessiert er sich für verschiedene Ideen, Methoden und Ansätze die das menschliche Miteinander im beruflichen Alltag effizienter und effektiver werden lassen ohne dabei den Mensch und die Menschlichkeit aus den Augen zu verlieren.
Weitere Artikel

Das könnte Sie auch interessieren