search-replace --export във WP CLI e счупен

Командата може да е изключително примамлива при мигриране на сайтове, но е доста подвеждаща и това може да ни изиграе лоша шега, счупен сайт или в най-добрия случай — изгубено време. Все пак целта може да бъде постигната и с алтернативни начини при работа с WP CLI.

За какво ни е WP CLI?

Нека да започнем с това, че WP CLI е страхотен инструмент, който може да ни спести страшно много работа по време на разработка свързана с WordPress. По лично мое мнение, едно от най-тегавите повтарящи се действия по време на разработка с WordPress е прехвърляне на сайт от едно място на друго. Примерно — от staging на production server. Една от причините за това е, че освен качването на файловете трябва да се мигрира и базата данни. Последното е усложнено от факта, че често пъти имаме сериализирани масиви и обекти и обикновен search-replace не върши работа.

Как работи сериализирането в PHP?

Нека създадем един обект и го сериализираме:

$my_obj = new stdClass();
$my_obj->foo = "bar";
$my_obj->baz = 1;
$my_obj->zab = ['one', 2, true];
print_r($my_obj);

Резултатът е:

stdClass Object
(
    [foo] => bar
    [baz] => 1
    [zab] => Array
        (
            [0] => one
            [1] => 2
            [2] => 1
        )

)

Нека сега го сериализираме:

echo serialize($my_obj);

// O:8:"stdClass":3:{s:3:"foo";s:3:"bar";s:3:"baz";i:1;s:3:"zab";a:3:{i:0;s:3:"one";i:1;i:2;i:2;b:1;}}

Как да четем сериализираните данни?

Те по принцип не са предназначени, да са четени от хора. Все пак, започваме с O:8:"stdClass":3, което означава, че:

  • имаме обект
  • името ва на клася му е с 8 символа
  • тези 8 символа са stdClass.

Последното 3 накрая обозначава 3те property-та, които обектът има.

После имаме : след, които почваме да изреждаме 3те property-та и стойностите им между {}.

s:3:"foo";s:3:"bar"; е първото property. Самото property е string с 3 символа, които са “foo”. Стойността му е string с 3 символа, които са “bar”.

С малко въображение вероятно ще успеете да разчетете оставащата част.

Защо сериализираните обекти в PHP се представят толкова сложно?

“Сложно” в случая е относително. Да речем, че съпоставяме с JSON сериализация, защото json_encode() на същия обект ще ни даде просто:

{"foo":"bar","baz":1,"zab":["one",2,true]}

Само 42 знака, съпоставени с 99-те знака от PHP сериализацията. Щом JSON е над 2 пъти по-малък, значи е по-добър, нали? “По-добър” би могло да има различни измерения. Обективно може да се каже, че JSON е по-компресиран. Той е и по-четим от хора. Но той е по-тежък и по-бавен. Точно причината, че в PHP за всичко описваме дължина прави десериализацията по-бърза.

Колко по-бавна е JSON сериализацията от PHP сериализацията?

Направих няколко теста с данни за всички местоположения на Starbucks (поне според https://raw.githubusercontent.com/mmcloughlin/starbucks/master/locations.json).

Заредих данните като променлива в PHP. След това опитах по няколко пъти да сериализирам и десериализирам данните с serialize/unserialize и с json_encode/json_decode.

$start = microtime(true); json_decode(json_encode($locations)); echo microtime(true) - $start;
$start = microtime(true); unserialize(serialize($locations)); echo microtime(true) - $start;

Пуснах ги като 2 отделни файла, а не две поредни команди в един файл, за да не се окаже, че cold start има касателство.

После пуснах всеки файл по няколко пъти и това, което получих е:

Runserialize/unserializejson_encode/json_decode
10.0901319980621340.083483219146729
20.0709500312805180.085309982299805
30.0713939666748050.080581903457642
40.0554380416870120.07876992225647
50.0823829174041750.081361055374146
60.0716471672058110.081040143966675
70.0749800205230710.079762935638428

Средно имаме 74ms при PHP сериализация и 81ms при JSON сериализация. С други деми — PHP сериализацията е с около 9% по-бърза. Не е огромно предимство, но няма как да не се отчете.

Все пак очаквах дори по-голямо преимущество. Затова реших да сравня по отделно функциите за сериализиране и десериализиране.

Runserializejson_encode
10.0477728843688960.049137115478516
20.0455250740051270.048465013504028
30.0501539707183840.050597190856934
40.0500259399414060.049487113952637
50.0498769283294680.050249099731445
60.0559279918670650.040657997131348
70.047971010208130.051151990890503

Средните стойности са 50ms при serialize и 49ms при json_encode. Ако вземем предвид някакво поле на грешка, породена от малък брой данни и тестове — можем да кажем, че са наравно.

Така че идва време на десериализирането:

Rununserializejson_decode
10.0357611179351810.03124213218689
20.0366878509521480.050729990005493
30.0369880199432370.050434112548828
40.0393800735473630.03825306892395
50.0387699604034420.050342082977295
60.0382471084594730.048661947250366
70.0371050834655760.049883127212524

Тук вече предимството на unserialize пред json_decode се вижда по-ясно със средна стойност от 37.5ms спрямо 45.6ms — 18% по-бързо.

Точно при декодирането на string към реален обект, дължината на елементите помага при парсирането.

Като се има предвид, че WordPress пази сериализирана информация в базата данни, където по презюнкция имаме повече четене, отколкото писане — значи имаме и повече десериализиране, отколкото сериализиране. Затова си заслужава да ползваме serialize/unserialize спрямо json_encode/json_decode.

По какъв начин сериализирането на данни чупи мигрирането на база от данни в WordPress?

Представете си, че имате сериализиран обект, който съдържа URL към някоя от страниците в сайта ви. Property-то с URL-а ще бъде сериалиизрано горе-долу като s:17:"http://localhost/". Ако трябва да мигрираме към домейн https://magadanski.com/ URL-а става от 17 на 23 знака и сериализираната версия ще бъде s:23:"https://magadanski.com/". Ако сме направили един обикновен search-replace в базата данни, заменяйки http://localhost/ и https://magadanski.com/. Тогава бихме имали счупер сериализиран обект с грешна информация, че низът е от 17 знака (колкото е бил стария).

Това много често съм го виждал да се случва при widget-и. Всички опции за един widget се пазят като сериализиран theme option. И винаги, когато в widget поле има URL — сериализираният обект се чупи и целият widget просто изчезва.

Как ни помага wp search-replace?

Цялата команда е доста комплексна, но най-общо казано — зарежда всичката информация от сайта таблица по таблица, ред по ред, колона по колона и проверява дали стойността на клетката е със сериализирани данни.

Ако не са сериализирани — прави search-reaplace на SQL ниво. Ако са сериализирани ги десериализира, обхожда рекурсивно всички property-та и прави search-replace на PHP ниво.

Когато сме подали аргумент --export обаче, вместо да прави search-replace в базата данни (или с PHP и после update в базата данни), построява SQL statement с данните и го записва във файл.

При обработка на таблици се прави DROP TABLE IF EXISTS и CREATE TABLE и като цяло се върши къртовски труд, за да се направи с PHP нещо възможно най-близко до mysqldump, като междувременно извършва search-replace.

Ако опитате да направите wp search-replace --export към файл, най-вероятно първите 10 реда от файла ще изглеждат така:

DROP TABLE IF EXISTS `wp_commentmeta`;
CREATE TABLE `wp_commentmeta` (
  `meta_id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `comment_id` bigint unsigned NOT NULL DEFAULT '0',
  `meta_key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
  `meta_value` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
  PRIMARY KEY (`meta_id`),
  KEY `comment_id` (`comment_id`),
  KEY `meta_key` (`meta_key`(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Ако обаче направим dump на базата данни (без search-replace) то резултатът започва така (ще разгледаме 43 реда, вместо 10):

-- MySQL dump 10.13  Distrib 8.1.0, for macos13.3 (arm64)
--
-- Host: localhost    Database: demo
-- ------------------------------------------------------
-- Server version    8.0.33

/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;

--
-- Table structure for table `wp_commentmeta`
--

DROP TABLE IF EXISTS `wp_commentmeta`;
/*!40101 SET @saved_cs_client     = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `wp_commentmeta` (
  `meta_id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `comment_id` bigint unsigned NOT NULL DEFAULT '0',
  `meta_key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
  `meta_value` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
  PRIMARY KEY (`meta_id`),
  KEY `comment_id` (`comment_id`),
  KEY `meta_key` (`meta_key`(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;

--
-- Dumping data for table `wp_commentmeta`
--

LOCK TABLES `wp_commentmeta` WRITE;
/*!40000 ALTER TABLE `wp_commentmeta` DISABLE KEYS */;
/*!40000 ALTER TABLE `wp_commentmeta` ENABLE KEYS */;
UNLOCK TABLES;

Макар и да има някои коментари, има инструкции, които мнозина начинаещи ще игнорират. Само че те са доста важни. Иначе нямаше да ги има.

Става въпрос за неща като този ред:

/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;

Това, което той прави е:

  1. записва стойността на SQL_MODE в променлива OLD_SQL_MODE
  2. задава стойност NO_AUTO_VALUE_ON_ZERO на SQL_MODE

В края на dump файла имаме ред, който връща SQL_MODE на стойността от OLD_SQL_MODE променливата. Така че NO_AUTO_VALUE_ON_ZERO е било само временото състояние по време на imort на dump-а.

Това може да се погрижи базата ни от данни да не се счупи от следната структура (извадката е от comments таблицата):

`comment_date` datetime NOT NULL DEFAULT '0000-00-00 00:00:00'

Хем имаме NOT NULL, хем имаме DEFAULT '0000-00-00 00:00:00', което не е точно NULL, но е един вид null-value.

И ако нямаме SQL_MODE='NO_AUTO_VALUE_ON_ZERO' има шанс (зависи от версията на MySQL, която ползваме) INSERT statement-а за коментари да ни даде грешка и да прекрати целия import.

Какво можем да направим в такъв случай?

Би било трудно да:

  1. добавим редовете, които backup-ват стари настройки на базата и ги презаписват с безопасни за import такива
  2. накрая да върнем старите стойности

А и с излизане на нови версии на MySQL може да се наложи да ги коригираме. Така че това не звучи като добър подход.

Другото е да си правим dump на базата данни с wp db export. После да импортираме с грешен siteurl. След import да извикваме wp search-replace локално и всичко ще е наред. Аз като цяло бих предпочел този начин на работа, тъй като е най-лесен и няма много неща, които да могат да се объркат.

Ако все пак държим да имаме dump с желания siteurl, който просто да import-нем можем:

  1. първо да направим wp search-replace за да преминем към този URL
  2. след това да направим wp db export.

Само после не трябва да забравим да извършим search-replace-а в обратна посока. Така ще продължим да работим с локалния си host.

Макар и изпълнение на три поредни команди да не е нещо твърде сложно, разбирам, че мнозина вероятно са разочаровани, че вместо просто да напишат:

wp search-replace old new --export=file.sql

ще трябва да пишат:

wp search-replace old new
wp db export file.sql
wp search-replace new old

И не съм ироничен в случая, защото знам, че old и new вероятно ще са нещо по-дълго. И ако в единия случай wp search-replace $(wp option get siteurl) new да ни върши работа, в обратна посока не е толкова лесно. Вместо това вероятно първо ще е трябвало да сме изпълнили самостоятелно wp option get siteur. Също ще трябва да правим copy-paste между команди. А ако се налага да подаваме допълнителни аргументи става още по-дълго и сложно.

Затова в помощ на миграцията съм разработил custom WP CLI команда, която обединява трите стъпки в една:

wp search-replace-export old new dump.sql

Има и допълнителни аргументи (и примери), с които ви съветвам да се запознаете от документацията, която съм добавил: https://github.com/magadanskiuchen/wp-search-replace-export.

За да използвате командата е достатъчно да я инсталирате като допълнителен пакет с wp package install https://github.com/magadanskiuchen/wp-search-replace-export.git.

Да правим 2 search-replace-а не е ли по-бавно?

Да, по-бавно е. И то точно толкова по-бавно, колкото се очаква — 2 пъти.

Тествайки с реален проект със средна големина, на моя компютър wp search-replace със --export отне около 300ms.

Същият проект с wp search-replace-export отне 600ms. И в двата случая писането на командата е пъти по-времеотнемащо отколкото изпълнението ѝ. Така че лично мене това време не ме притеснява много. Това не е време, което отива при зареждане на всяка страница на сайта. Не, това е време, което отнема изпълнението на вътрешен script, който извикваме само при подготовка на dump за друг сървър.

Ако ситуацията е такава, че трябва да качваме нещо от local dev на production, мене биха ме притеснявали повече времената за:

  1. качване на uploads
  2. качване на останалите промени
  3. import на самата мигрирана база данни
  4. подготовка на static asset build файлове, които вече са стандарт

Всички те отнемат по повече от 1 секунда. Така че забавянето от wp search-replace-export според мене е най-малкият проблем. Работата, която обаче командата върши, е много ценна.

Вашият коментар

Вашият имейл адрес няма да бъде публикуван. Задължителните полета са отбелязани с *

Този сайт използва Akismet за намаляване на спама. Научете как се обработват данните ви за коментари.