Son bir neçə ayda üç Booknetic addonu çıxartmışam və üzdən baxanda bunlar bir-birindən tamamilə fərqlidir: oflayn bank köçürməsi ödəniş gateway-i, heç bir gateway əlavə etməyən “ilk dəfə gələn müştərilərə depozit məcbur et” qaydası, və bütöv subdomenləri və xüsusi domenləri (CNAME) düzgün tenant-a yönləndirən, yalnız SaaS üçün olan addon. Fərqli problemlər, üç ayrı git repo. Amma içəridə hamısının ortaq bir onurğası var və əslində danışmaq istədiyim də elə odur — Booknetic-in üzərində öz kodumu necə qurduğum. Çünki addonun saxlanıla bilən qalacağını, yoxsa toxunmağa qorxduğum bir yapışqan yığınına çevriləcəyini həll edən hissə budur.
Booknetic sənədləşdirilmiş bir addon API-si təqdim edir: addonu qeyd etməyin yolu, harada işləyəcəyini müəyyən edən lifecycle hook-ları, SaaS üçün capability qatı, migrasiya mexanizmi və bir sıra add_filter / add_action genişləndirmə nöqtələri. Bütün bunlar platformaya məxsusdur. Mənim işim isə onun üzərində təmiz qurmaqdır. Ona görə bu yazı mənim qərarlarım haqqındadır, Booknetic-in daxili kodunun analizi yox.
Müstəqil plugin, heç vaxt fork yox
Özümə qoyduğum birinci qayda: hər addon öz repo-sunda müstəqil plugin-dir. Booknetic-i heç vaxt fork etmirəm, core-a patch vurmuram, platforma ilə gələn faylı redaktə etmirəm. Etdiyim hər şey Booknetic-in təqdim etdiyi açıq genişləndirmə nöqtələrindən asılır. Bunun qazancı odur ki, core yeniləməsi işimi səssizcə pozа bilmir, və hər addonu kiçik, özü-özünə yetən vahid kimi anlaya bilirəm. Qiyməti isə intizamdır — davranışı dəyişmək istəyirəmsə, mənim olmayan bir şeyə əl atmaq əvəzinə düzgün hook-u tapmalıyam. Bu mübadiləni hər dəfə qəbul edərəm.
Hər addonun paylaşdığı skelet
Addonlarımın hansını açsanız, eyni bir neçə faylı görəcəksiniz. init.php plugin başlığını daşıyır, autoload-ı qurur və addonu Booknetic-in load filtri vasitəsilə qeyd edir. uninstall.php defined('WP_UNINSTALL_PLUGIN') or exit; ilə qorunur. composer.json bir PSR-4 namespace-i App/-a xəritələyir. Addonun əsas sinfi App/-da yaşayır və Booknetic-in addon baza sinfinə qoşulur. Statik hook callback-ləri App/Listener.php-də yaşayır. Asset-lər assets/{backend,frontend}/{js,css}-ə, tərcümələr languages/booknetic-<slug>.pot-a bölünür.
Namespace, qovluq adı, slug və text domain-i üçünün də arasında uyğun saxlayıram — booknetic-bank-transfer, booknetic-force-deposit və booknetic-cname hər biri eyni cür düzülür. Bu uyğunluq öz rahatlığım üçün izlədiyim konvensiyadır ki, heç vaxt tərcümə olunmayan sətrin və ya görünməyən addonun dalınca düşmək məcburiyyətində qalmayım. Uyğun saxlamaq ucuzdur, səhv etmək isə baha, ona görə hər dəfə sadəcə belə edirəm.
Üçdən ikisi (bank-transfer, force-deposit) qatılmış asılılıqlarsız gəlir, ona görə init.php mövcud olanda generasiya olunmuş Composer autoloader-i işlədir, əks halda kiçik bir PSR-4 SPL autoloader qeyd edir ki, serverdə composer install heç vaxt işlədilməyibsə belə plugin qutudan çıxan kimi işləsin. CNAME addonu əksini edir — build olunmuş vendor-dan asılıdır, ona görə sadəcə require_once __DIR__ . '/vendor/autoload.php'; edir, hər yerdə declare(strict_types=1) işlədir, və composer.lock-unu commit edən yeganədir, çünki güvəndiyim dev alətlərini pin edir.
Addonun harada yaşayacağını seçmək
Booknetic-in addon baza sinfi sənə lifecycle metodları verir və sənədləşdirilmiş bölgü faydalı hissədir: həmişə-aktiv bir yol, ayrıca backend və frontend yolları, və yalnız multi-tenant məhsulda mövcud olmalı davranış üçün SaaS-spesifik yollar. Düzgününü seçməyi detal yox, dizayn qərarı kimi qəbul edirəm.
CNAME addonu təmiz nümunədir. O, yalnız Booknetic SaaS-da məna kəsb edir, ona görə adi init yolları boş qalır və bütün real iş SaaS yollarında baş verir. Booknetic-in verdiyi lifecycle ilə vuruşmuram — qoyuram o, kodu düzgün yerə yönləndirsin. Force-deposit isə əksinədir: o, tək-saytlı quraşdırmada da, SaaS-da da eyni davranmalıdır, ona görə məntiqi həmişə-aktiv yolda oturur və dəyişən yeganə şey SaaS capability gate-idir.
SaaS üçün gate-i platformanın istədiyi kimi
Booknetic-in SaaS qatı capability sistemi təqdim edir: super-admin-in plan üzrə yandırıb-söndürdüyü plan səviyyəli capability-lər və tenant-ın öz istifadəçilərinin nə edə biləcəyi üçün rol səviyyəli capability-lər. Addonun SaaS-da düzgün işləməsini istəyəndə öz icazə sxemimi uydurmaq əvəzinə həmin sistem vasitəsilə qeyd edirəm və feature-ləri onun üzərində gate edirəm. Məsələn, bank-transfer əlaqəli capability-lərdən ibarət kiçik bir ağac qeyd edir — bir settings cap-i, bir “köçürmələri gör” cap-i, və onun altında iç-içə approve/reject cap-ləri — və mənim handler-lərimin hər biri nəsə etməzdən əvvəl düzgününü yoxlayır.
Mənim tərəfimdə vacib olan odur ki, gate öz kod yollarımın ən başında və hər AJAX handler-in içində oturur, beləcə capability-si olmayan tenant-ın keçə biləcəyi yol yoxdur. “Bu icazəlidirmi?” sualını hər dəfə funksiyanın birinci sətri kimi qəbul edirəm və ona cavab vermək üçün Booknetic-in capability yoxlamalarına söykənirəm.
Anbar: default-ları verilənlər bazasının məcbur edə biləcəyi yerdə kodlaşdırıram
Addona anbar lazım olanda, qaça bildiyim yerdə sxemi əldə düzəltmək əvəzinə Booknetic-in migrasiya mexanizminə söykənirəm. Sahib olduğum cədvəlin forması budur, verilənlər bazasının özünün invariantları məcbur etməsi üçün yazılmışdır:
CREATE TABLE IF NOT EXISTS `{tableprefix}cname_domains` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`tenant_id` INT UNSIGNED NOT NULL,
`hostname` VARCHAR(253) NOT NULL,
`verification_status` VARCHAR(16) NOT NULL DEFAULT 'pending',
`is_primary` TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_hostname` (`hostname`),
KEY `idx_tenant_primary` (`tenant_id`,`is_primary`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Əhəmiyyət verdiyim seçim budur: default-ları və unikallığı DDL-ə itələyirəm (DEFAULT 'pending', hostname üzərindəki unique key) ki, data qatı həqiqətin mənbəyi olsun, ora-bura səpələnmiş PHP yox. Həmin UNIQUE KEY “iki tenant eyni host-u sahiblənə bilməz”i ümid yox, zəmanət edən şeydir. Bank-transfer-ə fərqli lifecycle lazım olanda qoydum o, öz cədvəlini idarə etsin və uninstall zamanı cədvəli qəsdən saxladım ki, yenidən quraşdırma tenant-ın tarixçəsini silməsin — bir dəfə kodlaşdırılmış məhsul qərarı.
Təmiz servis + nazik listener
Üçünün də arxasındakı memarlıq tezisi budur və ən qürur duyduğum hissədir. Hər real qərarı içində heç bir framework olmayan adi servis sinfində saxlayıram, və hər WordPress və Booknetic çağırışını nazik bir listener-də karantinə alıram. Servis plugin içində işlədiyini bilmir; listener qərar vermir, sadəcə giriş-çıxışı qoşur.
ForceDepositService ən təmiz nümunədir — iki statik metod, isFirstTime() və resolveForcedDeposit(), adi dəyərlər alıb adi dəyərlər qaytaran. Bütün faktiki siyasət (faiz hesabı, sabit məbləğ, ikisinin böyüyü, subtotal limiti, sıfır-subtotal sərhədi) orada yaşayır, framework-siz, və heç bir WordPress yüklənməmiş halda işləyən unit testlərlə əhatə olunur. Bootstrap sadəcə ABSPATH-ı define edir ki, qorunan giriş faylları exit etməsin, və testlər riyaziyyatı birbaşa işlədir.
final class ForceDepositService
{
public static function resolveForcedDeposit(float $subtotal, array $rule): float
{
$percentPart = $subtotal * ($rule['percent'] ?? 0) / 100;
$fixedPart = (float) ($rule['fixed'] ?? 0);
$amount = max($percentPart, $fixedPart);
return min($amount, $subtotal); // heç vaxt rezervasiyadan çox tutma
}
}
Həmin tək metodun arxasında səkkiz test var — məhz ona görə ki, pul riyaziyyatı səssiz bir nə-isə-xətasının real müştəriyə real pula başa gəldiyi yerdir.
CNAME eyni fikri ports-and-adapters-ə daha da aparır. DomainResolver::resolve($host, $baseDomain): ResolutionResult təxminən on iki hallı həqiqət cədvəli üzərində təmiz funksiyadır — apex passthrough, www→root redirect, primary host-a kanonik redirect, wildcard/naməlum host, tenant uyğunluğu. O, heç bir I/O etmir. Yan effektlər ayrıca bir adapter-də yaşayır — o, təmiz resolver-i çağırır və sonra WordPress ilə danışır. Onun xarici dünyadan ehtiyac duyduğu hissələr — hostname lookup, DNS lookup, verification state-i saxlamaq üçün yer — kiçik interfeyslərdir. İstehsalatda real adapter-lər alır (DNS olanı dns_get_record(..., DNS_TXT)-i sarıyır); testlərdə in-memory fake-lər alır, beləcə bütöv resolver test dəsti DNS-ə və ya verilənlər bazasına toxunmadan işləyir. Layihəni ciddi səviyyədə PHPStan-təmiz saxlayıram və hər dəyişiklikdə işlədirəm.
İnterfeyslərlə əlləşməyimin səbəbi təmizlik xatirinə təmizlik deyil. Səbəb odur ki, testə fake DNS server qoyub TXT record yoxdursa, səhvdirsə, ya düzdürsə dəqiq nə baş verdiyini yoxlaya bilirəm — real internetə qarşı təkrar etmək əzab olan hallar.
Stub testlərin öyrətmədiyi dərs
Dürüst hissə budur. Bütün həmin unit testlər mənə məntiqə real inam verdi və hər dəqiqəyə dəydi. Amma onlar mənə buraxılış yolu ilə yalan da dedilər: təcrid içində mükəmməl olan servis hələ də səhv hook-a qoşula, səhv vaxtda işləyə, ya da ətrafdakı axının gözləmədiyi bir formanı geri verə bilər. Stub-lar sərhədin necə olduğunu düşündüyümü yoxlayır, faktiki necə olduğunu yox.
Ona görə indiki qaydam budur: unit testlər qərarı sübut edir, canlı smoke test isə inteqrasiyanı sübut edir. Məhz bunun üçün lokal Docker Booknetic SaaS saytı saxlayıram. Yaşıl test işindən sonra addonu orada quraşdırıram, real axını kliklə keçirəm — rezervasiya et, ödə, təsdiqlə, ya CNAME halında bir host-u ona yönəlt və redirect-ə bax — və yalnız ondan sonra inanıram. Real çıxartdığım hər inteqrasiya baqı unit dəstimin yanından keçərdi; onları tutan Docker saytındakı smoke test idi. Bank-transfer-in “müştərini sonsuz spinner-də qoyma” davranışı və CNAME-in hook sıralaması — hər ikisi yalnız canlıda üzə çıxdı, heç vaxt stub-da yox.
Feature ideyadan merge-ə necə gəlir
Workflow hər addon üçün eyni dövrədir və onu darıxdırıcı saxlamaq əsas məsələdir. Əvvəlcə beyin fırtınası edirəm — sadəcə özümlə mübahisə edirəm ki, bu şey nə etməli və nə etməməlidir, sərhədlər haradadır, qəsdən nəyi kənarda qoyuram. Bu, qısa bir spesifikasiyaya çevrilir: davranış adi dillə, sahib olduğu data, uğursuzluq halları. Spesifikasiya bir-bir bitirib yoxlaya biləcəyim kiçik chunk-lara bölünmüş plana çevrilir. Sonra chunk-ları işləyirəm: servisi və testlərini yazıram, nazik listener-i yazıram, PHPStan və unit dəstini işlədirəm, və növbətiyə keçməzdən əvvəl chunk-ı Docker SaaS saytında smoke-test edirəm.
Beyin fırtınası → spesifikasiya → plan → chunk-lar mərasim deyil. Bu, feature-in dağılmasının qarşısını necə aldığımdır, və hər addonun servis siniflərinin spesifikasiya kimi oxunacaq, listener-lərinin isə az qala darıxdırıcı olacaq qədər kiçik çıxmasının səbəbidir. Darıxdırıcı listener-lər məqsəddir.
Məsələnin forması budur. Müstəqil plugin qal, yalnız Booknetic-in verdiyi genişləndirmə nöqtələri üzərində qur və onları dürüst attribut et, hər real qərarı WordPress-siz test edə biləcəyin framework-siz servisdə saxla, invariantlarını verilənlər bazasına itələ, və şeyi real saytda işlədiyini görənə qədər heç bir yaşıl unit işinə güvənmə. Məntiq qayğımı xərclədiyim yerdir; inteqrasiya isə həqiqətin yaşadığı yer.