العمل مع قواعد البيانات من خلال عيون المطور



عند تطوير وظائف جديدة باستخدام قاعدة بيانات ، تتضمن دورة التطوير عادةً (على سبيل المثال لا الحصر) الخطوات التالية:



كتابة عمليات ترحيل SQL ← كتابة التعليمات البرمجية ← الاختبار ← الإصدار ← المراقبة.



في هذه المقالة ، أود أن أشارك بعض النصائح العملية حول كيفية تقليل وقت هذه الدورة في كل مرحلة ، مع عدم تقليل الجودة ، بل زيادتها. 



نظرًا لأننا نعمل مع PostgreSQL في الشركة ، ونكتب رمز الخادم في Java ، فستعتمد الأمثلة على هذه المجموعة ، على الرغم من أن معظم الأفكار لا تعتمد على قاعدة البيانات ولغة البرمجة المستخدمة.



ترحيل SQL



المرحلة الأولى من التطوير بعد التصميم هي كتابة ترحيل SQL. النصيحة الرئيسية - لا تقم بإجراء أي تغييرات يدوية على مخطط البيانات ، ولكن قم بذلك دائمًا من خلال البرامج النصية وقم بتخزينها في مكان واحد. 



في شركتنا ، يكتب المطورون عمليات ترحيل SQL بأنفسهم ، لذلك يتم تخزين جميع عمليات الترحيل في مستودع مع الكود الرئيسي. في بعض الشركات ، يشارك مسؤولو قاعدة البيانات في تغيير المخطط ، وفي هذه الحالة يكون سجل الترحيل في مكان ما معهم. بطريقة أو بأخرى ، يجلب هذا النهج المزايا التالية:



  • يمكنك دائمًا إنشاء قاعدة جديدة بسهولة من البداية أو ترقية قاعدة موجودة إلى الإصدار الحالي. يتيح لك هذا النشر السريع لبيئات الاختبار وبيئات التطوير المحلية.
  • جميع القواعد لها نفس التصميم - لا مفاجآت في الخدمة.
  • يوجد محفوظات لجميع التغييرات (تعيين الإصدار).


هناك العديد من الأدوات الجاهزة لأتمتة هذه العملية، سواء التجارية والحرية: جويا ، liquibase ، sqitch ، وما إلى ذلك في هذه المقالة وأنا لن مقارنة واختيار أفضل وسيلة - وهذا هو موضوع كبير منفصل، ويمكنك العثور على العديد من المقالات على ذلك ... 



نحن نستخدم flyway ، لذا إليك بعض المعلومات عنها:



  • هناك 2 أنواع الهجرات: القائم على مزود و جافا المستندة إلى
  • عمليات ترحيل SQL غير قابلة للتغيير (غير قابلة للتغيير). بعد التنفيذ الأول ، لا يمكن تغيير ترحيل SQL. يقوم Flyway بحساب المجموع الاختباري لمحتويات ملف الترحيل والتحقق منه عند كل تشغيل. مطلوب معالجات يدوية إضافية لجعل عمليات الترحيل Java غير قابلة للتغيير .
  • flyway_schema_history ( schema_version). , , , .


وفقًا لاتفاقياتنا الداخلية ، يتم إجراء جميع تغييرات مخطط البيانات فقط من خلال عمليات ترحيل SQL. تضمن ثباتها أنه يمكننا دائمًا الحصول على مخطط فعلي مطابق تمامًا لجميع البيئات. 



تُستخدم عمليات ترحيل Java لـ DML فقط ، عندما يكون من المستحيل الكتابة بلغة SQL نقية. بالنسبة لنا ، المثال النموذجي لمثل هذا الموقف هو عمليات الترحيل لنقل البيانات إلى Postgres من قاعدة بيانات أخرى (نحن ننتقل من Redis إلى Postgres ، لكن هذه قصة مختلفة تمامًا). مثال آخر هو تحديث بيانات جدول كبير ، والذي يتم إجراؤه في العديد من المعاملات لتقليل وقت قفل الجدول. تجدر الإشارة إلى أنه من الإصدار الحادي عشر من Postgres ، يمكن القيام بذلك باستخدام إجراءات SQL على plpgsql.



عندما يكون كود Java قديمًا ، يمكن إزالة الترحيل حتى لا يتم إنتاج تراث (تظل فئة ترحيل Java نفسها ، ولكن بداخلها فارغة). بالنسبة لنا ، لا يمكن أن يحدث هذا قبل شهر من الانتقال إلى الإنتاج - نعتقد أن هذا وقت كافٍ لتحديث جميع بيئات الاختبار وبيئات التطوير المحلية. تجدر الإشارة إلى أنه نظرًا لأن عمليات ترحيل Java تُستخدم فقط لـ DML ، فإن إزالتها لا تؤثر على إنشاء قواعد بيانات جديدة من البداية بأي شكل من الأشكال.



فارق بسيط مهم لأولئك الذين يستخدمون pg_bouncer



يطبق Flyway قفلًا أثناء الترحيل لمنع التنفيذ المتزامن لعمليات الترحيل المتعددة. بشكل مبسط ، يعمل على النحو التالي:



  • يحدث التقاط القفل 
  • أداء عمليات الترحيل في معاملات منفصلة
  • رفع الحظر. 


بالنسبة لـ Postgres ، فإنه يستخدم أقفالًا استشارية في وضع الجلسة ، مما يعني أنه لكي يعمل بشكل صحيح ، من الضروري تشغيل خادم التطبيق على نفس الاتصال أثناء التقاط القفل وإطلاقه. إذا كنت تستخدم pg_bouncer في وضع المعاملات (وهو الأكثر شيوعًا) أو في وضع الطلب الفردي ، فقد تُعيد اتصالاً جديدًا لكل معاملة ولن تتمكن flyway من تحرير قفل محدد. 



لحل هذه المشكلة ، نستخدم تجمع اتصال صغير منفصل على pg_bouncer في وضع الجلسة ، وهو مخصص لعمليات الترحيل فقط. من جانب التطبيق ، يوجد أيضًا مجمع منفصل يحتوي على اتصال واحد ، ويتم إغلاقه بسبب انتهاء المهلة بعد الترحيل ، حتى لا تضيع الموارد.



الترميز



تم إنشاء الترحيل ، والآن نكتب الرمز.



هناك 3 طرق للعمل مع قاعدة البيانات من جانب التطبيق:



  • استخدام ORM (إذا تحدثنا عن Java ، فإن السبات هو المعيار الفعلي)
  • باستخدام عادي sql + jdbcTemplate إلخ.
  • استخدام مكتبات DSL.


يتيح لك استخدام ORM تقليل متطلبات معرفة SQL - يتم إنشاء الكثير تلقائيًا: 

  • يمكن إنشاء مخطط البيانات من xml-description أو Java- الكيان المتوفر في الكود
  • يتم تعريف علاقات الكائن باستخدام وصف تعريفي - ستعمل ORM على إنشاء صلات لك
  • عند استخدام Spring Data JPA ، يمكن أيضًا إنشاء المزيد من الاستعلامات الصعبة تلقائيًا من خلال توقيع أسلوب المستودع .


"المكافأة" الأخرى هي وجود تخزين مؤقت للبيانات خارج الصندوق (للإسبات ، هذه هي 3 مستويات من ذاكرات التخزين المؤقت).



لكن من المهم أن نلاحظ أن ORM ، مثل أي أداة قوية أخرى ، تتطلب مؤهلات معينة عند استخدامها. بدون التكوين المناسب ، ستعمل الشفرة على الأرجح ، ولكنها بعيدة عن أن تكون مثالية.



العكس هو كتابة SQL باليد. يتيح لك هذا التحكم الكامل في طلباتك - يتم تنفيذ ما كتبته بالضبط ، ولا مفاجآت. لكن من الواضح أن هذا يزيد من حجم العمل اليدوي ويزيد من متطلبات مؤهلات المطورين.



مكتبات DSL



في المنتصف تقريبًا بين هذه الأساليب ، هناك طريقة أخرى تتمثل في استخدام مكتبات DSL ( jOOQ ، Querydsl ، إلخ). عادة ما تكون أخف بكثير من ORMs ، ولكنها أكثر ملاءمة من عمل قاعدة البيانات اليدوي بالكامل. يعد استخدام DSLs أقل شيوعًا ، لذا ستلقي هذه المقالة نظرة سريعة على هذا النهج. 



سنتحدث عن إحدى المكتبات - jOOQ . ماذا تقدم:



  • فحص قاعدة البيانات والتوليد التلقائي للفئات
  • بطلاقة API لكتابة الطلبات.


jOOQ ليس عبارة عن ORM - لا يوجد إنشاء تلقائي للاستعلامات ، ولا تخزين مؤقت ، ولكن في الوقت نفسه ، يتم إغلاق بعض مشكلات النهج اليدوي تمامًا:

  • يتم إنشاء فئات للجداول ، وجهات النظر ، والوظائف ، وما إلى ذلك ، كائنات قاعدة البيانات تلقائيًا 
  • تتم كتابة الطلبات بلغة Java ، وهذا يضمن النوع الآمن - لن يتم ترجمة طلب غير صحيح من الناحية التركيبية أو طلب مع معلمة من النوع الخاطئ - سيطالبك IDE على الفور بخطأ ، ولن تضطر إلى إضاعة الوقت في تشغيل التطبيق للتحقق من صحة الطلب. يؤدي ذلك إلى تسريع عملية التطوير وتقليل احتمالية حدوث أخطاء.


في الكود ، تبدو الطلبات كما يلي:



BookRecord book = dslContext.selectFrom(BOOK)
                        .where(BOOK.LANGUAGE.eq("DE"))
                        .orderBy(BOOK.TITLE)
                        .fetchAny();


يمكنك استخدام لغة SQL العادية إذا كنت تريد:



Result<Record> records = dslContext.fetch("SELECT * FROM BOOK WHERE LANGUAGE = ? ORDER BY TITLE LIMIT 1", "DE");


من الواضح ، في هذه الحالة ، أن صحة الاستعلام وتحليل النتائج تقع على عاتقك تمامًا.



سجل jOOQ و POJO



BookRecord في المثال أعلاه عبارة عن غلاف فوق صف في جدول الكتاب ويقوم بتنفيذ نمط السجل النشط . نظرًا لأن هذه الفئة هي جزء من طبقة الوصول إلى البيانات (إلى جانب تنفيذها المحدد) ، فقد لا ترغب في نقلها إلى طبقات أخرى من التطبيق ، ولكنك تستخدم نوعًا من كائن pojo الخاص بك. لتوفير الراحة لتحويل سجل <-> POJO جوخ العروض عدة آليات: التلقائي و دليل . يحتوي توثيق الروابط أعلاه على مجموعة متنوعة من أمثلة القراءة ، ولكن لا توجد أمثلة لإدخال بيانات جديدة وتحديثها. دعونا نملأ هذه الفجوة: 



private static final RecordUnmapper<Book, BookRecord> unmapper = 
    book -> new BookRecord(book.getTitle(), ...); // - 

public void create(Book book) {
    context.insertInto(BOOK)
            .set(unmapper.unmap(book))
            .execute();
}


كما ترى ، كل شيء بسيط للغاية.



يسمح لك هذا الأسلوب بإخفاء تفاصيل التنفيذ داخل فئة طبقة الوصول إلى البيانات وتجنب "التسرب" إلى طبقات أخرى من التطبيق. 



يمكن لـ jooq أيضًا إنشاء فئات DAO مع مجموعة من الأساليب الأساسية لتبسيط العمل مع بيانات الجدول وتقليل مقدار الكود اليدوي (هذا يشبه إلى حد كبير Spring Data JPA):



public interface DAO<R extends TableRecord<R>, P, T> {
    void insert(P object) throws DataAccessException;    
    void update(P object) throws DataAccessException;
    void delete(P... objects) throws DataAccessException;
    void deleteById(T... ids) throws DataAccessException;
    boolean exists(P object) throws DataAccessException;
    ...
}


في الشركة ، لا نستخدم التوليد التلقائي لفئات DAO - فنحن نقوم فقط بإنشاء أغلفة فوق كائنات قاعدة البيانات ، ونكتب الاستعلامات بأنفسنا. يحدث إنشاء الأغلفة في كل مرة يتم فيها إعادة بناء وحدة منفصلة ، حيث يتم تخزين عمليات الترحيل. بعد ذلك بقليل ، ستكون هناك تفاصيل حول كيفية تنفيذ ذلك.



اختبارات



تعد اختبارات الكتابة جزءًا مهمًا من عملية التطوير - الاختبارات الجيدة تضمن جودة الكود الخاص بك وتوفر الوقت مع الحفاظ عليه. في الوقت نفسه ، من العدل أن نقول إن العكس صحيح أيضًا - الاختبارات السيئة يمكن أن تخلق وهم رمز الجودة ، وتخفي الأخطاء ، وتبطئ عملية التطوير. وبالتالي ، لا يكفي أن تقرر فقط أنك ستكتب الاختبارات ، بل عليك القيام بذلك بشكل صحيح . في الوقت نفسه ، فإن مفهوم صحة الاختبارات غامض للغاية وكل شخص لديه القليل من الخاص به. 



الشيء نفسه ينطبق على مسألة تصنيف الاختبار. تقترح هذه المقالة استخدام خيار التقسيم التالي:



  • اختبار الوحدة (اختبار الوحدة) 
  • اختبار التكامل
  • الاختبار الشامل (من طرف إلى طرف).


يتضمن اختبار الوحدة التحقق من وظائف الوحدات الفردية بمعزل عن بعضها البعض. حجم الوحدة هو مرة أخرى شيء غير محدد ، بالنسبة للبعض هو طريقة منفصلة ، بالنسبة للبعض هو فئة. تعني العزلة أن جميع الوحدات الأخرى عبارة عن نماذج وهمية أو بذرة (في اللغة الروسية ، هذه تقليد أو بذرة ، لكنها بطريقة ما لا تبدو جيدة جدًا). اتبع هذا الرابط لقراءة مقال مارتن فاولر عن الفرق بين الاثنين. اختبارات الوحدة صغيرة وسريعة ، لكنها تضمن فقط صحة منطق الوحدة الفردية.



اختبارات التكاملعلى عكس اختبارات الوحدة ، فإنها تتحقق من تفاعل عدة وحدات مع بعضها البعض. يعتبر العمل مع قاعدة بيانات مثالاً جيدًا عندما تكون اختبارات التكامل منطقية ، لأنه من الصعب جدًا "قفل" قاعدة بيانات بجودة عالية ، مع مراعاة جميع الفروق الدقيقة فيها. تعد اختبارات التكامل في معظم الحالات بمثابة حل وسط جيد بين سرعة التنفيذ وضمان الجودة عند اختبار قاعدة بيانات مقارنة بأنواع الاختبارات الأخرى. لذلك ، في هذه المقالة سوف نتحدث أكثر عن هذا النوع من الاختبارات. يعتبر



الاختبار الشامل هو الأكثر شمولاً. لتنفيذه ، من الضروري رفع البيئة بأكملها. يضمن أعلى مستوى من الثقة في جودة المنتج ، ولكنه الأبطأ والأغلى.



اختبار التكامل



عندما يتعلق الأمر باختبار تكامل الكود الذي يعمل مع قاعدة بيانات ، فإن معظم المطورين يسألون أنفسهم أسئلة: كيفية بدء قاعدة البيانات ، وكيفية تهيئة حالتها بالبيانات الأولية ، وكيفية القيام بذلك في أسرع وقت ممكن؟



منذ بعض الوقت ، كان h2 ممارسة شائعة إلى حد ما في اختبار التكامل . إنها قاعدة بيانات في الذاكرة مكتوبة بلغة جافا ولديها أوضاع توافق مع معظم قواعد البيانات الشائعة. إن عدم الحاجة إلى تثبيت قاعدة بيانات وتعدد استخدامات h2 جعلها بديلاً مناسبًا جدًا لقواعد البيانات الحقيقية ، خاصةً إذا كان التطبيق لا يعتمد على قاعدة بيانات محددة ويستخدم فقط ما هو مدرج في معيار SQL (وهذا ليس هو الحال دائمًا). 



لكن المشاكل تبدأ في الوقت الحالي عندما تستخدم بعض وظائف قاعدة البيانات الصعبة (أو وظيفة جديدة تمامًا من إصدار جديد) ، والتي لم يتم تنفيذ الدعم لها في h2. وبشكل عام ، نظرًا لأن هذه "محاكاة" لنظام DBMS معين ، يمكن دائمًا أن تكون هناك بعض الاختلافات في السلوك.



خيار آخر هو استخدام postgres المضمنة . هذا هو Postgres حقيقي ، يتم شحنه كأرشيف ولا يتطلب التثبيت. يسمح لك بالعمل كإصدار عادي من Postgres. 



هناك العديد من التطبيقات ، أشهرها من Yandex و openTable... نحن في الشركة استخدمنا الإصدار من Yandex. من بين السلبيات - إنه بطيء جدًا عند بدء التشغيل (في كل مرة يتم فيها تفريغ الأرشيف وبدء تشغيل قاعدة البيانات - يستغرق الأمر من 2 إلى 5 ثوانٍ ، اعتمادًا على قوة الكمبيوتر) ، وهناك أيضًا مشكلة في التأخر في إصدار الإصدار الرسمي. لقد واجهنا أيضًا مشكلة أنه بعد محاولة التوقف عن الشفرة ، حدث خطأ ما وظلت عملية Postgres معلقة في نظام التشغيل - كان عليك قتلها يدويًا. 



حاويات الاختبار



الخيار الثالث هو استخدام عامل الإرساء. بالنسبة لجافا ، توجد مكتبة حاويات اختبار توفر واجهة برمجة تطبيقات للعمل مع حاويات عامل الإرساء من التعليمات البرمجية. وبالتالي ، يمكن استبدال أي تبعية في تطبيقك تحتوي على صورة عامل إرساء في الاختبارات باستخدام حاويات الاختبار. أيضًا ، بالنسبة للعديد من التقنيات الشائعة ، هناك فئات منفصلة جاهزة توفر واجهة برمجة تطبيقات أكثر ملاءمة ، اعتمادًا على الصورة المستخدمة:





بالمناسبة ، عندما أصبح مشروع tescontainers شائعًا للغاية ، أعلن مطورو yandex رسميًا أنهم توقفوا عن تطوير مشروع postgres المضمن ونصحوا بالتبديل إلى حاويات الاختبار.



ما هي الايجابيات:



  • حاويات الاختبار سريعة (يستغرق بدء Postgres فارغة أقل من ثانية)
  • يصدر مجتمع postgres صور عامل ميناء رسمي لكل إصدار جديد
  • تحتوي حاويات الاختبار على عملية خاصة تقتل الحاويات المتدلية بعد إغلاق jvm ، إلا إذا قمت بذلك بشكل برمجي
  • مع testcontainers ، يمكنك استخدام نهج موحد لاختبار التبعيات الخارجية للتطبيق الخاص بك ، مما يسهل الأمور بشكل واضح.


مثال اختبار باستخدام Postgres:



@Test
public void testSimple() throws SQLException {
    try (PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>()) {
        postgres.start();
        ResultSet resultSet = performQuery(postgres, "SELECT 1");
        int resultSetInt = resultSet.getInt(1);
        assertEquals("A basic SELECT query succeeds", 1, resultSetInt);
    }
}


إذا كان هناك أي فئة منفصلة للصورة في testcontainers، ثم خلق تبدو الحاويات مثل هذا :



public static GenericContainer redis = new GenericContainer("redis:3.0.2")
            .withExposedPorts(6379);


إذا كنت تستخدم JUnit4 أو JUnit5 أو Spock ، فإن حاويات الاختبار تحتوي على المزيد. دعم هذه الأطر ، مما يجعل كتابة الاختبارات أسهل.



تسريع الاختبارات باستخدام حاويات الاختبار



على الرغم من أن التبديل من postgres المضمنة إلى حاويات الاختبار جعل اختباراتنا أسرع عن طريق تشغيل Postgres بشكل أسرع ، إلا أن الاختبارات بدأت تتباطأ مرة أخرى بمرور الوقت. ويرجع ذلك إلى زيادة عدد عمليات ترحيل SQL التي يتم تنفيذها بواسطة flyway عند بدء التشغيل. عندما تجاوز عدد عمليات الترحيل المائة ، كان وقت التنفيذ حوالي 7-8 ثوانٍ ، مما أدى إلى إبطاء الاختبارات بشكل كبير. عملت شيئًا كهذا:



  1. قبل فصل الاختبار التالي ، تم إطلاق حاوية "نظيفة" تحتوي على Postgres
  2. نفذت flyway الهجرات
  3. تم إجراء اختبارات لهذه الفئة
  4. تم إيقاف الحاوية وإزالتها
  5. كرر من البند 1 لفئة الاختبار التالية.


من الواضح ، بمرور الوقت ، استغرقت الخطوة الثانية المزيد والمزيد من الوقت.



في محاولة لحل هذه المشكلة ، أدركنا أنه يكفي إجراء عمليات الترحيل مرة واحدة فقط قبل كل الاختبارات ، وحفظ حالة الحاوية ثم استخدام هذه الحاوية في جميع الاختبارات. لذلك تغيرت الخوارزمية:



  1. يتم تشغيل حاوية "نظيفة" مع Postgres قبل كل الاختبارات
  2. Flyway ينفذ الهجرات
  3. استمرت حالة الحاوية
  4. قبل فصل الاختبار التالي ، يتم تشغيل حاوية معدة مسبقًا
  5. يتم تنفيذ اختبارات هذه الفئة
  6. الحاوية تتوقف وتتم إزالتها
  7. كرر من الخطوة 4 لفئة الاختبار التالية.


الآن لا يعتمد وقت تنفيذ الاختبار الفردي على عدد عمليات الترحيل ، ومع العدد الحالي لعمليات الترحيل (200+) ، يوفر النظام الجديد عدة دقائق في كل عملية تشغيل لجميع الاختبارات.



فيما يلي بعض التفاصيل الفنية حول كيفية تنفيذ ذلك.



يحتوي Docker على آلية مضمنة لإنشاء صورة جديدة من حاوية قيد التشغيل باستخدام أمر التنفيذ . يسمح لك بتخصيص الصور ، على سبيل المثال ، عن طريق تغيير أي إعدادات. 



تحذير مهم هو أن الأمر لا يحفظ بيانات الأقسام المركبة. ولكن إذا أخذت صورة عامل إرساء Postgres الرسمية ، فسيكون دليل PGDATA ، الذي يتم تخزين البيانات فيه ، موجودًا في قسم منفصل (بحيث لا يتم فقدان البيانات بعد إعادة تشغيل الحاوية) ، لذلك ، عند تنفيذ الالتزام ، لا يتم حفظ حالة قاعدة البيانات نفسها. 



الحل بسيط - لا تستخدم قسم PGDATA ، ولكن احتفظ بالبيانات في الذاكرة ، وهو أمر طبيعي تمامًا للاختبارات. هناك طريقتان للقيام بذلك - استخدم ملف عامل الإرساء (شيء من هذا القبيل) بدون إنشاء قسم ، أو تجاوز متغير PGDATA عند بدء الحاوية الرسمية (سيبقى القسم ، لكن لن يتم استخدامه). الطريقة الثانية تبدو أبسط بكثير:



PostgreSQLContainer<?> container = ...
container.addEnv("PGDATA", "/var/lib/postgresql/data-no-mounted");
container.start();


قبل الالتزام، فمن المستحسن أن نقطة تفتيش بوستجرس للتغيرات تدفق من مخازن المشترك "القرص" (والتي تتطابق مع متغير PGDATA متجاوزة):



container.execInContainer("psql", "-c", "checkpoint");


الالتزام نفسه يسير على النحو التالي:



CommitCmd cmd = container.getDockerClient().commitCmd(container.getContainerId())
                .withMessage("Container for integration tests. ...")
                .withRepository(imageName)
                .withTag(tag);
String imageId = cmd.exec();


وتجدر الإشارة إلى أن هذا الأسلوب باستخدام الصور المعدة يمكن تطبيقه على العديد من الصور الأخرى ، مما سيوفر أيضًا الوقت عند إجراء اختبارات التكامل.



بضع كلمات أخرى حول تحسين وقت الإنشاء



كما ذكرنا سابقًا ، عند تجميع وحدة منفصلة مع عمليات الترحيل ، من بين أشياء أخرى ، يتم إنشاء أغلفة جافا فوق كائنات قاعدة البيانات. لهذا الغرض ، يتم استخدام مكون إضافي مافن مكتوب ذاتيًا ، والذي يتم تشغيله قبل تجميع الكود الرئيسي وتنفيذ 3 إجراءات:



  1. يشغل حاوية عامل ميناء "نظيفة" مع postgres
  2. إطلاق Flyway ، الذي ينفذ عمليات ترحيل SQL لجميع قواعد البيانات ، وبالتالي التحقق من صحتها
  3. يقوم بتشغيل Jooq ، الذي يفحص مخطط قاعدة البيانات وينشئ فئات جافا للجداول وطرق العرض والوظائف وكائنات المخطط الأخرى.


كما ترى بسهولة ، فإن أول خطوتين متطابقتين مع تلك التي يتم إجراؤها عند إجراء الاختبارات. لتوفير الوقت عند بدء الحاوية وتشغيل عمليات الترحيل قبل الاختبارات ، نقلنا حفظ حالة الحاوية إلى مكون إضافي. وهكذا ، الآن فور إعادة بناء الوحدة ، تظهر الصور الجاهزة لاختبارات التكامل لجميع قواعد البيانات المستخدمة في الكود في المستودع المحلي لصور عامل الإرساء.



مثال كود أكثر تفصيلا
@ThreadSafe
public class PostgresContainerAdapter implements PostgresExecutable {
  private static final String ORIGINAL_IMAGE = "postgres:11.6-alpine";

  @GuardedBy("this")
  @Nullable
  private PostgreSQLContainer<?> container; // not null if it is running

  @Override
  public synchronized String start(int port, String db, String user, String password) 
  {
    Preconditions.checkState(container == null, "postgres is already running");

    PostgreSQLContainer<?> newContainer = new PostgreSQLContainer<>(ORIGINAL_IMAGE)
        .withDatabaseName(db)
        .withUsername(user)
        .withPassword(password);

    newContainer.addEnv("PGDATA", "/var/lib/postgresql/data-no-mounted");

    // workaround for using fixed port instead of random one chosen by docker
    List<String> portBindings = new ArrayList<>(newContainer.getPortBindings());
    portBindings.add(String.format("%d:%d", port, POSTGRESQL_PORT));
    newContainer.setPortBindings(portBindings);
    newContainer.start();

    container = newContainer;
    return container.getJdbcUrl();
  }

  @Override
  public synchronized void saveState(String name) {
    try {
      Preconditions.checkState(container != null, "postgres isn't started yet");

      // flush all changes
      doCheckpoint(container);

      commitContainer(container, name);
    } catch (Exception e) {
      stop();
      throw new RuntimeException("Saving postgres container state failed", e);
    }
  }

  @Override
  public synchronized void stop() {
    Preconditions.checkState(container != null, "postgres isn't started yet");

    container.stop();
    container = null;
  }

  private static void doCheckpoint(PostgreSQLContainer<?> container) {
    try {
      container.execInContainer("psql", "-c", "checkpoint");
    } catch (IOException | InterruptedException e) {
      throw new RuntimeException(e);
    }
  }

  private static void commitContainer(PostgreSQLContainer<?> container, String image)
  {
    String tag = "latest";
    container.getDockerClient().commitCmd(container.getContainerId())
        .withMessage("Container for integration tests. It uses non default location for PGDATA which is not mounted to a volume")
        .withRepository(image)
        .withTag(tag)
        .exec();
  }
  // ...
}


( «start»):

@Mojo(name = "start")
public class PostgresPluginStartMojo extends AbstractMojo {
  private static final Logger logger = LoggerFactory.getLogger(PostgresPluginStartMojo.class);

  @Nullable
  static PostgresExecutable postgres;

  @Parameter(defaultValue = "5432")
  private int port;
  @Parameter(defaultValue = "dbName")
  private String db;
  @Parameter(defaultValue = "userName")
  private String user;
  @Parameter(defaultValue = "password")
  private String password;

  @Override
  public void execute() throws MojoExecutionException {
    if (postgres != null) { 
      logger.warn("Postgres already started");
      return;
    }
    logger.info("Starting Postgres");
    if (!isDockerInstalled()) {
      throw new IllegalStateException("Docker is not installed");
    }
    String url = start();
    testConnection(url, user, password);
    logger.info("Postgres started at " + url);
  }

  private String start() {
    postgres = new PostgresContainerAdapter();
    return postgres.start(port, db, user, password);
  }

  private static void testConnection(String url, String user, String password) throws MojoExecutionException {
    try (Connection conn = DriverManager.getConnection(url, user, password)) {
      conn.createStatement().execute("SELECT 1");
    } catch (SQLException e) {
      throw new MojoExecutionException("Exception occurred while testing sql connection", e);
    }
  }

  private static boolean isDockerInstalled() {
    if (CommandLine.executableExists("docker")) {
      return true;
    }
    if (CommandLine.executableExists("docker.exe")) {
      return true;
    }
    if (CommandLine.executableExists("docker-machine")) {
      return true;
    }
    if (CommandLine.executableExists("docker-machine.exe")) {
      return true;
    }
    return false;
  }
}


save-state stop .



:



<build>
  <plugins>
    <plugin>
      <groupId>com.miro.maven</groupId>
      <artifactId>PostgresPlugin</artifactId>
      <executions>
        <!-- running a postgres container -->
        <execution>
          <id>start-postgres</id>
          <phase>generate-sources</phase>
          <goals>
            <goal>start</goal>
          </goals>
          
          <configuration>
            <db>${db}</db>
            <user>${dbUser}</user>
            <password>${dbPassword}</password>
            <port>${dbPort}</port>
          </configuration>
        </execution>
        
        <!-- applying migrations and generation java-classes -->
        <execution>
          <id>flyway-and-jooq</id>
          <phase>generate-sources</phase>
          <goals>
            <goal>execute-mojo</goal>
          </goals>
          
          <configuration>
            <plugins>
              <!-- applying migrations -->
              <plugin>
                <groupId>org.flywaydb</groupId>
                <artifactId>flyway-maven-plugin</artifactId>
                <version>${flyway.version}</version>
                <executions>
                  <execution>
                    <id>migration</id>
                    <goals>
                      <goal>migrate</goal>
                    </goals>
                    
                    <configuration>
                      <url>${dbUrl}</url>
                      <user>${dbUser}</user>
                      <password>${dbPassword}</password>
                      <locations>
                        <location>filesystem:src/main/resources/migrations</location>
                      </locations>
                    </configuration>
                  </execution>
                </executions>
              </plugin>

              <!-- generation java-classes -->
              <plugin>
                <groupId>org.jooq</groupId>
                <artifactId>jooq-codegen-maven</artifactId>
                <version>${jooq.version}</version>
                <executions>
                  <execution>
                    <id>jooq-generate-sources</id>
                    <goals>
                      <goal>generate</goal>
                    </goals>
                      
                    <configuration>
                      <jdbc>
                        <url>${dbUrl}</url>
                        <user>${dbUser}</user>
                        <password>${dbPassword}</password>
                      </jdbc>
                      
                      <generator>
                        <database>
                          <name>org.jooq.meta.postgres.PostgresDatabase</name>
                          <includes>.*</includes>
                          <excludes>
                            #exclude flyway tables
                            schema_version | flyway_schema_history
                            # other excludes
                          </excludes>
                          <includePrimaryKeys>true</includePrimaryKeys>
                          <includeUniqueKeys>true</includeUniqueKeys>
                          <includeForeignKeys>true</includeForeignKeys>
                          <includeExcludeColumns>true</includeExcludeColumns>
                        </database>
                        <generate>
                          <interfaces>false</interfaces>
                          <deprecated>false</deprecated>
                          <jpaAnnotations>false</jpaAnnotations>
                          <validationAnnotations>false</validationAnnotations>
                        </generate>
                        <target>
                          <packageName>com.miro.persistence</packageName>
                          <directory>src/main/java</directory>
                        </target>
                      </generator>
                    </configuration>
                  </execution>
                </executions>
              </plugin>
            </plugins>
          </configuration>
        </execution>

        <!-- creation an image for integration tests -->
        <execution>
          <id>save-state-postgres</id>
          <phase>generate-sources</phase>
          <goals>
            <goal>save-state</goal>
          </goals>
          
          <configuration>
            <name>postgres-it</name>
          </configuration>
        </execution>

        <!-- stopping the container -->
        <execution>
          <id>stop-postgres</id>
          <phase>generate-sources</phase>
          <goals>
            <goal>stop</goal>
          </goals>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>




إطلاق سراح



تمت كتابة الكود واختباره - حان وقت إصداره. بشكل عام ، يعتمد مدى تعقيد الإصدار على العوامل التالية:



  • على عدد قواعد البيانات (واحد أو أكثر)
  • على حجم قاعدة البيانات
  • على عدد خوادم التطبيق (واحد أو أكثر)
  • إصدار سلس أم لا (سواء سمح بوقت تعطل التطبيق).


يفرض البندان 1 و 3 متطلبات التوافق مع الإصدارات السابقة على الكود ، لأنه في معظم الحالات يكون من المستحيل تحديث جميع قواعد البيانات وجميع خوادم التطبيقات في وقت واحد - ستكون هناك دائمًا نقطة زمنية عندما يكون لقواعد البيانات مخططات مختلفة ، وستكون للخوادم إصدارات مختلفة من الكود.



يؤثر حجم قاعدة البيانات على وقت الترحيل - فكلما كبرت قاعدة البيانات ، زاد احتمال احتياجك لإجراء ترحيل طويل.



السلاسة هي عامل ناتج جزئيًا - إذا تم الإصدار مع إيقاف التشغيل (وقت التوقف) ، فإن النقاط الثلاث الأولى ليست مهمة جدًا وتؤثر فقط على وقت عدم توفر التطبيق.



إذا تحدثنا عن خدمتنا ، فهذه هي:



  • حوالي 30 مجموعة قواعد البيانات


  • حجم قاعدة واحدة 200 - 400 جيجا بايت
  • ( 100),
  • .


نستخدم إصدارات كناري : يتم عرض إصدار جديد من التطبيق لأول مرة على عدد صغير من الخوادم (نطلق عليه إصدار ما قبل الإصدار) ، وبعد فترة ، إذا لم يتم العثور على أخطاء في الإصدار التجريبي ، يتم إصداره إلى خوادم أخرى. وبالتالي ، يمكن تشغيل خوادم الإنتاج على إصدارات مختلفة.



عند التشغيل ، يتحقق كل خادم تطبيق من إصدار قاعدة البيانات بإصدارات البرامج النصية الموجودة في الكود المصدري (من حيث مسار الرحلة ، يُسمى هذا التحقق من الصحة ). إذا كانت مختلفة ، فلن يبدأ الخادم. هذا يضمن التوافق مع التعليمات البرمجية وقاعدة البيانات . لا يمكن أن تنشأ مثل هذه الحالة عندما ، على سبيل المثال ، تعمل الشفرة مع جدول لم يتم إنشاؤه بعد ، لأن الترحيل في إصدار مختلف من الخادم.



لكن هذا بالطبع لا يحل المشكلة عندما يكون هناك ، على سبيل المثال ، في الإصدار الجديد من التطبيق ترحيل يحذف عمودًا في الجدول يمكن استخدامه في الإصدار القديم من الخادم. الآن نتحقق من مثل هذه المواقف فقط في مرحلة المراجعة (إنها إلزامية) ، ولكن بطريقة ودية من الضروري تقديم المزيد. المرحلة مع هذا الفحص في دورة CI / CD.  



في بعض الأحيان ، قد تستغرق عمليات الترحيل وقتًا طويلاً (على سبيل المثال ، عند تحديث البيانات من جدول كبير) ولكي لا تبطئ الإصدارات في نفس الوقت ، نستخدم تقنية عمليات الترحيل المدمجة... تتكون المجموعة من تشغيل الترحيل يدويًا على خادم قيد التشغيل (من خلال لوحة الإدارة ، بدون مسار سريع ، وبالتالي ، بدون التسجيل في محفوظات الترحيل) ، ثم الإخراج "العادي" لنفس الترحيل في الإصدار التالي من الخادم. تخضع عمليات الترحيل هذه للمتطلبات التالية:



  • أولاً ، يجب كتابته بطريقة لا تمنع التطبيق أثناء تنفيذ طويل (النقطة الرئيسية هنا ليست الحصول على أقفال طويلة الأجل على مستوى قاعدة البيانات). للقيام بذلك ، لدينا إرشادات داخلية للمطورين حول كيفية كتابة عمليات الترحيل. في المستقبل ، يمكنني أيضًا مشاركتها على Habré.
  • ثانيًا ، يجب أن يحدد الترحيل عند البداية "العادية" أنه قد تم إجراؤه بالفعل في الوضع اليدوي ولا يفعل شيئًا في هذه الحالة - فقط قم بتسجيل سجل جديد في التاريخ. بالنسبة إلى عمليات ترحيل SQL ، يتم إجراء هذا الفحص عن طريق تنفيذ بعض استعلام SQL للتغييرات. هناك طريقة أخرى لعمليات ترحيل Java وهي استخدام الأعلام المنطقية المخزنة ، والتي يتم تعيينها بعد التشغيل اليدوي.




هذا النهج يحل مشكلتين:

  • الإصدار سريع (وإن كان بإجراءات يدوية)
  • ( ) - .




بمجرد إصداره ، لا تنتهي دورة التطوير. لفهم ما إذا كانت الوظيفة الجديدة تعمل (وكيف تعمل) ، من الضروري "تضمين" المقاييس. يمكن تقسيمها إلى مجموعتين: الأعمال والنظام. 



تعتمد المجموعة الأولى بشدة على مجال الموضوع: بالنسبة لخادم البريد ، من المفيد معرفة عدد الرسائل المرسلة ، بالنسبة لمورد الأخبار - عدد المستخدمين الفريدين في اليوم ، إلخ.



مقاييس المجموعة الثانية هي نفسها تقريبًا للجميع - فهي تحدد الحالة التقنية للخادم: وحدة المعالجة المركزية ، والذاكرة ، والشبكة ، وقاعدة البيانات ، وما إلى ذلك ، 



ما يجب مراقبته بالضبط وكيفية القيام به هو موضوع عدد كبير من المقالات المنفصلة ولن يتم التطرق إليه هنا. أود أن أذكر فقط الأشياء الأساسية (حتى القبطان):



تحديد المقاييس مقدمًا



يجب تحديد قائمة المقاييس الأساسية. ويجب أن يتم ذلك مسبقًا ، قبل الإصدار ، وليس بعد الحادث الأول ، عندما لا تفهم ما يحدث مع النظام.



إعداد التنبيهات التلقائية



سيؤدي ذلك إلى تسريع وقت رد الفعل وتوفير الوقت في المراقبة اليدوية. من الناحية المثالية ، يجب أن تعرف المشاكل قبل أن يشعر بها المستخدمون ويكتبون لك.



جمع المقاييس من جميع العقد



المقاييس ، مثل السجلات ، ليست كثيرة أبدًا. يسمح لك وجود البيانات من كل عقدة في نظامك (خادم التطبيق ، قاعدة البيانات ، مجتذب الاتصال ، الموازن ، وما إلى ذلك) بالحصول على صورة كاملة عن حالتها ، وإذا لزم الأمر ، يمكنك توطين المشكلة بسرعة. 



مثال بسيط: بدأ تحميل بيانات صفحة الويب في التباطؤ. يمكن أن يكون هناك أسباب عديدة:



  • خادم الويب محمّل بشكل زائد ويستغرق وقتًا طويلاً للاستجابة للطلبات


  • يستغرق استعلام SQL وقتًا أطول في التنفيذ
  • تراكمت قائمة انتظار على تجمع الاتصال ولا يمكن لخادم التطبيق تلقي اتصال
  • مشاكل في الشبكة
  • شيء آخر


بدون المقاييس ، لن يكون العثور على سبب المشكلة سهلاً.



بدلا من الانتهاء



أود أن أقول عبارة مبتذلة للغاية حول حقيقة عدم وجود حل سحري واختيار أسلوب أو آخر يعتمد على متطلبات مهمة معينة ، وما يصلح للآخرين قد لا يكون قابلاً للتطبيق بالنسبة لك. ولكن كلما زادت الطرق المختلفة التي تعرفها ، زادت دقة ونوعية هذا الاختيار. آمل أن تكون قد تعلمت من هذا المقال شيئًا جديدًا يساعدك في المستقبل. سأكون سعيدًا بالتعليق على الأساليب التي تستخدمها لتحسين عملية العمل مع قاعدة البيانات.



All Articles