Spring naar hoofdtekst

Inhoudsopgave

Synchronisatie met Google-account

Geplaatst op door ,
Laatste aanpassing op .

Inleiding

Voor mijn zelfgebouwde adresboekapplicatie had ik van begin af aan al plannen om de gegevens te koppelen aan mijn Google-account. Daarmee kon ik dan de groepen en contactpersonen centraal beheren, maar ook in mijn e-mailprogramma en op mijn smartphone gebruiken. Omdat een échte synchronisatie op dat moment boven mijn pet ging, besloot ik met een CSV-export en -import te beginnen.

Dat kan beter

Ik ben niet de enige die gebruik maakt van de genoemde adresboekapplicatie. Maar ik kon het mijn medegebruiker niet aandoen om elke keer de omstandelijke CSV-procedure te moeten doorlopen. Aldus zocht ik een manier om deze actie comfortabel te automatiseren.

Gelukkig zijn de API's van Google goed gedocumenteerd (zie GData 2.0 en Google Contacts 3.0). De meest eenvoudige, maar ook rigoreuze manier van synchroniseren is samen te vatten als: "Alles weg, alles nieuw". De API biedt, naast de enkele bewerking ook een reeks-verwerking, oftewel Batch processing aan.

Volledig

In onderstaande voorbeelden is A de collectie die wordt uitgelezen en B de collectie die wordt aangepast. De volledige synchronisatie werkt als volgt:

  1. Loop door B
    • verwijder item
  2. Loop door A
    • voeg item toe

Deze procedure heeft als voordeel dat de gegevens in B na afloop gegarandeerd gelijk zijn aan die in A. Het nadeel is, dat er onnodig veel communicatie plaatsvindt; zowel tussen applicatie en Google, als e-mailprogramma en Google, en ook tussen smartphone en Google.

Een rondje met 1200 items die op deze manier worden verwijderd en opnieuw toegevoegd, duurde gemiddeld meer dan een minuut. Op een zware dag voor de server duurde het ooit zelfs meer dan twee minuten, waardoor de systeem-time-out van 120 seconden werd aangetikt. Dit moest beter!

Update

Aldus trok ik dan toch maar de stoute schoenen aan en bedacht het volgende stappenplan:

  1. Loop door B
    • Item bestaat in A: update item in B
    • Item ontbreekt in A: verwijder item in B
    • onthoud verwerkt item (A)
  2. Loop door A
    • item al verwerkt: doe niets
    • item niet verwerkt: voeg item toe aan B

Toen ik dit principe in code had gegoten, verliep de synchronisatie een héél stuk sneller. Alleen de items die toegevoegd, gewijzigd of verwijderd waren, worden daadwerkelijk overgepompt.

Script

<?php
//...
$allLabelsProcessed = array();
foreach ($googleGroups as $labelB)
{
    // Skip system-groups
    if (stripos($labelB->title->{'$t'}, 'System Group:') === 0) {
        continue;
    }

    $dtGoogle = new \DateTime($labelB->updated->{'$t'});
    $dtGoogle->setTimezone(new \DateTimeZone('UTC'));

    // Find original label-ID using gd:extendedProperty
    $labelIdOld = null;
    if (property_exists($labelB, 'gd$extendedProperty')) {
        foreach ($labelB->{'gd$extendedProperty'} as $z) {
            if ($z->name == GDATA_SCHEMA_URL.'#label.id') {
                $labelIdOld = $z->value;
                break;
            }
        }
    }

    // Find original label in A to compare to
    $labelA = array_filter(
        $allLabels,
        function($y) use ($labelIdOld) {
            return $y->getID() == $labelIdOld;    
        }
    );
    $labelA = $labelA ? array_shift($labelA) : null;

    // Does the item no longer exist in A?
    if (is_null($labelA)) {
        // delete from B
        $b[] = new FR\BatchItem(FR\BatchAction::DELETE, $labelB);

    // Has the item been modified in A?
    } else if ($labelA->lastUpdated > $dtGoogle) {
        // update in B
        $b[] = new FR\BatchItem(FR\BatchAction::UPDATE, $labelA);
        $allLabelsProcessed[] = $labelA;

    } else {
        // Unchanged, nothing to do
        $allLabelsProcessed[] = $labelA;
    }
}

foreach ($allLabels as $l) {
    $processed = array_filter(
        $allLabelsProcessed,
        function($y) use ($l) {
            return $y->getID() == $l->getID();
        }
    );

    // Has the item been processed?
    $toInsert = $processed ? null : $l;

    if ($toInsert) {
        // insert to B
        $b[] = new FR\BatchItem(FR\BatchAction::CREATE, $l);
    }
}

// Execute the batch of operations
$results = $google_client->batchGroups($b);

Uitdagingen

Een van de uitdagingen was om de items na de eenmalige volledige synchronisatie te kunnen herkennen tijdens een update. Ook hierin kwam de API mij tegemoet; dit maal in de vorm van een gd:extendedProperty. Dit is een extra veld dat je kunt meegeven en uitlezen, maar dat niet wordt weergegeven in bijvoorbeeld de grafische omgeving van Gmail. Ideaal om het ID uit mijn applicatie in op te slaan.

Er restte nog slechts één probleem dat wachtte op een oplossing: hoe ging ik vaststellen of een item in de applicatie gewijzigd was ten opzichte van hetzelfde item bij Google?

Het object dat Google leverde bevatte een eigenschap genaamd ->updated->{'$t'} (let op de bijzondere PHP-syntax rond de naam met een dollarteken). Hiervan kon ik een DateTime-object maken. Toen ik daarna voor alle items in mijn applicatie de laatste mutatiedatum uit de database haalde, kon het grote vergelijken beginnen.

Tijdzones

Maar wat was dat? Direct na het wijzigen van een item in mijn applicatie, werd het elke keer opnieuw gesynchroniseerd. Telkens als ik de update uitvoerde, werd het item weer overgepompt - alsof het elke keer opnieuw gewijzigd was.

Toen keek ik in detail naar de twee datums die ik met elkaar vergeleek:

<?php
$dtA = $labelA->lastUpdated; // DateTime object from database
var_dump($dtA);
/*
object(DateTime)#31337 (3) {
  ["date"]=>
  string(26) "2017-04-03T16:31:55.000000"
  ["timezone_type"]=>
  int(3)
  ["timezone"]=>
  string(16) "Europe/Amsterdam"
} */

$dtB = $labelB->updated->{'$t'}; // "2017-04-03T14:31:55.899Z" from Google
var_dump(new \DateTime($dtB));
/*
object(DateTime)#36936 (3) {
  ["date"]=>
  string(26) "2017-04-03 14:31:55.000000"
  ["timezone_type"]=>
  int(2)
  ["timezone"]=>
  string(1) "Z"
} */

Het viel me op dat de tijdzones van beide data verschillen. Met behulp van een vraag en antwoord op internet ontdekte ik dat hier de kern van het probleem lag. De tijd die Google aanleverde was zonder tijdzone (Z, Zulu). Mijn applicatie en database werkten daarentegen met een tijdzone van Europe/Amsterdam. Alleen bij vergelijken van DateTimes met timezone_type == 3 wordt correct rekening gehouden met zomer- en wintertijd.

Ik maakte er met behulp van setTimezone(new \DateTimeZone('UTC')) een type 3 datum van en waarempel: de vergelijking klopte, keer op keer!