Laden...

HTML Minification mit ASP.NET MVC

Erstellt von Abt vor 9 Jahren Letzter Beitrag vor 9 Jahren 11.382 Views
Abt Themenstarter:in
16.806 Beiträge seit 2008
vor 9 Jahren
HTML Minification mit ASP.NET MVC

Beschreibung:

Angetrieben von der Tatsache, dass das zu übertragende Volumen einer Webseite direkte Auswirkungen auf die Reaktionsgeschwindigkeit hat, habe ich mich am Wochenende an den Schreibtisch gesetzt, um einen ActionFilter für ASP.NET MVC zu schreiben, der unnötige Elemente aus dem HTML-Code, das an den Client gesendet werden soll, entfernt.

Dafür habe ich ein entsprechendes Snippet geschrieben

namespace HtmlMinifyExample
{
    /// <summary>
    /// Minifies the HTML code. Removes all whitespaces
    /// </summary>
    public class HtmlMinifyStream : MemoryStream
    {
        #region Properties and Fields
        /// <summary>
        /// Regex; Pattern by http://stackoverflow.com/questions/8762993/remove-white-space-from-entire-html-but-inside-pre-with-regular-expressions
        /// </summary>
        private static readonly Regex RegExpressionWhiteSpaces = new Regex( @"(?<=\s)\s+(?![^<>]*</pre>)", RegexOptions.Compiled );
        // Regex instances are read-only => Thread-safe and can be static

        /// <summary>
        /// Stream to minify.
        /// Do not dispose by yourself!
        /// </summary>
        private readonly Stream _responseStream;
        #endregion

        #region Constructor
        /// <summary>
        /// Create new minify stream based on the current response stream. Overwrites contents
        /// </summary>
        public HtmlMinifyStream( Stream responseStream )
        {
            this._responseStream = responseStream;
        }
        #endregion

        /// <summary>
        /// Override close method and implement minification
        /// </summary>
        public override void Close()
        {
            // Minify
            var minified = Encoding.UTF8.GetBytes( RegExpressionWhiteSpaces.Replace( Encoding.UTF8.GetString( this.ToArray( ) ), String.Empty ) );

            // write data to stream
            _responseStream.Write( minified, 0, minified.Length );
            // flush all
            _responseStream.Flush( );


            base.Close( );
        }
    }
}

Dieses kann sowohl als **Filter **für die gesamte ASP.NET MVC Anwendung verwendet werden

public class HtmlMinimyFilter : IActionFilter
{
    public void OnActionExecuting( ActionExecutingContext filterContext )
    {
        // We dont care
    }
 
    /// <summary>
    /// Minification at the end of the request
    /// </summary>
    public void OnActionExecuted( ActionExecutedContext filterContext )
    {
        if ( IsSupportedContentType( filterContext ) )
        {
            filterContext.HttpContext.Response.Filter = new HtmlMinifyStream( filterContext.HttpContext.Response.Filter );
        }
    }
 
    /// <summary>
    /// Returns true if filter is not null and content type is html.
    /// </summary>
    private Boolean IsSupportedContentType( ActionExecutedContext filterContext )
    {
        return filterContext.HttpContext.Response.Filter != null && filterContext.HttpContext.Response.ContentType.Equals( "text/html", StringComparison.OrdinalIgnoreCase );
    }
}

oder als **Attribut **für ausgewählte Actions

public class HtmlMinifyAttribute : ActionFilterAttribute
{
    /// <summary>
    /// Implement attribute behavior
    /// </summary>
    public override void OnActionExecuted( ActionExecutedContext filterContext )
    {
        if ( IsSupportedContentType( filterContext ) )
        {
            filterContext.HttpContext.Response.Filter = new HtmlMinifyStream( filterContext.HttpContext.Response.Filter );
        }
    }
 
    /// <summary>
    /// Returns troe if filter is not null and content type is html.
    /// </summary>
    private Boolean IsSupportedContentType( ActionExecutedContext filterContext )
    {
        return filterContext.HttpContext.Response.Filter != null && filterContext.HttpContext.Response.ContentType.Equals( "text/html", StringComparison.OrdinalIgnoreCase );
    }
}

Einsparpotential gängier Webseiten: ca. 10- 30%.
Performance-Verlust vernachlässigbar

Falls weitere Erklärung bzgl. Messung und Umsetzung notwendig: ist auch in meinem Blog unter HtmlMinifier für ASP.NET MVC zu lesen.

Den Quellcode der gesamten Testanwendung habe ich auf GitHub bereit gestellt.

Schlagwörter: ASP.NET, Minification, Html, Performance, Compression

S
417 Beiträge seit 2008
vor 9 Jahren

Hallo Abt,

dazu habe ich gleich mehrere Anmerkungen:1.Pattern nur bedingt korrekt: Das Regex pattern matched nur aufeinanderfolgende Whitespaces und lässt das erste stehen, d.h. es werden z.b. nicht alle Zeilenumbrüche entfernt wie angegeben, sondern aus \r\n wird \r. Das lässt sich noch optimieren. 1.Problem mit <pre> Tags: Dein Regex-Pattern versucht zwar diese Problem zu lösen, allerdings wäre das so nur korrekt, wenn du dein Regex auf den kompletten HTML-String anwendest. Du arbeitest in der Write-Methode aber Block-basiert, d.h. ein </pre>-Tag könnte erst im nächsten Block enden, wodurch du fälschlicherweise die Whitespaces entfernen würdest. 1.Performance: Die von dir genutzte Performance-Messung macht keinen Sinn, da die OnActionExecuted-Methode deines Filters (und damit das Ende der Messung) aufgerufen wird, bevor die eigentliche Minification stattfindet. Wenn du misst wie lange das eigentliche Minifizieren dauert, wirst du sehen, dass es einen wahnsinnig hohen Performance Impact hat. Ich habe eine kurze Messung gemacht, mit und ohne Minifikation (jeweils 100 Durchläufe). Das Ergebnis ist als Boxplot unten beigefügt.

Abt Themenstarter:in
16.806 Beiträge seit 2008
vor 9 Jahren

Hi, danke für Deine Rückmeldung.
Das mit der Performance kann ich so nicht nachvollziehen. Du siehst, dass ich nicht nur die Ticks beachtet habe, sondern auch die Gesamtzeit der Auslieferung.

Warum hab ich das?
Normalerweise arbeiten wir im Web ja mittlerweile mit GZipStreams.
Sprich ich liefer weniger aus, und ich muss weniger komprimieren. Dadurch komm ich bei 1000 Durchläufen auf eine totale Differenz von 2ms.
Ich hab das lokal wie auch auf Azure getestet und konnte hier in der Relation gleiche Ergebnis erreichen.
Laut Debug greift mein PerformanceFilter ++NACH ++dem Minify und misst somit korrekt.

Ich arbeite auch nicht Block-basierend sondern durchforste den gesamten Inhalt auf einmal.

S
417 Beiträge seit 2008
vor 9 Jahren

Also ich hab mir aus Github den Master runtergeladen und da kommt das Minify definitiv danach. Ich hab zum Spaß einfach mal ein Thread.Sleep(100) (nach dem Minify) eingefügt und die Ausgabe auf der Webseite war weiterhin unter 100 Ticks. Oder verwendest du eine andere Version?

Bzgl. dem Block-basiert: Dein Quellcode zeigt doch, dass du in der Write-Methode mit dem übergebenen Buffer und damit nicht mit dem gesamten HTML auf einmal arbeitest. Du wirst beim Debuggen sicher sehen, dass mehrmals in die Write-Methode gesprungen wird.

Übrigens kann ich mir das mit den 2ms nicht wirklich vorstellen. Mein Rechner benötigt für folgendes schon länger als 3ms:

private static readonly Regex RegExpressionWhiteSpaces = new Regex(@"(?<=\s)\s+(?![^<>]*</pre>)", RegexOptions.Compiled);

private const string Test = "lksdhf sdkhf  shkdfahöksdklf slfhkl ahklfahklfhl ksafdhk fafdhkl fldhk fldhk sdhklfhkl afhkl fdhkl afhkl fhkl ahklf hk sdakfh sdafh lsd aflk sdlfs ";

static void Main(string[] args)
{
	var sw = Stopwatch.StartNew();
	RegExpressionWhiteSpaces.Replace(Test, string.Empty);
	sw.Stop();

	Console.WriteLine(sw.Elapsed.TotalMilliseconds);
	Console.Read();
}
Abt Themenstarter:in
16.806 Beiträge seit 2008
vor 9 Jahren

Das braucht 3 ms, weil 2.9 ms dafür das Erstellen des Regex braucht.... ⚠ Der wird ja erst beim ersten Zugriff erstellt. Du misst aber immer nur den ersten Zugriff.
Bei mir braucht auch der erste Request > 300ms und 80.000 Ticks. Jeder weitere aber nicht.

Korrekt wäre:

    class Program
    {
        private static readonly Regex RegExpressionWhiteSpaces = new Regex( @"(?<=\s)\s+(?![^<>]*</pre>)", RegexOptions.Compiled );

        private const string Test = "lksdhf sdkhf  shkdfahöksdklf slfhkl ahklfahklfhl ksafdhk fafdhkl fldhk fldhk sdhklfhkl afhkl fdhkl afhkl fhkl ahklf hk sdakfh sdafh lsd aflk sdlfs ";

        static void Main( string[ ] args )
        {
            // First call -> compile
            var fc = Stopwatch.StartNew( );
            RegExpressionWhiteSpaces.Replace( Test, string.Empty );
            fc.Stop( );
            var sw = Stopwatch.StartNew( );
            for ( var i = 0 ; i < 1000 ; i++ )
            {
                RegExpressionWhiteSpaces.Replace( Test, string.Empty );
            }
            sw.Stop( );

            Console.WriteLine( "First Call: " + fc.Elapsed.TotalMilliseconds + " Total: " + sw.Elapsed.TotalMilliseconds + " - per Call: " + ( sw.Elapsed.TotalMilliseconds / 1000 ) );
            Console.Read( );
        }
    }

Komm ich auf den Output:
First Call: 1,8968 Total: 8,5726 - per Call: 0,0085726
Ist bei mir "deutlich" unter 3ms - Debug Modus - und da ist sogar der Schleifen-Overhead enthalten 😉

Du wirst beim Debuggen sicher sehen, dass mehrmals in die Write-Methode gesprungen wird.

Ja das seh ich; aber erst ab einer ziemlich großen Seite und nicht mit dem Testcode. Da werd ich mir evtl. noch eine Lösung einfallen lassen.

Abt Themenstarter:in
16.806 Beiträge seit 2008
vor 9 Jahren

Danke für den Einwand mit der Blockverarbeitung ab einer gewissen Seitengröße
Hier das Update:

namespace HtmlMinifyExample
{
    /// <summary>
    /// Minifies the HTML code. Removes all whitespaces
    /// </summary>
    public class HtmlMinifyStream : MemoryStream
    {
        #region Properties and Fields
        /// <summary>
        /// Regex; Pattern by http://stackoverflow.com/questions/8762993/remove-white-space-from-entire-html-but-inside-pre-with-regular-expressions
        /// </summary>
        private static readonly Regex RegExpressionWhiteSpaces = new Regex( @"(?<=\s)\s+(?![^<>]*</pre>)", RegexOptions.Compiled );
        // Regex instances are read-only => Thread-safe and can be static

        /// <summary>
        /// Stream to minify.
        /// Do not dispose by yourself!
        /// </summary>
        private readonly Stream _responseStream;
        #endregion

        #region Constructor
        /// <summary>
        /// Create new minify stream based on the current response stream. Overwrites contents
        /// </summary>
        public HtmlMinifyStream( Stream responseStream )
        {
            this._responseStream = responseStream;
        }
        #endregion

        /// <summary>
        /// Override close method and implement minification
        /// </summary>
        public override void Close()
        {
            // Minify
            var minified = Encoding.UTF8.GetBytes( RegExpressionWhiteSpaces.Replace( Encoding.UTF8.GetString( this.ToArray( ) ), String.Empty ) );

            // write data to stream
            _responseStream.Write( minified, 0, minified.Length );
            // flush all
            _responseStream.Flush( );


            base.Close( );
        }
    }
}

Es wird nur noch ein einziges Mal minified.

S
417 Beiträge seit 2008
vor 9 Jahren

Das braucht 3 ms, weil 2.9 ms dafür das Erstellen des Regex braucht.... :] Der wird ja erst beim ersten Zugriff erstellt. Du misst aber immer nur den ersten Zugriff.

Da hast du Recht, das Erstellen hab ich nicht berücksichtigt. Na gut, war ein schlechtes Beispiel um dich zu überzeugen. Ich versuch es nochmal 😄

Folgender Quellcode verwendet einen String, der wohl realistischer für das Szenario ist als aus meinem obigen Beispiel. In dem Fall wären das 24000 Zeichen, also keine unübliche Größe für ein generiertes HTML, sondern eher noch am unteren Limit. Es erfolgt weiterhin der erste call um das Regex zu kompilieren, danach wird mit dem echten String gemessen.
Ergebnis auf meinem Rechner: Über 300 ms für einen Durchlauf.

private static readonly Regex RegExpressionWhiteSpaces = new Regex(@"(?<=\s)\s+(?![^<>]*</pre>)", RegexOptions.Compiled);
private const string Test = "lksdhf sdkhf  shkdfa\r\nhöksdklf slfhkl ahklfahklfhl ksa    fdhk fafdhkl fldhk fldhk sdhklfhkl afhkl     fdhkl \r\n afhkl fhkl ahklf hk sdakfh sdafh lsd aflk sdlfs ";

static void Main(string[] args)
{
	// First call -> compile
	var fc = Stopwatch.StartNew();
	RegExpressionWhiteSpaces.Replace(Test, string.Empty);
	fc.Stop();

	var sb = new StringBuilder();
	for (int i = 0; i < 150; ++i)
		sb.Append(Test);

	var s = sb.ToString();
	// Länge des erzeugten strings = 24000
	Console.WriteLine("String length: " + s.Length);

	var sw = Stopwatch.StartNew();
	RegExpressionWhiteSpaces.Replace(s, string.Empty);
	sw.Stop();

	Console.WriteLine("First Call: " + fc.Elapsed.TotalMilliseconds + " Second call: " + sw.Elapsed.TotalMilliseconds);
	Console.Read();
}
Abt Themenstarter:in
16.806 Beiträge seit 2008
vor 9 Jahren

Ich hab mal die aktuelle Seite von heise.de verwendet, das sind 571109 Zeichen laut Deinem Code - also mehr als das 20-Fache Deines Tests:
Resultat siehe Anhang.

Du musst aber eins Bedenken: HtmlMinifcation ist die Optimierung auf der "letzten Meile" und ist Bestandteil des Cachings.
Wenn Du ständig Seiten mit einer halben Million Zeichen komprimierst, dann solltest Du Dir eher Überlegungen über das Gesamtkonzept machen.
"Realistisch" ist dieser Test also gewiss nicht.

In modernen Webseiten wird der Rahmen ohne dynamischen Daten nur noch aus dem Cache geliefert. Hier spielt Minification eine Rolle, weil der komprimierte, statische Inhalt anschließend nur noch gepusht wird - und genau hier ist es interessant.
Die Daten werden dann dynamisch zB wie AngularJS und Co nachgeladen.

Statistik auf Azure - Vorher und Nachher
Lieferung ohne Minify aus dem Cache: 51ms (Info)
Ohne Minify Filter: 93ms (Info)
Mit Minify Filter: 104ms (Info)
Lieferung mit Minify aus dem Cache: 40ms (Info)

Das Auslieferungsvolumen hat sich um 27% verringert - das Lieferverhalten aus dem Cache um 20% verbessert.
Hast noch weitere Gegenargumente? 😃

742 Beiträge seit 2005
vor 9 Jahren

Nice, ich finds gut.

Habe zwei Wünsche:

  1. Mach ein NuGet Packet draus
  2. Packe noch eine globale Einstellung dazu, damit ich es es im Debug-Modus etc. global deaktivieren kann. Statisches Property oder AppSetting würde mir reichen.

Btw: Dein PerformanceMessureFilter ist nicht für parallele Requests geeignet 😉