Apa yang harus dikembalikan oleh presenter dalam Arsitektur Bersih?

Dec 25 2020

Dalam Arsitektur Bersih, kasus penggunaan memanggil penyaji, berdasarkan objek keluaran yang disepakati. Saat penyaji dipanggil, ia mengembalikan ViewModel yang digunakan oleh tampilan. Tidak apa-apa sampai Anda memiliki lebih dari dua tampilan: CLI dan Web, misalnya. Jika Anda memiliki dua pandangan ini, Anda memerlukan dua penyaji yang berbeda juga. Tapi kasus penggunaan akan sama untuk kedua penyaji. Setiap tampilan mungkin membutuhkan ViewModel yang berbeda, jadi setiap penyaji perlu mengembalikan data yang berbeda.

Masalah muncul ketika setiap penyaji mengembalikan data yang berbeda. Kasus penggunaan harus mengembalikan dua jenis yang berbeda. Tetapi ini sulit dicapai untuk bahasa yang diketik dengan kuat seperti Java atau C ++.

Saya telah menemukan pertanyaan terkait ini , di mana pengguna mendefinisikan penyaji abstrak yang digunakan kasus penggunaan dan setiap penyaji mengembalikan model tampilan yang berbeda. Desain itu tidak apa-apa sampai Anda mencoba menerapkannya, karena Anda akan menemukan masalah yang telah saya jelaskan.

Mungkin saya terlalu memikirkannya atau kurang memahami arsitektur yang bersih. Bagaimana cara mengatasi masalah ini?

Jawaban

9 JKlen Dec 25 2020 at 16:01

Pertama, saya akan berasumsi bahwa Anda menggunakan interpretasi Paman Bob tentang arsitektur bersih, jadi saya mengutip sumbernya di sini:

Misalnya, pertimbangkan bahwa use case perlu memanggil presenter. Namun, panggilan ini tidak boleh langsung karena akan melanggar Aturan Ketergantungan: Tidak ada nama di lingkaran luar yang dapat disebutkan oleh lingkaran dalam. Jadi kita memiliki use case yang memanggil antarmuka (Ditampilkan di sini sebagai Use Case Output Port) di lingkaran dalam, dan penyaji di lingkaran luar mengimplementasikannya.

Jadi kasus penggunaan Anda sama sekali tidak boleh menampilkan jenis yang berbeda untuk penyaji yang berbeda. Itu hanya akan merusak arsitektur bersih.

Use-case tidak peduli dengan spesifik dari layer presentasi Anda (yang oleh Paman Bob disebut "Adaptor Antarmuka"), dan paling banyak itu hanya mengetahui jenis data yang dibutuhkan antarmuka Anda. Jadi itu membuat model yang sama untuk semua antarmuka yang mungkin mengkonsumsinya.

Model itu kemudian diteruskan ke abstraksi penyaji, yang kemudian diselesaikan ke penyaji tertentu tanpa pengakuan apa pun dari bagian kasus penggunaan Anda.

Penyaji melanjutkan untuk mengambil model umum yang sama dan membangun model tampilan yang, memang, khusus untuk antarmuka.

Paket Presenter+ViewModel+Viewini, kurang lebih, khusus untuk antarmuka Anda, baik itu web atau cli, meskipun Anda mungkin harus berusaha agar mereka tahu sesedikit mungkin tentang satu sama lain. Namun, itu sebenarnya bukan bagian dari arsitektur bersih inti.

Saya berpendapat bahwa inti dari mendefinisikan kasus penggunaan adalah untuk memisahkan kasus penggunaan ... yah ... kasus penggunaan yang berbeda. Jika penyaji Anda perlu mengembalikan data yang sangat berbeda, dan tidak masuk akal jika semua data ini berada di dalam satu model yang diturunkan dari kasus penggunaan Anda, maka Anda mungkin harus mendefinisikan ulang kasus penggunaan Anda, karena tampaknya Anda mencampur beberapa dari mereka menjadi satu.

3 candied_orange Dec 26 2020 at 01:02

Mari kita perjelas dengan beberapa contoh:

  • Indikasi kemajuan ditampilkan setelah pengguna meminta beberapa perhitungan intensif

  • Menu ditampilkan setelah pengguna membuat pilihan

Keduanya adalah kasus penggunaan. Keduanya dapat dilakukan dengan web atau CLI . Keduanya membutuhkan Interaktor Kasus Penggunaan yang berbeda. Tetapi jika hanya mengubah dari CLI ke web yang mengharuskan Anda mengubah Interaktor Kasus Penggunaan, maka Anda telah membiarkan detail Penyaji bocor ke dalam Interaktor Kasus Penggunaan. Anda membuat Interactor melakukan bagian dari pekerjaan Presenter.

Anda harus dapat melihat Data Keluaran dan mengetahui apakah Anda melihat indikator kemajuan atau menu. Ini bisa menjadi kelas / struktur data yang sangat berbeda. Tetapi Anda seharusnya tidak dapat mengetahui apakah itu akan ditampilkan di web atau di CLI. Itulah pekerjaan Lihat Model.

Inilah yang saya yakini @JKlen maksud dengan:

Bundel Presenter + ViewModel + View ini, lebih atau kurang, khusus untuk antarmuka Anda, baik itu web atau cli

Saya sangat mendukung jawaban @ JKlen. Hanya berpikir aku akan memberi sedikit lebih banyak cahaya.

Masalah muncul ketika setiap penyaji mengembalikan data yang berbeda. Kasus penggunaan harus mengembalikan dua jenis yang berbeda. Tetapi ini sulit dicapai untuk bahasa yang diketik dengan kuat seperti Java atau C ++.

Tidak sulit jika Anda tahu cara mengatasinya. Use Case Interactor "kembali" berdasarkan Use Case Interactor itu (misalnya, kemajuan atau menu). Ini berfungsi karena beberapa Penyaji (tidak semua) tahu bagaimana menangani hasil dari Interaktor Kasus Penggunaan tertentu. Anda hanya perlu mencocokkannya dengan benar saat Anda membuat grafik objek ini. Karena mengirim menu ke Progress Presenter akan menimbulkan masalah. Web atau CLI.

2 FilipMilovanović Dec 26 2020 at 09:50

Izinkan saya mencoba dan melengkapi jawaban lainnya dengan mengambil perspektif yang sedikit berbeda.

Saya pikir apa yang mungkin membingungkan Anda adalah bahwa ada (tampaknya) banyak "bagian yang bergerak" dalam Arsitektur Bersih, dan jika Anda baru mengenalnya, tidak jelas bagaimana mereka cocok satu sama lain. Banyak konsep yang sepertinya membicarakan tentang sesuatu yang eksotis yang belum pernah Anda temui sebelumnya, tetapi sebenarnya bukan itu masalahnya.

Jadi mari kita singkirkan komplikasi ini, dan mari kita pikirkan tentang satu fungsi . Mari kita mulai dengan pendekatan yang akan terasa langsung bagi seseorang yang terbiasa dengan aplikasi berbasis CRUD, dan lihat bagaimana kita dapat mengembangkan arsitektur dari sana.

Pendekatan berbasis tarik

Misalkan Anda memiliki fungsi seperti ini:

    public ProcessingResult ProcessProducts(ProductCategory category) { ... }

Jadi, fungsi ini mengimplementasikan beberapa kasus penggunaan. Dibutuhkan ProductCategory, melakukan sesuatu dengannya secara internal untuk melakukan beberapa pemrosesan pada sekumpulan produk, dan mengembalikan a ProcessingResult- sebuah objek yang berisi beberapa informasi umum tentang operasi tersebut, dan mungkin daftar produk yang diproses. Untuk saat ini, dan untuk tujuan diskusi ini, kami tidak peduli apa yang terjadi di dalam fungsi, jika dipisahkan dengan benar, apakah mengikuti Arsitektur Bersih atau tidak, dll. Mari kita fokus pada antarmukanya - tanda tangan 1 dari fungsinya.


1 Untuk kejelasan, dalam jawaban ini, tanda tangan mengacu pada nama fungsi, tipe yang muncul di daftar parameter, dan tipe kembalian - hal-hal yang bergantung pada kode lain saat menggunakan fungsi. Beberapa bahasa secara formal tidak menganggap tipe pengembalian sebagai bagian dari tanda tangan (Anda tidak bisa membebani tipe pengembalian), tapi itu tidak berguna saat mendiskusikan desain.


Sebuah use case interactor (yang, dalam contoh yang disederhanakan ini, bahkan bukan sebuah objek - hanya fungsi ini), memiliki data masukan dan data keluaran (alias model masukan , dan model keluaran ). Ini hanyalah nama-nama umum; Anda sebenarnya tidak akan menggunakan nama-nama itu dalam aplikasi Anda - sebaliknya, Anda akan memilih nama yang lebih bermakna.

Dalam hal ini model masukan hanyalah ProductCategorykelas - ia memiliki beberapa properti yang mewakili detail tertentu dari kategori produk yang dibutuhkan oleh kasus penggunaan. Itulah arti kata "model" - model adalah representasi dari sesuatu. Demikian pula model keluarannya di sini adalah ProcessingResultkelas.

BAIK. Jadi, katakanlah semua detail implementasi di belakang ProcessProductsfungsi dianggap sebagai "lapisan dalam" (lapisan dalam ini bisa memiliki lapisan di dalamnya, tapi kami mengabaikannya untuk saat ini). Fungsi itu sendiri, dan tipe ProductCategory& ProcessingResult, termasuk dalam lapisan yang sama ini, tetapi mereka istimewa karena berada di batas lapisan (mereka adalah API ke lapisan dalam, jika Anda mau). Kode dari lapisan luar akan memanggil fungsi ini, dan ini akan merujuk ke jenis ini berdasarkan nama. Dengan kata lain, kode dari lapisan luar akan secara langsung bergantung pada fungsi ini dan jenis yang muncul di tanda tangannya, tetapi tidak akan tahu apa-apa tentang kode di belakang fungsi (detail implementasinya) - yang memungkinkan Anda mengubah keduanya. mandiri, selama Anda tidak perlu mengubah tanda tangan dari fungsi ini .

Memperkenalkan lapisan luar - tanpa model tampilan

Sekarang, misalkan Anda ingin memiliki dua tampilan berbeda. Kode yang terkait dengan ini akan hidup di lapisan luar Anda . Satu tampilan adalah HTML, yang lainnya adalah teks biasa untuk ditampilkan sebagai output dari alat CLI.

Nah, yang perlu Anda lakukan hanyalah memanggil fungsi ini, mengambil hasilnya, dan mengubahnya ke format yang sesuai. Jangan gunakan model tampilan untuk saat ini (Anda tidak memerlukan model tampilan untuk semuanya). Sebagai contoh:

    // In your web code:
    
    var result = ProcessProducts(category);   // controller invoking the use case

    // Presentation code 
    // (could be in the same function, but maybe it's in a separate function):

    // fill HTML elements with result.summary
    // create an <ul>
    // for each product in result.ProcessedProducts, create an <li>

atau:

    // In your CLI code:
    
    var result = ProcessProducts(category);   // controller invoking the use case

    // Presentation code
    // (could be in the same function, but maybe it's in a separate function):
    Console.WriteLine(result.summary);
    foreach(var product in result.ProcessedProducts)
        Console.WriteLine(result.summary);

Jadi, pada titik ini, Anda memiliki ini - pengontrol Anda langsung mereferensikan kasus penggunaan, dan mengoordinasikan logika presentasi:

Lihat model

Jika tampilan Anda memiliki beberapa logika nontrivial, dan mungkin menambahkan data spesifik tampilan miliknya sendiri, atau jika tidak nyaman untuk bekerja dengan data yang dikembalikan oleh kasus penggunaan, maka memperkenalkan model tampilan sebagai tingkat tipuan akan membantu Anda mengatasinya.

Dengan model tampilan, kodenya tidak jauh berbeda dari yang di atas, kecuali Anda tidak membuat tampilan secara langsung; sebagai gantinya, Anda mengambil resultdan membuat model tampilan darinya. Mungkin Anda kemudian mengembalikannya, atau mungkin menyebarkannya ke sesuatu yang membuat tampilan. Atau Anda tidak melakukan semua itu: jika kerangka kerja yang Anda gunakan bergantung pada pengikatan data , Anda cukup memperbarui model tampilan, dan mekanisme pengikatan data memperbarui tampilan yang terhubung.

Mendesain ulang menuju antarmuka berbasis push

Sekarang, apa yang saya jelaskan di atas adalah pendekatan "berbasis tarik" - Anda secara aktif meminta ("menarik") hasil. Misalkan Anda menyadari bahwa Anda perlu mendesain ulang ke UI 2 "berbasis push" - yaitu, Anda ingin menjalankan fungsi ProcessProducts, dan membuatnya memulai pembaruan beberapa tampilan setelah menyelesaikan pemrosesan?


2 Saya tidak mengatakan bahwa mendorong data ke UI lebih baik , hanya itu pilihan. Yang saya coba adalah mengapa Arsitektur Bersih memiliki elemen-elemen yang dimilikinya.


Ingat, Anda ingin kode dalam use case ditulis tanpa mengacu pada tampilan konkret, karena, Anda harus mendukung dua tampilan yang sangat berbeda. Anda tidak dapat memanggil tampilan / penyaji langsung dari dalam, jika tidak, Anda melanggar aturan ketergantungan. Nah, gunakan inversi ketergantungan .

Inversi ketergantungan

Anda ingin mendorong ProcessingResultke beberapa lokasi keluaran, tetapi Anda tidak ingin fungsinya mengetahui apa itu. Jadi, Anda memerlukan semacam ... oh entahlah ... keluaran abstraksi? Arsitektur bersih memiliki gagasan tentang batas keluaran (alias port keluaran) - antarmuka yang mengabstraksi ketergantungan pada sesuatu yang Anda perlukan untuk mendorong data. Sekali lagi, dalam kode Anda, Anda akan memberinya nama yang lebih bermakna (yang saya buat di sini tidak bagus, saya akui). Dalam contoh di sini, yang perlu dimiliki antarmuka ini adalah metode yang menerima ProcessingResultsebagai parameter:

    public interface IProcessingOutputPresenter {
        void Show(ProcessingResult result);
    }

Jadi, sekarang Anda mendesain ulang tanda tangan fungsi menjadi seperti ini:

    public void ProcessProducts(ProductCategory category, IProcessingOutputPresenter presenter) { 
        // stuff happens...
        ProcessingResult result = <something>; 
        presenter.Show(result);
    }

Atau mungkin itu adalah operasi yang berjalan lama:

    public async Task ProcessProductsAsync(ProductCategory category, IProcessingOutputPresenter presenter) { 
        // stuff happens...
        ProcessingResult result = await <something>; 

        presenter.Show(result);
    }

Jadi sekarang, Anda bisa melakukan ini:

    // presenter class:
    public class WebPresenter : IProcessingOutputPresenter { ... }

    // In your web controller:    
    ProcessProducts(category, this.webPresenter);

atau:

    // presenter class:
    public class CliPresenter : IProcessingOutputPresenter { ... }

    // In your CLI controller:
    ProcessProducts(category, this.cliPresenter);

atau, dalam pengujian Anda :

    // mock presenter:
    public class MockPresenter : IProcessingOutputPresenter { ... }

    // In your test:
    var presenter = new MockPresenter();
    ProcessProducts(category, mockPresenter);

Jadi, sekarang Anda telah menggunakan kembali ProcessProducts kode dalam tiga konteks berbeda.

Pada dasarnya, ProcessProductstidak perlu khawatir dengan tampilan, cukup "aktifkan dan lupa" dengan menelepon .Show(result). Ini adalah tugas penyaji untuk mengonversi masukan menjadi apa pun yang dibutuhkan tampilan (misalkan ada juga mekanisme pengikatan data yang terlibat, yang memicu pembaruan tampilan saat model tampilan berubah).

Ini adalah struktur ketergantungan yang penting di sini, bukan apakah Anda menggunakan objek atau fungsi. Faktanya, karena IProcessingOutputPresenterini adalah antarmuka metode tunggal, Anda bisa menggunakan lambda - polanya masih sama, ide arsitekturnya sama. Lambda memainkan peran sebagai port keluaran:

    public ProcessProducts(ProductCategory category, Action<ProcessingResult> presenterAction);

    // then:
    ProcessProducts(category, (result) => presenter.Show(result));

Itu adalah hal yang sama.

Apa yang Anda miliki dengan pengaturan ini adalah bagian yang disorot di sini:

Anda juga dapat mendesain ulang antarmuka Anda untuk memungkinkan beberapa tampilan secara bersamaan:

    public void ProcessProducts(ProductCategory category, IEnumerable<IProcessingOutputPresenter> presenters)
    {
        // stuff happens...
        // ProcessingResult result = <something> 
        foreach (var presenter in presenters)
            presenter.Show(result);
    }

Bagaimana jika Anda memiliki objek, bukan hanya fungsi?

Ini pada dasarnya adalah ide dasar yang sama , kecuali bahwa Anda biasanya akan meneruskan penyaji (implementasi antarmuka batas keluaran) ke konstruktor kasus penggunaan. Alih-alih meneruskan presenter dari pengontrol seperti sebelumnya, Anda dapat menyiapkannya dalam wadah injeksi ketergantungan, atau bahkan secara manual, di root komposisi (misalnya, di Main()):

    var cliPresenter = new CliPresenter();
    var productRepository = new ProductRepository(/* ... */);
    var productProcessor = new ProductProcessor(cliPresenter, productRepository);  // <----
    var cliController = new CliController(productProcessor);
    RunCliApplication(cliController);
    
    // (or something of the sort)

Perhatikan bahwa kode akses data telah dimasukkan dengan cara yang serupa:

Atau, jika Anda ingin dapat mengubah tujuan keluaran secara dinamis, Anda dapat menetapkan tujuan keluaran menjadi parameter metode objek kasus penggunaan (misalnya, mungkin keluaran untuk kategori produk yang berbeda harus ditampilkan dalam dua tampilan berbeda. dalam aplikasi yang sama):

productProcessor.Process(trackedProducts, graphPresenter);
productProcessor.Process(untrackedProducts, listPresenter);

Ide yang sama berlaku melintasi batas lapisan

Ide dasar yang sama ini berlaku di seluruh aplikasi - baik memanggil lapisan dalam secara langsung, atau mengimplementasikan antarmuka yang ditentukan di lapisan dalam sehingga dapat memanggil Anda, meskipun kode itu tidak menyadarinya.

Hanya saja Anda perlu menerapkan teknik ini dengan bijaksana . Anda tidak memerlukan (atau menginginkan) 5 lapisan abstraksi yang semuanya mengulangi struktur data yang sama. Karena Anda akan salah paham (bahkan jika Anda berpengalaman), dan kemudian Anda akan ragu untuk mendesain ulang karena terlalu banyak pekerjaan. Ya, Anda akan mengetahui beberapa elemen arsitektur yang berbeda dari analisis awal, tetapi secara umum, mulailah dengan sederhana, lalu dekomposisi dan restrukturisasi di sana-sini saat kode menjadi lebih rumit - mencegahnya menjadi terlalu kusut saat Anda melanjutkan . Anda dapat melakukannya karena detail penerapan tersembunyi di balik antarmuka kasus penggunaan Anda. Anda dapat "membentuk kembali" bagian dalam lapisan dalam seiring bertambahnya kompleksitas.

Anda menjaga kode tetap dapat dipelihara dengan memperhatikan bahwa itu mulai menjadi kurang dapat dipelihara, dan melakukan sesuatu tentangnya.

Di sini kami mulai dengan fungsi sederhana, yang dipanggil oleh pengontrol yang pada awalnya juga melakukan pekerjaan penyaji. Setelah beberapa refactorings, Anda akan dapat mengekstrak bagian yang berbeda, menentukan antarmuka, tanggung jawab terpisah dari subkomponen yang berbeda, dll - akhirnya mendekati sesuatu yang lebih dekat dengan Arsitektur Bersih yang ideal.

Ada dua kesimpulan di sini. Pertama, Anda mungkin pernah melihat teknik ini digunakan di luar konteks CA; CA tidak melakukan sesuatu yang sangat baru atau berbeda. Tidak ada yang terlalu misterius tentang CA. Ini hanya memberi Anda cara untuk memikirkan hal-hal ini. Kedua, Anda tidak harus memikirkan setiap elemen arsitektur sekaligus (pada kenyataannya, Anda berisiko melakukan rekayasa berlebihan dengan melakukannya); sebagai gantinya, Anda ingin menunda beberapa keputusan itu sampai Anda melihat seperti apa kodenya.