خدمة "بلدي" هي وكيل بين وحدات معينة لمشروع كبير. للوهلة الأولى ، يمكنك دراستها في إحدى الأمسيات والتعرف على أشياء أكثر أهمية. لكن بدأت في العمل ، أدركت أنني مخطئ. تمت كتابة الخدمة قبل ستة أشهر في غضون أسبوعين بهدف اختبار MVP. طوال هذا الوقت رفض العمل: فقد الأحداث والبيانات أو أعاد كتابتها. تم طرح المشروع من فريق إلى فريق ، لأنه لم يرغب أحد في القيام به ، ولا حتى منشئوه. أصبح من الواضح الآن سبب بحثهم عن مبرمج منفصل لها.
تعتبر خدمة "بلادي" مثالاً على البنية الرديئة والتصميم غير الصحيح بطبيعته. نتفهم جميعًا أنه لا يمكنك القيام بذلك. لكن لماذا لا ، ما هي العواقب التي تؤدي إليها وكيفية محاولة إصلاح كل شيء ، سأخبرك.
كيف العمارة السيئة تعيق الطريق
قصة نموذجية:
- جعل MVP.
- اختبار الفرضيات عليها ؛
- , MVP;
- ...;
- PROFIT.
لكن هذا لا يمكن القيام به (وهو ما نفهمه جميعًا).
عندما يتم إنشاء الأنظمة على عجل ، فإن الطريقة الوحيدة للاستمرار في إصدار إصدارات جديدة من المنتج هي "تضخيم" الموظفين. في البداية ، يُظهر المطورون إنتاجية تقترب من 100٪ ، ولكن عندما يتضخم المنتج "الأولي" بالميزات والتبعيات ، يستغرق الأمر وقتًا أطول وأطول لمعرفة ذلك.
مع كل إصدار جديد ، تنخفض إنتاجية المطور. لا أحد يفكر في نظافة الكود وتصميمه وهندسته. نتيجة لذلك ، يمكن أن يزيد سعر سطر التعليمات البرمجية 40 مرة.
يمكن رؤية هذه العمليات بوضوح في الرسوم البيانية من روبرت مارتن. على الرغم من حقيقة أن فريق التطوير يتزايد من إصدار إلى آخر ، فإن معدل نمو المنتج يتباطأ فقط. التكاليف آخذة في الازدياد والإيرادات آخذة في الانخفاض ، الأمر الذي يؤدي بالفعل إلى انخفاض في عدد الموظفين
تحدي العمارة النظيفة
لا يهم كيف تم تصميم التطبيق وكتابته. من المهم للأعمال أن يتصرف المنتج بالطريقة التي يريدها المستخدمون وأن يكون مربحًا. لكن في بعض الأحيان (ليس في بعض الأحيان ، ولكن في كثير من الأحيان) يغير العمل التجاري حلوله ومتطلباته. مع الهيكل الضعيف ، يصعب التكيف مع المتطلبات الجديدة وتغيير المنتجات وإضافة وظائف جديدة.
النظام المصمم جيدًا أسهل في التوافق مع السلوك المطلوب. مرة أخرى ، يعتقد روبرت مارتن أن السلوك ثانوي ويمكن دائمًا تصحيحه إذا كان النظام مصممًا جيدًا.
تعزز العمارة النظيفة التواصل بين الطبقات في المشروع ، حيث يكون المركز هو منطق الأعمال مع جميع الكيانات التي تتعامل مع المشكلات التطبيقية.
- جميع الطبقات الخارجية محولات للتواصل مع العالم الخارجي.
- يجب ألا تخترق عناصر العالم الخارجي الجزء المركزي من المشروع.
لا يهتم منطق الأعمال بمن هو: تطبيق سطح مكتب أو خادم ويب أو متحكم دقيق. لا ينبغي أن تعتمد على "التسمية". يجب عليها أداء مهام محددة. كل شيء آخر هو التفاصيل ، على سبيل المثال ، قواعد البيانات أو سطح المكتب.
مع بنية نظيفة ، نحصل على نظام مستقل. على سبيل المثال ، يكون مستقلاً عن قاعدة البيانات أو إصدار إطار العمل. يمكننا استبدال تطبيق سطح المكتب لاحتياجات الخادم دون تغيير المكون الداخلي لمنطق الأعمال. هذا هو ما يتم تقديره لمنطق العمل.
تقلل البنية النظيفة من التعقيد المعرفي للمشروع ، وتقلل من تكاليف الدعم ، وتبسط التطوير والصيانة الإضافية للمبرمجين.
كيفية التعرف على العمارة "السيئة"
لا يوجد مفهوم العمارة "السيئة" في البرمجة. هناك معايير للهندسة المعمارية الضعيفة: الصلابة ، والجمود ، والمتانة ، والتكرار المفرط. على سبيل المثال ، هذه هي المعايير التي استخدمتها لفهم أن بنية الخدمة المصغرة الخاصة بي سيئة.
جمود . إنه عدم قدرة النظام على الاستجابة حتى للتغيرات الصغيرة ، فعندما يصبح من الصعب تغيير أجزاء من المشروع دون الإضرار بالنظام بأكمله ، يكون النظام جامدًا. على سبيل المثال ، عندما يتم استخدام بنية واحدة في عدة طبقات من المشروع في وقت واحد ، فإن تغييرها الصغير يخلق مشاكل في المشروع بأكمله مرة واحدة.
يتم علاج المشكلة عن طريق التحويل على كل طبقة. عندما تعمل كل طبقة على كائناتها فقط ، والتي تم الحصول عليها عن طريق "تحويل" الكائن الخارجي ، تصبح الطبقات مستقلة تمامًا عن عدم
الحركة... عندما تم بناء النظام بفصل ضعيف (أو نقص) في وحدات قابلة لإعادة الاستخدام. من الصعب إعادة بناء الأنظمة الثابتة.
على سبيل المثال ، عندما تدخل معلومات حول قواعد البيانات إلى منطقة منطق الأعمال ، فإن استبدال قاعدة البيانات بأخرى سيؤدي إلى إعادة هيكلة كل منطق الأعمال.
اللزوجة . عندما يؤدي تقسيم المسؤوليات بين الحزم إلى مركزية غير ضرورية. ومن المثير للاهتمام ، ما يحدث بالعكس ، عندما تؤدي اللزوجة إلى اللامركزية - كل شيء مقسم إلى حزم صغيرة جدًا. في Go ، يمكن أن يؤدي هذا إلى عمليات استيراد دائرية. على سبيل المثال ، يحدث هذا عندما تبدأ حزم المحول في تلقي منطق إضافي.
التكرار المفرط... في Go ، تعتبر عبارة "النسخة الصغيرة أفضل من التبعية الصغيرة" شائعة. لكن هذا لا يؤدي إلى حقيقة أن هناك عددًا أقل من التبعيات - بل يصبح عددًا أكبر من النسخ. غالبًا ما أرى نسخًا من التعليمات البرمجية من حزم أخرى في حزم Go مختلفة.
على سبيل المثال ، كتب روبرت مارتن في كتابه "Clean Architecture" أنه في الماضي ، طلبت Google إعادة استخدام أي سلاسل يمكنها تخصيصها في مكتبات منفصلة. تسبب هذا في تغيير 2-3 خطوط لخدمة صغيرة للتأثير على جميع الخدمات الأخرى ذات الصلة. لا تزال الشركة تعمل على حل المشاكل مع هذا النهج.
الرغبة في إعادة البناء... هذا معيار إضافي للهندسة المعمارية السيئة. لكن هناك فروق دقيقة. بغض النظر عن مدى سوء كتابة المشروع ، بواسطتك أم لا ، يجب ألا تعيد كتابته من البداية أبدًا ، فهذا سيخلق مشاكل إضافية فقط. قم بإعادة البناء التكراري.
كيف تصمم بشكل صحيح نسبيا
استمرت خدمة البروكسي لمدة ستة أشهر وطوال هذا الوقت لم تنجز مهامها. كيف عاش كل هذه المدة؟
عندما تختبر شركة ما منتجًا وتظهر عدم فعاليته ، يتم التخلي عنه أو إتلافه. هذا امر طبيعي. عندما يتم اختبار MVP ويتضح أنه فعال ، فإنه يستمر. ولكن عادة لا تتم إعادة كتابة MVP وهي تعمل على "كما هي" ، متضخمة مع التعليمات البرمجية والوظائف. لذلك ، تعد "منتجات الزومبي" التي تم إنشاؤها لأفضل لاعبين في اللعبة ممارسة شائعة.
عندما اكتشفت كيف أن خدمة الوكيل الخاصة بي لا تعمل ، قرر الفريق إعادة كتابتها. تم تعيين هذا العمل لي ولزميل لي وتم تخصيص أسبوعين: هناك القليل من منطق العمل ، والخدمة صغيرة. كان هذا خطأ آخر.
بدأت الخدمة في إعادة كتابتها بالكامل. عندما قاموا بقص أجزاء من الكود وإعادة كتابتها وتحميلها إلى بيئة الاختبار ، تحطم جزء من النظام الأساسي. اتضح أن الخدمة لديها الكثير من منطق الأعمال غير الموثق الذي لا يعرفه أحد. لقد فشلت أنا وزميلي ، لكن هذا خطأ في منطق الخدمة.
قررنا الاقتراب من إعادة البناء من الجانب الآخر:
- التراجع إلى الإصدار السابق ؛
- لم تتم إعادة كتابة الكود ؛
- نقسم الكود إلى أجزاء - حزم ؛
- يتم تغليف كل حزمة في واجهة منفصلة.
لم نفهم ما كانت تفعله الخدمة ، لأنه لم يفهمها أحد. لذلك ، فإن الخيار الوحيد هو "نشر" الخدمة في أجزاء ومعرفة مسؤولية كل جزء.
بعد ذلك ، أصبح من الممكن إعادة بناء كل عبوة على حدة. يمكننا إصلاح كل جزء من الخدمة بشكل منفصل و / أو تنفيذه في أجزاء أخرى من المشروع. في نفس الوقت ، يستمر العمل في الخدمة حتى يومنا هذا.
اتضح مثل هذا.
كيف نكتب خدمة مماثلة إذا صممناها "بشكل جيد" من البداية؟ اسمحوا لي أن أعرض لكم مثالاً لخدمة صغيرة صغيرة تسجل وترخص المستخدم.
استهلالي
نحن بحاجة إلى: جوهر النظام ، كيان يحدد وينفذ منطق الأعمال من خلال معالجة الوحدات الخارجية.
type Core struct {
userRepo UserRepo
sessionRepo SessionRepo
hashing Hasher
auth Auth
}
بعد ذلك ، تحتاج إلى عقدين يسمحان لك باستخدام طبقة الريبو. يوفر لنا العقد الأول واجهة. بمساعدتها ، سنتواصل مع طبقة قاعدة البيانات التي تخزن معلومات حول المستخدمين.
// UserRepo interface for user data repository.
type UserRepo interface {
// CreateUser adds to the new user in repository.
// This method is also required to create a notifying hoard.
// Errors: ErrEmailExist, ErrUsernameExist, unknown.
CreateUser(context.Context, User, TaskNotification) (UserID, error)
// UpdatePassword changes password.
// Resets all codes to reset the password.
// Errors: unknown.
UpdatePassword(context.Context, UserID, []byte) error
// UserByID returning user info by id.
// Errors: ErrNotFound, unknown.
UserByID(context.Context, UserID) (*User, error)
// UserByEmail returning user info by email.
// Errors: ErrNotFound, unknown.
UserByEmail(context.Context, string) (*User, error)
// UserByUsername returning user info by id.
// Errors: ErrNotFound, unknown.
UserByUsername(context.Context, string) (*User, error)
}
العقد الثاني "يتصل" بالطبقة التي تخزن المعلومات حول جلسات المستخدم.
// SessionRepo interface for session data repository.
type SessionRepo interface {
// SaveSession saves the new user Session in a database.
// Errors: unknown.
SaveSession(context.Context, UserID, TokenID, Origin) error
// Session returns user Session.
// Errors: ErrNotFound, unknown.
SessionByTokenID(context.Context, TokenID) (*Session, error)
// UserByAuthToken returning user info by authToken.
// Errors: ErrNotFound, unknown.
UserByTokenID(context.Context, TokenID) (*User, error)
// DeleteSession removes user Session.
// Errors: unknown.
DeleteSession(context.Context, TokenID) error
}
أنت الآن بحاجة إلى واجهة للعمل مع كلمات المرور وتجزئةها ومقارنتها. وأيضًا أحدث واجهة للعمل مع رموز التفويض ، والتي ستسمح بإنشاءها وتحديدها أيضًا.
// Hasher module responsible for working with passwords.
type Hasher interface {
// Password returns the hashed version of the password.
// Errors: unknown.
Password(password string) ([]byte, error)
// Compare compares two passwords for matches.
Compare(hashedPassword []byte, password []byte) error
}
// Auth module is responsible for working with authorization tokens.
type Auth interface {
// Token generates an authorization auth with a specified lifetime,
// and can also use the UserID if necessary.
// Errors: unknown.
Token(expired time.Duration) (AuthToken, TokenID, error)
// Parse and validates the auth and checks that it's expired.
// Errors: ErrInvalidToken, ErrExpiredToken, unknown.
Parse(token AuthToken) (TokenID, error)
}
لنبدأ في كتابة المنطق نفسه. السؤال الرئيسي هو ماذا نريد من منطق الأعمال للتطبيق؟
- تسجيل المستخدم.
- التحقق من البريد واللقب.
- تفويض.
الفحوصات
لنبدأ بأساليب بسيطة - التحقق من البريد الإلكتروني أو الاسم المستعار. UserRepo لدينا ليس لديه طرق للتحقق. لكننا لن نضيفها ، يمكننا التحقق مما إذا كانت هذه البيانات أو تلك مشغولة عن طريق مطالبة المستخدم بهذه البيانات.
// VerificationEmail for implemented UserApp.
func (a *Application) VerificationEmail(ctx context.Context, email string) error {
_, err := a.userRepo.UserByEmail(ctx, email)
switch {
case errors.Is(err, ErrNotFound):
return nil
case err == nil:
return ErrEmailExist
default:
return err
}
}
// VerificationUsername for implemented UserApp.
func (a *Application) VerificationUsername(ctx context.Context, username string) error {
_, err := a.userRepo.UserByUsername(ctx, username)
switch {
case errors.Is(err, ErrNotFound):
return nil
case err == nil:
return ErrUsernameExist
default:
return err
}
}
هناك نوعان من الفروق الدقيقة هنا.
لماذا الشيك يمر لخطأ
ErrNotFound؟ يجب ألا يعتمد تنفيذ منطق الأعمال على SQL أو أي قاعدة بيانات أخرى ، لذلك sql.ErrNoRowsيجب تحويلها إلى الخطأ الذي يناسب منطق الأعمال لدينا.
نرفع أيضًا خطأ طبقة منطق الأعمال مع طبقة API ، ويجب حل رمز الخطأ أو أي شيء آخر على مستوى API. يجب ألا يعتمد منطق العمل على بروتوكول الاتصال مع العميل واتخاذ القرارات بناءً على ذلك.
التسجيل والترخيص
// CreateUser for implemented UserApp.
func (a *Application) CreateUser(ctx context.Context, email, username, password string, origin Origin) (*User, AuthToken, error) {
passHash, err := a.password.Password(password)
if err != nil {
return nil, "", err
}
email = strings.ToLower(email)
newUser := User{
Email: email,
Name: username,
PassHash: passHash,
}
_, err = a.userRepo.CreateUser(ctx, newUser)
if err != nil {
return nil, "", err
}
return a.Login(ctx, email, password, origin)
}
// Login for implemented UserApp.
func (a *Application) Login(ctx context.Context, email, password string, origin Origin) (*User, AuthToken, error) {
email = strings.ToLower(email)
user, err := a.userRepo.UserByEmail(ctx, email)
if err != nil {
return nil, "", err
}
if err := a.password.Compare(user.PassHash, []byte(password)); err != nil {
return nil, "", err
}
token, tokenID, err := a.auth.Token(TokenExpire)
if err != nil {
return nil, "", err
}
err = a.sessionRepo.SaveSession(ctx, user.ID, tokenID, origin)
if err != nil {
return nil, "", err
}
return user, token, nil
}
إنه رمز بسيط وحتمي يسهل قراءته والحفاظ عليه. يمكنك البدء في كتابة هذا الرمز على الفور عند التصميم. لا يهم قاعدة البيانات التي نضيف إليها المستخدم ، أو البروتوكول الذي نختاره للتواصل مع العملاء ، أو كيفية تجزئة كلمات المرور. لا يهتم منطق الأعمال بكل هذه الطبقات ، من المهم فقط أن يقوم بأداء مهام منطقة التطبيق الخاصة به.
طبقة تجزئة بسيطة
ماذا يعني؟ يجب ألا تتخذ جميع الطبقات الخارجية غير الطبقات قرارات بشأن المهام المتعلقة بمنطقة التطبيق. إنهم يؤدون مهمة محددة وبسيطة يتطلبها منطق عملنا. على سبيل المثال ، لنأخذ طبقة لتجزئة كلمات المرور.
// Package hasher contains methods for hashing and comparing passwords.
package hasher
import (
"errors"
"github.com/zergslaw/boilerplate/internal/app"
"golang.org/x/crypto/bcrypt"
)
type (
// Hasher is an implements app.Hasher.
// Responsible for working passwords, hashing and compare.
Hasher struct {
cost int
}
)
// New creates and returns new app.Hasher.
func New(cost int) app.Hasher {
return &Hasher{cost: cost}
}
// Hashing need for implements app.Hasher.
func (h *Hasher) Password(password string) ([]byte, error) {
return bcrypt.GenerateFromPassword([]byte(password), h.cost)
}
// Compare need for implements app.Hasher.
func (h *Hasher) Compare(hashedPassword []byte, password []byte) error {
err := bcrypt.CompareHashAndPassword(hashedPassword, password)
switch {
case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword):
return app.ErrNotValidPassword
case err != nil:
return err
}
return nil
}
هذه طبقة بسيطة لأداء مهام تجزئة كلمات المرور ومقارنتها. كل شيء. إنه نحيف وبسيط ولا يعرف شيئًا آخر. ولا ينبغي.
الريبو
لنفكر في طبقة تفاعل التخزين.
دعنا نعلن عن التنفيذ ونشير إلى الواجهات التي يجب تنفيذها.
var _ app.SessionRepo = &Repo{}
var _ app.UserRepo = &Repo{}
// Repo is an implements app.UserRepo.
// Responsible for working with database.
type Repo struct {
db *sqlx.DB
}
// New creates and returns new app.UserRepo.
func New(repo *sqlx.DB) *Repo {
return &Repo{db: repo}
}
سيكون من الممكن السماح لقارئ الكود بفهم العقود التي يتم تنفيذها بواسطة الطبقة ، وكذلك مراعاة المهام المحددة لـ Repo الخاص بنا.
دعنا ننتقل إلى التنفيذ. من أجل عدم تمديد المقال ، سأقدم جزءًا فقط من الأساليب.
// CreateUser need for implements app.UserRepo.
func (repo *Repo) CreateUser(ctx context.Context, newUser app.User, task app.TaskNotification) (userID app.UserID, err error) {
const query = `INSERT INTO users (username, email, pass_hash) VALUES ($1, $2, $3) RETURNING id`
hash := pgtype.Bytea{
Bytes: newUser.PassHash,
Status: pgtype.Present,
}
err = repo.db.QueryRowxContext(ctx, query, newUser.Name, newUser.Email, hash).Scan(&userID)
if err != nil {
return 0, fmt.Errorf("create user: %w", err)
}
return userID, nil
}
// UserByUsername need for implements app.UserRepo.
func (repo *Repo) UserByUsername(ctx context.Context, username string) (user *app.User, err error) {
const query = `SELECT * FROM users WHERE username = $1`
u := &userDBFormat{}
err = repo.db.GetContext(ctx, u, query, username)
if err != nil {
return nil, err
}
return u.toAppFormat(), nil
}
تتميز طبقة Repo بطرق بسيطة وأساسية. إنهم لا يعرفون كيف يفعلون أي شيء باستثناء "حفظ ، إرسال ، تحديث ، حذف ، بحث". تتمثل مهمة الطبقة فقط في أن تكون مزودًا مناسبًا للبيانات لأي قاعدة بيانات يحتاجها مشروعنا.
API
لا تزال هناك طبقة API للتفاعل مع العميل
يلزم نقل البيانات من العميل إلى منطق الأعمال ، وإرجاع النتائج مرة أخرى ، وتلبية جميع احتياجات HTTP بالكامل - تحويل أخطاء التطبيق.
func (api *api) handler(w http.ResponseWriter, r *http.Request) {
params := &arg{}
err := json.NewDecoder(r.Body).Decode(params)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
origin := orifinFromReq(r)
res, err := api.app.CreateUser(
r.Context(),
params.Email,
params.Username,
params.Password,
request,
)
switch {
case errors.Is(err, app.ErrNotFound):
http.Error(w, app.ErrNotFound.Error(), http.StatusNotFound)
case errors.Is(err, app.ErrChtoto):
http.Error(w, app.ErrChtoto.Error(), http.StatusTeapot)
case err == nil:
json.NewEncoder(w).Encode(res)
default:
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
في هذا الصدد ، تنتهي مهامه: أحضر البيانات ، وحصل على النتيجة ، وحولها إلى تنسيق مناسب لـ HTTP.
ما هو حقا حاجة العمارة النظيفة؟
لماذا كل هذا؟ لماذا ننفذ بعض الحلول المعمارية؟ ليس من أجل "نظافة" الشفرة ، ولكن من أجل قابلية الاختبار. نحن بحاجة إلى القدرة على اختبار الكود الخاص بنا بشكل ملائم وبسيط وسهل.
على سبيل المثال ، رمز مثل هذا سيء :
func (api *api) handler(w http.ResponseWriter, r *http.Request) {
params := &arg{}
err := json.NewDecoder(r.Body).Decode(params)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
rows, err := api.db.QueryContext(r.Context(), "sql query", params.Param)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var arrayRes []val
for rows.Next() {
value := val{}
err := rows.Scan(&value)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
arrayRes = append(arrayRes, value)
}
//
err = json.NewEncoder(w).Encode(arrayRes)
w.WriteHeader(http.StatusOK)
}
ملاحظة: نسيت أن أشير إلى أن هذا الرمز سيء. قد يكون هذا مضللًا إذا قرأت قبل التحديث. اسف بشأن ذلك.
تعد القدرة على اختبار الكود الخاص بك دون مشاكل كبيرة هي الميزة الرئيسية للبنية النظيفة.
يمكننا اختبار كل منطق الأعمال من خلال الاستخراج من قاعدة البيانات ، الخادم ، البروتوكول. من المهم بالنسبة لنا فقط أداء المهام التطبيقية لتطبيقنا. الآن ، باتباع قواعد معينة وبسيطة ، يمكننا بسهولة توسيع وتغيير التعليمات البرمجية الخاصة بنا دون عناء.
أي منتج له منطق عمل. تساعد البنية الجيدة ، على سبيل المثال ، في تجميع منطق الأعمال في حزمة واحدة ، وتتمثل مهمتها في العمل مع الوحدات الخارجية لأداء مهام التطبيق.
لكن الهندسة المعمارية النظيفة ليست جيدة دائمًا. في بعض الأحيان يمكن أن يتحول إلى شر ، ويجلب تعقيدًا غير ضروري. إذا حاولت الكتابة بشكل مثالي على الفور ، فسوف نضيع وقتًا ثمينًا ونترك المشروع يسقط. لست مضطرًا إلى الكتابة بشكل مثالي - اكتب جيدًا بناءً على أهداف عملك.
, Golang Live 2020 14 17 . — 14 , — , .