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:
- Loop door
B
- verwijder item
- 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:
- Loop door
B
- Item bestaat in
A
: update item inB
- Item ontbreekt in
A
: verwijder item inB
- onthoud verwerkt item (
A
)
- Item bestaat in
- 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
//...
$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:
$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 DateTime
s 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!