Ostatnim razem w artykule Refactoring w Ruby krok po kroku - część 1 przeszłyśmy od kodu proceduralnego do kodu bardziej zorientowanego obiektowo. Tym razem będziemy kontynuować naszą podróż przez refaktoring. Będziemy mówić o małych obiektach zastępujących duże klasy, o kompozycji i o wstrzykiwaniu konkretnych zachowań do obiektów. Zaczynajmy!
Krok 8 - Bardziej opisowe wyniki
Ten krok zaczynamy od kodu i skupimy się na czytelności tej metody:
def check
return 9 if straight_flush?
return 8 if four_of_a_kind?
return 7 if full_house?
return 6 if flush?
return 5 if straight?
return 4 if three_of_a_kind?
return 3 if two_pair?
return 2 if one_pair?
return 1 if high_card?
return 0
end
Muszę przyznać, że to dość ważny krok. Kiedy myślimy o czytelności kodu o wiele łatwiej zrozumieć nam tekst :straight_flush
niż zwykłą liczbę 9
. W przypadku tekstu nie musisz głęboko wchodzić w szczegóły implementacji kodu by wiedzieć, co on będzie robił. Z drugiej strony, podeszłam do tej zmiany dość egoistycznie. Myślałam tylko o tej klasie bez zastanowienia nad konsekwencjami dla całego systemu. Zmieniłam to, co zwraca metoda check
. W niektórych przypadkach taka zmiana może być bardzo trudna do wprowadzenia lub nawet niemożliwa, przy bieżącym stanie kodu. W moim przypadku zmiana ta wymagała poprawy wszystkich testów. Dlatego musisz być ostrożna i świadoma przy wprowadzaniu takich zmian. W każdym razie, uważam że w moim przypadku zmiana była warta zachodu.
Kod po zmianie:
def check
return :straight_flush if straight_flush?
return :four_of_a_kind if four_of_a_kind?
return :full_house if full_house?
return :flush if flush?
return :straight if straight?
return :three_of_a_kind if three_of_a_kind?
return :two_pair if two_pair?
return :one_pair if one_pair?
return :high_card if high_card?
return :none
end
Nową wersję kodu możesz znaleźć tutaj.
Statystyki:
- LOC - 80
- LOT - 190
- Flog - 62.5
- Flay - 0
- Tests - 104 examples, 0 failures
Krok 9 - Meta-programowanie
Ten krok jest całkowicie opcjonalny. Zrobiłam go ponieważ nie podobał mi się wygląd metody check
i te powtórzenia wewnątrz. Uważam, że po tej zmianie kod jest dalej czytelny, ale za to krótszy. Niestety nie zawsze tak jest. Czasem meta-programowanie bardzo pogarsza czytelność kodu. Dlatego zawsze jako programistka Ty jesteś odpowiedzialna za swój kod i to Ty musisz wybrać rozwiązanie, które Twoim zadaniem jest lepsze. W moim przypadku stworzyłam tablicę z poprawną kolejnością sprawdzania ręki pokerowej i użyłam jej w metodzie check
.
Kod przed zmianą:
def check
return :straight_flush if straight_flush?
return :four_of_a_kind if four_of_a_kind?
return :full_house if full_house?
return :flush if flush?
return :straight if straight?
return :three_of_a_kind if three_of_a_kind?
return :two_pair if two_pair?
return :one_pair if one_pair?
return :high_card if high_card?
return :none
end
Kod po zmianie:
def check
@order_checking.each do |name|
method_name = (name.to_s + '?').to_sym
return name if send(method_name)
end
end
gdzie @order_checking
to tablica ustawień ręki w pokerze.
@order_checking = [
:straight_flush, :four_of_a_kind, :full_house, :flush, :straight,
:three_of_a_kind, :two_pair, :one_pair, :high_card, :none
]
Cały kod możesz znaleźć tutaj.
Statystyki
- LOC - 82
- LOT - 190
- Flog - 59.3
- Flay - 0
- Tests - 104 examples, 0 failures
Krok 10 - Małe obiekty
Ten krok był dość duży ale również ważny. Może nawet najważniejszy. Zaczęłam od zasad SOLID. Jeśli nie słyszałaś o tych zasadach lub chciałabyś szybko odświeżyć sobie pamięć, to zajrzyj na stronę Wiki. Polecam też prezentację Sandy Metz - SOLID Object-Oriented Design. Zaczęłam od S - Single responsibility principle - zasady pojedynczej odpowiedzialności. Kiedy popatrzysz na kod klasy Hand
, stwierdzisz, że ta klasa wie i robi wszystko. Tak jak już mówiłam w poprzednim artykule ten kod jest bardziej proceduralny niż obiektowy. Zaczęłam, więc wyciągać funkcjonalności jedna po drugiej. Każdy etap był dość podobny do poprzedniego, dlatego pokażę go na jednym przykładzie. Na przykładnie metody four_of_a_kind
:
- Przeniosłam logikę metody
four_of_a_kind
do osobnej klasy. Tak, to wytworzy w kodzie pewne powtórzenia, ale jak mówi Sandi duplication is better than a wrong abstraction (powtórzenia są lepsze od złej abstrakcji).
module Poker
class FourOfAKind
def initialize(array)
@array = array.sort
@figures, @colors = cards_figures_and_colors
@frequency = cards_frequency.values
end
def check
:four_of_a_kind if @frequency.include?(4)
end
private
def cards_figures_and_colors
@array.map { |item| [item / 4, item % 4] }.transpose
end
def cards_frequency
@figures.each_with_object(Hash.new(0)) do |item, hash|
hash[item] += 1
end
end
end
end
- Tworzę a w późniejszych krokach modyfikuję brzydko wyglądający warunek
if
w klasieHand
tak by obsłużył on nowy kod.
def check
@order_checking.each do |name|
if name == :four_of_a_kind
return name if FourOfAKind.new(@array).check == name
else
method_name = (name.to_s + '?').to_sym
return name if send(method_name)
end
end
end
- Przygotowuję testy dla nowej klasy. Tak, powieliłam już istniejące testy dla metody
check
w tej nowej klasie. Na tą chwilę musi to tak zostać.
require 'spec_helper'
describe Poker::FourOfAKind do
[
[0, 1, 2, 3, 4],
[4, 5, 6, 0, 7],
[8, 9, 0, 10, 11],
[12, 0, 13, 14, 15],
[0, 16, 17, 18, 19],
[20, 21, 22, 23, 0].shuffle,
[24, 25, 26, 27, 0].shuffle,
[28, 29, 30, 31, 0].shuffle,
[32, 33, 34, 35, 0].shuffle,
[36, 37, 38, 39, 0].shuffle
].each do |cards|
it "detects four_of_a_kind for #{cards}" do
hand = described_class.new(cards)
expect(hand.check).to eq :four_of_a_kind
end
end
end
Podczas tego procesu zrobiłam jeszcze jedną rzecz. Wstrzyknęłam kolejność sprawdzania ręki pokerowej do inicjalizera klasy Hand
. ORDER_CHECKING
to stała, którą można traktować jako konfigurację i wyciągnąć do osobnego pliku jeżeli zajdzie taka potrzeba. Zrobiłam to na wypadek gdybym chciała szybko zmienić kolejność sprawdzania tego, co gracz ma w ręce w testach lub w aplikacji.
def initialize(array, order_checking = ORDER_CHECKING)
...
end
Kod dla tego etapu znajdziesz tutaj. Został on powtórzony przeze mnie dla każdej ręki pokerowej. Po każdej iteracji wszystkie testy przechodziły. Jeżeli chciałabyś zobaczyć jak to wygląda z większej perspektywy, to zajrzyj tutaj. W tym miejscu istnieją już 3 klasy stworzone na bazie powyższych kroków.
Statystyki
- LOC - 85
- LOT - 200
- Flog - 65.5
- Flay - 0
- Tests - 116 examples, 0 failures
Krok 11 - Usuwanie powieleń
W tym kroku zajęłam się usuwaniem analogii w kodzie. A muszę przyznać, że było tego dość sporo. Za każdym razem przy tworzeniu nowej klasy zmieniałam tylko dwie rzeczy: nazwę klasy i jedną linię w metodzie check
. By pozbyć się tego powielenia, musiałam wydzielić jakieś zachowanie, może metodę lub nawet klasę, która zajmie się pewnego rodzaju sortowaniem kart i będzie miała wiedzę na temat kolorów i figur. Zdecydowałam, że stworzę normalizację kart. Dzięki temu powstała klasa CardsNormalization
, która jest pewnego rodzaju reprezentacją dość ogólnej klasy Normalization
. Możesz zapytać mnie dlaczego stworzyłam 2 klasy, kiedy potrzebuje tylko jedną? No cóż, mam przeczucie, że tworzę mały interfejs, który będzie miał wiele różnych reprezentacji. Na tą chwilę istnieje tylko jedna reprezentacja, ale kto wie, co będzie w przyszłości? Przykładem innej reprezentacji (innej normalizacji) jest gra w kości. Zasady są bardzo podobne, ale normalizacja wprowadzanych danych będzie wyglądać inaczej. Kod tych dwóch nowych klas znajdziesz tutaj. Kod który był powielony w nowych klasach znalazł się w klasie Normalization
:
class Normalization
attr_reader :figures, :colors, :figures_frequency
def initialize(array)
@array = array.sort
@figures = prepare_figures
@colors = prepare_colors
@figures_frequency = prepare_figures_frequency.values
end
private
def prepare_colors
@array
end
def prepare_figures
@array
end
def prepare_figures_frequency
@figures.each_with_object(Hash.new(0)) do |item, hash|
hash[item] += 1
end
end
end
a tutaj klasa CardsNormalization
:
class CardsNormalization < Normalization
private
def prepare_colors
@array.map { |item| item % 4 }
end
def prepare_figures
@array.map { |item| item / 4 }
end
end
Podczas tego kroku zrobiłam jeszcze jedną rzecz. Wstrzyknęłam normalizację jako argument do inicjalizera klasy Hand
:
def initialize(array, order_checking = ORDER_CHECKING, normalization = CardsNormalization)
Po tych wszystkich zmianach moja klasa OnePair
wygląda następująco:
module Poker
class OnePair
def initialize(array, normalization = Normalization)
@normalize_array = normalization.new(array)
end
def check
:one_pair if @normalize_array.figures_frequency.include?(2)
end
end
end
Tylko jedna metoda wygląda okropnie. To metoda check
. Posiada ona teraz wiele warunków. Wiem jednak, że jest to stan przejściowy. Zajmę się tą metodą, jak tylko zakończę wyciąganie kolejnych funkcjonalności z klasy Hand
. Na tą chwilę metoda ta wygląda tak:
def check
@order_checking.each do |name|
if [:one_pair].include? name
class_name = 'Poker::' + name.to_s.split('_').collect(&:capitalize).join
return name if Object.const_get(class_name).new(@array, @normalization).check == name
end
if [:four_of_a_kind, :three_of_a_kind, :one_pair].include? name
class_name = 'Poker::' + name.to_s.split('_').collect(&:capitalize).join
return name if Object.const_get(class_name).new(@array).check == name
else
method_name = (name.to_s + '?').to_sym
return name if send(method_name)
end
end
end
Następnie, powtórzyłam to samo rozumowanie dla reszty istniejących klas. Użyłam CardsNormalization
dla FourOfAKind
i ThreeOfAKind
. Kod znajdziesz tutaj.
Statystyki:
- LOC - 87
- LOT - 200
- Flog - 82.5
- Flog total - 122.4
- Flay - 0
- Tests - 124 examples, 0 failures
Krok 12 - Usunięcie kolejnych powtórzeń
Tym razem skupiłam się na innych powtórzeniach w nowych klasach. W kilku miejscach sprawdzam podobny warunek: @normalize_array.figures_frequency.include?(4)
zmienia się tylko liczba. Jest to dla mnie pewnego rodzaju zasada. Sprawdzamy czy w kartach mamy 2, 3, lub 4 te same figury. Zdecydowałam, że stworzę klasę FrequencyRule
a tak wygląda jej kod:
module Rules
class FrequencyRule
def initialize(frequency_array, count)
@frequency_array = frequency_array
@count = count
end
def check?
@frequency_array.include?(@count)
end
end
end
następnie dodałam @rule = Rules::FrequencyRule.new(@normalize_array.figures_frequency, 3)
do inicjalizera i użyłam metody @rule.check?
zamiast @normalize_array.figures_frequency.include?(3)
. Tak wygląda klasa ThreeOfAKind
po tej zmianie:
class ThreeOfAKind
def initialize(array, normalization = Normalization)
@normalize_array = normalization.new(array)
@rule = Rules::FrequencyRule.new(@normalize_array.figures_frequency, 3)
end
def check
:three_of_a_kind if @rule.check?
end
end
Cały kod wygląda tak. Po tej zmianie i napisaniu nowych testów, wszystkie nowe i stare testy przechodzą.
Statystyki
- LOC - 87
- LOT - 161
- Flog - 82.5
- Flog total - 134.9
- Flay - 0
- Tests - 95 examples, 0 failures
Krok 13 - Łączenie wielu zasad
Teraz interesująca sprawa. Czas na fula. W tym przypadku potrzebujemy sprawdzić czy mamy 3 i 2 te same figury w tym samym czasie. Tak jak tutaj: 2♠ 2♣ 2♥ 3♥ 3♦. Musimy więc sprawdzić tą samą zasadę dla dwóch i trzech tych samych figur. To dobry czas by stworzyć klasę, która będzie łączyć wiele różnych zasad. Nazwałam ją RulesFactory
. Kod wygląda następująco:
class RulesFactory
def initialize(*rules)
@rules = rules
end
def check?
@rules.each do |rule|
return false unless rule.check?
end
true
end
end
A tak wygląda kod klasy FullHouse
(karety):
module Poker
class FullHouse
def initialize(array, normalization = Normalization)
@normalize_array = normalization.new(array)
rule1 = Rules::FrequencyRule.new(@normalize_array.figures_frequency, 2)
rule2 = Rules::FrequencyRule.new(@normalize_array.figures_frequency, 3)
@rules = RulesFactory.new(rule1, rule2)
end
def check
:full_house if @rules.check?
end
end
end
Widzisz schemat? Oczywiście, trzeba jeszcze zrobić parę ulepszeń w klasie Hand
. Dopisać nowe testy i sprawdzić czy przechodzą. Cały kod jest dostępny tutaj.
Krok 14 - Powtarzanie kroków
Nadszedł czas by powtórzyć krok, który już wcześniej opisałam. Tworzymy nowe zasady: StraightnessRule
, FlushnessRule
i RoyalnessRule
. Tworzymy nowe klasy: Straight
, Flush
i RoyalFlush
. Kod po tej zmianie znajduje się tutaj.
Chciałabym wspomnieć o jednej magicznej sprawie. Kiedy zaczęłam tworzyć małe klasy z pojedyńczymi odpowiedzialnościami, klasy te stały się w dość proste. Dzięki temu można używać ich w nieoczekiwany sposób. Oto przykład:
module Rules
class FlushnessRule
def initialize(color_array)
@color_array = color_array
end
def check?
@color_array.uniq.size == 1
end
end
end
To co klasa powinna robić:
rule = FlushnessRule.new([1,1])
=> #<FlushnessRule:0x00000001eced60 @color_array=[1, 1]>
rule.check?
=> true
to co może robić:
rule = FlushnessRule.new(['#fff','#fff'])
=> #<FlushnessRule:0x00000001ebc020 @color_array=["#fff", "#fff"]>
rule.check?
=> true
Kiedy tworzyłam tą klasę nie przypuszczałam, że będę mogła ją wykorzystać w taki sposób. Nie myślałam, że będzie się ona nadawać do obsługi zarówno tablic jak i ciągów znaków. Wiem, że to poniekąd jest to zasługa samego języka Ruby i jego duck typing’u ale myślę, że też prostoty kodu.
Statystyki:
- LOC - 94
- LOT - 171
- Flog - 69.7
- Flog total - 191.2
- Flay - 0
- Tests - 142 examples, 0 failures
Krok 15 - Zasada dla Niczego
Kiedy grasz w pokera możesz mieć w ręce jedno z punktowanych ustawień figur lub po prostu nic. To “nic” trzeba obsłużyć w kodzie. Stworzyłam więc klasę dla “niczego” - None
. Dodatkowo zrobiłam też trochę porządków w klasie Hand
.
module Poker
class None
def initialize(array, normalization = Normalization)
@normalize_array = normalization.new(array)
end
def check
:none
end
end
end
Cały kod związany z tym krokiem znajdziesz tutaj. Jak zawsze wszystkie testy po tym kroku przechodzą.
Statystyki
- LOC - 55
- LOT - 171
- Flog - 56.0
- Flog total - 180.8
- Flay - 0
- Tests - 145 examples, 0 failures
Krok 16 - MultiFrequencyRule
To będzie nasz ostatni krok. Możesz wierzyć lub nie ale najtrudniejsza do sprawdzenia zasada to dwie pary. Do tej pory stworzyłyśmy zasadę do sprawdzania liczby tych samych figur w ręce - FrequencyRule
. Ta zasada jednak tu nie zadziała, ponieważ chcemy sprawdzić czy mamy 2 pary. Musimy więc stworzyć inną zasadę, inną logikę. Postanowiłam nazwać ją MultiFrequencyRule
:
module Rules
class MultiFrequencyRule
def initialize(frequency_array, count, times = 1)
@frequency_array = frequency_array
@count = count
@times = times
end
def check?
selected_frequency = @frequency_array.reject { |number| number < @count }
selected_frequency.count >= @times
end
end
end
Tym razem sprawdzamy tylko wybraną liczbę wystąpień w @frequency_array
. Ile razy ona pojawia się w tej tablicy. Chwila! Jeżeli mamy taką kombinację kart 2♠ 2♣ 2♥ 3♥ 3♦ mamy w ręce fula ale również dwie pary. Zasada do sprawdzania czy mamy w naszej ręce dwie pary też powinna zwrócić true
. Dlatego też sprawdzamy warunek: number < @count
i usuwamy te elementy z tablicy. Kod dla tego kroku znajdziesz tutaj.
Uwaga! Jeżeli przyjrzysz się bliżej, to zauważysz, że ten kod ma pewien problem. Przy ustawieniu 2♠ 2♣ 2♥ 2♦ 3♥ też mamy dwie pary. Zmodyfikujmy naszą metodę check?
:
def check?
selected_frequency = @frequency_array.map { |number| number / @count }
selected_frequency.sum >= @times
end
Nareszcie możemy całkowicie wyczyścić logikę klasy Hand
. Możemy też zastąpić klasę FrequencyRule
, klasą MultiFrequencyRule
, która jest bardziej ogólna. Kod znajdziesz tutaj. Sprawdźmy jak wygląda teraz nasza klasa Hand
:
module Poker
ORDER_CHECKING = [
:royal_flush, :straight_flush, :four_of_a_kind, :full_house, :flush,
:straight, :three_of_a_kind, :two_pair, :one_pair, :high_card, :none
].freeze
# Poker hand
class Hand
def initialize(
array,
order_checking = ORDER_CHECKING,
normalization = CardsNormalization
)
@array = array.sort
@order_checking = order_checking
@normalization = normalization
end
def check
@order_checking.each do |name|
return name if class_name(name).check == name
end
end
private
def class_name(name)
class_name = 'Poker::' + name.to_s.split('_').collect(&:capitalize).join
Object.const_get(class_name).new(@array, @normalization)
end
end
end
Jestem dumna z tej klasy. Jeżeli chciałabyś porównać tą klasę z początkową implementacją zajrzyj tutaj.
Statystyki końcowe:
- LOC - 37
- LOT - 173
- Flog - 28.0
- Flog total - 182.4
- Flay - 0
- Tests - 145 examples, 0 failures
Podsumowanie
Podsumuję teraz to co udało nam się osiągnąć:
- Użyłyśmy bardziej opisowych nazw w kodzie.
- Uprościłyśmy kod używając odrobiny meta-programowania.
- Stworzyłyśmy małe obiekty z małą odpowiedzialnością (wiedzą).
- Przygotowałyśmy całe zaplecze klas sprawdzających zasady pokera.
Co dalej?
Myślisz, że to już koniec refaktoringu? Ja myślę o tym w zupełnie inny sposób. Moim zadaniem refaktoring nigdy się nie kończy. To nie znaczy, że trzeba go robić w nieskończoność. Zawsze będzie coś do zmiany, do ulepszenia. Z czasem mamy więcej informacji o tym jaką logikę kod ma reprezentować. Wiemy dokładnie, co się stanie. Na bazie tej wiedzy możemy podejmować kolejne kroki dotyczące naszej architektury.
Kiedy teraz patrzę na ten kod, zmieniłabym go. Zaczęłabym od powtórzeń, które jeszcze widzę w istniejących klasach. Pewnie zauważyłaś też, że część tych klas jest dość generyczna i wyglądają dość podobnie. W następnym etapie usunęłabym te klasy i stworzyłabym jedną klasę, która pobierałaby nazwę i logikę potrzebnej zasady. Później zrobiłabym porządek w testach. Może wyciągnęłabym konfigurację z klasy Hand
do jakiegoś osobnego pliku np. .yml
. A może to konfiguracja mogłaby przygotowywać kod związany z zasadami? Czy to za dużo refaktoringu dla tak małej funkcjonalności? Pewnie tak, jest to tylko przykład co można zrobić w prawdziwym kodzie by ułatwić jego rozumienie.
Czego się nauczyłam?
- małe, czytelne i proste do testowania klasy wiele ułatwiają
- przy małych klasach, kod staje się bardziej elastyczny (Mogę używać kodu w różny, czasem nieoczekiwany, sposób)
- mniej zależności, w łatwy sposób możemy usuwać i dodawać nowe zasady
- jesteśmy otwarci na rozszerzanie kodu - nie musimy modyfikować istniejącego kodu by dodać nową logikę, wystarczy stworzyć nową klasę i przygotować odpowiednią konfigurację. Możemy też pomyśleć o konwencji, która przygotuje konfigurację za nas. Wtedy wszystko działałoby automatycznie.
- To była czysta zabawa!
Moje zasady na refaktoring
- po każdym kroku refaktoringu testy muszą przechodzić
- zacznij od detali a później przejdź do bardziej ogólnego spojrzenia
- nie zmieniaj wszystkiego naraz
- kod powinien być napisany tak, by nie potrzebował komentarzy, powinien być samo-komentujący się (są przypadki gdy warto umieścić komentarz w kodzie, ale nieczytelny kod nie powinien być do tego wymówką)
- narzędzia takie jak Rubocop lub Reek są po to, by pomóc; nie wiedzą jednak wszystkiego
- staraj się zapobiegać powtórzeniom w kodzie, jednak pamiętaj, że lepiej zostawić kod powielony niż wyciągnąć złą abstrakcję (sprawdź prezentację Sandi Mezt w bibliografii)
- publiczny interfejs powinien być mały
- zawsze myśl o całym swoim systemie, jak duży wpływ na niego będzie miała Twoja zmiana?
- pamiętaj o zasadach takich jak SOLID
- usuwaj powtórzenia w mądry sposób
- myśl ogólnie i zawsze pamiętaj o spojrzeniu całościowym
Na koniec
Chciałabym wspomnieć jeszcze o jednej rzeczy na koniec. Przy każdym kroku zamieszczałam statystyki (wyniki różnych metryk). Nie wiem czy zwróciłaś na to uwagę ale w trakcie trwania refaktoringu te statystyki były gorsze niż na początku. Nigdy nie rezygnuj z refaktoringu z tego powodu. Jeżeli masz dobrą intuicję i plan gdzie chcesz być na jego końcu, krok po kroku kontynuuj swoją podróż a zobaczysz efekty.
To wszystko. W tym artykule zamieściłam kilka pomysłów na refaktoring. Możesz ich używać całkowicie niezależenie. Kroki mogą być wykonywane w innej kolejności (nie wszystkie, ale część z pewnością). Nie musisz też wykorzystywać ich wszystkich. Jeżeli podobał Ci się ten artykuł, podziel się swoimi przemyśleniami poniżej w komentarzach. Do zobaczenia następnym razem!
Bibliografia
Książki
- Refaktoryzacja. Ulepszanie struktury istniejącego kodu - Martin Fowler
- Czysty kod. Podręcznik dobrego programisty - Robert C. Martin
- Ruby. Wzorce projektowe - Russ Olsen
- TDD. Sztuka tworzenia dobrego kodu - Ken Beck
- Practical Object-Oriented Design in Ruby: An Agile Primer - Sandi Metz [EN]
- Pragmatyczny programista. Od czeladnika do mistrza - Andrew Hund, David Thomas
Prezentacje angielskojęzyczne
- All the Little Things by Sandi Metz
- LA Ruby Conference 2013 Refactoring Fat Models with Patterns by Bryan Helmkamp
- Nothing is something by Sandi Metz
- Best Ruby on Rails refactoring talks
Potrzebujesz pomocy?
Jeśli szukasz doświadczonej programistki Ruby z ponad dziesięcioletnim stażem, śmiało skontaktuj się ze mną.
Mam doświadczenie w różnych domenach, a szczególną wagę przykładam do szybkiej reakcji na opinie użytkowników i pracy zespołowej. Pomogę Ci stworzyć świetny produkt.