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 има касателство.
После пуснах всеки файл по няколко пъти и това, което получих е:
Run | serialize /unserialize | json_encode /json_decode |
---|---|---|
1 | 0.090131998062134 | 0.083483219146729 |
2 | 0.070950031280518 | 0.085309982299805 |
3 | 0.071393966674805 | 0.080581903457642 |
4 | 0.055438041687012 | 0.07876992225647 |
5 | 0.082382917404175 | 0.081361055374146 |
6 | 0.071647167205811 | 0.081040143966675 |
7 | 0.074980020523071 | 0.079762935638428 |
Средно имаме 74ms при PHP сериализация и 81ms при JSON сериализация. С други деми – PHP сериализацията е с около 9% по-бърза. Не е огромно предимство, но няма как да не се отчете.
Все пак очаквах дори по-голямо преимущество. Затова реших да сравня по отделно функциите за сериализиране и десериализиране.
Run | serialize | json_encode |
---|---|---|
1 | 0.047772884368896 | 0.049137115478516 |
2 | 0.045525074005127 | 0.048465013504028 |
3 | 0.050153970718384 | 0.050597190856934 |
4 | 0.050025939941406 | 0.049487113952637 |
5 | 0.049876928329468 | 0.050249099731445 |
6 | 0.055927991867065 | 0.040657997131348 |
7 | 0.04797101020813 | 0.051151990890503 |
Средните стойности са 50ms при serialize
и 49ms при json_encode
. Ако вземем предвид някакво поле на грешка, породена от малък брой данни и тестове – можем да кажем, че са наравно.
Така че идва време на десериализирането:
Run | unserialize | json_decode |
---|---|---|
1 | 0.035761117935181 | 0.03124213218689 |
2 | 0.036687850952148 | 0.050729990005493 |
3 | 0.036988019943237 | 0.050434112548828 |
4 | 0.039380073547363 | 0.03825306892395 |
5 | 0.038769960403442 | 0.050342082977295 |
6 | 0.038247108459473 | 0.048661947250366 |
7 | 0.037105083465576 | 0.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' */;
Това, което той прави е:
- записва стойността на
SQL_MODE
в променливаOLD_SQL_MODE
- задава стойност
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.
Какво можем да направим в такъв случай?
Би било трудно да:
- добавим редовете, които backup-ват стари настройки на базата и ги презаписват с безопасни за import такива
- накрая да върнем старите стойности
А и с излизане на нови версии на MySQL може да се наложи да ги коригираме. Така че това не звучи като добър подход.
Другото е да си правим dump на базата данни с wp db export
. После да импортираме с грешен siteurl
. След import да извикваме wp search-replace
локално и всичко ще е наред. Аз като цяло бих предпочел този начин на работа, тъй като е най-лесен и няма много неща, които да могат да се объркат.
Ако все пак държим да имаме dump с желания siteurl, който просто да import-нем можем:
- първо да направим
wp search-replace
за да преминем към този URL - след това да направим
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, мене биха ме притеснявали повече времената за:
- качване на uploads
- качване на останалите промени
- import на самата мигрирана база данни
- подготовка на static asset build файлове, които вече са стандарт
Всички те отнемат по повече от 1 секунда. Така че забавянето от wp search-replace-export
според мене е най-малкият проблем. Работата, която обаче командата върши, е много ценна.