10
Mar

JavaScript "autoversioning" w Nancy

Są momenty, w których jedyne co wypada zrobić do dać sobie samemu blachę z wykrzyknieniem: “kRRRRetynie!”. Tak miałem ostatnio, gdy po wdrożeniu kolejnej wersji systemu dostałem od klienta komunikat: “e ziom, nie działa!”. Po inwestygacji logów przyczyna okazała się dość prozaiczna: owszem, deploy poszedł, wszystko się udało, ale “użytkownik końcowy” nie zrobił (bo i skąd miał wiedzieć że trzeba to zrobić?) full-refresh, aby przeglądarka zaciągnęła nowe wersje plików javascript. Więc serwer był nowy, skrypty stare, a życie, jak to życie, sprawiło, że jedno było z drugim nie-do-końca-kompatybilne. Luz. Aż się dziwię że dalej zdarza się mi się popełnić takie debilizmy. No ale…

Sposobów na rozwiązanie tego problemu jest parę. Można całkowicie wyłączyć cache dla takich plików, ale to głupie. Można zmieniać nazwę pliku przy każdym wdrożeniu, ale to też głupie.

Można postąpić mądrzej i do referencji javascript czy css “doklejać” query string z aktualną wersją biblioteki albo datą modyfikacji pliku, żeby efekt w przeglądarce wyglądał o tak:

<script type="text/javascript" src="~/Scripts/app/users.js?v=1.0.0.3"></script>

Kłopot z takim podejściem (chociaż jest ono z automatu stosowane, z tego co pamiętam, na przykład w T4MVC) jest taki, że niektóre przeglądarki MOGĄ takich plików nie cache’ować. Co prawda z serwera wróci najprawdopodobniej i tak “304 Not Modified”, ale po co robić zbędne połączenie dla każdego pliku, skoro można go nie robić?

Jest jeszcze jeden sposób: zamiast modyfikować referencje do pliku poprzez doklejanie query stringa, można zmodyfikować NAZWĘ pliku, o tak:

<script type="text/javascript" src="~/Scripts/app/users.v-1.0.0.3.js"></script>

Wtedy mamy wszystkie zalety każdego rozwiązania i, wydaje się, żadnych wad: nowa wersja spowoduje ściągnięcie nowych skryptów, a przeglądarka, po pierwszym ściągnięciu, ładnie je sobie scache’uje. Pojawiają się dwie kwestie: jak takie “referencje” wygenerować oraz jak je po stronie serwera obsłużyć?

First things first, więc zajmijmy się ich wygenerowaniem. To okazuje się banalnie proste (jeśli odpowiednio wersjonujemy swoje dllki, ale to chyba oczywista oczywistość). Wystarczy napisać taki helper:

public static class GetVersion
{
    public static Version NumberOnly()
    {
        var assembly = Assembly.GetExecutingAssembly();
        return assembly.GetName().Version;
    }
}

I go użyć w widokach Razora (odpada więc cieszenie się z “gołego” htmla, ale co za różnica):

<script type="text/javascript" src="~/Scripts/app/users.v-@(GetVersion.NumberOnly()).js"></script>

Uwaga: jak widać, powyższy kod korzysta z GetExecutingAssembly(). Zatem zostanie zwrócona biblioteki zawierającej tą klasę, a nie projektu z tym cshtmlem (chociaż często może to być ten sam projekt – u mnie nie jest). Nie ma to znaczenia jeśli zawsze wdrażamy całą paczkę, a nie robiąc copy/paste pojedynczych dllek, ale… Nie ma żadnego ale! Jeśli robisz wdrożenia przez kopiowanie pojedynczych plików to radź sobie sam ;). Tak czy siak warto o tym pamiętać. Jak i o tym, że GetCallingAssembly() zwróci nam śmieci jeśli wywołamy kod z Razora, a GetEntryAssembly() w aplikacji webowej to null.

Dokończenie zadania to znalezienie sposobu na poprawną interpretację tak skonstruowanych żądań… nie chcemy przecież faktycznie zmieniać nazw fizycznych plików przy każdym releasie? Jeden sposób to reguła do URL-rewrite na poziomie serwera www usuwająca taki tekst. Ja jednak zaimplementowałem to po prostu w kodzie. W mojej kochanej Nancy. Nioch nioch cmok.

Nancy zna takie pojęcie jak “static content” (wiki: Managing static content). Posiada kilka interfejsów odpowiadających za zabawę z takimi plikami. Nas interesuje IStaticContentProvider. Można, wzorując się na domyślnej implementacji, naskrobać coś takiego:

public class MyStaticContentProvider : IStaticContentProvider
{
    private readonly StaticContentsConventions _conventions;
    private readonly string _rootPath;

    static readonly Regex _versionRegex = new Regex(@"v-(\d+\.){4}");

    public MyStaticContentProvider(IRootPathProvider rootPathProvider, StaticContentsConventions conventions)
    {
        _conventions = conventions;
        _rootPath = rootPathProvider.GetRootPath();
    }

    public Response GetContent(NancyContext context)
    {
        RewriteAutoversionedUrl(context.Request);

        foreach (var convention in _conventions)
        {
            Response response = convention(context, _rootPath);
            if (response != null)
            {
                return response;
            }
        }
        return null;
    }

    public void RewriteAutoversionedUrl(Request request)
    {
        request.Url.Path = _versionRegex.Replace(request.Path, string.Empty);
    }
}

I interesującą nas logikę pokryć testami:

public class MyStaticContentProviderTests
{
    private MyStaticContentProvider _provider;

    public MyStaticContentProviderTests()
    {
        _provider = new MyStaticContentProvider(new DefaultRootPathProvider(), null);
    }

    string execute(string url)
    {
        var request = new Request("GET", url, "http");
        _provider.RewriteAutoversionedUrl(request);
        return request.Path;
    }

    [Fact]
    public void does_not_change_non_autoversioned_url()
    {
        var result = execute("/scripts/script.js");

        Assert.Equal("/scripts/script.js", result);
    }

    [Fact]
    public void rewrites_url_with_version_information()
    {
        var result = execute("/scripts/script.v-1.0.3.5.js");

        Assert.Equal("/scripts/script.js", result);
    }

    [Fact]
    public void rewrites_url_with_version_with_multiple_digits_information()
    {
        var result = execute("/scripts/script.v-1.0.13.25.js");

        Assert.Equal("/scripts/script.js", result);
    }

    [Fact]
    public void does_not_change_url_with_incomplete_version_information()
    {
        var result = execute("/scripts/script.v-1.0.13.js");

        Assert.Equal("/scripts/script.v-1.0.13.js", result);
    }
}

A na koniec zarejestrować w bootstrapperze. Ten krok jest o tyle nietypowy, że zwykle Nancy po prostu wykrywa fakt posiadania naszej implementacji jakiegoś interfejsu i ją używa, ale ten konkretny scenariusz jest wyjątkiem. Nie jestem do końca pewny dlaczego tak jest, ale komentarz w klasie NancyInternalConfiguration sugeruje, że po prostu to jest jeden z elementów Nancy, które zwykle nie wymagają zmian, więc ich modyfikacja jest trochę utrudniona.

public class MyBootstrapper : DefaultNancyBootstrapper
{
    protected override NancyInternalConfiguration InternalConfiguration
    {
        get
        {
            var config = base.InternalConfiguration;
            config.StaticContentProvider = typeof(MyStaticContentProvider);
            return config;
        }
    }

    // ... more bootstrapping code

I… that’s all, folks! Całkiem zgrabna kombinacja, “even if I say so myself”.

P.S. Ponownie społeczność Nancy na Jabbr okazała się bardzo pomocna – to tam dostałem info o IStaticContentProvider. Reszta była już prosta.

P.S. 2 Domyślnie Nancy traktuje katalog /Content jako “static”. Ja, żeby mieć skrypty w katalogu /scripts, jak lubię, musiałem w bootstrapperze dodać linijkę:

Conventions.StaticContentsConventions.AddDirectory("Scripts");
Autor

Maciej Aniserowicz

Maciej Aniserowicz
"Procent"
developer / architect

MVP
MCP

Search
Facebook
Twitter
Archiwum
Kategorie
© Copyright 2008-2014 Maciej Aniserowicz. All rights reserved. Running on WordPress.