واجهة مولد عميل Golang المستندة إلى قاعدة البيانات

مولد عميل قاعدة بيانات Golang استنادًا إلى الواجهة.



للعمل مع قواعد البيانات ، تقدم Golang حزمة database/sql ، والتي هي عبارة عن مجموعة من واجهة برمجة قواعد البيانات العلائقية. من ناحية ، تتضمن الحزمة وظائف قوية لإدارة تجمع الاتصال ، والعمل مع البيانات المعدة والمعاملات وواجهة استعلام قاعدة البيانات. من ناحية أخرى ، يجب عليك كتابة قدر كبير من نفس النوع من التعليمات البرمجية في تطبيق ويب للتفاعل مع قاعدة بيانات. تقدم مكتبة go-gad / sal حلاً في شكل إنشاء نفس نوع الكود استنادًا إلى الواجهة الموصوفة.


الدافع


يوجد اليوم عدد كافٍ من المكتبات التي تقدم حلولًا في شكل ORMs ومساعدين لبناء الاستعلامات وتوليد مساعدين استنادًا إلى مخطط قاعدة البيانات.



عندما انتقلت إلى لغة جولانج منذ عدة سنوات ، كنت قد اكتسبت بالفعل خبرة في العمل مع قواعد البيانات بلغات مختلفة. باستخدام ORM ، مثل ActiveRecord ، وبدون. بعد الانتقال من الحب إلى الكراهية ، وعدم وجود مشاكل في كتابة بعض الأسطر الإضافية من التعليمات البرمجية ، جاء التفاعل مع قاعدة البيانات في Golang بشيء يشبه نمط المستودع. وصفنا الواجهة للعمل مع قاعدة البيانات ، ونطبقها باستخدام معيار db.Query ، row.Scan. لاستخدام أغلفة إضافية ببساطة لم يكن له معنى ، لقد كان معتمًا ، وسيجب أن نكون في حالة تأهب.


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


أي أن التفاعل مع قاعدة البيانات جاء إلى طريقة مماثلة:


 type User struct { ID int64 Name string } type Store interface { FindUser(id int64) (*User, error) } type Postgres struct { DB *sql.DB } func (pg *Postgres) FindUser(id int64) (*User, error) { var resp User err := pg.DB.QueryRow("SELECT id, name FROM users WHERE id=$1", id).Scan(&resp.ID, &resp.Name) if err != nil { return nil, err } return &resp, nil } func HanlderFindUser(s Store, id int) (*User, error) { // logic of service object user, err := s.FindUser(id) //... } 

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


المتطلبات


  • وصف التفاعل في شكل واجهة.
  • يتم وصف الواجهة بطرق ورسائل الطلبات والاستجابات.
  • دعم متغيرات الربط والبيانات المعدة.
  • دعم الحجج المسماة.
  • ربط استجابة قاعدة البيانات بحقول بنية بيانات الرسالة.
  • دعم هياكل البيانات غير النمطية (الصفيف ، json).
  • عمل شفاف مع المعاملات.
  • الدعم الأصلي للوسيطة.

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


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


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


تستخدم قواعد البيانات ، مثل PostgreSQL و MySQL ، بناء جملة مختلفًا لاستخدام البدائل والروابط المتغيرة. يستخدم PostgreSQL التنسيق $1 ، $2 ، ... يستخدم MySQL ? بغض النظر عن موقع القيمة. اقترحت مكتبة قاعدة البيانات / sql تنسيقًا عالميًا للوسائط المسماة https://golang.org/pkg/database/sql/#NamedArg . مثال للاستخدام:


 db.ExecContext(ctx, `DELETE FROM orders WHERE created_at < @end`, sql.Named("end", endTime)) 

يفضل استخدام هذا التنسيق بالمقارنة مع حلول PostgreSQL أو MySQL.


يمكن تمثيل الاستجابة من قاعدة البيانات التي تعالج برنامج تشغيل البرنامج كما يلي:


 dev > SELECT * FROM rubrics; id | created_at | title | url ----+-------------------------+-------+------------ 1 | 2012-03-13 11:17:23.609 | Tech | technology 2 | 2015-07-21 18:05:43.412 | Style | fashion (2 rows) 

من وجهة نظر المستخدم على مستوى الواجهة ، من المريح وصف معلمة الإخراج كصفيف بنيات النموذج:


 type GetRubricsResp struct { ID int CreatedAt time.Time Title string URL string } 

بعد ذلك ، resp.ID قيمة id على resp.ID وما إلى ذلك. بشكل عام ، تغطي هذه الوظيفة معظم الاحتياجات.


عند الإعلان عن الرسائل من خلال هياكل البيانات الداخلية ، يطرح السؤال حول كيفية دعم أنواع البيانات غير القياسية. على سبيل المثال ، مجموعة. إذا كنت تستخدم برنامج التشغيل github.com/lib/pq عند العمل مع PostgreSQL ، فيمكنك استخدام الوظائف الإضافية مثل pq.Array(&x) عند تمرير وسيطات الاستعلام أو فحص الاستجابة. مثال من الوثائق:


 db.Query(`SELECT * FROM t WHERE id = ANY($1)`, pq.Array([]int{235, 401})) var x []sql.NullInt64 db.QueryRow('SELECT ARRAY[235, 401]').Scan(pq.Array(&x)) 

وفقا لذلك ، يجب أن تكون هناك طرق لإعداد هياكل البيانات.


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


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


بالنسبة للجزء الأكبر ، تمت صياغة المتطلبات كتنظيم لسيناريوهات قاعدة البيانات.


الحل: go-gad / sal


طريقة واحدة للتعامل مع رمز boilerplate هو توليدها. لحسن الحظ ، لدى Golang أدوات وأمثلة لهذا https://blog.golang.org/generate . تم اتباع نهج GoMock https://github.com/golang/mock كحل معماري للجيل ، حيث يتم إجراء تحليل الواجهة باستخدام الانعكاس. استنادًا إلى هذا النهج ، ووفقًا للمتطلبات ، تمت كتابة الأداة المساعدة salgen ومكتبة sal ، والتي تُنشئ شفرة تنفيذ الواجهة وتوفر مجموعة من الوظائف الإضافية.


لبدء استخدام هذا الحل ، من الضروري وصف واجهة تصف سلوك طبقة التفاعل مع قاعدة البيانات. حدد go:generate توجيه باستخدام مجموعة من الوسائط وبدء التشغيل. سوف تحصل على مُنشئ ومجموعة من التعليمات البرمجية جاهزة للاستخدام.


 package repo import "context" //go:generate salgen -destination=./postgres_client.go -package=dev/taxi/repo dev/taxi/repo Postgres type Postgres interface { CreateDriver(ctx context.Context, r *CreateDriverReq) error } type CreateDriverReq struct { taxi.Driver } func (r *CreateDriverReq) Query() string { return `INSERT INTO drivers(id, name) VALUES(@id, @name)` } 

واجهة


كل شيء يبدأ بإعلان الواجهة والأمر الخاص لأداة go generate :


 //go:generate salgen -destination=./client.go -package=github.com/go-gad/sal/examples/profile/storage github.com/go-gad/sal/examples/profile/storage Store type Store interface { ... 

هنا يتم وصف أنه بالنسبة لواجهة Store الخاصة بنا ، سيتم استدعاء salgen الأداة المساعدة من الحزمة ، مع خيارين salgen . الخيار الأول - تحديد يحدد في أي ملف سيتم كتابة التعليمات البرمجية التي تم إنشاؤها. يحدد الخيار الثاني - -package المسار الكامل (مسار الاستيراد) للمكتبة للتنفيذ الذي تم إنشاؤه. فيما يلي حجتان. يصف الأول مسار الحزمة الكاملة ( github.com/go-gad/sal/examples/profile/storage ) حيث توجد الواجهة ، بينما يشير الثاني إلى اسم الواجهة نفسها. لاحظ أنه يمكن تحديد موقع الأمر go generate أي مكان ، وليس بالضرورة بجوار الواجهة الهدف.


بعد تنفيذ الأمر go generate ، نحصل على مُنشئ اسمه اسمه عن طريق إضافة بادئة New إلى اسم الواجهة. يأخذ المُنشئ المعلمة المطلوبة المقابلة لواجهة sal.QueryHandler :


 type QueryHandler interface { QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) } 

هذه الواجهة يتوافق مع الكائن *sql.DB


 connStr := "user=pqgotest dbname=pqgotest sslmode=verify-full" db, err := sql.Open("postgres", connStr) client := storage.NewStore(db) 

طرق


تحدد أساليب الواجهة مجموعة استعلامات قاعدة البيانات المتوفرة.


 type Store interface { CreateAuthor(ctx context.Context, req CreateAuthorReq) (CreateAuthorResp, error) GetAuthors(ctx context.Context, req GetAuthorsReq) ([]*GetAuthorsResp, error) UpdateAuthor(ctx context.Context, req *UpdateAuthorReq) error } 

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

الوسيطة الأولى هي دائمًا كائن context.Context . سيتم تمرير هذا السياق عند استدعاء قاعدة البيانات ومجموعة الأدوات. تتوقع الوسيطة الثانية معلمة مع struct النوع الأساسي (أو مؤشر إلى struct ). يجب أن تفي المعلمة الواجهة التالية:


 type Queryer interface { Query() string } 

سيتم استدعاء الأسلوب Query() قبل تنفيذ استعلام قاعدة البيانات. سيتم تحويل السلسلة الناتجة إلى تنسيق خاص بقاعدة البيانات. بمعنى ، بالنسبة لـ PostgreSQL ، سيتم استبدال &req.End بـ $1 ، وسيتم تمرير القيمة &req.End إلى مجموعة الوسائط


اعتمادًا على معلمات الإخراج ، يتم تحديد أي من الطرق (Query / Exec) سيتم استدعاؤها:


  • إذا كانت المعلمة الأولى من struct النوع الأساسي (أو مؤشر إلى struct ) ، فسيتم QueryContext أسلوب QueryContext . إذا كانت استجابة قاعدة البيانات لا تحتوي على صف واحد ، فسيتم sql.ErrNoRows خطأ sql.ErrNoRows . وهذا هو ، السلوك يشبه db.QueryRow .
  • إذا كانت المعلمة الأولى مع slice النوع الأساسي ، فسيتم QueryContext أسلوب QueryContext . إذا كانت استجابة قاعدة البيانات لا تحتوي على صفوف ، فسيتم إرجاع قائمة فارغة. يجب أن يكون النوع الأساسي لعنصر القائمة stuct (أو stuct ).
  • إذا كانت المعلمة الإخراج واحدة مع نوع error ، سيتم استدعاء الأسلوب ExecContext .

البيانات المعدة


الكود الذي تم إنشاؤه يدعم التعبيرات المعدة. التعبيرات المعدة مؤقتًا. بعد التحضير الأول للتعبير ، يتم تخزينه مؤقتًا. تضمن مكتبة قاعدة البيانات / sql نفسها أن التعبيرات المعدة يتم تطبيقها بشفافية على اتصال قاعدة البيانات المطلوبة ، بما في ذلك معالجة الاتصالات المغلقة. بدورها ، تهتم مكتبة go-gad/sal بإعادة استخدام العبارة المعدة في سياق المعاملة. عند تنفيذ التعبير المحضر ، يتم تمرير الوسائط باستخدام ربط متغير وشفاف للمطور.


لدعم الوسائط المسماة على جانب مكتبة go-gad/sal ، يتم تحويل الطلب إلى طريقة عرض مناسبة لقاعدة البيانات. يوجد الآن دعم للتحويل لـ PostgreSQL. يتم استخدام أسماء الحقول لكائن الاستعلام للاستبدال في الوسائط المسماة. لتحديد اسم مختلف بدلاً من اسم حقل الكائن ، يجب عليك استخدام علامة sql لحقول البنية. النظر في مثال:


 type DeleteOrdersRequest struct { UserID int64 `sql:"user_id"` CreateAt time.Time `sql:"created_at"` } func (r * DeleteOrdersRequest) Query() string { return `DELETE FROM orders WHERE user_id=@user_id AND created_at<@end` } 

سيتم تحويل سلسلة الاستعلام ، وباستخدام جدول المراسلات وربط المتغير ، سيتم تمرير قائمة إلى وسيطات تنفيذ الاستعلام:


 // generated code: db.Query("DELETE FROM orders WHERE user_id=$1 AND created_at<$2", &req.UserID, &req.CreatedAt) 

خريطة الهياكل لطلب الحجج ورسائل الاستجابة


تهتم مكتبة go-gad/sal بربط خطوط استجابة قاعدة البيانات بهياكل الاستجابة وأعمدة الجدول مع حقول البنية:


 type GetRubricsReq struct {} func (r GetRubricReq) Query() string { return `SELECT * FROM rubrics` } type Rubric struct { ID int64 `sql:"id"` CreateAt time.Time `sql:"created_at"` Title string `sql:"title"` } type GetRubricsResp []*Rubric type Store interface { GetRubrics(ctx context.Context, req GetRubricsReq) (GetRubricsResp, error) } 

وإذا كانت استجابة قاعدة البيانات هي:


 dev > SELECT * FROM rubrics; id | created_at | title ----+-------------------------+------- 1 | 2012-03-13 11:17:23.609 | Tech 2 | 2015-07-21 18:05:43.412 | Style (2 rows) 

بعد ذلك ، ستعود إلينا قائمة GetRubricsResp ، حيث تكون عناصرها بمثابة مؤشرات إلى Rubric ، حيث يتم ملء الحقول بقيم من الأعمدة التي تتوافق مع أسماء العلامات.


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


 dev > select * from rubrics, subrubrics; id | title | id | title ----+-------+----+---------- 1 | Tech | 3 | Politics 

 type Rubric struct { ID int64 `sql:"id"` Title string `sql:"title"` } type Subrubric struct { ID int64 `sql:"id"` Title string `sql:"title"` } type GetCategoryResp struct { Rubric Subrubric } 

أنواع البيانات غير القياسية


توفر حزمة database/sql دعمًا لأنواع البيانات الأساسية (السلاسل والأرقام). من أجل معالجة أنواع البيانات مثل المصفوفة أو json في طلب أو استجابة ، من الضروري دعم sql.Scanner و sql.Scanner . تطبيقات برنامج التشغيل المختلفة لها وظائف المساعد الخاصة. على سبيل المثال lib/pq.Array ( https://godoc.org/github.com/lib/pq#Array ):


 func Array(a interface{}) interface { driver.Valuer sql.Scanner } 

بشكل افتراضي ، مكتبة go-gad/sql لحقول بنية العرض


 type DeleteAuthrosReq struct { Tags []int64 `sql:"tags"` } 

سيستخدم القيمة &req.Tags . إذا كان الهيكل يلبي واجهة sal.ProcessRower ،


 type ProcessRower interface { ProcessRow(rowMap RowMap) } 

ثم يمكن ضبط القيمة المستخدمة


 func (r *DeleteAuthorsReq) ProcessRow(rowMap sal.RowMap) { rowMap.Set("tags", pq.Array(r.Tags)) } func (r *DeleteAuthorsReq) Query() string { return `DELETE FROM authors WHERE tags=ANY(@tags::UUID[])` } 

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


المعاملات


لدعم المعاملات ، يجب توسيع الواجهة (المتجر) بالطرق التالية:


 type Store interface { BeginTx(ctx context.Context, opts *sql.TxOptions) (Store, error) sal.Txer ... 

سيتم إنشاء الطرق. يستخدم الأسلوب BeginTx الاتصال من كائن sal.QueryHandler الحالي ويفتح المعاملة db.BeginTx(...) ؛ تقوم بإرجاع كائن تطبيق جديد لواجهة Store ، لكن يستخدم الكائن *sql.Tx كجهاز *sql.Tx


الوسيطة


يتم توفير السنانير لأدوات التضمين.


 type BeforeQueryFunc func(ctx context.Context, query string, req interface{}) (context.Context, FinalizerFunc) type FinalizerFunc func(ctx context.Context, err error) 

سيتم BeforeQueryFunc ربط BeforeQueryFunc قبل تنفيذ db.PrepareContext أو db.Query . وهذا هو ، في بداية البرنامج ، عندما تكون ذاكرة التخزين المؤقت للتعبير المعدة فارغة ، وعندما store.GetAuthors استدعاء BeforeQueryFunc سيتم استدعاء ربط BeforeQueryFunc مرتين. يمكن أن يقوم الخطاف BeforeQueryFunc بإرجاع خطاف FinalizerFunc ، والذي سيتم استدعاؤه قبل الخروج من طريقة المستخدم ، في store.GetAuthors للحالة. store.GetAuthors ، باستخدام store.GetAuthors .


في وقت تنفيذ الخطافات ، يتم ملء السياق بمفاتيح الخدمة مع القيم التالية:


  • ctx.Value(sal.ContextKeyTxOpened) تحدد القيمة المنطقية ما إذا كانت الطريقة تسمى في سياق المعاملة أم لا.
  • ctx.Value(sal.ContextKeyOperationType) ، قيمة السلسلة لنوع العملية ، "QueryRow" ، "Query" ، "Exec" ، "Commit" ، إلخ.
  • ctx.Value(sal.ContextKeyMethodName) قيمة سلسلة أسلوب الواجهة ، مثل "GetAuthors" .

كوسيطة ، يقبل ربط BeforeQueryFunc سلسلة sql للاستعلام والوسيطة req الخاصة بأسلوب استعلام المستخدم. يأخذ ربط FinalizerFunc متغير err كوسيطة.


 beforeHook := func(ctx context.Context, query string, req interface{}) (context.Context, sal.FinalizerFunc) { start := time.Now() return ctx, func(ctx context.Context, err error) { log.Printf( "%q > Opeartion %q: %q with req %#v took [%v] inTx[%v] Error: %+v", ctx.Value(sal.ContextKeyMethodName), ctx.Value(sal.ContextKeyOperationType), query, req, time.Since(start), ctx.Value(sal.ContextKeyTxOpened), err, ) } } client := NewStore(db, sal.BeforeQuery(beforeHook)) 

أمثلة الإخراج:


 "CreateAuthor" > Opeartion "Prepare": "INSERT INTO authors (Name, Desc, CreatedAt) VALUES($1, $2, now()) RETURNING ID, CreatedAt" with req <nil> took [50.819µs] inTx[false] Error: <nil> "CreateAuthor" > Opeartion "QueryRow": "INSERT INTO authors (Name, Desc, CreatedAt) VALUES(@Name, @Desc, now()) RETURNING ID, CreatedAt" with req bookstore.CreateAuthorReq{BaseAuthor:bookstore.BaseAuthor{Name:"foo", Desc:"Bar"}} took [150.994µs] inTx[false] Error: <nil> 

ما التالي


  • دعم متغيرات الربط والتعبيرات المعدة لـ MySQL.
  • ربط RowAppender لضبط الاستجابة.
  • Exec.Result قيمة Exec.Result .

Source: https://habr.com/ru/post/ar431984/


All Articles