Üretimde Hizmet Çalışanları

Dikey ekran görüntüsü

Özet

Google I/O 2015 web uygulamasını hızlı ve çevrimdışı öncelikli hale getirmek için Service Worker kitaplıklarından nasıl yararlandığımızı öğrenin.

Genel bakış

Bu yılın Google I/O 2015 web uygulaması, Google'ın Geliştirici İlişkileri ekibi tarafından yazıldı ve sesli/görsel deneysel çalışma, Instrument şirketindeki arkadaşlarının tasarımlarına dayanılarak hazırlandı. Ekibimizin misyonu, I/O web uygulamasının (kod adıyla IOWA) modern web'in yapabileceği her şeyi sergilemesini sağlamaktı. Mutlaka çevrimdışına öncelik veren deneyim, olmazsa olmaz özellikler listemizin en başında geliyordu.

Yakın zamanda bu sitedeki diğer makalelerden herhangi birini okuduysanız kuşkusuz hizmet çalışanları ile karşılaşmışsınızdır. Bu nedenle, IOWA'nın çevrimdışı desteğinin büyük ölçüde bu çalışanlara bağımlı olduğunu duymak sizi şaşırtmayacaktır. IOWA'nın gerçek dünyadaki ihtiyaçlarından yola çıkarak, iki farklı çevrimdışı kullanım alanını yönetmek için iki kitaplık geliştirdik: Statik kaynakların önceden önbelleğe alınmasını otomatikleştirmek için sw-precache ve çalışma zamanı önbelleğe alma ile yedek stratejilerini yönetmek için sw-toolbox.

Kitaplıklar birbirini çok iyi tamamlıyor ve IOWA’nın statik içeriği “kabuğunun” her zaman doğrudan önbellekten sunulduğu ve dinamik ya da uzak kaynakların gerektiğinde önbelleğe alınmış veya statik yanıtlara yedeklerle ağdan sunulduğu etkili bir strateji uygulamamıza olanak tanıdı.

sw-precache ile önbelleğe alma

IOWA’nın statik kaynakları (HTML, JavaScript, CSS ve görüntüleri), web uygulaması için temel kabuğu sağlar. Bu kaynakları önbelleğe almayı düşünürken önemli olan iki özel gereksinim vardı: Statik kaynakların çoğunun önbelleğe alındığından ve bunların güncel tutulmasından emin olmak istedik. sw-precache, bu gereksinimler göz önünde bulundurularak tasarlanmıştır.

Derleme Zamanı Entegrasyonu

sw-precache, IOWA’nın gulp tabanlı derleme süreciyle çalışır. IOWA'nın kullandığı tüm statik kaynakların tam listesini oluşturmak için bir dizi glob kalıbından yararlanırız.

staticFileGlobs: [
    rootDir + '/bower_components/**/*.{html,js,css}',
    rootDir + '/elements/**',
    rootDir + '/fonts/**',
    rootDir + '/images/**',
    rootDir + '/scripts/**',
    rootDir + '/styles/**/*.css',
    rootDir + '/data-worker-scripts.js'
]

Alternatif yaklaşımlar, dosya adları listesini bir diziye sabit bir şekilde kodlamak ve bu dosya değişikliklerinden herhangi biri her seferinde bir önbellek sürümü numarasını çarpmayı hatırlamak gibi çok hataya açık oluyordu. Özellikle de kodu kontrol eden birden fazla ekip üyesinin olması düşünüldüğünde bu yaklaşımlar arasında çok hata oluşturuyordu. Hiç kimse manuel olarak yönetilen bir dizide yeni bir dosya bırakarak çevrimdışı desteği bozmak istemez! Derleme zamanı entegrasyonu, bunları endişe etmeden mevcut dosyalarda değişiklikler yapabileceğimiz ve yeni dosyalar ekleyebileceğimiz anlamına geliyordu.

Önbelleğe Alınan Kaynakları Güncelleme

sw-precache, önceden önbelleğe alınan her kaynak için benzersiz bir MD5 karması içeren temel bir Service Worker komut dosyası oluşturur. Mevcut bir kaynak her değiştiğinde veya yeni bir kaynak eklendiğinde Service Worker komut dosyası yeniden oluşturulur. Bu işlem, yeni kaynakların önbelleğe alındığı ve güncel olmayan kaynakların tamamen silindiği Service Worker güncelleme akışını otomatik olarak tetikler. Aynı MD5 karmalarına sahip mevcut tüm kaynaklar olduğu gibi kalır. Bu, siteyi ziyaret etmiş kullanıcıların en az değiştirilen kaynak kümesini indirmesi anlamına gelir ve önbelleğin tamamının toplu olarak süresinin dolmasından çok daha verimli bir deneyime yol açar.

Bir kullanıcı IOWA'yı ilk ziyaret ettiğinde glob kalıplardan biriyle eşleşen her dosya indirilir ve önbelleğe alınır. Yalnızca sayfayı oluşturmak için gereken kritik kaynakların önceden önbelleğe alınmasını sağlamaya çalıştık. Ses/görsel denemede kullanılan medya gibi ikincil içerikler veya oturum konuşmacılarının profil resimleri kasıtlı olarak önceden önbelleğe alınmadı. Bunun yerine, bu kaynaklara yönelik çevrimdışı istekleri işlemek için sw-toolbox kitaplığını kullandık.

Tüm Dinamik İhtiyaçlarımız İçin sw-toolbox

Daha önce de belirtildiği gibi, bir sitenin çevrimdışı çalışması için gereken her kaynağı önbelleğe almak uygun değildir. Bazı kaynaklar çok büyüktür veya uzun süre dayanmamak için nadiren kullanılır. Bazı kaynaklar ise dinamiktir (uzak API'den veya hizmetten gelen yanıtlar gibi). Ancak bir isteğin önceden önbelleğe alınmamış olması, NetworkError ile sonuçlanacağı anlamına gelmez. sw-toolbox, bazı kaynaklar için çalışma zamanı önbelleğe alma işlemini, bazıları için de özel yedekleri işleyen istek işleyicilerini uygulama esnekliği sağladı. Bu hizmeti, push bildirimlerine yanıt olarak önceden önbelleğe alınan kaynaklarımızı güncellemek için de kullanıyoruz.

Aşağıda, sw-toolbox'ın üzerine derlediğimiz özel istek işleyicilerle ilgili birkaç örnek verilmiştir. Bağımsız JavaScript dosyalarını Service Worker'ın kapsamına alan sw-precache importScripts parameter aracı sayesinde bunları, temel hizmet çalışanı komut dosyasıyla entegre etmek kolaydı.

Ses/Görüntü Denemesi

İşitsel/görsel deneme için sw-toolbox networkFirst önbellek stratejisini kullandık. Denemenin URL kalıbıyla eşleşen tüm HTTP istekleri öncelikle ağa karşı oluşturulur ve başarılı bir yanıt döndürülürse bu yanıt, Cache Storage API kullanılarak saklanır. Ağ kullanılamaz durumdayken sonraki bir istek yapıldıysa daha önce önbelleğe alınan yanıt kullanılır.

Başarılı bir ağ yanıtı her geldiğinde önbellek otomatik olarak güncellendiğinden, özel olarak kaynak sürümleri oluşturmamız veya girişlerin geçerlilik süresini dolmamız gerekmiyordu.

toolbox.router.get('/experiment/(.+)', toolbox.networkFirst);

Konuşmacı Profili Resimleri

Konuşmacı profili resimleri için amacımız, belirli bir konuşmacının resminin önceden önbelleğe alınmış bir sürümünü (varsa) görüntülemek ve olmadığı durumda resmi almak için ağa geri dönmekti. Bu ağ isteği başarısız olursa son yedek olarak önceden önbelleğe alınmış (ve bu nedenle her zaman kullanılabilir olan) genel bir yer tutucu resim kullandık. Bu, genel bir yer tutucuyla değiştirilebilecek görüntülerle çalışırken yaygın olarak kullanılan bir stratejidir. sw-toolbox cacheFirst ve cacheOnly işleyicilerini zincirleyerek de kolayca uygulanabilir.

var DEFAULT_PROFILE_IMAGE = 'images/touch/homescreen96.png';

function profileImageRequest(request) {
    return toolbox.cacheFirst(request).catch(function() {
    return toolbox.cacheOnly(new Request(DEFAULT_PROFILE_IMAGE));
    });
}

toolbox.precache([DEFAULT_PROFILE_IMAGE]);
toolbox.router.get('/(.+)/images/speakers/(.*)',
                    profileImageRequest,
                    {origin: /.*\.googleapis\.com/});
Oturum sayfasından profil resimleri
Oturum sayfasından profil resimleri.

Kullanıcı Programlarında Yapılan Güncellemeler

IOWA'nın temel özelliklerinden biri, oturum açmış kullanıcıların katılmayı planladıkları oturumların programını oluşturmalarına ve sürdürmelerine olanak tanımaktı. Tahmin edeceğiniz gibi, oturum güncellemeleri bir arka uç sunucusuna HTTP POST istekleri üzerinden yapıldı ve kullanıcı çevrimdışıyken durumu değiştiren bu istekleri ele almanın en iyi yolunu bulmak için biraz zaman harcadık. IndexedDB'de sıraya alınan başarısız isteklerin bir kombinasyonunu oluşturduk. Bu kombinasyon ana web sayfasındaki mantığın yanı sıra, sıraya alınan istekler için IndexedDB'yi kontrol eden ve bulduğu istekleri yeniden denedi.

var DB_NAME = 'shed-offline-session-updates';

function queueFailedSessionUpdateRequest(request) {
    simpleDB.open(DB_NAME).then(function(db) {
    db.set(request.url, request.method);
    });
}

function handleSessionUpdateRequest(request) {
    return global.fetch(request).then(function(response) {
    if (response.status >= 500) {
        return Response.error();
    }
    return response;
    }).catch(function() {
    queueFailedSessionUpdateRequest(request);
    });
}

toolbox.router.put('/(.+)api/v1/user/schedule/(.+)',
                    handleSessionUpdateRequest);
toolbox.router.delete('/(.+)api/v1/user/schedule/(.+)',
                        handleSessionUpdateRequest);

Yeniden deneme işlemleri ana sayfanın bağlamında yapıldığı için yeni bir kullanıcı kimlik bilgisi grubu içerdiklerinden emin olabildik. Yeniden deneme işlemleri başarılı olduktan sonra, kullanıcıya daha önce sıraya alınan güncellemelerinin uygulandığını bildiren bir mesaj gösterdik.

simpleDB.open(QUEUED_SESSION_UPDATES_DB_NAME).then(function(db) {
    var replayPromises = [];
    return db.forEach(function(url, method) {
    var promise = IOWA.Request.xhrPromise(method, url, true).then(function() {
        return db.delete(url).then(function() {
        return true;
        });
    });
    replayPromises.push(promise);
    }).then(function() {
    if (replayPromises.length) {
        return Promise.all(replayPromises).then(function() {
        IOWA.Elements.Toast.showMessage(
            'My Schedule was updated with offline changes.');
        });
    }
    });
}).catch(function() {
    IOWA.Elements.Toast.showMessage(
    'Offline changes could not be applied to My Schedule.');
});

Çevrimdışı Google Analytics

Benzer bir şekilde, başarısız Google Analytics isteklerini sıraya almak ve daha sonra ağın kullanılabilir hale gelmesini beklerken yeniden oynatmayı denemek için bir işleyici uyguladık. Bu yaklaşımla çevrimdışı olmak, Google Analytics'in sunduğu bilgilerden ödün vermek anlamına gelmez. Google Analytics arka ucuna uygun bir etkinlik ilişkilendirme zamanı geldiğinden emin olmak için, sıraya alınan her isteğe qt parametresini ekledik. Bu parametre, isteğin ilk denenmesinden bu yana geçen süreye ayarlanır. Google Analytics, qt için en fazla 4 saatlik değerleri resmi olarak desteklemektedir. Bu nedenle, Service Worker her başlatıldığında bu istekleri mümkün olan en kısa sürede yeniden oynatmak için elimizden geleni yaptık.

var DB_NAME = 'offline-analytics';
var EXPIRATION_TIME_DELTA = 86400000;
var ORIGIN = /https?:\/\/((www|ssl)\.)?google-analytics\.com/;

function replayQueuedAnalyticsRequests() {
    simpleDB.open(DB_NAME).then(function(db) {
    db.forEach(function(url, originalTimestamp) {
        var timeDelta = Date.now() - originalTimestamp;
        var replayUrl = url + '&qt=' + timeDelta;
        fetch(replayUrl).then(function(response) {
        if (response.status >= 500) {
            return Response.error();
        }
        db.delete(url);
        }).catch(function(error) {
        if (timeDelta > EXPIRATION_TIME_DELTA) {
            db.delete(url);
        }
        });
    });
    });
}

function queueFailedAnalyticsRequest(request) {
    simpleDB.open(DB_NAME).then(function(db) {
    db.set(request.url, Date.now());
    });
}

function handleAnalyticsCollectionRequest(request) {
    return global.fetch(request).then(function(response) {
    if (response.status >= 500) {
        return Response.error();
    }
    return response;
    }).catch(function() {
    queueFailedAnalyticsRequest(request);
    });
}

toolbox.router.get('/collect',
                    handleAnalyticsCollectionRequest,
                    {origin: ORIGIN});
toolbox.router.get('/analytics.js',
                    toolbox.networkFirst,
                    {origin: ORIGIN});

replayQueuedAnalyticsRequests();

Push Bildirimi Açılış Sayfaları

Service Worker'lar yalnızca IOWA'nın çevrimdışı işlevini işlemekle kalmadı, aynı zamanda kullanıcıları yer işareti koydukları oturumlardaki güncellemeler hakkında bilgilendirmek için kullandığımız push bildirimlerini de geliştirdiler. Bu bildirimlerle ilişkili açılış sayfasında, güncellenmiş oturum ayrıntıları gösteriliyordu. Bu açılış sayfaları, sitenin genelinin bir parçası olarak zaten önbelleğe alınıyordu, dolayısıyla çevrimdışı da çalışıyorlardı, ancak söz konusu sayfadaki oturum ayrıntılarının, çevrimdışı görüntülense bile güncel olduğundan emin olmamız gerekiyordu. Bunun için, önceden önbelleğe alınan oturum meta verilerini push bildirimini tetikleyen güncellemelerle değiştirdik ve sonucu önbellekte depoladık. İster online ister çevrimdışı olsun, oturum ayrıntıları sayfasının bir sonraki açılışında bu güncel bilgiler kullanılacaktır.

caches.open(toolbox.options.cacheName).then(function(cache) {
    cache.match('api/v1/schedule').then(function(response) {
    if (response) {
        parseResponseJSON(response).then(function(schedule) {
        sessions.forEach(function(session) {
            schedule.sessions[session.id] = session;
        });
        cache.put('api/v1/schedule',
                    new Response(JSON.stringify(schedule)));
        });
    } else {
        toolbox.cache('api/v1/schedule');
    }
    });
});

Anlaşılan Noktalar ve Dikkat Edilmesi Gerekenler

Elbette kimse IOWA'nın ölçeğindeki bir projede birkaç kez anlaşmaya varmadan çalışmaz. İşte karşılaştığımız sorunlardan bazıları ve bunların üstesinden nasıl geldiğimiz.

Eski İçerik

İster hizmet çalışanları aracılığıyla ister standart tarayıcı önbelleğiyle uygulanmış bir önbelleğe alma stratejisi planlarken, kaynakları mümkün olduğunca hızlı bir şekilde sunmak ile en yeni kaynakları sunmak arasında bir denge söz konusudur. sw-precache ile uygulamamızın kabuğu için agresif bir önbellek öncelikli strateji uyguladık. Yani, hizmet çalışanımız sayfada HTML, JavaScript ve CSS'yi döndürmeden önce ağdaki güncellemeleri kontrol etmiyordu.

Neyse ki, sayfa yüklendikten sonra yeni içeriğin ne zaman kullanılabilir olduğunu algılamak için Service Worker yaşam döngüsü olaylarından yararlanabildik. Güncellenmiş bir Service Worker tespit edildiğinde kullanıcıya, en yeni içeriği görebilmesi için sayfasını yeniden yüklemesi gerektiğini bildiren bir önemli mesaj mesajı görüntüleriz.

if (navigator.serviceWorker && navigator.serviceWorker.controller) {
    navigator.serviceWorker.controller.onstatechange = function(e) {
    if (e.target.state === 'redundant') {
        var tapHandler = function() {
        window.location.reload();
        };
        IOWA.Elements.Toast.showMessage(
        'Tap here or refresh the page for the latest content.',
        tapHandler);
    }
    };
}
En yeni içerik kısa mesajı
"En yeni içerik" kısa mesajı.

Statik İçeriğin Statik Olduğundan Emin Olun!

sw-precache, yerel dosyaların içeriğinin MD5 karmasını kullanır ve yalnızca karması değişen kaynakları getirir. Bu, kaynakların sayfada neredeyse anında kullanılabilir olduğu anlamına gelir. Ancak bir öğe önbelleğe alındıktan sonra, güncellenmiş bir Service Worker komut dosyasında yeni bir karma değer atanana kadar önbellekte kalır.

Arka ucumuzun konferansın her günü için canlı yayın YouTube video kimliklerini dinamik olarak güncellemesi gerektiğinden I/O sırasında bu davranışla ilgili bir sorunla karşılaştık. Temel şablon dosyası statik olduğundan ve değişmediğinden, hizmet çalışanı güncelleme akışımız tetiklenmedi ve YouTube videolarının güncellenmesiyle sunucudan gelen dinamik bir yanıt olması amaçlanan yanıt, birçok kullanıcı için önbelleğe alınmış yanıt oldu.

Web uygulamanızın, kabuğun her zaman statik olup güvenli bir şekilde önceden önbelleğe alınabileceği şekilde yapılandırıldığından emin olarak bu tür sorunlardan kaçınabilirsiniz. Kabuğu değiştiren dinamik kaynaklar ise bağımsız olarak yüklenir.

Önbelleğe Alma İsteklerinizi Önbelleği Bozma

sw-precache, kaynakların önceden önbelleğe alınması için istekte bulunduğunda, dosyanın MD5 karmasının değişmediğini düşündüğü sürece bu yanıtları süresiz olarak kullanır. Bu, önbelleğe alma isteğine verilen yanıtın yeni olduğundan ve tarayıcının HTTP önbelleğinden döndürülmediğinden emin olunması özellikle önemlidir. (Evet, Service Worker'da yapılan fetch() istekleri tarayıcının HTTP önbelleğindeki verilerle yanıt verebilir.)

Önbelleğe aldığımız yanıtların tarayıcının HTTP önbelleğinden değil, doğrudan ağdan alındığından emin olmak için sw-precache, istediği her URL'ye otomatik olarak önbellek bozan bir sorgu parametresi ekler. sw-precache kullanmıyorsanız ve önbellek öncelikli yanıt stratejisinden yararlanıyorsanız kendi kodunuzda benzer bir işlem yaptığınızdan emin olun.

Önbellek bozma için daha temiz bir çözüm, reload'e önbelleğe alma işlemi için kullanılan her Request için önbellek modunu ayarlamak olabilir. Böylece yanıtın ağdan gelmesi sağlanır. Ancak bu yazı hazırlandığı sırada Chrome'da önbellek modu seçeneği desteklenmez.

Giriş ve Çıkış Desteği

IOWA; kullanıcıların, Google Hesaplarını kullanarak giriş yapmalarına ve özelleştirilmiş etkinlik programlarını güncellemelerine olanak tanıyordu. Ancak bu, aynı zamanda kullanıcıların daha sonra çıkış yapmalarına da olanak tanıyordu. Kişiselleştirilmiş yanıt verilerini önbelleğe almak açıkça zor bir konu ve her zaman tek bir doğru yaklaşım yoktur.

Kişisel planlamanızı çevrimdışıyken bile görüntülemek IOWA deneyiminin temelini oluşturduğundan, önbelleğe alınmış verileri kullanmanın uygun olduğuna karar verdik. Kullanıcı oturumu kapattığında, önceden önbelleğe alınan oturum verilerini temizlediğimizden emin olduk.

    self.addEventListener('message', function(event) {
      if (event.data === 'clear-cached-user-data') {
        caches.open(toolbox.options.cacheName).then(function(cache) {
          cache.keys().then(function(requests) {
            return requests.filter(function(request) {
              return request.url.indexOf('api/v1/user/') !== -1;
            });
          }).then(function(userDataRequests) {
            userDataRequests.forEach(function(userDataRequest) {
              cache.delete(userDataRequest);
            });
          });
        });
      }
    });

Fazladan Sorgu Parametrelerine Dikkat Edin!

Service Worker önbelleğe alınmış bir yanıt olup olmadığını kontrol ettiğinde anahtar olarak istek URL'sini kullanır. Varsayılan olarak istek URL'si, URL'nin arama bölümündeki sorgu parametreleri dahil olmak üzere önbelleğe alınan yanıtı depolamak için kullanılan URL ile tam olarak eşleşmelidir.

Bu da geliştirme sırasında trafiğimizin nereden geldiğini izlemek için URL parametrelerini kullanmaya başladığımızda bizim için bir soruna yol açtı. Örneğin, bildirimlerimizden biri tıklandığında açılan URL'lere utm_source=notification parametresini ekledik ve web uygulaması manifest'imiz için start_url öğesinde utm_source=web_app_manifest değerini kullandık. Daha önce önbelleğe alınan yanıtlarla eşleşen URL'ler, bu parametreler eklendiğinde eksiklik olarak geliyordu.

Bu sorun, Cache.match() çağrılırken kullanılabilecek ignoreSearch seçeneğiyle kısmen ele alınmıştır. Ne yazık ki Chrome, ignoreSearch henüz desteklememektedir. Desteklese bile bu, ya hep ya hiç mantığında bir davranış değildir. İhtiyacımız olan şey, anlamlı olan diğerlerini dikkate alırken bazı URL sorgu parametrelerini yoksaymaktı.

Önbellek eşleşmesini kontrol etmeden önce bazı sorgu parametrelerini çıkarmak ve geliştiricilerin ignoreUrlParametersMatching seçeneği ile hangi parametrelerin yoksayılacağını özelleştirmesine olanak tanımak için sw-precache kapsamını genişlettik. Temel uygulama aşağıdaki gibidir:

function stripIgnoredUrlParameters(originalUrl, ignoredRegexes) {
    var url = new URL(originalUrl);

    url.search = url.search.slice(1)
    .split('&')
    .map(function(kv) {
        return kv.split('=');
    })
    .filter(function(kv) {
        return ignoredRegexes.every(function(ignoredRegex) {
        return !ignoredRegex.test(kv[0]);
        });
    })
    .map(function(kv) {
        return kv.join('=');
    })
    .join('&');

    return url.toString();
}

Bu Ne Anlama Geliyor?

Google I/O Web Uygulaması'ndaki Service Worker entegrasyonu, muhtemelen bu noktaya dağıtılan en karmaşık ve gerçek dünya kullanım biçimidir. sw-precache ve sw-toolbox geliştirdiğimiz araçların yanı sıra kendi web uygulamalarınızı destekleyecek tekniklerden yararlanmayı web geliştiricisi topluluğuna bekliyoruz. Service Worker'lar, hemen kullanmaya başlayabileceğiniz progresif bir geliştirmedir. Düzgün yapılandırılmış bir web uygulamasının bir parçası olarak kullanıldığında hız ve çevrimdışı avantajları kullanıcılarınız açısından önemlidir.