Robuuste webtoepassingen schrijven de verloren kunst van het omgaan met uitzonderingen

Als ontwikkelaars willen we dat de applicaties die we bouwen bestand zijn tegen falen, maar hoe bereik je dit doel? Als u de hype gelooft, zijn microservices en een slim communicatieprotocol het antwoord op al uw problemen, of misschien automatische DNS-failover. Hoewel dat soort dingen zijn plaats heeft en zorgt voor een interessante conferentiepresentatie, is de enigszins minder glamoureuze waarheid dat het maken van een krachtige applicatie begint met uw code. Maar zelfs goed ontworpen en goed geteste applicaties missen vaak een vitaal onderdeel van veerkrachtige code - afhandeling van uitzonderingen.

Gesponsorde inhoud

Deze inhoud is gemaakt in opdracht van Engine Yard en is geschreven en / of bewerkt door het Tuts + -team. Ons doel met gesponsorde inhoud is het publiceren van relevante en objectieve zelfstudies, casestudy's en inspirerende interviews die echte educatieve waarde bieden aan onze lezers en ons in staat stellen om het creëren van bruikbare inhoud te financieren..

Ik verbaas me er altijd over hoe onderbenutte uitzonderingsafhandeling zich zelfs in volwassen codebases voordoet. Laten we een voorbeeld bekijken.


Wat kan er mis gaan?

Stel dat we een Rails-app hebben en een van de dingen die we met deze app kunnen doen, is een lijst ophalen van de nieuwste tweets voor een gebruiker, gezien hun handvat. Onze TweetsController zou er als volgt kunnen uitzien:

klasse TweetsController < ApplicationController def show person = Person.find_or_create_by(handle: params[:handle]) if person.persisted? @tweets = person.fetch_tweets else flash[:error] = "Unable to create person with handle: #person.handle" end end end

En de Persoon model dat we hebben gebruikt, lijkt op het volgende:

klas Persoon < ActiveRecord::Base def fetch_tweets client = Twitter::REST::Client.new do |config| config.consumer_key = configatron.twitter.consumer_key config.consumer_secret = configatron.twitter.consumer_secret config.access_token = configatron.twitter.access_token config.access_token_secret = configatron.twitter.access_token_secret end client.user_timeline(handle).map|tweet| tweet.text end end

Deze code lijkt heel redelijk, er zijn tientallen apps met code zoals deze die in productie zitten, maar laten we eens wat nauwkeuriger kijken.

  • find_or_create_by is een Rails-methode, het is geen 'knal'-methode, dus er mogen geen uitzonderingen worden gegooid, maar als we naar de documentatie kijken, kunnen we zien dat door de manier waarop deze methode werkt, het een ActiveRecord :: RecordNotUnique fout. Dit zal niet vaak gebeuren, maar als onze applicatie behoorlijk wat verkeer heeft, gebeurt dit waarschijnlijker dan je zou verwachten (ik heb het vaak zien gebeuren).
  • Hoewel we het over het onderwerp hebben, kan elke bibliotheek die u gebruikt onverwachte fouten veroorzaken als gevolg van fouten in de bibliotheek zelf en Rails vormt daarop geen uitzondering. Afhankelijk van ons niveau van paranoia mogen we onze verwachten find_or_create_by om op elk moment een soort van onverwachte fout te gooien (een gezond niveau van paranoia is een goede zaak als het gaat om het bouwen van robuuste software). Als we geen algemene manier hebben om onverwachte fouten af ​​te handelen (we bespreken dit hieronder), willen we deze misschien afzonderlijk behandelen.
  • Dan is er person.fetch_tweets die een Twitter-client maakt en enkele tweets probeert op te halen. Dit is een netwerkoproep en is vatbaar voor allerlei soorten mislukkingen. We willen misschien de documentatie lezen om erachter te komen wat de mogelijke fouten zijn die we zouden kunnen verwachten, maar we weten dat fouten hier niet alleen mogelijk zijn, maar hoogstwaarschijnlijk (de Twitter API is bijvoorbeeld misschien niet beschikbaar, iemand met dat handvat zou misschien niet bestaat enz.). Geen uitzonderingsafhandelingslogica instellen rond netwerkoproepen vraagt ​​om problemen.

Onze kleine hoeveelheid code heeft enkele serieuze problemen, laten we proberen het te verbeteren.


De juiste hoeveelheid uitzonderingsafhandeling

We zullen onze pakken find_or_create_by en duw het naar beneden in de Persoon model:

klas Persoon < ActiveRecord::Base class << self def find_or_create_by_handle(handle) begin Person.find_or_create_by(handle: handle) rescue ActiveRecord::RecordNotUnique Rails.logger.warn  "Encountered a non-fatal RecordNotUnique error for: #handle"  retry rescue => e Rails.logger.error "Er is een fout opgetreden bij het zoeken naar of maken van persoon voor: # handle, # e.message # e.backtrace.join (" \ n ")" null end end einde

We hebben de ActiveRecord :: RecordNotUnique volgens de documentatie en nu weten we voor een feit dat we ofwel een Persoon object of nul als er iets misgaat. Deze code is nu solide, maar hoe zit het met het ophalen van onze tweets:

klas Persoon < ActiveRecord::Base def fetch_tweets client.user_timeline(handle).map|tweet| tweet.text rescue => e Rails.logger.error "Fout tijdens het ophalen van tweets voor: # handle, # e.message # e.backtrace.join (" \ n ")" nihil einde private def-client @ client || = Twitter :: REST :: Client.new do | config | config.consumer_key = configatron.twitter.consumer_key config.consumer_secret = configatron.twitter.consumer_secret config.access_token = configatron.twitter.access_token config.access_token_secret = configatron.twitter.access_token_secret end end end

We pushen de Twitter-client in een eigen methode te plaatsen en omdat we niet wisten wat er mis kon gaan als we de tweets haalden, redden we alles.

Je hebt misschien ergens gehoord dat je altijd specifieke fouten moet opvangen. Dit is een lovenswaardig doel, maar mensen interpreteren het vaak verkeerd als: "als ik iets niet specifiek kan vangen, zal ik niets vangen". In werkelijkheid, als je iets niet specifiek kunt vangen, zou je alles moeten vangen! Op deze manier heb je tenminste de mogelijkheid om iets te doen, zelfs als het alleen maar is om de fout te loggen en opnieuw te verhogen.

An Aside on OO Design

Om onze code robuuster te maken, werden we gedwongen om te refactoren en nu is onze code aantoonbaar beter dan voorheen. U kunt uw wens voor meer veerkrachtige code gebruiken om uw ontwerpbeslissingen te informeren.

Een beetje naast testen

Telkens wanneer u een uitzonderingsafhandelingslogica aan een methode toevoegt, is het ook een extra pad door die methode en moet het worden getest. Het is van vitaal belang dat je het uitzonderlijke pad test, misschien nog meer dan dat je het gelukkige pad test. Als er iets mis gaat op het gelukkige pad, heb je nu de extra verzekering van de redden blokkeren om te voorkomen dat uw app omvalt. Elke logica binnen het reddingsblok zelf heeft echter geen dergelijke verzekering. Test je uitzonderlijke pad goed, zodat gekke dingen zoals het verkeerd typen van een variabele naam in de redden blokkeren veroorzaken niet dat uw toepassing wordt opgeblazen (dit is mij zo vaak overkomen - serieus, test gewoon uw redden blokken).


Wat te doen met de fouten die we vangen

Ik heb dit soort code talloze keren door de jaren heen gezien:

begin widgetron.create rescue # hoeft niets te doen

We redden een uitzondering en doen er niets mee. Dit is bijna altijd een slecht idee. Wanneer je over zes maanden vanaf nu een productieprobleem opspoort, in een poging om uit te vinden waarom je widgetron niet in de database staat, zul je je niet herinneren dat onschuldige opmerkingen en urenlange frustratie zullen volgen.

Slik geen uitzonderingen door! U moet op zijn minst elke uitzondering registreren die u ophaalt, bijvoorbeeld:

begin foo.bar rescue => e Rails.logger.error "# e.message # e.backtrace.join (" \ n ")" einde

Op deze manier kunnen we de logs doorzoeken en zullen we de oorzaak en stack traceren van de fout die moet worden bekeken.

Beter nog, u kunt een foutmonitoring-service gebruiken, zoals Rollbar, die best aardig is. Dit heeft veel voordelen:

  • Uw foutmeldingen worden niet afgewisseld met andere logberichten
  • U krijgt statistieken over hoe vaak dezelfde fout is opgetreden (zodat u kunt achterhalen of het een serieus probleem is of niet)
  • U kunt extra informatie samen met de fout verzenden om het probleem te diagnosticeren
  • U kunt meldingen ontvangen (via e-mail, pagerduty etc.) wanneer er fouten optreden in uw app
  • U kunt de implementaties volgen om te zien wanneer bepaalde fouten zijn geïntroduceerd of hersteld
  • enz.
begin foo.bar rescue => e Rails.logger.error "# e.message # e.backtrace.join (" \ n ")" Rollbar.report_exception (e) end

U kunt natuurlijk zowel inloggen als een bewakingsservice gebruiken zoals hierboven.

Als jouw redden blok is het laatste in een methode, ik raad aan om expliciet te retourneren:

def my_method begin foo.bar rescue => e Rails.logger.error "# e.message # e.backtrace.join (" \ n ")" Rollbar.report_exception (e) nul einde

Misschien wil je niet altijd terugkeren nul, soms bent u misschien beter af met een nul object of wat dan ook zinvol is in de context van uw toepassing. Consistent gebruik van expliciete retourwaarden zal iedereen veel verwarring besparen.

Je kunt dezelfde fout opnieuw verhogen of een andere in je account verhogen redden blok. Een patroon dat ik vaak handig vind, is de bestaande uitzondering in een nieuwe te verpakken en die op te heffen om het originele stapeltracering niet te verliezen (ik heb zelfs een juweel geschreven omdat Ruby deze functionaliteit niet uit de doos biedt) ). Later in het artikel zullen we u laten zien waarom dit nuttig kan zijn als we het hebben over externe diensten.


Fouten wereldwijd behandelen

Met Rails kunt u aangeven hoe u aanvragen voor bronnen van een bepaalde indeling (HTML, XML, JSON) moet verwerken met behulp van respond_to en antwoorden met. Ik zie zelden apps die deze functionaliteit correct gebruiken, tenslotte als je geen a gebruikt respond_to blokkeer alles werkt goed en Rails maakt uw sjabloon correct. We raken onze tweets-controller via / Tweets / yukihiro_matz en krijg een HTML-pagina vol met de laatste tweets van Matzs. Wat mensen vaak vergeten is dat het heel gemakkelijk is om te proberen een ander formaat van dezelfde bron, bijvoorbeeld. /tweets/yukihiro_matz.json. Op dit punt zal Rails moedig proberen een JSON-representatie van de tweets van Matzs terug te sturen, maar het zal niet goed gaan, omdat de zienswijze er niet voor bestaat. Een ActionView :: MissingTemplate fout zal worden verhoogd en onze app wordt op spectaculaire wijze opgeblazen. En JSON is een legitiem formaat, in een toepassing met veel verkeer kunt u waarschijnlijk een verzoek indienen /tweets/yukihiro_matz.foobar. Tuts + krijgt dit soort verzoeken de hele tijd (waarschijnlijk van bots die slim willen zijn).

De les is dit, als u niet van plan bent om een ​​legitiem antwoord voor een bepaalde indeling terug te sturen, beperk dan uw controllers om te proberen aan verzoeken voor die formaten te voldoen. In het geval van onze TweetsController:

klasse TweetsController < ApplicationController respond_to :html def show… respond_to do |format| format.html end end end

Wanneer we nu aanvragen voor valse indelingen ontvangen, zullen we relevanter worden ActionController :: UnknownFormat fout. Onze controllers voelen zich wat strakker, wat geweldig is als ze steviger worden.

Handling Fouten op de Rails Way

Het probleem dat we nu hebben, is dat ondanks onze semantisch aangename fout, onze applicatie nog steeds opdoemt in het gezicht van onze gebruikers. Hier komt mondiale afhandeling van uitzonderingen om de hoek kijken. Soms levert onze toepassing fouten op waarop we consequent willen reageren, ongeacht waar ze vandaan komen (zoals onze ActionController :: UnknownFormat). Er zijn ook fouten die kunnen worden opgeheven door het framework voordat een van onze code in het spel komt. Een perfect voorbeeld hiervan is ActionController :: RoutingError. Wanneer iemand een URL opvraagt ​​die niet bestaat, zoals / Tweets2 / yukihiro_matz, er is nergens voor ons om in te haken om deze fout te redden, met behulp van traditionele uitzonderingsafhandeling. Dit is waar Rails ' exceptions_app komt binnen.

U kunt een Rack-app binnen configureren application.rb te worden aangeroepen wanneer een fout die we niet hebben afgehandeld wordt geproduceerd (zoals onze ActionController :: RoutingError of ActionController :: UnknownFormat). De manier waarop u dit normaal gesproken ziet, is om uw routes-app te configureren als de exceptions_app, definieer vervolgens de verschillende routes voor de fouten die u wilt verwerken en leid ze naar een speciale foutencontroller die u maakt. Zo onze application.rb zou er als volgt uitzien:

... config.exceptions_app = self.routes ... 

Onze routes.rb zal dan het volgende bevatten:

... match '/ 404' => 'errors # not_found', via:: all match '/ 406' => 'errors # not_acceptable', via:: all match '/ 500' => 'errors # internal_server_error', via: :allemaal… 

In dit geval onze ActionController :: RoutingError zou worden opgepikt door de 404 route en de ActionController :: UnknownFormat zal worden opgepikt door de 406 route. Er zijn veel mogelijke fouten die kunnen opduiken. Maar zolang je de gewone dingen doet (404, 500, 422 etc.) om mee te beginnen, u kunt anderen toevoegen als en wanneer ze gebeuren.

Binnen onze foutencontroller kunnen we nu de relevante sjablonen voor elke soort fout weergeven, samen met onze lay-out (als het geen 500 is) om de branding te behouden. We kunnen de fouten ook registreren en naar onze bewakingsservice verzenden, hoewel de meeste bewakingsservices automatisch aan dit proces zullen deelnemen, zodat u de fouten niet zelf hoeft te verzenden. Wanneer onze applicatie nu wordt opgeblazen, gebeurt dit voorzichtig, met de juiste statuscode afhankelijk van de fout en een pagina waarop we de gebruiker enig idee kunnen geven van wat er is gebeurd en wat hij kan doen (contact opnemen met ondersteuning) - een oneindig betere ervaring. Wat nog belangrijker is, onze app zal (en zal eigenlijk) veel meer solide zijn.

Meerdere fouten van hetzelfde type in een controller

In elke Rails-controller kunnen we specifieke fouten definiëren die globaal binnen die controller moeten worden afgehandeld (ongeacht welke actie ze krijgen) - we doen dit via rescue_from. De vraag is wanneer te gebruiken redden van? Ik vind meestal dat een goed patroon is om het te gebruiken voor fouten die kunnen optreden in meerdere acties (bijvoorbeeld dezelfde fout in meer dan één actie). Als een fout slechts door één actie wordt geproduceerd, moet u deze via het traditionele verwerken begin ... red ... einde mechanisme, maar als we waarschijnlijk dezelfde fout op meerdere plaatsen krijgen en we willen het op dezelfde manier aanpakken - het is een goede kandidaat voor een redden van. Laten we zeggen ons TweetsController heeft ook een creëren actie:

klasse TweetsController < ApplicationController respond_to :html def show… respond_to do |format| format.html end end def create… end end

Laten we ook zeggen dat beide acties een tegenkomen TwitterError en als ze dat doen, willen we de gebruiker laten weten dat er iets mis is met Twitter. Dit is waar redden van kan erg handig zijn:

klasse TweetsController < ApplicationController respond_to :html rescue_from TwitterError, with: twitter_error private def twitter_error render :twitter_error end end

Nu hoeven we ons geen zorgen te maken dat we dit in onze acties verwerken en ze zullen er veel schoner uitzien en we kunnen / moeten - natuurlijk - onze fouten registreren en / of onze foutmonitoringdienst binnen de twitter_error methode. Als je gebruikt redden van correct kan het niet alleen helpen om uw applicatie robuuster te maken, maar kan het ook uw controllercode schoner maken. Dit maakt het gemakkelijker om uw code te onderhouden en te testen, waardoor uw applicatie weer net iets veerkrachtiger is.


Externe services gebruiken in uw toepassing

Het is moeilijk om tegenwoordig een belangrijke applicatie te schrijven zonder een aantal externe services / API's te gebruiken. In het geval van onze TweetsController, Twitter kwam om de hoek via een robijn edelsteen die de Twitter API omhult. Idealiter maken we al onze externe API-aanroepen asynchroon, maar we behandelen geen asynchrone verwerking in dit artikel en er zijn tal van toepassingen die ervoor zorgen dat er ten minste enkele API- / netwerkaanvragen in het proces plaatsvinden.

Het maken van netwerkoproepen is een uiterst voor fouten gevoelige taak en een goede afhandeling van uitzonderingen is een must. U kunt verificatiefouten, configuratieproblemen en verbindingsfouten krijgen. De bibliotheek die u gebruikt, kan een onbeperkt aantal codefouten produceren en dan is er sprake van trage verbindingen. Ik ben dit punt aan het verduidelijken, maar het is o zo cruciaal omdat je niet kunt omgaan met langzame verbindingen via exception handling. U moet time-outs correct in uw netwerkbibliotheek configureren of, als u een API-wrapper gebruikt, zorg ervoor dat deze haken biedt om time-outs te configureren. Er is geen slechtere ervaring voor een gebruiker dan daar te moeten zitten wachten zonder dat uw toepassing een indicatie geeft van wat er gebeurt. Bijna iedereen vergeet het instellen van time-outs op de juiste manier (ik weet dat ik het heb), dus pas op.

Als u op meerdere plaatsen in uw toepassing een externe service gebruikt (bijvoorbeeld meerdere modellen), stelt u grote delen van uw toepassing bloot aan het volledige landschap van fouten die kunnen worden geproduceerd. Dit is geen goede situatie. Wat we willen doen, is onze blootstelling beperken en een manier om dit te doen is door alle toegang tot onze externe diensten achter een façade te plaatsen, alle fouten daar te redden en één semantisch passende fout opnieuw te verhogen (raise that TwitterError waar we het over hadden als er fouten optreden wanneer we de Twitter API proberen te raken). We kunnen dan eenvoudig technieken zoals gebruiken redden van om met deze fouten om te gaan en we stellen grote delen van onze applicatie niet bloot aan een onbekend aantal fouten van externe bronnen.

Een nog beter idee zou kunnen zijn om van uw gevel een foutloze API te maken. Retourneer alle succesvolle reacties als is en retourneer nils of nulobjecten wanneer u een of andere fout redt (we moeten ons nog steeds aanmelden / ons op de hoogte stellen van de fouten via enkele van de methoden die we hierboven hebben besproken). Op deze manier hoeven we geen verschillende soorten controlestromen te mengen (exception control flow vs if ... else) waardoor we aanzienlijk schonere code kunnen krijgen. Laten we bijvoorbeeld onze Twitter API-toegang in a. Omzetten TwitterClient voorwerp:

class TwitterClient attr_reader: client def initialize @client = Twitter :: REST :: Client.new do | config | config.consumer_key = configatron.twitter.consumer_key config.consumer_secret = configatron.twitter.consumer_secret config.access_token = configatron.twitter.access_token config.access_token_secret = configatron.twitter.access_token_secret end end def latest_tweets (handle) client.user_timeline (handle). kaart | tweet | tweet.text rescue => e Rails.logger.error "# e.message # e.backtrace.join (" \ n ")" nul einde

We kunnen dit nu doen: TwitterClient.new.latest_tweets ( 'yukihiro_matz'), overal in onze code en we weten dat het nooit een fout zal veroorzaken, of beter gezegd, het zal nooit de fout verder verspreiden TwitterClient. We hebben een extern systeem geïsoleerd om ervoor te zorgen dat fouten in dat systeem onze hoofdtoepassing niet naar beneden halen.


Maar wat als ik een uitstekende testdekking heb?

Als je een goed geteste code hebt, dan beveel ik je op je ijver aan, het zal je een lange weg naar een robuustere applicatie brengen. Maar een goede testsuite kan vaak een vals gevoel van veiligheid bieden. Goede tests kunnen u helpen om met vertrouwen te refacteren en u te beschermen tegen regressie. Maar je kunt alleen tests schrijven voor dingen waarvan je verwacht dat ze zullen gebeuren. Bugs zijn, door hun aard, onverwacht. Om ons tweets-voorbeeld te gebruiken, totdat we ervoor kiezen om een ​​test voor onze te schrijven fetch_tweets methode waar client.user_timeline (steel) werpt een fout op en dwingt ons om een ​​a te verpakken redden blokkeren rond de code, al onze tests zullen groen zijn geweest en onze code zou foutgevoelig zijn gebleven.

Het schrijven van tests ontslaat ons niet van de verantwoordelijkheid om kritisch naar onze code te kijken om erachter te komen hoe deze code kan breken. Aan de andere kant kan dit soort evaluatie ons zeker helpen om betere, completere testsuites te schrijven.


Conclusie

Veerkrachtige systemen komen niet volledig voort uit een weekend hack-sessie. Een applicatie robuust maken, is een continu proces. U ontdekt fouten, repareert ze en schrijft tests om ervoor te zorgen dat ze niet terugkomen. Wanneer uw toepassing wordt verbroken vanwege een storing van een extern systeem, isoleert u dat systeem om te zorgen dat de storing niet meer kan sneeuwballen. Exception handling is je beste vriend als het gaat om dit te doen. Zelfs de meest foutgevoelige applicatie kan worden omgezet in een robuuste toepassing als u consequent goede uitzonderingsprocedures toepast, in de loop van de tijd.

Uiteraard is exception handling niet de enige tool in je arsenaal als het gaat om het weerbaarder maken van applicaties. In de volgende artikelen zullen we het hebben over asynchrone verwerking, hoe en wanneer toe te passen en wat het kan doen om uw toepassing fouttolerant te maken. We zullen ook kijken naar enkele implementatie- en infrastructuurtips die een aanzienlijke impact kunnen hebben zonder de bank te breken in termen van zowel geld als tijd - stay tuned.