تخطٍ إلى المحتوى الرئيسي

كل ما يمكنك تخزينه مؤقتًا

الطبقة الثانية
التخزين المؤقت
التخزين
المستوى المتوسط
أوري بوميرانتز
15 سبتمبر 2022
21 دقيقة قراءة

عند استخدام تكديس المعاملات، تكون تكلفة البايت في المعاملة أغلى بكثير من تكلفة خانة التخزين. لذلك، من المنطقي تخزين أكبر قدر ممكن من المعلومات مؤقتًا على السلسلة.

في هذه المقالة، ستتعلم كيفية إنشاء واستخدام عقد تخزين مؤقت بطريقة يتم بها تخزين أي قيمة معلمة من المحتمل استخدامها عدة مرات مؤقتًا وتكون متاحة للاستخدام (بعد المرة الأولى) بعدد أقل بكثير من البايت، وكيفية كتابة نص برمجي خارج السلسلة يستخدم ذاكرة التخزين المؤقت هذه.

إذا كنت تريد تخطي المقالة ورؤية النص البرمجي المصدري فقط، فهو هنا (opens in a new tab). مكدس التطوير هو فاوندري (opens in a new tab).

التصميم العام

من أجل البساطة، سنفترض أن جميع معلمات المعاملة هي uint256، بطول 32 بايت. عندما نتلقى معاملة، سنقوم بتحليل كل معلمة على النحو التالي:

  1. إذا كان البايت الأول هو 0xFF، فخذ 32 بايت التالية كقيمة معلمة واكتبها في ذاكرة التخزين المؤقت.

  2. إذا كان البايت الأول هو 0xFE، فخذ 32 بايت التالية كقيمة معلمة ولكن لا تكتبها في ذاكرة التخزين المؤقت.

  3. لأي قيمة أخرى، خذ البتات الأربعة العلوية كرقم للبايتات الإضافية، والبتات الأربعة السفلية كأكثر البتات أهمية في مفتاح ذاكرة التخزين المؤقت. وفيما يلي بعض الأمثلة:

    بايتات في بيانات الاستدعاءمفتاح ذاكرة التخزين المؤقت
    0x0F0x0F
    0x10,0x100x10
    0x12,0xAC0x02AC
    0x2D,0xEA, 0xD60x0DEAD6

معالجة ذاكرة التخزين المؤقت

يتم تنفيذ ذاكرة التخزين المؤقت في Cache.sol (opens in a new tab). دعنا نراجعه سطراً بسطر.

1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.13;
3
4contract Cache {
5
6 bytes1 public constant INTO_CACHE = 0xFF;
7 bytes1 public constant DONT_CACHE = 0xFE;

تُستخدم هذه الثوابت لتفسير الحالات الخاصة التي نقدم فيها جميع المعلومات، وسواء كنا نريد كتابتها في ذاكرة التخزين المؤقت أم لا. تتطلب الكتابة في ذاكرة التخزين المؤقت عمليتي SSTORE (opens in a new tab) في خانات تخزين غير مستخدمة سابقًا بتكلفة 22100 غاز لكل منهما، لذلك نجعلها اختيارية.

1
2 mapping(uint => uint) public val2key;

ربط (opens in a new tab) بين القيم ومفاتيحها. هذه المعلومات ضرورية لترميز القيم قبل إرسال المعاملة.

1 // الموقع n له القيمة للمفتاح n+1، لأننا بحاجة إلى الحفاظ على
2 // الصفر كـ "ليس في ذاكرة التخزين المؤقت".
3 uint[] public key2val;

يمكننا استخدام مصفوفة للربط من المفاتيح إلى القيم لأننا نعين المفاتيح، ومن أجل البساطة، نقوم بذلك بشكل تسلسلي.

1 function cacheRead(uint _key) public view returns (uint) {
2 require(_key <= key2val.length, "قراءة إدخال ذاكرة تخزين مؤقت غير مهيأ");
3 return key2val[_key-1];
4 } // قراءة_ذاكرة_التخزين_المؤقت

اقرأ قيمة من ذاكرة التخزين المؤقت.

1 // اكتب قيمة في ذاكرة التخزين المؤقت إذا لم تكن موجودة بالفعل
2 // عامة فقط لتمكين عمل الاختبار
3 function cacheWrite(uint _value) public returns (uint) {
4 // إذا كانت القيمة موجودة بالفعل في ذاكرة التخزين المؤقت، فقم بإرجاع المفتاح الحالي
5 if (val2key[_value] != 0) {
6 return val2key[_value];
7 }

لا فائدة من وضع نفس القيمة في ذاكرة التخزين المؤقت أكثر من مرة. إذا كانت القيمة موجودة بالفعل، فما عليك سوى إرجاع المفتاح الحالي.

1 // نظرًا لأن 0xFE حالة خاصة، فإن أكبر مفتاح يمكن أن تحتفظ به ذاكرة التخزين المؤقت هو
2 // 0x0D متبوعًا بـ 15 0xFF's. إذا كان طول ذاكرة التخزين المؤقت بهذا الحجم
3 // بالفعل، فافشل.
4 // 1 2 3 4 5 6 7 8 9 A B C D E F
5 require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,
6 "تجاوز سعة ذاكرة التخزين المؤقت");

لا أعتقد أننا سنحصل على ذاكرة تخزين مؤقت بهذا الحجم (حوالي 1.8 * 1037 إدخالًا، الأمر الذي سيتطلب حوالي 1027 تيرابايت لتخزينها). ومع ذلك، أنا كبير بما يكفي لأتذكر "640 كيلوبايت ستكون كافية دائمًا" (opens in a new tab). هذا الاختبار رخيص جدا.

1 // اكتب القيمة باستخدام المفتاح التالي
2 val2key[_value] = key2val.length+1;

أضف البحث العكسي (من القيمة إلى المفتاح).

1 key2val.push(_value);

أضف البحث الأمامي (من المفتاح إلى القيمة). نظرًا لأننا نعين القيم بشكل تسلسلي، يمكننا فقط إضافتها بعد قيمة المصفوفة الأخيرة.

1 return key2val.length;
2 } // كتابة_ذاكرة_التخزين_المؤقت

أرجع الطول الجديد لـkey2val، وهي الخلية التي يتم فيها تخزين القيمة الجديدة.

1 function _calldataVal(uint startByte, uint length)
2 private pure returns (uint)

تقرأ هذه الدالة قيمة من بيانات الاستدعاء ذات طول عشوائي (يصل إلى 32 بايت، وهو حجم الكلمة).

1 {
2 uint _retVal;
3
4 require(length < 0x21,
5 "_calldataVal الحد الأقصى للطول هو 32 بايت");
6 require(length + startByte <= msg.data.length,
7 "_calldataVal محاولة القراءة بعد حجم بيانات الاستدعاء");

هذه الدالة داخلية، لذا إذا كان بقية النص البرمجي مكتوبًا بشكل صحيح، فإن هذه الاختبارات ليست مطلوبة. ومع ذلك، فهي لا تكلف الكثير، لذلك قد يكون من الأفضل وجودها.

1 assembly {
2 _retVal := calldataload(startByte)
3 }

هذا النص البرمجي مكتوب بلغة يول (opens in a new tab). تقرأ قيمة 32 بايت من بيانات الاستدعاء. يعمل هذا حتى لو توقفت بيانات الاستدعاء قبل startByte+32 لأن المساحة غير المهيأة في EVM تعتبر صفرًا.

1 _retVal = _retVal >> (256-length*8);

لا نريد بالضرورة قيمة 32 بايت. هذا يتخلص من البايتات الزائدة.

1 return _retVal;
2 } // _calldataVal
3
4 // اقرأ معلمة واحدة من بيانات الاستدعاء، بدءًا من _fromByte
5 function _readParam(uint _fromByte) internal
6 returns (uint _nextByte, uint _parameterValue)
7 {

اقرأ معلمة واحدة من بيانات الاستدعاء. لاحظ أننا بحاجة إلى إرجاع ليس فقط القيمة التي نقرأها، ولكن أيضًا موقع البايت التالي لأن المعلمات يمكن أن تتراوح من بايت واحد إلى 33 بايت.

1 // يخبرنا البايت الأول بكيفية تفسير الباقي
2 uint8 _firstByte;
3
4 _firstByte = uint8(_calldataVal(_fromByte, 1));

تحاول سوليديتي تقليل عدد الأخطاء عن طريق حظر تحويلات النوع الضمنية (opens in a new tab) التي قد تكون خطيرة. يجب أن يكون خفض المستوى، على سبيل المثال من 256 بت إلى 8 بت، صريحًا.

1
2 // اقرأ القيمة، لكن لا تكتبها في ذاكرة التخزين المؤقت
3 if (_firstByte == uint8(DONT_CACHE))
4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));
5
6 // اقرأ القيمة، واكتبها في ذاكرة التخزين المؤقت
7 if (_firstByte == uint8(INTO_CACHE)) {
8 uint _param = _calldataVal(_fromByte+1, 32);
9 cacheWrite(_param);
10 return(_fromByte+33, _param);
11 }
12
13 // إذا وصلنا إلى هنا، فهذا يعني أننا بحاجة إلى القراءة من ذاكرة التخزين المؤقت
14
15 // عدد البايتات الإضافية للقراءة
16 uint8 _extraBytes = _firstByte / 16;
إظهار الكل

خذ النيبل (opens in a new tab) السفلي وادمجه مع البايتات الأخرى لقراءة القيمة من ذاكرة التخزين المؤقت.

1 uint _key = (uint256(_firstByte & 0x0F) << (8*_extraBytes)) +
2 _calldataVal(_fromByte+1, _extraBytes);
3
4 return (_fromByte+_extraBytes+1, cacheRead(_key));
5
6 } // _readParam
7
8 // اقرأ n من المعلمات (تعرف الدوال عدد المعلمات التي تتوقعها)
9 function _readParams(uint _paramNum) internal returns (uint[] memory) {
إظهار الكل

يمكننا الحصول على عدد المعلمات التي لدينا من بيانات الاستدعاء نفسها، ولكن الدوال التي تستدعينا تعرف عدد المعلمات التي تتوقعها. من الأسهل أن ندعهم يخبروننا.

1 // المعلمات التي نقرأها
2 uint[] memory params = new uint[](_paramNum);
3
4 // تبدأ المعلمات عند البايت 4، وقبل ذلك يوجد توقيع الدالة
5 uint _atByte = 4;
6
7 for(uint i=0; i<_paramNum; i++) {
8 (_atByte, params[i]) = _readParam(_atByte);
9 }
إظهار الكل

اقرأ المعلمات حتى تحصل على العدد الذي تحتاجه. إذا تجاوزنا نهاية بيانات الاستدعاء، فإن _readParams ستعيد الاستدعاء.

1
2 return(params);
3 } // readParams
4
5 // لاختبار _readParams، اختبر قراءة أربع معلمات
6 function fourParam() public
7 returns (uint256,uint256,uint256,uint256)
8 {
9 uint[] memory params;
10 params = _readParams(4);
11 return (params[0], params[1], params[2], params[3]);
12 } // fourParam
إظهار الكل

إحدى المزايا الكبيرة لـ فاوندري هي أنها تسمح بكتابة الاختبارات بلغة سوليديتي (انظر اختبار ذاكرة التخزين المؤقت أدناه). هذا يجعل اختبارات الوحدة أسهل بكثير. هذه دالة تقرأ أربع معلمات وتعيدها حتى يتمكن الاختبار من التحقق من صحتها.

1 // احصل على قيمة، وأرجع البايتات التي ستقوم بترميزها (باستخدام ذاكرة التخزين المؤقت إن أمكن)
2 function encodeVal(uint _val) public view returns(bytes memory) {

encodeVal هي دالة يستدعيها النص البرمجي خارج السلسلة للمساعدة في إنشاء بيانات استدعاء تستخدم ذاكرة التخزين المؤقت. تتلقى قيمة واحدة وتعيد البايتات التي تقوم بترميزها. هذه الدالة هي view، لذلك لا تتطلب معاملة وعند استدعائها خارجيًا لا تكلف أي غاز.

1 uint _key = val2key[_val];
2
3 // القيمة ليست في ذاكرة التخزين المؤقت بعد، أضفها
4 if (_key == 0)
5 return bytes.concat(INTO_CACHE, bytes32(_val));

في EVM، يُفترض أن كل مساحة التخزين غير المهيأة هي أصفار. لذلك إذا بحثنا عن مفتاح لقيمة غير موجودة، نحصل على صفر. في هذه الحالة، تكون البايتات التي تقوم بترميزها هي INTO_CACHE (لذلك سيتم تخزينها مؤقتًا في المرة القادمة)، متبوعة بالقيمة الفعلية.

1 // إذا كان المفتاح <0x10، فأرجعه كبايت واحد
2 if (_key < 0x10)
3 return bytes.concat(bytes1(uint8(_key)));

البايتات المفردة هي الأسهل. نحن فقط نستخدم bytes.concat (opens in a new tab) لتحويل نوع bytes<n> إلى مصفوفة بايت يمكن أن تكون بأي طول. على الرغم من الاسم، فإنه يعمل بشكل جيد عند تزويده بوسيطة واحدة فقط.

1 // قيمة بايتين، مرمزة كـ 0x1vvv
2 if (_key < 0x1000)
3 return bytes.concat(bytes2(uint16(_key) | 0x1000));

عندما يكون لدينا مفتاح أقل من 163، يمكننا التعبير عنه في بايتين. نقوم أولاً بتحويل _key، وهي قيمة 256 بت، إلى قيمة 16 بت ونستخدم "أو" المنطقية لإضافة عدد البايتات الإضافية إلى البايت الأول. ثم نقوم فقط بتحويلها إلى قيمة bytes2، والتي يمكن تحويلها إلى bytes.

1 // ربما هناك طريقة ذكية للقيام بالأسطر التالية كحلقة،
2 // لكنها دالة عرض لذا أقوم بالتحسين من أجل وقت المبرمج و
3 // البساطة.
4
5 if (_key < 16*256**2)
6 return bytes.concat(bytes3(uint24(_key) | (0x2 * 16 * 256**2)));
7 if (_key < 16*256**3)
8 return bytes.concat(bytes4(uint32(_key) | (0x3 * 16 * 256**3)));
9 .
10 .
11 .
12 if (_key < 16*256**14)
13 return bytes.concat(bytes15(uint120(_key) | (0xE * 16 * 256**14)));
14 if (_key < 16*256**15)
15 return bytes.concat(bytes16(uint128(_key) | (0xF * 16 * 256**15)));
إظهار الكل

القيم الأخرى (3 بايتات، 4 بايتات، إلخ.) يتم التعامل معها بنفس الطريقة، فقط بأحجام حقول مختلفة.

1 // إذا وصلنا إلى هنا، فهناك خطأ ما.
2 revert("خطأ في encodeVal، لا ينبغي أن يحدث");

إذا وصلنا إلى هنا، فهذا يعني أننا حصلنا على مفتاح ليس أقل من 16 * 25615. لكن cacheWrite تحد من المفاتيح حتى لا نتمكن حتى من الوصول إلى 14 * 25616 (والتي سيكون لها بايت أول بقيمة 0xFE، لذلك ستبدو مثل DONT_CACHE). لكنه لا يكلفنا الكثير لإضافة اختبار في حالة قيام مبرمج مستقبلي بإدخال خطأ.

1 } // encodeVal
2
3} // Cache

اختبار ذاكرة التخزين المؤقت

إحدى مزايا فاوندري هي أنها تتيح لك كتابة الاختبارات بلغة سوليديتي (opens in a new tab)، مما يسهل كتابة اختبارات الوحدة. اختبارات فئة Cache موجودة هنا (opens in a new tab). نظرًا لأن النص البرمجي للاختبار متكرر، كما هو الحال في الاختبارات، فإن هذه المقالة تشرح الأجزاء المثيرة للاهتمام فقط.

1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.13;
3
4import "forge-std/Test.sol";
5
6// تحتاج إلى تشغيل `forge test -vv` لوحدة التحكم.
7import "forge-std/console.sol";

هذا مجرد نص معياري ضروري لاستخدام حزمة الاختبار و console.log.

1import "src/Cache.sol";

نحتاج إلى معرفة العقد الذي نختبره.

1contract CacheTest is Test {
2 Cache cache;
3
4 function setUp() public {
5 cache = new Cache();
6 }

يتم استدعاء دالة setUp قبل كل اختبار. في هذه الحالة، نقوم فقط بإنشاء ذاكرة تخزين مؤقت جديدة، حتى لا تؤثر اختباراتنا على بعضها البعض.

1 function testCaching() public {

الاختبارات هي دوال تبدأ أسماؤها بـtest. تتحقق هذه الدالة من وظائف ذاكرة التخزين المؤقت الأساسية، وكتابة القيم وقراءتها مرة أخرى.

1 for(uint i=1; i<5000; i++) {
2 cache.cacheWrite(i*i);
3 }
4
5 for(uint i=1; i<5000; i++) {
6 assertEq(cache.cacheRead(i), i*i);

هذه هي الطريقة التي تقوم بها بالاختبار الفعلي، باستخدام دوال assert... (opens in a new tab). في هذه الحالة، نتحقق من أن القيمة التي كتبناها هي القيمة التي قرأناها. يمكننا تجاهل نتيجة cache.cacheWrite لأننا نعلم أن مفاتيح ذاكرة التخزين المؤقت يتم تعيينها خطيًا.

1 }
2 } // testCaching
3
4 // خزن نفس القيمة مؤقتًا عدة مرات، وتأكد من بقاء المفتاح
5 // كما هو
6 function testRepeatCaching() public {
7 for(uint i=1; i<100; i++) {
8 uint _key1 = cache.cacheWrite(i);
9 uint _key2 = cache.cacheWrite(i);
10 assertEq(_key1, _key2);
11 }
إظهار الكل

أولاً، نكتب كل قيمة مرتين في ذاكرة التخزين المؤقت ونتأكد من أن المفاتيح هي نفسها (مما يعني أن الكتابة الثانية لم تحدث بالفعل).

1 for(uint i=1; i<100; i+=3) {
2 uint _key = cache.cacheWrite(i);
3 assertEq(_key, i);
4 }
5 } // testRepeatCaching

من الناحية النظرية، قد يكون هناك خطأ لا يؤثر على عمليات الكتابة المتتالية في ذاكرة التخزين المؤقت. لذلك هنا نقوم ببعض عمليات الكتابة غير المتتالية ونرى أن القيم لا تزال لم تتم إعادة كتابتها.

1 // اقرأ uint من مخزن الذاكرة المؤقت (للتأكد من أننا نستعيد المعلمات
2 // التي أرسلناها)
3 function toUint256(bytes memory _bytes, uint256 _start) internal pure
4 returns (uint256)

اقرأ كلمة 256 بت من مخزن bytes memory المؤقت. تتيح لنا هذه الدالة المساعدة التحقق من أننا نتلقى النتائج الصحيحة عندما نقوم بتشغيل استدعاء دالة يستخدم ذاكرة التخزين المؤقت.

1 {
2 require(_bytes.length >= _start + 32, "toUint256_خارج_الحدود");
3 uint256 tempUint;
4
5 assembly {
6 tempUint := mload(add(add(_bytes, 0x20), _start))
7 }

لا تدعم يول هياكل البيانات التي تتجاوز uint256، لذلك عندما تشير إلى بنية بيانات أكثر تعقيدًا، مثل مخزن الذاكرة المؤقت _bytes، فإنك تحصل على عنوان تلك البنية. تقوم سوليديتي بتخزين قيم bytes memory ككلمة 32 بايت تحتوي على الطول، متبوعة بالبايتات الفعلية، لذلك للحصول على رقم البايت _start، نحتاج إلى حساب _bytes+32+_start.

1
2 return tempUint;
3 } // toUint256
4
5 // توقيع الدالة لـ fourParams()، مقدمة من
6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d
7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;
8
9 // مجرد بعض القيم الثابتة لنرى أننا نحصل على القيم الصحيحة مرة أخرى
10 uint256 constant VAL_A = 0xDEAD60A7;
11 uint256 constant VAL_B = 0xBEEF;
12 uint256 constant VAL_C = 0x600D;
13 uint256 constant VAL_D = 0x600D60A7;
إظهار الكل

بعض الثوابت التي نحتاجها للاختبار.

1 function testReadParam() public {

استدعاء fourParams()، وهي دالة تستخدم readParams، لاختبار قدرتنا على قراءة المعلمات بشكل صحيح.

1 address _cacheAddr = address(cache);
2 bool _success;
3 bytes memory _callInput;
4 bytes memory _callOutput;

لا يمكننا استخدام آلية واجهة التطبيق الثنائية العادية لاستدعاء دالة باستخدام ذاكرة التخزين المؤقت، لذلك نحتاج إلى استخدام آلية المستوى المنخفض <address>.call() (opens in a new tab). تأخذ هذه الآلية bytes memory كمدخل، وتعيد ذلك (بالإضافة إلى قيمة منطقية) كمخرج.

1 // الاستدعاء الأول، ذاكرة التخزين المؤقت فارغة
2 _callInput = bytes.concat(
3 FOUR_PARAMS,

من المفيد أن يدعم نفس العقد كلاً من الدوال المخزنة مؤقتًا (للمكالمات مباشرة من المعاملات) والدوال غير المخزنة مؤقتًا (للمكالمات من العقود الذكية الأخرى). للقيام بذلك، نحتاج إلى الاستمرار في الاعتماد على آلية سوليديتي لاستدعاء الدالة الصحيحة، بدلاً من وضع كل شيء في دالة fallback (opens in a new tab). القيام بذلك يجعل قابلية التركيب أسهل بكثير. سيكون بايت واحد كافيًا لتحديد الدالة في معظم الحالات، لذلك نحن نهدر ثلاثة بايتات (16 * 3 = 48 غاز). ومع ذلك، أثناء كتابتي لهذا، تكلف تلك الـ 48 غازًا 0.07 سنتًا، وهي تكلفة معقولة لنص برمجي أبسط وأقل عرضة للأخطاء.

1 // القيمة الأولى، أضفها إلى ذاكرة التخزين المؤقت
2 cache.INTO_CACHE(),
3 bytes32(VAL_A),

القيمة الأولى: علامة تقول إنها قيمة كاملة يجب كتابتها في ذاكرة التخزين المؤقت، متبوعة بـ 32 بايت من القيمة. القيم الثلاث الأخرى متشابهة، باستثناء أن VAL_B لا يتم كتابتها في ذاكرة التخزين المؤقت وVAL_C هي المعلمة الثالثة والرابعة.

1 .
2 .
3 .
4 );
5 (_success, _callOutput) = _cacheAddr.call(_callInput);

هنا نستدعي بالفعل عقد Cache.

1 assertEq(_success, true);

نتوقع أن يكون الاستدعاء ناجحًا.

1 assertEq(cache.cacheRead(1), VAL_A);
2 assertEq(cache.cacheRead(2), VAL_C);

نبدأ بذاكرة تخزين مؤقت فارغة ثم نضيف VAL_A متبوعة بـVAL_C. نتوقع أن يكون الأول له المفتاح 1، والثاني له المفتاح 2.

1 assertEq(toUint256(_callOutput,0), VAL_A);
2 assertEq(toUint256(_callOutput,32), VAL_B);
3 assertEq(toUint256(_callOutput,64), VAL_C);
4 assertEq(toUint256(_callOutput,96), VAL_C);

الناتج هو المعلمات الأربع. هنا نتحقق من صحتها.

1 // الاستدعاء الثاني، يمكننا استخدام ذاكرة التخزين المؤقت
2 _callInput = bytes.concat(
3 FOUR_PARAMS,
4
5 // القيمة الأولى في ذاكرة التخزين المؤقت
6 bytes1(0x01),

مفاتيح ذاكرة التخزين المؤقت الأقل من 16 هي بايت واحد فقط.

1 // القيمة الثانية، لا تضفها إلى ذاكرة التخزين المؤقت
2 cache.DONT_CACHE(),
3 bytes32(VAL_B),
4
5 // القيمتان الثالثة والرابعة، نفس القيمة
6 bytes1(0x02),
7 bytes1(0x02)
8 );
9 .
10 .
11 .
12 } // testReadParam
إظهار الكل

الاختبارات التي تلي الاستدعاء مطابقة لتلك التي تلي الاستدعاء الأول.

1 function testEncodeVal() public {

هذه الدالة مشابهة لـ testReadParam، إلا أننا بدلاً من كتابة المعلمات بشكل صريح نستخدم encodeVal().

1 .
2 .
3 .
4 _callInput = bytes.concat(
5 FOUR_PARAMS,
6 cache.encodeVal(VAL_A),
7 cache.encodeVal(VAL_B),
8 cache.encodeVal(VAL_C),
9 cache.encodeVal(VAL_D)
10 );
11 .
12 .
13 .
14 assertEq(_callInput.length, 4+1*4);
15 } // testEncodeVal
إظهار الكل

الاختبار الإضافي الوحيد في testEncodeVal() هو التحقق من صحة طول _callInput. بالنسبة للاستدعاء الأول، هو 4+33_4. أما بالنسبة للثاني، حيث توجد كل قيمة بالفعل في ذاكرة التخزين المؤقت، فهو 4+1_4.

1 // اختبر encodeVal عندما يكون المفتاح أكثر من بايت واحد
2 // ثلاثة بايتات كحد أقصى لأن ملء ذاكرة التخزين المؤقت بأربعة بايتات يستغرق
3 // وقتًا طويلاً جدًا.
4 function testEncodeValBig() public {
5 // ضع عددًا من القيم في ذاكرة التخزين المؤقت.
6 // للحفاظ على بساطة الأمور، استخدم المفتاح n للقيمة n.
7 for(uint i=1; i<0x1FFF; i++) {
8 cache.cacheWrite(i);
9 }
إظهار الكل

تكتب دالة testEncodeVal أعلاه أربع قيم فقط في ذاكرة التخزين المؤقت، لذلك لم يتم التحقق من الجزء من الدالة الذي يتعامل مع القيم متعددة البايتات (opens in a new tab). لكن هذا النص البرمجي معقد وعرضة للأخطاء.

الجزء الأول من هذه الدالة هو حلقة تكتب جميع القيم من 1 إلى 0x1FFF إلى ذاكرة التخزين المؤقت بالترتيب، لذلك سنكون قادرين على ترميز تلك القيم ومعرفة إلى أين تذهب.

1 .
2 .
3 .
4
5 _callInput = bytes.concat(
6 FOUR_PARAMS,
7 cache.encodeVal(0x000F), // One byte 0x0F
8 cache.encodeVal(0x0010), // Two bytes 0x1010
9 cache.encodeVal(0x0100), // Two bytes 0x1100
10 cache.encodeVal(0x1000) // Three bytes 0x201000
11 );
إظهار الكل

اختبر قيم بايت واحد، وبايتين، وثلاثة بايتات. لا نختبر ما هو أبعد من ذلك لأنه سيستغرق وقتًا طويلاً جدًا لكتابة ما يكفي من إدخالات المكدس (على الأقل 0x10000000، أي ما يقرب من ربع مليار).

1 .
2 .
3 .
4 .
5 } // testEncodeValBig
6
7 // اختبر ما يحدث مع مخزن مؤقت صغير بشكل مفرط، فنحصل على ارتداد
8 function testShortCalldata() public {

اختبر ما يحدث في الحالة غير الطبيعية حيث لا توجد معلمات كافية.

1 .
2 .
3 .
4 (_success, _callOutput) = _cacheAddr.call(_callInput);
5 assertEq(_success, false);
6 } // testShortCalldata

نظرًا لأنه يعود، يجب أن تكون النتيجة التي نحصل عليها false.

1 // استدعاء بمفاتيح ذاكرة تخزين مؤقت غير موجودة
2 function testNoCacheKey() public {
3 .
4 .
5 .
6 _callInput = bytes.concat(
7 FOUR_PARAMS,
8
9 // القيمة الأولى، أضفها إلى ذاكرة التخزين المؤقت
10 cache.INTO_CACHE(),
11 bytes32(VAL_A),
12
13 // القيمة الثانية
14 bytes1(0x0F),
15 bytes2(0x1234),
16 bytes11(0xA10102030405060708090A)
17 );
إظهار الكل

تحصل هذه الدالة على أربع معلمات شرعية تمامًا، إلا أن ذاكرة التخزين المؤقت فارغة لذا لا توجد قيم هناك لقراءتها.

1 .
2 .
3 .
4 // اختبار ما يحدث مع مخزن مؤقت طويل بشكل مفرط، كل شيء يعمل بشكل جيد
5 function testLongCalldata() public {
6 address _cacheAddr = address(cache);
7 bool _success;
8 bytes memory _callInput;
9 bytes memory _callOutput;
10
11 // الاستدعاء الأول، ذاكرة التخزين المؤقت فارغة
12 _callInput = bytes.concat(
13 FOUR_PARAMS,
14
15 // القيمة الأولى، أضفها إلى ذاكرة التخزين المؤقت
16 cache.INTO_CACHE(), bytes32(VAL_A),
17
18 // القيمة الثانية، أضفها إلى ذاكرة التخزين المؤقت
19 cache.INTO_CACHE(), bytes32(VAL_B),
20
21 // القيمة الثالثة، أضفها إلى ذاكرة التخزين المؤقت
22 cache.INTO_CACHE(), bytes32(VAL_C),
23
24 // القيمة الرابعة، أضفها إلى ذاكرة التخزين المؤقت
25 cache.INTO_CACHE(), bytes32(VAL_D),
26
27 // وقيمة أخرى لـ"الحظ السعيد"
28 bytes4(0x31112233)
29 );
إظهار الكل

ترسل هذه الدالة خمس قيم. نعلم أن القيمة الخامسة يتم تجاهلها لأنها ليست إدخالًا صالحًا لذاكرة التخزين المؤقت، وهو ما كان سيتسبب في ارتداد لو لم يتم تضمينها.

1 (_success, _callOutput) = _cacheAddr.call(_callInput);
2 assertEq(_success, true);
3 .
4 .
5 .
6 } // testLongCalldata
7
8} // CacheTest
9
إظهار الكل

تطبيق نموذجي

إن كتابة الاختبارات بلغة سوليديتي أمر جيد جدًا، ولكن في نهاية اليوم، يحتاج التطبيق اللامركزي إلى أن يكون قادرًا على معالجة الطلبات من خارج السلسلة ليكون مفيدًا. توضح هذه المقالة كيفية استخدام التخزين المؤقت في تطبيق لامركزي مع WORM، والذي يرمز إلى "الكتابة مرة واحدة، والقراءة عدة مرات". إذا لم تتم كتابة المفتاح بعد، فيمكنك كتابة قيمة له. إذا تمت كتابة المفتاح بالفعل، فستحصل على ارتداد.

العقد

هذا هو العقد (opens in a new tab). يكرر في الغالب ما قمنا به بالفعل مع Cache وCacheTest، لذلك نحن نغطي فقط الأجزاء المثيرة للاهتمام.

1import "./Cache.sol";
2
3contract WORM is Cache {

أسهل طريقة لاستخدام Cache هي وراثتها في عقدنا الخاص.

1 function writeEntryCached() external {
2 uint[] memory params = _readParams(2);
3 writeEntry(params[0], params[1]);
4 } // writeEntryCached

هذه الدالة مشابهة لـ fourParam في CacheTest أعلاه. نظرًا لأننا لا نتبع مواصفات واجهة التطبيق الثنائية، فمن الأفضل عدم الإعلان عن أي معلمات في الدالة.

1 // لتسهيل الاتصال بنا
2 // توقيع الدالة لـ writeEntryCached()، مقدمة من
3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
4 bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;

سيحتاج النص البرمجي الخارجي الذي يستدعي writeEntryCached إلى بناء بيانات الاستدعاء يدويًا، بدلاً من استخدام worm.writeEntryCached، لأننا لا نتبع مواصفات واجهة التطبيق الثنائية. وجود هذه القيمة الثابتة يجعل كتابتها أسهل.

لاحظ أنه على الرغم من أننا نحدد WRITE_ENTRY_CACHED كمتغير حالة، فلقراءته خارجيًا من الضروري استخدام دالة getter الخاصة به، worm.WRITE_ENTRY_CACHED().

1 function readEntry(uint key) public view
2 returns (uint _value, address _writtenBy, uint _writtenAtBlock)

دالة القراءة هي view، لذلك لا تتطلب معاملة ولا تكلف غاز. نتيجة لذلك، لا توجد فائدة من استخدام ذاكرة التخزين المؤقت للمعلمة. مع دوال العرض، من الأفضل استخدام الآلية القياسية الأبسط.

النص البرمجي للاختبار

هذا هو النص البرمجي لاختبار العقد (opens in a new tab). مرة أخرى، دعونا نلقي نظرة فقط على ما هو مثير للاهتمام.

1 function testWReadWrite() public {
2 worm.writeEntry(0xDEAD, 0x60A7);
3
4 vm.expectRevert(bytes("entry already written"));
5 worm.writeEntry(0xDEAD, 0xBEEF);

هذا (vm.expectRevert) (opens in a new tab) هو كيف نحدد في اختبار فاوندري أن الاستدعاء التالي يجب أن يفشل، والسبب المبلغ عنه للفشل. ينطبق هذا عندما نستخدم بناء الجملة <contract>.<function name>() بدلاً من بناء بيانات الاستدعاء واستدعاء العقد باستخدام واجهة المستوى المنخفض (<contract>.call()، إلخ).

1 function testReadWriteCached() public {
2 uint cacheGoat = worm.cacheWrite(0x60A7);

هنا نستخدم حقيقة أن cacheWrite يعيد مفتاح ذاكرة التخزين المؤقت. هذا ليس شيئًا نتوقع استخدامه في الإنتاج، لأن cacheWrite يغير الحالة، وبالتالي لا يمكن استدعاؤه إلا أثناء المعاملة. لا تحتوي المعاملات على قيم إرجاع، إذا كان لديهم نتائج، فمن المفترض أن يتم إصدار هذه النتائج كأحداث. لذلك، لا يمكن الوصول إلى قيمة إرجاع cacheWrite إلا من نص برمجي على السلسلة، ولا يحتاج النص البرمجي على السلسلة إلى تخزين مؤقت للمعلمات.

1 (_success,) = address(worm).call(_callInput);

هذه هي الطريقة التي نخبر بها سوليديتي أنه بينما يحتوي <contract address>.call() على قيمتي إرجاع، فإننا نهتم فقط بالأولى.

1 (_success,) = address(worm).call(_callInput);
2 assertEq(_success, false);

نظرًا لأننا نستخدم دالة <address>.call() ذات المستوى المنخفض، لا يمكننا استخدام vm.expectRevert() وعلينا النظر إلى قيمة النجاح المنطقية التي نحصل عليها من الاستدعاء.

1 event EntryWritten(uint indexed key, uint indexed value);
2
3 .
4 .
5 .
6
7 _callInput = bytes.concat(
8 worm.WRITE_ENTRY_CACHED(), worm.encodeVal(a), worm.encodeVal(b));
9 vm.expectEmit(true, true, false, false);
10 emit EntryWritten(a, b);
11 (_success,) = address(worm).call(_callInput);
إظهار الكل

هذه هي الطريقة التي نتحقق بها من أن النص البرمجي يصدر حدثًا بشكل صحيح (opens in a new tab) في فاوندري.

العميل

شيء واحد لا تحصل عليه مع اختبارات سوليديتي هو نص برمجي جافا سكريبت يمكنك قصه ولصقه في تطبيقك الخاص. لكتابة هذا النص البرمجي، قمت بنشر WORM على أوبتيميزم جيرلي (opens in a new tab)، شبكة الاختبار الجديدة لـ أوبتيميزم (opens in a new tab). إنه على العنوان 0xd34335b1d818cee54e3323d3246bd31d94e6a78a (opens in a new tab).

يمكنك رؤية نص جافا سكريبت البرمجي للعميل هنا (opens in a new tab). لاستخدامه:

  1. استنسخ مستودع git:

    1git clone https://github.com/qbzzt/20220915-all-you-can-cache.git
  2. ثبِّت الحزم الضرورية:

    1cd javascript
    2yarn
  3. انسخ ملف التهيئة:

    1cp .env.example .env
  4. حرر .env للتهيئة الخاصة بك:

    ParameterValue
    MNEMONICالذاكرة المساعدة لحساب به ما يكفي من ETH لدفع ثمن المعاملة. يمكنك الحصول على ETH مجانًا لشبكة أوبتيميزم جيرلي هنا (opens in a new tab).
    OPTIMISM_GOERLI_URLعنوان URL لـ أوبتيميزم جيرلي. نقطة النهاية العامة، https://goerli.optimism.io، محدودة المعدل ولكنها كافية لما نحتاجه هنا
  5. شغّل index.js.

    1node index.js

    يكتب هذا التطبيق النموذجي أولاً إدخالًا إلى WORM، ويعرض بيانات الاستدعاء ورابطًا للمعاملة على إيثرسكان. ثم يقرأ هذا الإدخال مرة أخرى، ويعرض المفتاح الذي يستخدمه والقيم الموجودة في الإدخال (القيمة ورقم الكتلة والمؤلف).

معظم العميل هو جافا سكريبت عادي للتطبيقات اللامركزية. لذا مرة أخرى سنتناول الأجزاء المثيرة للاهتمام فقط.

1.
2.
3.
4const main = async () => {
5 const func = await worm.WRITE_ENTRY_CACHED()
6
7 // بحاجة إلى مفتاح جديد في كل مرة
8 const key = await worm.encodeVal(Number(new Date()))

لا يمكن الكتابة في خانة معينة إلا مرة واحدة، لذلك نستخدم الطابع الزمني للتأكد من أننا لا نعيد استخدام الخانات.

1const val = await worm.encodeVal("0x600D")
2
3// اكتب إدخالًا
4const calldata = func + key.slice(2) + val.slice(2)

تتوقع Ethers أن تكون بيانات الاستدعاء سلسلة سداسية عشرية، 0x متبوعة بعدد زوجي من الأرقام السداسية العشرية. نظرًا لأن key وval يبدآن بـ0x، فنحن بحاجة إلى إزالة تلك الرؤوس.

1const tx = await worm.populateTransaction.writeEntryCached()
2tx.data = calldata
3
4sentTx = await wallet.sendTransaction(tx)

كما هو الحال مع النص البرمجي لاختبار سوليديتي، لا يمكننا استدعاء دالة مخزنة مؤقتًا بشكل طبيعي. بدلاً من ذلك، نحتاج إلى استخدام آلية ذات مستوى أدنى.

1 .
2 .
3 .
4 // اقرأ الإدخال الذي تم كتابته للتو
5 const realKey = '0x' + key.slice(4) // إزالة علامة FF
6 const entryRead = await worm.readEntry(realKey)
7 .
8 .
9 .
إظهار الكل

لقراءة الإدخالات، يمكننا استخدام الآلية العادية. ليست هناك حاجة لاستخدام التخزين المؤقت للمعلمات مع دوال view.

الخلاصة

النص البرمجي في هذه المقالة هو إثبات للمفهوم، والغرض منه هو جعل الفكرة سهلة الفهم. للحصول على نظام جاهز للإنتاج، قد ترغب في تنفيذ بعض الوظائف الإضافية:

  • تعامل مع القيم التي ليست uint256. على سبيل المثال، السلاسل النصية.

  • بدلاً من ذاكرة التخزين المؤقت العالمية، ربما يكون لديك ربط بين المستخدمين وذاكرة التخزين المؤقت. يستخدم المستخدمون المختلفون قيماً مختلفة.

  • القيم المستخدمة للعناوين تختلف عن تلك المستخدمة لأغراض أخرى. قد يكون من المنطقي وجود ذاكرة تخزين مؤقت منفصلة للعناوين فقط.

  • حاليًا، مفاتيح ذاكرة التخزين المؤقت موجودة على خوارزمية "من يأتي أولاً، يحصل على أصغر مفتاح". يمكن إرسال القيم الست عشرة الأولى كبايت واحد. يمكن إرسال القيم الـ 4080 التالية كبايتين. القيم المليونية التالية تقريبًا هي ثلاثة بايتات، إلخ. يجب أن يحتفظ نظام الإنتاج بعدادات الاستخدام على إدخالات ذاكرة التخزين المؤقت ويعيد تنظيمها بحيث تكون القيم الست عشرة الأكثر شيوعًا بايتًا واحدًا، والقيم الـ 4080 الأكثر شيوعًا التالية بايتين، إلخ.

    ومع ذلك، فهذه عملية خطيرة محتملة. تخيل تسلسل الأحداث التالي:

    1. يستدعي نعيم الساذج encodeVal لترميز العنوان الذي يريد إرسال الرموز إليه. هذا العنوان هو أحد العناوين الأولى المستخدمة في التطبيق، لذا فإن القيمة المشفرة هي 0x06. هذه دالة view، وليست معاملة، لذا فهي بين نعيم والعقدة التي يستخدمها، ولا أحد آخر يعرف عنها

    2. يقوم المالك أوين بتشغيل عملية إعادة ترتيب ذاكرة التخزين المؤقت. يستخدم عدد قليل جدًا من الأشخاص هذا العنوان، لذلك يتم ترميزه الآن كـ 0x201122. يتم تعيين قيمة مختلفة، 1018، إلى 0x06.

    3. يرسل نعيم الساذج رموزه إلى 0x06. تذهب إلى العنوان 0x0000000000000000000000000de0b6b3a7640000، وبما أنه لا أحد يعرف المفتاح الخاص لهذا العنوان، فهي عالقة هناك. نعيم ليس سعيدًا.

    هناك طرق لحل هذه المشكلة، والمشكلة ذات الصلة بالمعاملات الموجودة في المحطة المؤقتة أثناء إعادة ترتيب ذاكرة التخزين المؤقت، ولكن يجب أن تكون على دراية بها.

لقد أوضحت التخزين المؤقت هنا مع أوبتيميزم، لأنني موظف في أوبتيميزم وهذا هو الرول أب الذي أعرفه أفضل. ولكن يجب أن يعمل مع أي رول أب يفرض تكلفة دنيا للمعالجة الداخلية، بحيث يكون كتابة بيانات المعاملة إلى L1 هو المصروف الرئيسي بالمقارنة.

انظر هنا لمزيد من أعمالي (opens in a new tab).

آخر تحديث للصفحة: 25 فبراير 2026

هل كانت تعليمات الاستخدام هذه مفيدة؟