Node.js 中遇到大數(shù)處理精度丟失如何解決?前端也適用!
在 JavaScript 中浮點數(shù)運算時經(jīng)常出現(xiàn) 0.1+0.2=0.30000000000000004 這樣的問題,除此之外還有一個不容忽視的大數(shù)危機(大數(shù)處理精度丟失)問題。
這個問題之前看大家在群里也聊了不止一次,周末在另一個「Nodejs技術(shù)棧-交流群」又聊到了這個問題,當時簡單的在群里大家一塊討論了下,這種交流學習的氛圍是挺好的,下面是大家周末在群里的討論。
之前也分享過這個問題,我在做個梳理分享給大家,前端也適用,因為大家都是同一門語言 JavaScript。
JavaScript 最大安全整數(shù)
在開始本節(jié)之前,希望你能事先了解一些 JavaScript 浮點數(shù)的相關(guān)知識,在上篇文章 JavaScript 浮點數(shù)之迷:0.1 + 0.2 為什么不等于 0.3? 中很好的介紹了浮點數(shù)的存儲原理、為什么會產(chǎn)生精度丟失(建議事先閱讀下)。
IEEE 754 雙精確度浮點數(shù)(Double 64 Bits)中尾數(shù)部分是用來存儲整數(shù)的有效位數(shù),為 52 位,加上省略的一位 1 可以保存的實際數(shù)值為 。
- Math.pow(2, 53) // 9007199254740992
- Number.MAX_SAFE_INTEGER // 最大安全整數(shù) 9007199254740991
- Number.MIN_SAFE_INTEGER // 最小安全整數(shù) -9007199254740991
只要不超過 JavaScript 中最大安全整數(shù)和最小安全整數(shù)范圍都是安全的。
大數(shù)處理精度丟失問題復現(xiàn)
例一
當你在 Chrome 的控制臺或者 Node.js 運行環(huán)境里執(zhí)行以下代碼后會出現(xiàn)以下結(jié)果,What?為什么我定義的 200000436035958034 卻被轉(zhuǎn)義為了 200000436035958050,在了解了 JavaScript 浮點數(shù)存儲原理之后,應該明白此時已經(jīng)觸發(fā)了 JavaScript 的最大安全整數(shù)范圍。
- const num = 200000436035958034;
- console.log(num); // 200000436035958050
例二
以下示例通過流讀取傳遞的數(shù)據(jù),保存在一個字符串 data 中,因為傳遞的是一個 application/json 協(xié)議的數(shù)據(jù),我們需要對 data 反序列化為一個 obj 做業(yè)務處理。
- const http = require('http');
- http.createServer((req, res) => {
- if (req.method === 'POST') {
- let data = '';
- req.on('data', chunk => {
- data += chunk;
- });
- req.on('end', () => {
- console.log('未 JSON 反序列化情況:', data);
- try {
- // 反序列化為 obj 對象,用來處理業(yè)務
- const obj = JSON.parse(data);
- console.log('經(jīng)過 JSON 反序列化之后:', obj);
- res.setHeader("Content-Type", "application/json");
- res.end(data);
- } catch(e) {
- console.error(e);
- res.statusCode = 400;
- res.end("Invalid JSON");
- }
- });
- } else {
- res.end('OK');
- }
- }).listen(3000)
運行上述程序之后在 POSTMAN 調(diào)用,200000436035958034 這個是一個大數(shù)值。
以下為輸出結(jié)果,發(fā)現(xiàn)沒有經(jīng)過 JSON 序列化的一切正常,當程序執(zhí)行 JSON.parse() 之后,又發(fā)生了精度問題,這又是為什么呢?JSON 轉(zhuǎn)換和大數(shù)值精度之間又有什么貓膩呢?
- 未 JSON 反序列化情況: {
- "id": 200000436035958034
- }
- 經(jīng)過 JSON 反序列化之后: { id: 200000436035958050 }
經(jīng)過 JSON 反序列化之后: { id: 200000436035958050 }
這個問題也實際遇到過,發(fā)生的方式是調(diào)用第三方接口拿到的是一個大數(shù)值的參數(shù),結(jié)果 JSON 之后就出現(xiàn)了類似問題,下面做下分析。
JSON 序列化對大數(shù)值解析有什么貓膩?
先了解下 JSON 的數(shù)據(jù)格式標準,Internet Engineering Task Force 7159,簡稱(IETF 7159),是一種輕量級的、基于文本與語言無關(guān)的數(shù)據(jù)交互格式,源自 ECMAScript 編程語言標準.
https://www.rfc-editor.org/rfc/rfc7159.txt 訪問這個地址查看協(xié)議的相關(guān)內(nèi)容。
我們本節(jié)需要關(guān)注的是 “一個 JSON 的 Value 是什么呢?” 上述協(xié)議中有規(guī)定必須為 object, array, number, or string 四個數(shù)據(jù)類型,也可以是 false, null, true 這三個值。
到此,也就揭開了這個謎底,JSON 在解析時對于其它類型的編碼都會被默認轉(zhuǎn)換掉。對應我們這個例子中的大數(shù)值會默認編碼為 number 類型,這也是造成精度丟失的真正原因。
大數(shù)運算的解決方案
1. 常用方法轉(zhuǎn)字符串
在前后端交互中這是通常的一種方案,例如,對訂單號的存儲采用數(shù)值類型 Java 中的 long 類型表示的最大值為 2 的 64 次方,而 JS 中為 Number.MAX_SAFE_INTEGER (Math.pow(2, 53) - 1),顯然超過 JS 中能表示的最大安全值之外就要丟失精度了,最好的解法就是將訂單號由數(shù)值型轉(zhuǎn)為字符串返回給前端處理,這是在工作對接過程中實實在在遇到的一個坑。
2. 新的希望 BigInt
Bigint 是 JavaScript 中一個新的數(shù)據(jù)類型,可以用來操作超出 Number 最大安全范圍的整數(shù)。
創(chuàng)建 BigInt 方法一
一種方法是在數(shù)字后面加上數(shù)字 n
- 200000436035958034n; // 200000436035958034n
創(chuàng)建 BigInt 方法二
另一種方法是使用構(gòu)造函數(shù) BigInt(),還需要注意的是使用 BigInt 時最好還是使用字符串,否則還是會出現(xiàn)精度問題,看官方文檔也提到了這塊 github.com/tc39/proposal-bigint#gotchas--exceptions 稱為疑難雜癥
- BigInt('200000436035958034') // 200000436035958034n
- // 注意要使用字符串否則還是會被轉(zhuǎn)義
- BigInt(200000436035958034) // 200000436035958048n 這不是一個正確的結(jié)果
檢測類型
BigInt 是一個新的數(shù)據(jù)類型,因此它與 Number 并不是完全相等的,例如 1n 將不會全等于 1。
- typeof 200000436035958034n // bigint
- 1n === 1 // false
運算
BitInt 支持常見的運算符,但是永遠不要與 Number 混合使用,請始終保持一致。
- // 正確
- 200000436035958034n + 1n // 200000436035958035n
- // 錯誤
- 200000436035958034n + 1
- ^
- TypeError: Cannot mix BigInt and other types, use explicit conversions
BigInt 轉(zhuǎn)為字符串
- String(200000436035958034n) // 200000436035958034
- // 或者以下方式
- (200000436035958034n).toString() // 200000436035958034
與 JSON 的沖突
使用 JSON.parse('{"id": 200000436035958034}') 來解析會造成精度丟失問題,既然現(xiàn)在有了一個 BigInt 出現(xiàn),是否使用以下方式就可以正常解析呢?
- JSON.parse('{"id": 200000436035958034n}');
運行以上程序之后,會得到一個 SyntaxError: Unexpected token n in JSON at position 25 錯誤,最麻煩的就在這里,因為 JSON 是一個更為廣泛的數(shù)據(jù)協(xié)議類型,影響面非常廣泛,不是輕易能夠變動的。
在 TC39 proposal-bigint 倉庫中也有人提過這個問題 github.comtc39/proposal-bigint/issues/24 截至目前,該提案并未被添加到 JSON 中,因為這將破壞 JSON 的格式,很可能導致無法解析。
BigInt 的支持
BigInt 提案目前已進入 Stage 4,已經(jīng)在 Chrome,Node,F(xiàn)irefox,Babel 中發(fā)布,在 Node.js 中支持的版本為 12+。
BigInt 總結(jié)
我們使用 BigInt 做一些運算是沒有問題的,但是和第三方接口交互,如果對 JSON 字符串做序列化遇到一些大數(shù)問題還是會出現(xiàn)精度丟失,顯然這是由于與 JSON 的沖突導致的,下面給出第三種方案。
3. 第三方庫
通過一些第三方庫也可以解決,但是你可能會想為什么要這么曲折呢?轉(zhuǎn)成字符串大家不都開開心心的嗎,但是呢,有的時候你需要對接第三方接口,取到的數(shù)據(jù)就包含這種大數(shù)的情況,且遇到那種拒不改的,業(yè)務總歸要完成吧!這里介紹第三種實現(xiàn)方案。
還拿我們上面 大數(shù)處理精度丟失問題復現(xiàn) 的第二個例子進行講解,通過 json-bigint 這個庫來解決。
知道了 JSON 規(guī)范與 JavaScript 之間的沖突問題之后,就不要直接使用 JSON.parse() 了,在接收數(shù)據(jù)流之后,先通過字符串方式進行解析,利用 json-bigint 這個庫,會自動的將超過 2 的 53 次方類型的數(shù)值轉(zhuǎn)為一個 BigInt 類型,再設(shè)置一個參數(shù) storeAsString: true 會將 BigInt 自動轉(zhuǎn)為字符串。
- const http = require('http');
- const JSONbig = require('json-bigint')({ 'storeAsString': true});
- http.createServer((req, res) => {
- if (req.method === 'POST') {
- let data = '';
- req.on('data', chunk => {
- data += chunk;
- });
- req.on('end', () => {
- try {
- // 使用第三方庫進行 JSON 序列化
- const obj = JSONbig.parse(data)
- console.log('經(jīng)過 JSON 反序列化之后:', obj);
- res.setHeader("Content-Type", "application/json");
- res.end(data);
- } catch(e) {
- console.error(e);
- res.statusCode = 400;
- res.end("Invalid JSON");
- }
- });
- } else {
- res.end('OK');
- }
- }).listen(3000)
再次驗證會看到以下結(jié)果,這次是正確的,問題也已經(jīng)完美解決了!
- JSON 反序列化之后 id 值: { id: '200000436035958034' }
json-bigint 結(jié)合 Request client
介紹下 axios、node-fetch、undici、undici-fetch 這些請求客戶端如何結(jié)合 json-bigint 處理大數(shù)。
模擬服務端
使用 BigInt 創(chuàng)建一個大數(shù)模擬服務端返回數(shù)據(jù),此時,若請求的客戶端不處理是會造成精度丟失的。
- const http = require('http');
- const JSONbig = require('json-bigint')({ 'storeAsString': true});
- http.createServer((req, res) => {
- res.end(JSONbig.stringify({
- num: BigInt('200000436035958034')
- }))
- }).listen(3000)
axios
創(chuàng)建一個 axios 請求實例 request,其中的 transformResponse 屬性我們對原始的響應數(shù)據(jù)做一些自定義處理。
- const axios = require('axios').default;
- const JSONbig = require('json-bigint')({ 'storeAsString': true});
- const request = axios.create({
- baseURL: 'http://localhost:3000',
- transformResponse: [function (data) {
- return JSONbig.parse(data)
- }],
- });
- request({
- url: '/api/test'
- }).then(response => {
- // 200000436035958034
- console.log(response.data.num);
- });
node-fetch
node-fetch 在 Node.js 里用的也不少,一種方法是對返回的 text 數(shù)據(jù)做處理,其它更便捷的方法沒有深入研究。
- const fetch = require('node-fetch');
- const JSONbig = require('json-bigint')({ 'storeAsString': true});
- fetch('http://localhost:3000/api/data')
- .then(async res => JSONbig.parse(await res.text()))
- .then(data => console.log(data.num));
undici
request 這個已標記為廢棄的客戶端就不介紹了,再推薦一個值得關(guān)注的 Node.js 請求客戶端 undici,前一段也寫過一篇文章介紹 request 已廢棄 - 推薦一個超快的 Node.js HTTP Client undici。
- const undici = require('undici');
- const JSONbig = require('json-bigint')({ 'storeAsString': true});
- const client = new undici.Client('http://localhost:3000');
- (async () => {
- const { body } = await client.request({
- path: '/api',
- method: 'GET',
- });
- body.setEncoding('utf8');
- let str = '';
- for await (const chunk of body) {
- str += chunk;
- }
- console.log(JSONbig.parse(str)); // 200000436035958034
- console.log(JSON.parse(str)); // 200000436035958050 精度丟失
- })();
undici-fetch
undici-fetch 是一個構(gòu)建在 undici 之上的 WHATWG fetch 實現(xiàn),使用和 node-fetch 差不多。
- const fetch = require('undici-fetch');
- const JSONbig = require('json-bigint')({ 'storeAsString': true});
- (async () => {
- const res = await fetch('http://localhost:3000');
- const json = JSONbig.parse(await res.text());
- console.log(json.num); // 200000436035958034
- })();
總結(jié)
本文提出了一些產(chǎn)生大數(shù)精度丟失的原因,同時又給出了幾種解決方案,如遇到類似問題,都可參考。還是建議大家在系統(tǒng)設(shè)計時去遵循雙精度浮點數(shù)的規(guī)范來做,在查找問題的過程中,有看到有些使用正則來匹配,個人角度還是不推薦的,一是正則本身就是一個耗時的操作,二操作起來還要查找一些匹配規(guī)律,一不小心可能會把返回結(jié)果中的所有數(shù)值都轉(zhuǎn)為字符串,也是不可行的。
本文轉(zhuǎn)載自微信公眾號「Nodejs技術(shù)?!?,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系Nodejs技術(shù)棧公眾號。