JavaScript "autoversioning" w Nancy

7

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");
Share.

About Author

Programista, trener, prelegent, pasjonat, blogger. Autor jedynego polskiego podcasta programistycznego: DevTalk.pl. Jeden z liderów Białostockiej Grupy .NET. Od 2008 Microsoft MVP w kategorii .NET. Więcej informacji znajdziesz na stronie O autorze. Napisz do mnie ze strony Kontakt. Dodatkowo: Twitter, Facebook.

7 Comments

  1. Pingback: dotnetomaniak.pl

  2. extstopcodepls on

    Ciekawe rozwiązanie. A co jeśli można by było korzystać z
    Assembly.GetAssembly(typeof(“Projekt Nancy”)).GetName().Version.ToString()
    i po prostu robić clean i build na projekcie. A na koniec zrobić do tego UrlHelper, który pobierałby ścieżkę do pliku w parametrze a wypluwał odpowiedni string?

  3. tomaszk-poz on

    Czytam Twego bloga i tak się zastanawiam, jako to jest. Ciągle nowe cudne frameworki jeden lepszy od drugiego i zawsze jakieś zawracanie głowy duperelami. Czy to zawsze musi być dochodzenie detektywa, czy rzeczy, które opisujesz, nie mogłyby być robione automatycznie we frameworku ?
    Nie żebym strasznie narzekał, ale irytują mnie w programowaniu takie “przystanki”.

  4. extstopcodepls,
    Można zrobić typeof(DefaultNancyBootstrapper).Assembly.GetName()…. ale wtedy dostanę wersję dllki Nancy a nie swojej. A mi potrzebna moja :). UrlHelper można dodać też, ale jak dla mnie nie ma znaczenia czy to helper czy takie wywołanie jak u mnie.

  5. tomaszk-poz,
    Mnie też takie przystanki irytują bo odwracają uwagę od tego co istotne – czyli aplikacji. Akurat ten konkretny przypadek to nie jest niczyja wina, pewnie gdybym użył jakiegoś “minimalizatora” JS to mógłbym w prostszy sposób uzyskać pożądany efekt. Tutaj musiałem robić to ręcznie.

  6. Użycie standardowych Bundle zdecydowanie rozwiązało by sprawę. Dodatkowo ściąganie jednego pliku ze skryptami jest zdecydowanie efektywniejsze/szybsze niż 15 bibliotek js osobno. Wtedy o czyszczeniu cache trzeba by było pamiętać tylko w konfiguracji Debug.

  7. @Jacek

    nie zupelnie, bundle polaczy pliki, udostepni plik pod urlem /bundle?costam=costam – niby fajnie, ale same to powoduje jedynie, ze przegladarka zrobi request i dostanie 304. To co Procent pokazuje to sposob jak zrobic by przegladarka napisala “From Cache” i nie wykonywala requestu sprawdzajacego.

    tak naprawde jest to tylko obejscie problemu ustawienia Expires header i pewnie jeszcze tam jakiegos dodatkowego by ten bundle dzialal tak jak Procent by chcial.

    Tak na marginesie, to co Procent pokazal, to tez jeden z sposobow obejscia 304 jezeli nie mozna zmieniac i ustawiac headersow.