Problematyczne liczbowe ID w JavaScript/TypeScript

Kiedy wchodzisz w świat aplikacji frontendowych, które nie są ściśle powiązane z konkretnym backendem wkraczasz na obszar nowego typu wyzwań i problemów. Jeśli do tego dołożysz „nietypowe” decyzje projektowe w backendzie możesz być pewny, że te nowe wyzwania i błędy Cię dosięgną. I właśnie niedawno jeden z takich problemów dosięgnął nas w pracy. Sprawa związana jest z liczbami.

Zarys problemu

Wszystko działo się pewnego spokojnego, wiosennego dnia w godzinach pracy. Dostałem od kolegi wiadomość na komunikatorze, że aplikacja frontendowa, którą się zajmuję wysyła złe ID do API, nad którym on siedzi. Konkretnie chodziło o to, że ostatnia cyfra w liczbie to było 0 zamiast 2. To samo ID wcześniej przyszło do frontendu z API. Była to spora wartość bo (tutaj odwołanie do wstępu i „nietypowych” decyzji) w aplikacja, jeszcze głębiej, w części, za którą nie odpowiadamy, wartość ID są losowane, zaś w bazie danych jest to kolumna typu long. Gdyby ID było sekwencją to nawet mając je wspólne dla wszystkich tabel ciężko by było osiągnąć taką wartość jaka może być wylosowana już za pierwszym razem.

Sprawdziliśmy czy gdzieś nie robimy przypadkiem jakiegoś zaokrąglenia, czy typy wszędzie się zgadzają itd. Jednak błędu nie znaleźliśmy.

Długość ma znaczenie

Dużą podpowiedzią było to, że ta ostatnia cyfra zostaje zamieniona na zero. To pozwoliło złapać jakiś trop.

Problem okazał się leżeć u styku dwóch języków: C# i JavaScriptu (u nas pod postacią TypeScriptu).

Jak już wspomniałem w bazie danych, a więc również w pozostałej .NETowej części backendu, ID było trzymane jako typ long. Po stronie JavaScriptu za dużego wyboru nie ma – po prostu ID lądowało jako number (w TypeScripcie mamy to jawnie zadeklarowane, ale należy pamiętać, że JS również odróżnia typy danych). I tutaj dochodzimy do przyczyny problemu – porównania długości.

ID jest tutaj oczywiście liczbą całkowitą. Sprawdźmy więc jaką maksymalną wartość może przyjąć po stronie C#. Zerkając na stałą MaxValue w dokumentacji .NETa (https://msdn.microsoft.com/en-us/library/system.int64.maxvalue(v=vs.110).aspx):

The value of this constant is 9,223,372,036,854,775,807;

Teraz przyjrzyjmy się jak sprawa wygląda w JavaScripcie (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER):

The MAX_SAFE_INTEGER constant has a value of 9007199254740991

 

Różnica w długości maksymalnej wartości jest widoczna od razu. C# może po prostu trzymać dłuższe liczby całkowite. Powoduje to, że poprawna wartość pobrana np. z API zaczyna być błędna po stronie aplikacji klienckiej. Błąd może być trudny do zidentyfikowania. Tak jak w naszym przypadku gdzie po prostu ostatnia cyfra została zamieniona i wędrowała sobie dalej, a była na tyle długa, że bez porównania jednej liczby obok drugiej łatwo było pominąć taką  różnicę.

Skąd ta różnica?

Ktoś z Was może zadań pytanie – skąd ta różnica się bierze?

Odpowiedź jest taka, że bierze się z różnicy w reprezentacji liczb w tych językach. C# jako język posiadający bardziej rozbudowany system typów ma dla wartości całkowitych całkowicie osobne typy danych, które wszystkie swoje bity wykorzystują do przechowywania dokładnych liczb. W języku JavaScript sytuacja wygląda tak, że wszystkie liczby są reprezentowane w postaci zmiennoprzecinkowej podwójnej precyzji (standard IEEE 754). W tym wypadku część bitów przeznaczona jest na wartości po przecinku. Dodatkowo jeśli mówimy o liczbach zmiennoprzecinkowych to dochodzi nam tutaj również temat precyzji. W dosyć przystępny sposób jest to opisane w tej książce: http://speakingjs.com/es5/ch11.html, konkretnie w akapitach The Internal Representation of NumbersHandling Rounding Errors. Z tych właśnie powodów w JavaScripcie maksymalna bezpieczna wartość liczby całkowitej jest mniejsza niż maksymalna wartość długiej liczby całkowitej w C# jak również w innych językach posiadających osobny typ danych dla liczb całkowitych.

Jakieś rozwiązania?

Jeżeli faktycznie potrzebujemy przesyłać pomiędzy backendem i frontendem tak długie liczby to właściwie nie ma za dużego pola do manewru. Najprostszym i jednocześnie w większości przypadków skutecznym rozwiązaniem będzie zrezygnowanie z trzymania na frontendzie takich liczb w postaci liczb, a zamiast tego zamienianie ich na ciąg znaków. Dopóki mówimy tutaj o wartościach reprezentujących np. identyfikator nie ma absolutnie żadnych problemów. Z tego rozwiązania sami skorzystaliśmy.

Gorzej sprawa będzie się miała jeżeli byśmy chcieli na tych liczbach wykonywać operacje. W takim przypadku nadal możemy zostać przy ciągach znaków i wykorzystać do tego jakąś bibliotekę do operacji na dużych liczbach (znajdziemy taką na pewno w zasobach internetu albo mając nieco czasu napiszemy swoją).

Lepiej uważać

Na koniec taka uwaga, żeby nigdy nie zakładać, że coś na pewno się nie wydarzy.

Szukając potwierdzenia swoich przypuszczeń i obserwacji trafiłem na taki wpis na stronie jakiejś firmy zajmującej się oprogramowaniem: http://www.avioconsulting.com/blog/overcoming-javascript-numeric-precision-issues.

Autor mówiąc o problemie przekonuje, że nie ma możliwości, żeby w realnej aplikacji doszło do takiej sytuacji:

  • Assuming that you represent DB table ids as integers. Are you going to insert in a table more than 9 quadrillion rows?

[…]

In most scenarios, we could declare that there is no problem. Our code breaks when provided with obscenely large numbers, but we simply do not use numbers that large and we never will.

Dodatkowo proponuje rozwiązanie, które w naszym opisanym powyżej przypadku powodowałoby odrzucanie jak najbardziej poprawnych wartości z bazy danych:

So, as a solution for integer values, we’ll reject values outside of the safe range, even when they fit in a double. For this, you can use custom serializers/deserializers to prevent sending/receiving integer values outside of the safe range.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *