Layanan "saya" adalah proxy antara modul tertentu dari sebuah proyek besar. Sekilas, Anda bisa mempelajarinya dalam satu malam dan turun ke hal-hal yang lebih penting. Tetapi mulai bekerja, saya menyadari bahwa saya salah. Layanan ini ditulis enam bulan lalu dalam beberapa minggu dengan tugas menguji MVP. Selama ini dia menolak untuk bekerja: dia kehilangan peristiwa dan data, atau menulis ulang. Proyek itu terlempar dari satu tim ke tim lain, karena tidak ada yang mau melakukannya, bahkan pembuatnya. Sekarang menjadi jelas mengapa mereka mencari programmer terpisah untuk itu.
Layanan "saya" adalah contoh arsitektur yang buruk dan desain yang salah secara inheren. Kita semua mengerti bahwa ini tidak boleh dilakukan. Tetapi mengapa tidak, konsekuensi apa yang ditimbulkannya dan bagaimana mencoba memperbaiki semuanya, saya akan memberi tahu Anda.
Betapa buruknya arsitektur menghalangi
Cerita khas:
- membuat MVP;
- menguji hipotesis di atasnya;
- , MVP;
- ...;
- PROFIT.
Tapi ini tidak bisa dilakukan (yang kita semua mengerti).
Ketika sistem dibuat dengan terburu-buru, satu-satunya cara untuk terus merilis versi baru dari sebuah produk adalah dengan "membengkak" staf. Awalnya, pengembang menunjukkan produktivitas mendekati 100%, tetapi ketika produk yang awalnya "mentah" dipenuhi dengan fitur dan ketergantungan, butuh waktu lebih lama dan lebih lama untuk mengetahuinya.
Dengan setiap versi baru, produktivitas pengembang turun. Tidak ada yang memikirkan kebersihan kode, desain, dan arsitektur. Akibatnya, harga sebaris kode bisa naik 40 kali lipat.
Proses-proses ini dapat dilihat dengan jelas pada grafik dari Robert Martin. Terlepas dari kenyataan bahwa staf pengembangan meningkat dari versi ke versi, tingkat pertumbuhan produk hanya melambat. Biaya bertambah, pendapatan turun, yang sudah menyebabkan pengurangan staf.
Tantangan arsitektur bersih
Tidak masalah bagi bisnis bagaimana aplikasi dirancang dan ditulis. Penting untuk bisnis bahwa produk berperilaku seperti yang diinginkan pengguna dan menguntungkan. Tetapi terkadang (tidak terkadang, tetapi sering) bisnis mengubah solusi dan persyaratannya. Dengan struktur yang buruk, sulit untuk beradaptasi dengan persyaratan baru, mengubah produk, dan menambah fungsionalitas baru.
Sistem yang dirancang dengan baik lebih mudah disesuaikan dengan perilaku yang diinginkan. Sekali lagi, Robert Martin percaya bahwa perilaku itu sekunder dan selalu dapat diperbaiki jika sistem dirancang dengan baik.
Arsitektur bersih mempromosikan komunikasi antara lapisan dalam proyek, di mana pusatnya adalah logika bisnis dengan semua entitasnya yang menangani masalah yang diterapkan.
- Semua lapisan luar adalah adaptor untuk komunikasi dengan dunia luar.
- Elemen dunia luar tidak boleh menembus bagian tengah proyek.
Logika bisnis tidak peduli siapa itu: aplikasi desktop, server web, atau mikrokontroler. Seharusnya tidak bergantung pada "label". Dia harus melakukan tugas tertentu. Yang lainnya adalah detail, misalnya database atau desktop.
Dengan arsitektur yang bersih, kami mendapatkan sistem yang independen. Misalnya, ini tidak tergantung pada versi database atau kerangka kerja. Kita dapat mengganti aplikasi desktop untuk kebutuhan server tanpa mengubah komponen internal logika bisnis. Untuk itulah logika bisnis dihargai.
Arsitektur yang bersih mengurangi kompleksitas kognitif proyek, biaya dukungan, dan menyederhanakan pengembangan dan pemeliharaan programmer lebih lanjut.
Bagaimana mengidentifikasi arsitektur "buruk"
Tidak ada konsep arsitektur "buruk" dalam pemrograman. Ada kriteria untuk arsitektur yang buruk: kekakuan, imobilitas, ketangguhan, dan pengulangan yang berlebihan. Misalnya, ini adalah kriteria yang saya gunakan untuk memahami bahwa arsitektur layanan mikro saya buruk.
Kekakuan . Ini adalah ketidakmampuan sistem untuk bereaksi bahkan terhadap perubahan kecil. Ketika menjadi sulit untuk mengubah bagian proyek tanpa merusak keseluruhan sistem, sistem tersebut kaku. Misalnya, ketika satu struktur digunakan di beberapa lapisan proyek sekaligus, maka perubahan kecilnya menciptakan masalah di seluruh proyek sekaligus.
Masalahnya diatasi dengan mengubah setiap lapisan. Ketika setiap lapisan hanya mengoperasikan objeknya, yang diperoleh dengan "mengubah" objek eksternal, lapisan menjadi sepenuhnya independen.
Imobilitas... Ketika sistem dibangun dengan pemisahan yang buruk (atau kurangnya) menjadi modul yang dapat digunakan kembali. Sistem tetap sulit untuk difaktor ulang.
Misalnya, ketika informasi tentang database memasuki area logika bisnis, mengganti database dengan yang lain akan mengarah pada pemfaktoran ulang semua logika bisnis.
Viskositas . Ketika pembagian tanggung jawab antar paket mengarah pada sentralisasi yang tidak perlu. Menariknya, yang terjadi sebaliknya, ketika viskositas mengarah pada desentralisasi - semuanya dibagi menjadi paket-paket yang terlalu kecil. Di Go, ini dapat menyebabkan impor melingkar. Misalnya, ini terjadi ketika paket adaptor mulai menerima logika tambahan.
Pengulangan yang berlebihan... Di Go, frasa "Salinan kecil lebih baik daripada ketergantungan kecil" sangat populer. Tetapi ini tidak mengarah pada fakta bahwa ada lebih sedikit ketergantungan - itu hanya menjadi lebih banyak salinan. Saya sering melihat salinan kode dari paket lain di paket Go yang berbeda.
Misalnya, Robert Martin menulis dalam bukunya "Arsitektur Bersih" bahwa di masa lalu, Google harus menggunakan kembali string apa pun yang dapat digunakannya dan mengalokasikannya ke perpustakaan terpisah. Hal ini menyebabkan mengubah 2-3 baris layanan kecil memengaruhi semua layanan terkait lainnya. Perusahaan masih memperbaiki masalah dengan pendekatan ini.
Keinginan untuk refactor... Ini adalah kriteria bonus untuk arsitektur yang buruk. Tapi ada nuansa. Tidak peduli seberapa buruk proyek itu ditulis, oleh Anda atau tidak, Anda tidak boleh menulis ulang dari awal, ini hanya akan menimbulkan masalah tambahan. Lakukan refactoring berulang-ulang.
Cara mendesainnya relatif benar
Layanan proxy "saya" hidup selama enam bulan dan selama ini tidak memenuhi tugasnya. Bagaimana dia bisa hidup begitu lama?
Ketika sebuah bisnis menguji suatu produk dan itu menunjukkan ketidakefektifan, itu ditinggalkan atau dihancurkan. Ini normal. Ketika MVP diuji dan ternyata efisien, maka MVP tetap hidup. Tapi biasanya MVP tidak ditulis ulang dan hidup "sebagaimana adanya", ditumbuhi dengan kode dan fungsionalitas. Oleh karena itu, "produk zombie" yang dibuat untuk MVP adalah praktik yang umum.
Ketika saya mengetahui bagaimana layanan proxy saya tidak berfungsi, tim memutuskan untuk menulis ulang. Bisnis ini ditugaskan kepada saya dan seorang kolega dan dialokasikan dua minggu: ada sedikit logika bisnis, layanannya kecil. Ini adalah kesalahan lainnya.
Layanan mulai ditulis ulang seluruhnya. Ketika mereka memotong, menulis ulang bagian-bagian kode dan mengunggahnya ke lingkungan pengujian, bagian dari platform macet. Ternyata layanan tersebut memiliki banyak logika bisnis tidak berdokumen yang tidak diketahui siapa pun. Kolega saya dan saya gagal, tetapi ini adalah kesalahan dalam logika layanan.
Kami memutuskan untuk mendekati refactoring dari sisi lain:
- memutar kembali ke versi sebelumnya;
- kode tidak ditulis ulang;
- kami membagi kode menjadi beberapa bagian - paket;
- setiap paket dibungkus dalam antarmuka terpisah.
Kami tidak memahami apa yang dilakukan layanan tersebut karena tidak ada yang memahaminya. Oleh karena itu, "menggergaji" layanan menjadi beberapa bagian dan mencari tahu apa yang menjadi tanggung jawab setiap bagian adalah satu-satunya pilihan.
Setelah itu, setiap paket dapat direfraktor secara terpisah. Kami dapat memperbaiki setiap bagian layanan secara terpisah dan / atau menerapkannya di bagian lain dari proyek. Pada saat yang sama, pengerjaan layanan berlanjut hingga hari ini.
Ternyata seperti ini.
Bagaimana kita akan menulis layanan serupa jika kita telah mendesainnya dengan "baik" sejak awal? Izinkan saya menunjukkan kepada Anda contoh layanan mikro kecil yang mendaftarkan dan mengotorisasi pengguna.
Pengantar
Kita membutuhkan: inti dari sistem, suatu entitas yang mendefinisikan dan menjalankan logika bisnis dengan memanipulasi modul eksternal.
type Core struct {
userRepo UserRepo
sessionRepo SessionRepo
hashing Hasher
auth Auth
}
Selanjutnya, Anda memerlukan dua kontrak yang memungkinkan Anda menggunakan lapisan repo. Kontrak pertama memberi kita sebuah antarmuka. Dengan bantuannya, kami akan berkomunikasi dengan lapisan database yang menyimpan informasi tentang pengguna.
// 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)
}
Kontrak kedua "berkomunikasi" dengan lapisan yang menyimpan informasi tentang sesi pengguna.
// 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
}
Sekarang Anda memerlukan antarmuka untuk bekerja dengan kata sandi, melakukan hashing dan membandingkannya. Dan juga antarmuka terbaru untuk bekerja dengan token otorisasi, yang memungkinkannya dibuat dan juga diidentifikasi.
// 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)
}
Mari mulai menulis logikanya sendiri. Pertanyaan utamanya adalah apa yang kita inginkan dari logika bisnis aplikasi?
- Pendaftaran pengguna.
- Memeriksa surat dan nama panggilan.
- Otorisasi.
Cek
Mari kita mulai dengan metode sederhana - memeriksa email atau nama panggilan. UserRepo kami tidak memiliki metode untuk memeriksa. Tetapi kami tidak akan menambahkannya, kami dapat memeriksa apakah data ini atau itu sibuk dengan meminta pengguna untuk data ini.
// 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
}
}
Ada dua nuansa di sini.
Mengapa cek lulus untuk kesalahan
ErrNotFound? Implementasi logika bisnis tidak boleh bergantung pada SQL atau database lain, jadi sql.ErrNoRowsharus diubah menjadi kesalahan yang sesuai untuk logika bisnis kita.
Kami juga meningkatkan kesalahan lapisan logika bisnis dengan lapisan API, dan kode kesalahan atau yang lainnya harus diselesaikan di tingkat API. Logika bisnis seharusnya tidak bergantung pada protokol komunikasi dengan klien dan membuat keputusan berdasarkan ini.
Pendaftaran dan otorisasi
// 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
}
Ini adalah kode imperatif sederhana yang mudah dibaca dan dipelihara. Anda dapat mulai menulis kode ini segera saat mendesain. Tidak masalah ke database mana kami menambahkan pengguna, protokol mana yang kami pilih untuk berkomunikasi dengan klien, atau bagaimana kata sandi di-hash. Logika bisnis tidak tertarik pada semua lapisan ini, itu hanya penting untuk melakukan tugas area aplikasinya.
Lapisan hashing sederhana
Apa artinya? Semua non-lapisan eksternal seharusnya tidak membuat keputusan tentang tugas-tugas yang terkait dengan area aplikasi. Mereka melakukan tugas khusus dan sederhana yang dibutuhkan logika bisnis kita. Sebagai contoh, mari kita ambil layer untuk hashing password.
// 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
}
Ini adalah beberapa lapisan sederhana untuk melakukan tugas pencirian dan perbandingan kata sandi. Semuanya. Dia kurus dan sederhana dan tidak tahu apa-apa lagi. Dan seharusnya tidak.
Repo
Mari pikirkan tentang lapisan interaksi penyimpanan.
Mari deklarasikan implementasinya dan tunjukkan antarmuka mana yang harus diimplementasikan.
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}
}
Dimungkinkan untuk membiarkan pembaca kode memahami kontrak mana yang diimplementasikan oleh layer, serta memperhitungkan tugas yang ditetapkan untuk Repo kami.
Mari kita mulai implementasi. Agar artikel tidak meregang, saya hanya akan memberikan sebagian dari metode.
// 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
}
Lapisan Repo memiliki metode sederhana dan dasar. Mereka tidak tahu bagaimana melakukan apa pun kecuali "Simpan, kirim, perbarui, hapus, temukan." Tugas lapisan hanya menjadi penyedia data yang mudah digunakan untuk database apa pun yang dibutuhkan proyek kita.
API
Masih ada lapisan API untuk berinteraksi dengan klien.
Diperlukan untuk mentransfer data dari klien ke logika bisnis, mengembalikan hasilnya, dan sepenuhnya memenuhi semua kebutuhan HTTP - ubah kesalahan aplikasi.
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)
}
}
Dalam hal ini, tugasnya berakhir: dia membawa data, mendapatkan hasilnya, mengubahnya menjadi format yang sesuai untuk HTTP.
Untuk apa arsitektur bersih benar-benar dibutuhkan?
Untuk apa ini semua? Mengapa menerapkan solusi arsitektur tertentu? Bukan untuk "kebersihan" kode, tetapi untuk kemudahan pengujian. Kita membutuhkan kemampuan untuk menguji kode kita sendiri dengan nyaman, sederhana dan mudah.
Misalnya, kode seperti ini buruk :
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)
}
Catatan: lupa menunjukkan bahwa kode ini buruk. Ini bisa menyesatkan jika Anda membaca sebelum pembaruan. Maaf soal itu.
Kemampuan untuk menguji kode tanpa masalah besar adalah keuntungan utama dari arsitektur yang bersih.
Kita dapat menguji semua logika bisnis dengan mengabstraksi dari database, server, protokol. Hanya penting bagi kami untuk melakukan tugas yang diterapkan dari aplikasi kami. Sekarang, dengan mengikuti aturan tertentu dan sederhana, kami dapat dengan mudah memperluas dan mengubah kode kami tanpa kesulitan.
Produk apa pun memiliki logika bisnis. Arsitektur yang baik membantu, misalnya, untuk mengemas logika bisnis ke dalam satu paket, yang tugasnya adalah mengoperasikan modul eksternal untuk melakukan tugas aplikasi.
Tetapi arsitektur yang bersih tidak selalu bagus. Terkadang bisa berubah menjadi kejahatan, membawa kerumitan yang tidak perlu. Jika Anda mencoba langsung menulis dengan sempurna, kami akan membuang waktu yang berharga dan mengecewakan proyek. Anda tidak harus menulis dengan sempurna - tulislah dengan baik berdasarkan tujuan bisnis Anda.
, Golang Live 2020 14 17 . — 14 , — , .