[DotNetBook] Pengecualian: Jenis Arsitektur Sistem

Dengan artikel ini, saya terus menerbitkan serangkaian artikel, yang hasilnya akan menjadi buku tentang karya .NET CLR, dan .NET secara umum. Untuk tautan - selamat datang ke kucing.


Arsitektur Pengecualian


Mungkin salah satu masalah terpenting tentang topik pengecualian adalah masalah membangun arsitektur pengecualian dalam aplikasi Anda. Pertanyaan ini menarik karena berbagai alasan. Bagi saya, hal utama adalah kesederhanaan yang nampak yang tidak selalu jelas apa yang harus dilakukan. Properti ini melekat dalam semua konstruksi dasar yang digunakan di mana-mana: properti ini IEnumerable , dan IDisposable dan IObservable dan lainnya. Di satu sisi, mereka memberi isyarat oleh kesederhanaan mereka, melibatkan diri mereka dalam menggunakan diri mereka dalam berbagai situasi. Dan di sisi lain, mereka penuh dengan pusaran air dan fords, dari mana, tidak tahu bagaimana kadang-kadang tidak keluar sama sekali. Dan, mungkin, melihat volume masa depan, pertanyaan Anda telah matang: jadi apa itu dalam situasi luar biasa?


Catatan


Bab yang diterbitkan di Habré tidak diperbarui dan, mungkin, sudah agak ketinggalan zaman. Dan karena itu, silakan buka teks asli untuk teks yang lebih baru:



Tetapi untuk sampai pada kesimpulan tentang konstruksi arsitektur kelas situasi luar biasa, kami harus mengumpulkan beberapa pengalaman dengan Anda mengenai klasifikasi mereka. Setelah semua, hanya setelah memahami apa yang akan kita hadapi, bagaimana dan dalam situasi apa programmer harus memilih jenis kesalahan, dan di mana - membuat pilihan mengenai menangkap atau melompati pengecualian, Anda dapat memahami bagaimana Anda bisa membangun sistem tipe sedemikian rupa sehingga menjadi jelas bagi pengguna Anda kode. Oleh karena itu, kami akan mencoba untuk mengklasifikasikan situasi luar biasa (bukan jenis pengecualian itu sendiri, tetapi situasi yang tepat) sesuai dengan berbagai kriteria.


Menurut kemungkinan teoritis menangkap pengecualian yang diproyeksikan


Dalam hal intersepsi teoritis, pengecualian dapat dengan mudah dibagi menjadi dua jenis: mereka yang akan mencegat secara akurat dan mereka yang sangat mungkin untuk mencegat. Mengapa dengan tingkat probabilitas yang tinggi ? Karena akan selalu ada seseorang yang akan mencoba mencegat, walaupun ini tidak harus dilakukan sepenuhnya.


Mari kita pertama-tama mengungkapkan fitur-fitur kelompok pertama: pengecualian yang harus dan akan ditangkap.


Ketika kami memperkenalkan pengecualian jenis ini, di satu sisi kami memberi tahu subsistem eksternal bahwa kami berada dalam posisi di mana tindakan lebih lanjut dalam data kami tidak masuk akal. Di sisi lain, kami berarti bahwa tidak ada global yang rusak dan jika kami dihapus, tidak ada yang akan berubah, dan karena itu pengecualian ini dapat dengan mudah dicegat untuk memperbaiki situasi. Properti ini sangat penting: properti menentukan kekritisan kesalahan dan keyakinan bahwa jika Anda menangkap pengecualian dan menghapus sumber daya, Anda dapat dengan aman mengeksekusi kode lebih lanjut.


Kelompok kedua, betapapun anehnya kedengarannya, bertanggung jawab atas pengecualian yang tidak perlu ditangkap. Mereka hanya dapat digunakan untuk menulis ke log kesalahan, tetapi tidak untuk memperbaiki situasi. Contoh paling sederhana adalah pengecualian grup ArgumentException dan NullReferenceException . Memang, dalam situasi normal, Anda tidak boleh, misalnya, menangkap pengecualian ArgumentNullException karena sumber masalahnya di sini adalah Anda, dan bukan orang lain. Jika Anda menangkap pengecualian ini, maka Anda menganggap bahwa Anda melakukan kesalahan dan memberikan metode yang tidak dapat Anda berikan kepada:


 void SomeMethod(object argument) { try { AnotherMethod(argument); } catch (ArgumentNullException exception) { // Log it } } 

Dalam metode ini, kami mencoba menangkap ArgumentNullException . Tapi menurut saya, intersepsi itu terlihat sangat aneh: melemparkan argumen yang benar ke metode sepenuhnya menjadi perhatian kita. Tidak akan benar untuk bereaksi setelah fakta: dalam situasi seperti itu, hal yang paling benar yang dapat dilakukan adalah memeriksa data yang dikirimkan terlebih dahulu, sebelum memanggil metode, atau bahkan lebih baik, untuk membangun kode sedemikian rupa sehingga menerima parameter yang salah tidak mungkin dilakukan.


Kelompok lain adalah penghapusan kesalahan fatal. Jika cache tertentu rusak dan operasi subsistem tidak akan benar dalam hal apa pun? Maka ini adalah kesalahan fatal dan kode yang paling dekat dengan stack tidak akan dijamin untuk mencegatnya:


 T GetFromCacheOrCalculate() { try { if(_cache.TryGetValue(Key, out var result)) { return result; } else { T res = Strategy(Key); _cache[Key] = res; return res; } } catch (CacheCorreptedException exception) { RecreateCache(); return GetFromCacheOrCalculate(); } } 

Dan biarkan CacheCorreptedException menjadi pengecualian yang berarti "cache pada hard drive tidak konsisten." Kemudian ternyata bahwa jika penyebab kesalahan seperti itu berakibat fatal bagi subsistem caching (misalnya, tidak ada izin ke file cache), maka kode selanjutnya jika tidak dapat membuat kembali cache dengan perintah RecreateCache , dan karena itu fakta menangkap pengecualian ini adalah kesalahan itu sendiri.


Pada intersepsi sebenarnya pengecualian


Pertanyaan lain yang menghentikan pemikiran kami dalam algoritme pemrograman adalah pemahaman: apakah layak untuk menangkap pengecualian ini atau lainnya atau layakkah seseorang yang memahami untuk membiarkannya melaluinya. Menerjemahkan ke dalam bahasa istilah pertanyaan yang perlu kita selesaikan adalah untuk membedakan antara bidang tanggung jawab. Mari kita lihat kode berikut:


 namespace JetFinance.Strategies { public class WildStrategy : StrategyBase { private Random random = new Random(); public void PlayRussianRoulette() { if(DateTime.Now.Second == (random.Next() % 60)) { throw new StrategyException(); } } } public class StrategyException : Exception { /* .. */ } } namespace JetFinance.Investments { public class WildInvestment { WildStrategy _strategy; public WildInvestment(WildStrategy strategy) { _strategy = strategy; } public void DoSomethingWild() { ?try? { _strategy.PlayRussianRoulette(); } catch(StrategyException exception) { } } } } using JetFinance.Strategies; using JetFinance.Investments; void Main() { var foo = new WildStrategy(); var boo = new WildInvestment(foo); ?try? { boo.DoSomethingWild(); } catch(StrategyException exception) { } } 

Manakah dari dua strategi yang diusulkan lebih benar? Area tanggung jawab sangat penting. Pada awalnya, mungkin terlihat bahwa karena pekerjaan WildInvestment dan konsistensinya sepenuhnya bergantung pada WildStrategy , jika WildInvestment mengabaikan pengecualian ini, itu akan naik ke level yang lebih tinggi dan tidak perlu melakukan hal lain. Namun, harap dicatat bahwa ada masalah arsitektur murni: metode Main menangkap pengecualian dari satu lapisan arsitektur dengan menggunakan metode yang berbeda secara arsitektur. Seperti apa dalam hal penggunaan? Ya, secara umum, ini terlihat seperti ini:


  • kekhawatiran untuk pengecualian ini hanya kalah dari kami;
  • pengguna kelas ini tidak yakin bahwa pengecualian ini dilemparkan melalui sejumlah metode sebelum kita secara khusus
  • kita mulai menggambar kecanduan yang tidak perlu, yang kita singkirkan, menyebabkan lapisan perantara.

Namun, kesimpulan lain mengikuti dari kesimpulan ini: kita harus menetapkan catch dalam metode DoSomethingWild . Dan ini agak aneh bagi kami: WildInvestment tampaknya sangat tergantung pada seseorang. Yaitu jika PlayRussianRoulette tidak dapat bekerja, maka DoSomethingWild juga: ia tidak memiliki kode kembali, tetapi harus memainkan roulette. Apa yang harus dilakukan dalam situasi yang tampaknya tanpa harapan? Jawabannya sebenarnya sederhana: berada di lapisan lain, DoSomethingWild harus membuang pengecualiannya sendiri, yang merujuk ke lapisan ini dan membungkus sumber asli sebagai sumber masalah - di InnerException :


 namespace JetFinance.Strategies { pubilc class WildStrategy { private Random random = new Random(); public void PlayRussianRoulette() { if(DateTime.Now.Second == (random.Next() % 60)) { throw new StrategyException(); } } } public class StrategyException : Exception { /* .. */ } } namespace JetFinance.Investments { public class WildInvestment { WildStrategy _strategy; public WildInvestment(WildStrategy strategy) { _strategy = strategy; } public void DoSomethingWild() { try { _strategy.PlayRussianRoulette(); } catch(StrategyException exception) { throw new FailedInvestmentException("Oops", exception); } } } public class InvestmentException : Exception { /* .. */ } public class FailedInvestmentException : Exception { /* .. */ } } using JetFinance.Investments; void Main() { var foo = new WildStrategy(); var boo = new WildInvestment(foo); try { boo.DoSomethingWild(); } catch(FailedInvestmentException exception) { } } 

Mengubah pengecualian ke yang lain, kami pada dasarnya mentransfer masalah dari satu lapisan aplikasi ke lapisan lain, menjadikan pekerjaannya lebih dapat diprediksi dari sudut pandang pengguna kelas ini: metode Main .


Untuk masalah penggunaan kembali


Sangat sering kita menghadapi tugas yang sulit: di satu sisi, kita terlalu malas untuk membuat jenis pengecualian baru, dan ketika kita memutuskan, tidak selalu jelas apa yang harus didorong: jenis yang harus diambil sebagai basis sebagai basis. Tetapi justru keputusan inilah yang menentukan seluruh arsitektur situasi luar biasa. Mari kita membahas solusi populer dan menarik beberapa kesimpulan.


Saat memilih jenis pengecualian, Anda dapat mencoba mengambil solusi yang sudah ada: cari pengecualian dengan makna yang sama dalam nama dan gunakan itu. Misalnya, jika kami diberi entitas melalui parameter yang entah bagaimana tidak cocok dengan kami, kami dapat melempar InvalidArgumentException , yang mengindikasikan penyebab kesalahan dalam Pesan. Skenario ini terlihat bagus, terutama mengingat bahwa InvalidArgumentException termasuk dalam kelompok pengecualian yang tidak dikenakan tangkapan wajib. Tetapi memilih InvalidDataException akan buruk jika Anda bekerja dengan data apa pun. Hanya karena jenis ini ada di zona System.IO , dan ini hampir tidak apa yang Anda lakukan. Yaitu ternyata menemukan jenis yang ada karena malas melakukan sendiri hampir selalu menjadi pendekatan yang salah. Hampir tidak ada pengecualian yang dibuat untuk lingkaran tugas umum. Hampir semua dari mereka diciptakan untuk situasi tertentu dan penggunaannya kembali akan menjadi pelanggaran berat arsitektur situasi luar biasa. Selain itu, setelah menerima pengecualian dari jenis tertentu (misalnya, System.IO.InvalidDataException sama), pengguna akan bingung: di satu sisi, ia akan melihat sumber masalah di System.IO sebagai pengecualian namespace, dan di sisi lain, namespace yang sama sekali berbeda untuk titik melempar. Plus, memikirkan aturan untuk melempar pengecualian ini, itu akan pergi ke Referenceource.microsoft.com dan menemukan semua tempat di mana itu dilemparkan :


  • internal class System.IO.Compression.Inflater

Dan dia akan mengerti itu hanya seseorang yang tangannya bengkok pilihan jenis pengecualian membingungkannya, karena metode yang melempar pengecualian tidak terlibat dalam kompresi.


Selain itu, untuk menyederhanakan penggunaan kembali, Anda cukup mengambil dan membuat satu pengecualian dengan mendeklarasikan bidang ErrorCode dengan kode kesalahan dan hidup bahagia selamanya. Tampaknya: solusi yang bagus. Lempar pengecualian yang sama di mana-mana, mengatur kode, menangkap hanya satu catch sehingga meningkatkan stabilitas aplikasi: dan tidak ada lagi yang bisa dilakukan. Namun, mohon tidak setuju dengan posisi ini. Bertindak dengan cara ini sepanjang aplikasi, di satu sisi, tentu saja, Anda menyederhanakan hidup Anda. Tetapi di sisi lain, Anda membuang kemampuan untuk menangkap subkelompok pengecualian, disatukan oleh beberapa fitur umum. Bagaimana ini dilakukan, misalnya, dengan ArgumentException , yang dengan sendirinya menggabungkan seluruh kelompok pengecualian dengan warisan. Minus serius kedua adalah lembar kode yang terlalu besar dan tidak dapat dibaca yang akan mengatur pemfilteran dengan kode kesalahan. Tetapi jika Anda mengambil situasi yang berbeda: ketika finalisasi kesalahan seharusnya tidak penting bagi pengguna akhir, pengenalan tipe generalisasi ditambah kode kesalahan terlihat sudah menjadi aplikasi yang jauh lebih benar:


 public class ParserException : Exception { public ParserError ErrorCode { get; } public ParserException(ParserError errorCode) { ErrorCode = errorCode; } public override string Message { get { return Resources.GetResource($"{nameof(ParserException)}{Enum.GetName(typeof(ParserError), ErrorCode)}"); } } } public enum ParserError { MissingModifier, MissingBracket, // ... } // Usage throw new ParserException(ParserError.MissingModifier); 

Kode yang melindungi panggilan parser hampir selalu acuh tak acuh untuk alasan apa parsing diblokir: fakta kesalahan itu sendiri penting untuk itu. Namun, jika ini tetap menjadi penting, pengguna akan selalu dapat mengekstrak kode kesalahan dari ErrorCode . Untuk melakukan ini, tidak perlu mencari kata-kata yang diperlukan dengan substring di Message .


Jika Anda mulai dari mengabaikan masalah penggunaan kembali, Anda bisa membuat tipe pengecualian untuk setiap situasi. Di satu sisi, ini terlihat logis: satu jenis kesalahan adalah satu jenis pengecualian. Namun, di sini, seperti dalam segala hal, hal utama adalah tidak berlebihan: memiliki operasi yang luar biasa pada setiap titik rilis, Anda menyebabkan masalah untuk intersepsi: kode metode panggilan akan kelebihan beban dengan catch block. Lagi pula, ia perlu menangani semua jenis pengecualian yang ingin Anda berikan kepadanya. Kekurangan lainnya adalah murni arsitektur. Jika Anda tidak menggunakan warisan, maka Anda mengacaukan pengguna pengecualian ini: mungkin ada banyak kesamaan di antara mereka, dan Anda harus mencegatnya secara individual.


Namun demikian, ada skenario yang baik untuk memperkenalkan tipe tertentu untuk situasi tertentu. Misalnya, ketika kerusakan terjadi bukan untuk seluruh entitas secara keseluruhan, tetapi untuk metode tertentu. Maka jenis ini harus berada dalam hierarki warisan di tempat sedemikian rupa sehingga tidak ada pemikiran untuk mencegatnya bersama dengan sesuatu yang lain: misalnya, memilihnya melalui cabang warisan yang terpisah.


Selain itu, jika Anda menggabungkan kedua pendekatan ini, Anda bisa mendapatkan kotak alat yang sangat kuat untuk bekerja dengan sekelompok kesalahan: Anda bisa memperkenalkan tipe abstrak umum untuk mewarisi situasi khusus tertentu. Kelas dasar (tipe generalisasi kami) harus dilengkapi dengan properti abstrak yang menyimpan kode kesalahan, dan ahli waris akan menimpa properti ini untuk menentukan kode kesalahan ini:


 public abstract class ParserException : Exception { public abstract ParserError ErrorCode { get; } public override string Message { get { return Resources.GetResource($"{nameof(ParserException)}{Enum.GetName(typeof(ParserError), ErrorCode)}"); } } } public enum ParserError { MissingModifier, MissingBracket } public class MissingModifierParserException : ParserException { public override ParserError ErrorCode { get; } => ParserError.MissingModifier; } public class MissingBracketParserException : ParserException { public override ParserError ErrorCode { get; } => ParserError.MissingBracket; } // Usage throw new MissingModifierParserException(ParserError.MissingModifier); 

Apa properti indah yang kita dapatkan dengan pendekatan ini?


  • di satu sisi, kami mempertahankan penangkapan pengecualian dengan tipe dasar;
  • di sisi lain, menangkap pengecualian dengan tipe dasar, masih mungkin untuk mengetahui situasi tertentu;
  • dan ditambah untuk semua itu mungkin untuk mencegat untuk tipe tertentu, dan bukan untuk yang dasar, tanpa menggunakan struktur datar kelas.

Bagi saya, ini adalah pilihan yang sangat nyaman.


Sehubungan dengan satu kelompok situasi perilaku


Kesimpulan apa yang dapat ditarik berdasarkan alasan yang dijelaskan sebelumnya? Mari kita coba merumuskannya:


Untuk memulai, mari kita putuskan apa yang dimaksud dengan situasi. Ketika kita berbicara tentang kelas dan objek, kita terutama terbiasa dengan entitas yang beroperasi dengan keadaan internal di mana kita dapat melakukan tindakan. Ternyata dengan melakukan itu kami menemukan jenis situasi perilaku pertama: tindakan pada entitas tertentu. Selanjutnya, jika Anda melihat grafik objek seolah-olah dari luar, Anda akan melihat bahwa itu secara logis digabungkan ke dalam kelompok fungsional: yang pertama berkaitan dengan caching, yang kedua berurusan dengan basis data, yang ketiga melakukan perhitungan matematis. Lapisan dapat melewati semua grup fungsional ini: lapisan logging dari berbagai status internal, proses logging, jejak pemanggilan metode. Lapisan dapat lebih mencakup: menggabungkan beberapa kelompok fungsional. Misalnya, lapisan model, lapisan pengontrol, lapisan presentasi. Kelompok-kelompok ini dapat berada dalam majelis yang sama, atau dalam kelompok yang sama sekali berbeda, tetapi masing-masing dari mereka dapat menciptakan situasi yang luar biasa.


Ternyata jika Anda berdebat dengan cara ini, Anda dapat membangun beberapa hierarki jenis situasi luar biasa, berdasarkan jenis yang dimiliki grup atau lapisan tertentu, sehingga menciptakan kemampuan untuk menangkap pengecualian pada kode untuk navigasi semantik yang mudah dalam hierarki jenis ini.


Mari kita lihat kodenya:


 namespace JetFinance { namespace FinancialPipe { namespace Services { namespace XmlParserService { } namespace JsonCompilerService { } namespace TransactionalPostman { } } } namespace Accounting { /* ... */ } } 

Seperti apa bentuknya? Bagi saya, ruang nama adalah peluang besar untuk secara alami mengelompokkan jenis pengecualian sesuai dengan situasi perilaku mereka: segala sesuatu yang termasuk dalam kelompok tertentu harus ada di sana, termasuk pengecualian. Selain itu, ketika Anda menerima pengecualian tertentu, di samping nama jenisnya, Anda akan melihat namespace-nya, yang dengan jelas akan menentukan afiliasinya. Ingat contoh penggunaan ulang yang buruk dari tipe InvalidDataException yang sebenarnya didefinisikan dalam namespace System.IO ? Yang termasuk dalam namespace ini berarti bahwa pada dasarnya pengecualian dari tipe ini dapat dibuang dari kelas yang terletak di System.IO namespace atau yang lebih bersarang. Tetapi pengecualian itu sendiri dikeluarkan dari tempat yang sama sekali berbeda, membingungkan peneliti tentang masalah yang muncul. Dengan memfokuskan tipe pengecualian pada ruang nama yang sama dengan tipe yang melempar pengecualian ini, Anda menjaga arsitektur tipe konsisten di satu sisi dan, di sisi lain, membuatnya lebih mudah bagi pengembang akhir untuk memahami alasan apa yang terjadi.


Apa cara pengelompokan kedua pada level kode? Warisan:


 public abstract class LoggerExceptionBase : Exception { protected LoggerExceptionBase(..); } public class IOLoggerException : LoggerExceptionBase { internal IOLoggerException(..); } public class ConfigLoggerException : LoggerExceptionBase { internal ConfigLoggerException(..); } 

Selain itu, jika dalam kasus entitas aplikasi biasa, pewarisan berarti pewarisan perilaku dan data, yang menggabungkan jenis dengan menjadi bagian dari satu kelompok entitas , maka dalam kasus pengecualian, warisan berarti milik satu kelompok situasi , karena esensi pengecualian bukanlah esensi, tetapi problematis.


Menggabungkan kedua metode pengelompokan ini, kita dapat menarik beberapa kesimpulan:


  • di dalam majelis ( Assembly ) harus menyajikan jenis pengecualian dasar yang dilemparkan majelis ini. Jenis pengecualian ini harus berada di ruang nama root untuk perakitan. Ini akan menjadi lapisan pertama pengelompokan;
  • Selanjutnya, di dalam perakitan itu sendiri, mungkin ada satu atau lebih ruang nama yang berbeda. Masing-masing membagi majelis menjadi beberapa zona fungsional, sehingga mendefinisikan kelompok-kelompok situasi yang muncul dalam majelis ini. Ini bisa menjadi zona pengendali, entitas basis data, algoritma pemrosesan data, dan lainnya. Bagi kami, ruang nama ini adalah pengelompokan jenis berdasarkan afiliasi fungsional, dan dari sudut pandang pengecualian, pengelompokan berdasarkan zona masalah dari majelis yang sama;
  • pewarisan pengecualian hanya bisa berasal dari tipe di namespace yang sama atau di root lebih. Ini menjamin pemahaman yang jelas tentang situasi oleh pengguna akhir dan tidak adanya intersepsi pengecualian kiri saat disadap oleh tipe dasar. : global::Finiki.Logistics.OhMyException , catch(global::Legacy.LoggerExeption exception) , :

 namespace JetFinance.FinancialPipe { namespace Services.XmlParserService { public class XmlParserServiceException : FinancialPipeExceptionBase { // .. } public class Parser { public void Parse(string input) { // .. } } } public abstract class FinancialPipeExceptionBase : Exception { } } using JetFinance.FinancialPipe; using JetFinance.FinancialPipe.Services.XmlParserService; var parser = new Parser(); try { parser.Parse(); } catch (XmlParserServiceException exception) { // Something wrong in parser } catch (FinancialPipeExceptionBase exception) { // Something else wrong. Looks critical because we don't know real reason } 

, : , , , XmlParserServiceException . , , , JetFinance.FinancialPipe.FinancialPipeExceptionBase , : XmlParserService , . , catch : .


?


  • . . — , : , -, UI. Yaitu ;
  • , : , catch ;
  • – . ;
  • , . : , , , . , - : , — , , , ;
  • , : ;
  • , Mixed Mode c ErrorCode.


. , , :


  • unsafe , . : , (, ) ;
  • , , , .. . , , . , , . — — InnerExcepton . — ;
  • Kode kami sendiri yang dimasukkan secara acak ke kondisi tidak konsisten. Parsing teks adalah contoh yang bagus. Tidak ada dependensi eksternal, tidak ada penarikan unsafe, tetapi ada kesalahan parsing.

Tautan ke seluruh buku



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


All Articles