PVS- استوديو زيارة اباتشي خلية

الشكل 1

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

حول PVS-Studio


يوجد محلل الشفرة الثابتة PVS-Studio لأكثر من 10 سنوات في سوق تكنولوجيا المعلومات وهو حل برمجي متعدد الوظائف ويمكن تنفيذه بسهولة. في الوقت الحالي ، يدعم المحلل لغات C و C ++ و C # ولغات Java ويعمل على أنظمة تشغيل Windows و Linux و macOS.

PVS-Studio هو حل B2B مدفوع ويستخدمه عدد كبير من الفرق في مختلف الشركات. إذا كنت ترغب في معرفة ما يمكن للمحلل تحليله ، فقم بتنزيل مجموعة التوزيع واطلب مفتاح تجريبي هنا .

إذا كنت من المهووسين بمصادر مفتوحة أو ، على سبيل المثال ، أنت طالب ، فيمكنك استخدام أحد خيارات الترخيص المجانية لبرنامج PVS-Studio.

حول اباتشي خلية


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

حاليا ، يعتبر أباتشي Hadoop واحدة من التقنيات الأساسية للبيانات الكبيرة. الأهداف الرئيسية لهذه التكنولوجيا هي تخزين ومعالجة وإدارة كميات كبيرة من البيانات. المكونات الرئيسية للإطار هي Hadoop Common ، HDFS ، Hadoop MapReduce ، Hadoop YARN . بمرور الوقت ، تم تكوين نظام بيئي كامل للمشروعات والتكنولوجيات ذات الصلة حول Hadoop ، والكثير منها تم تطويره في البداية كجزء من المشروع ، وأصبح فيما بعد مستقلًا. أحد هذه المشاريع هو Apache Hive .

اباتشي خلية هو مستودع البيانات الموزعة. يدير البيانات المخزنة في HDFS ويوفر لغة استعلام قائمة على SQL (HiveQL) للتعامل مع هذه البيانات. للحصول على معلومات مفصلة عن هذا المشروع ، يمكنك دراسة المعلومات هنا .

حول التحليل


تسلسل خطوات التحليل بسيط للغاية ولا يتطلب الكثير من الوقت:

  • حصلت اباتشي خلية مع جيثب ؛
  • لقد استخدمت الإرشادات لبدء محلل Java وبدأت التحليل ؛
  • تلقيت تقرير محلل ، وقمت بتحليله وسلطت الضوء على حالات مثيرة للاهتمام.

نتائج التحليل: تم إصدار 1456 تحذيرات من مستوى الثقة العالية والمتوسطة (602 و 854 ، على التوالي) لأكثر من 6500 ملف.

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

من بين التحذيرات ، لم يتم النظر في 407 تحذيرات (177 عالية و 230 متوسطة) لكل ملف اختبار. لم يتم اعتبار القاعدة التشخيصية V6022 (من الصعب فصل المواقف الخاطئة عن المواقف الصحيحة في كود غير مألوف) ، والتي تضم حتى 482 تحذيرًا. V6021 مع 179 تحذيرات لا يعتبر كذلك.

في النهاية ، ومع ذلك ، بقي عدد كافٍ من التحذيرات. وبما أنني لم أقوم بتكوين المحلل ، من بينها مرة أخرى هناك إيجابيات كاذبة. لا معنى لوصف عدد كبير من التحذيرات في مقال :). النظر في ما لفت انتباهي وبدا للاهتمام.

شروط محددة سلفا


قاعدة التشخيص V6007 هي حامل السجل بين جميع تحذيرات محلل المتبقية. ما يزيد قليلا عن 200 تحذير! البعض ، مثل ، غير ضار ، والبعض الآخر مشبوه ، والبعض الآخر أخطاء حقيقية تماما! دعونا نلقي نظرة على بعض منهم.

تعبير V6007 'key.startsWith ("hplsql.")' صحيح دائمًا. Exec.java (675)

void initOptions() { .... if (key == null || value == null || !key.startsWith("hplsql.")) { // <= continue; } else if (key.compareToIgnoreCase(Conf.CONN_DEFAULT) == 0) { .... } else if (key.startsWith("hplsql.conn.init.")) { .... } else if (key.startsWith(Conf.CONN_CONVERT)) { .... } else if (key.startsWith("hplsql.conn.")) { .... } else if (key.startsWith("hplsql.")) { // <= .... } } 

طويلة إلى حد ما إذا بناء آخر! يقسم المحلل في النهاية إذا (key.startsWith ("hplsql.")) ، مبينا حقيقته إذا وصل البرنامج إلى جزء الكود هذا. في الواقع ، إذا نظرت إلى بداية بنية if-else-if ، فإن الشيك قد اكتمل بالفعل. وفي حال لم يبدأ خطنا بالسلسلة الفرعية "hplsql". ، ثم قفز تنفيذ الكود على الفور إلى التكرار التالي.

تعبير V6007 'columnNameProperty.length () == 0' غير صحيح دائمًا. OrcRecordUpdater.java (238)

 private static TypeDescription getTypeDescriptionFromTableProperties(....) { .... if (tableProperties != null) { final String columnNameProperty = ....; final String columnTypeProperty = ....; if ( !Strings.isNullOrEmpty(columnNameProperty) && !Strings.isNullOrEmpty(columnTypeProperty)) { List<String> columnNames = columnNameProperty.length() == 0 ? new ArrayList<String>() : ....; List<TypeInfo> columnTypes = columnTypeProperty.length() == 0 ? new ArrayList<TypeInfo>() : ....; .... } } } .... } 

مقارنة أطوال سلسلة العمود اسم المنتج مع الصفر سوف تعود دائما خطأ . هذا لأن المقارنة قيد الاختبار ! Strings.isNullOrEmpty (columnNameProperty) . إذا وصلت حالة البرنامج إلى حالتنا المعنية ، فسيتم ضمان أن يكون سطر اسم العمود غير صفري وليس فارغًا.

هذا صحيح أيضًا بالنسبة إلى صف columnTypeProperty . خط التحذير أدناه:

  • تعبير V6007 'columnTypeProperty.length () == 0' غير صحيح دائمًا. OrcRecordUpdater.java (239)

تعبير V6007 'colOrScalar1.equals ("العمود")' غير صحيح دائمًا. GenVectorCode.java (3469)

 private void generateDateTimeArithmeticIntervalYearMonth(String[] tdesc) throws Exception { .... String colOrScalar1 = tdesc[4]; .... String colOrScalar2 = tdesc[6]; .... if (colOrScalar1.equals("Col") && colOrScalar1.equals("Column")) // <= { .... } else if (colOrScalar1.equals("Col") && colOrScalar1.equals("Scalar")) { .... } else if (colOrScalar1.equals("Scalar") && colOrScalar1.equals("Column")) { .... } 

}

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

تحذيرات أكثر مماثلة في الأسطر أدناه:

  • تعبير V6007 'colOrScalar1.equals ("Scalar")' غير صحيح دائمًا. GenVectorCode.java (3475)
  • تعبير V6007 'colOrScalar1.equals ("العمود")' غير صحيح دائمًا. GenVectorCode.java (3486)

نتيجة لذلك ، لن يتم تنفيذ أي إجراءات في if-else-if.

بعض التحذيرات الأخرى لـ V6007 :

  • تعبيرات V6007 'character == null' خاطئة دائمًا. RandomTypeUtil.java (43)
  • تعبير V6007 'writeIdHwm> 0' غير صحيح دائمًا. TxnHandler.java (1603)
  • تعبير V6007 'الحقول. المساواة ("*") "صحيح دائمًا. Server.java (983)
  • تعبير V6007 'currentGroups! = Null' صحيح دائمًا. GenericUDFCurrentGroups.java (90)
  • تعبير V6007 'this.wh == null' غير صحيح دائمًا. إرجاع جديد مرجع غير فارغ. StorageBasedAuthorizationProvider.java (93) ، StorageBasedAuthorizationProvider.java (92)
  • وهلم جرا ...

NPE


V6008 dereference خالية من "dagLock". QueryTracker.java (557) ، QueryTracker.java (553)

 private void handleFragmentCompleteExternalQuery(QueryInfo queryInfo) { if (queryInfo.isExternalQuery()) { ReadWriteLock dagLock = getDagLock(queryInfo.getQueryIdentifier()); if (dagLock == null) { LOG.warn("Ignoring fragment completion for unknown query: {}", queryInfo.getQueryIdentifier()); } boolean locked = dagLock.writeLock().tryLock(); ..... } } 

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

على الأرجح ، في حالة وجود مرجع خالي ، يجب عليك الخروج على الفور من الوظيفة أو رمي بعض الاستثناءات الخاصة.

V6008 dereference من "العازلة" في وظيفة "unlockSingleBuffer". MetadataCache.java (410) ، MetadataCache.java (465)

 private boolean lockBuffer(LlapBufferOrBuffers buffers, ....) { LlapAllocatorBuffer buffer = buffers.getSingleLlapBuffer(); if (buffer != null) { // <= return lockOneBuffer(buffer, doNotifyPolicy); } LlapAllocatorBuffer[] bufferArray = buffers.getMultipleLlapBuffers(); for (int i = 0; i < bufferArray.length; ++i) { if (lockOneBuffer(bufferArray[i], doNotifyPolicy)) continue; for (int j = 0; j < i; ++j) { unlockSingleBuffer(buffer, true); // <= } .... } .... } .... private void unlockSingleBuffer(LlapAllocatorBuffer buffer, ....) { boolean isLastDecref = (buffer.decRef() == 0); // <= if (isLastDecref) { .... } } 

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

لم تتبع التحول


V6034 التحول من قيمة 'bitShiftsInWord - 1' قد لا يتعارض مع حجم النوع: 'bitShiftsInWord - 1' = [-1 ... 30]. UnsignedInt128.java (1791)

 private void shiftRightDestructive(int wordShifts, int bitShiftsInWord, boolean roundUp) { if (wordShifts == 0 && bitShiftsInWord == 0) { return; } assert (wordShifts >= 0); assert (bitShiftsInWord >= 0); assert (bitShiftsInWord < 32); if (wordShifts >= 4) { zeroClear(); return; } final int shiftRestore = 32 - bitShiftsInWord; // check this because "123 << 32" will be 123. final boolean noRestore = bitShiftsInWord == 0; final int roundCarryNoRestoreMask = 1 << 31; final int roundCarryMask = (1 << (bitShiftsInWord - 1)); // <= .... } 

ممكن تعويض بواسطة -1. على سبيل المثال ، إذا وصل كل من wordShifts == 3 و bitShiftsInWord == 0 إلى إدخال الطريقة المعنية ، فسيحدث 1 << -1 في السطر المحدد. هل هذا مخطط له؟

V6034 التحول من قيمة 'j' يمكن أن يكون غير متوافق مع حجم النوع: 'j' = [0 ... 63]. IoTrace.java (272)

 public void logSargResult(int stripeIx, boolean[] rgsToRead) { .... for (int i = 0, valOffset = 0; i < elements; ++i, valOffset += 64) { long val = 0; for (int j = 0; j < 64; ++j) { int ix = valOffset + j; if (rgsToRead.length == ix) break; if (!rgsToRead[ix]) continue; val = val | (1 << j); // <= } .... } .... } 

في السطر المحدد ، يمكن للمتغير j أن يأخذ قيمة في النطاق [0 ... 63]. لهذا السبب ، قد لا يحدث حساب قيمة val في الحلقة كما يقصد المطور. في التعبير (1 << ي) ، تكون الوحدة من النوع int ، ونحولها من 32 أو أكثر ، نذهب إلى ما وراء حدود المسموح بها. لتصحيح الموقف ، يجب عليك كتابة ((طويل) 1 << ي) .

متحمس حول قطع الأشجار


V6046 تنسيق غير صحيح. من المتوقع وجود عدد مختلف من عناصر التنسيق. الوسيطات غير المستخدمة: 1 ، 2. StatsSources.java (89)

 private static ImmutableList<PersistedRuntimeStats> extractStatsFromPlanMapper (....) { .... if (stat.size() > 1 || sig.size() > 1) { StringBuffer sb = new StringBuffer(); sb.append(String.format( "expected(stat-sig) 1-1, got {}-{} ;", // <= stat.size(), sig.size() )); .... } .... if (e.getAll(OperatorStats.IncorrectRuntimeStatsMarker.class).size() > 0) { LOG.debug( "Ignoring {}, marked with OperatorStats.IncorrectRuntimeStatsMarker", sig.get(0) ); continue; } .... } 

عند تنسيق سلسلة من خلال String.format () ، يربك المطور بناء الجملة. خلاصة القول: المعلمات مرت لم ندخل في السلسلة الناتجة. أستطيع أن أفترض أنه في المهمة السابقة ، عمل المطور على التسجيل ، من حيث استعار بناء الجملة.

سرق الاستثناء


V6051 يمكن أن يؤدي استخدام عبارة "المرتجعات" في المربع "أخيرًا" إلى فقد الاستثناءات غير المعالجة. ObjectStore.java (9080)

 private List<MPartitionColumnStatistics> getMPartitionColumnStatistics(....) throws NoSuchObjectException, MetaException { boolean committed = false; try { .... /*some actions*/ committed = commitTransaction(); return result; } catch (Exception ex) { LOG.error("Error retrieving statistics via jdo", ex); if (ex instanceof MetaException) { throw (MetaException) ex; } throw new MetaException(ex.getMessage()); } finally { if (!committed) { rollbackTransaction(); return Lists.newArrayList(); } } } 

إن إرجاع شيء ما من المربع الأخير هو ممارسة سيئة للغاية ، وبهذا المثال سنرى ذلك.

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

تحذير مماثل:

  • V6051 يمكن أن يؤدي استخدام عبارة "المرتجعات" في المربع "أخيرًا" إلى فقد الاستثناءات غير المعالجة. ObjectStore.java (808)

... أخرى


دالة V6009 ' comparTo ' تستقبل وسيطة غريبة. يتم استخدام كائن "o2.getWorkerIdentity ()" كوسيطة لأسلوبه الخاص. LlapFixedRegistryImpl.java (244)

 @Override public List<LlapServiceInstance> getAllInstancesOrdered(....) { .... Collections.sort(list, new Comparator<LlapServiceInstance>() { @Override public int compare(LlapServiceInstance o1, LlapServiceInstance o2) { return o2.getWorkerIdentity().compareTo(o2.getWorkerIdentity()); // <= } }); .... } 

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

V6020 قسّم على صفر. يتضمن نطاق قيم المقام المقسوم عليه صفر. SqlMathUtil.java (265)

 public static long divideUnsignedLong(long dividend, long divisor) { if (divisor < 0L) { /*some comments*/ return (compareUnsignedLong(dividend, divisor)) < 0 ? 0L : 1L; } if (dividend >= 0) { // Both inputs non-negative return dividend / divisor; // <= } else { .... } } 

كل شيء بسيط جدا هنا. لم يحذر عدد من الشيكات من القسمة على 0.

المزيد من التحذيرات:

  • V6020 وزارة الدفاع من الصفر. يتضمن نطاق قيم المقام المقسوم عليه صفر. SqlMathUtil.java (309)
  • V6020 قسّم على صفر. يتضمن نطاق قيم المقام المقسوم عليه صفر. SqlMathUtil.java (276)
  • V6020 قسّم على صفر. يتضمن نطاق قيم المقام المقسوم عليه صفر. SqlMathUtil.java (312)

V6030 الطريقة الموجودة على يمين "|" سيتم استدعاء المشغل بغض النظر عن قيمة المعامل الأيسر. ربما ، من الأفضل استخدام '||'. OperatorUtils.java (573)

 public static Operator<? extends OperatorDesc> findSourceRS(....) { .... List<Operator<? extends OperatorDesc>> parents = ....; if (parents == null | parents.isEmpty()) { // reached end eg TS operator return null; } .... } 

بدلا من المشغل المنطقي || كتب عامل التشغيل bitwise | هذا يعني أنه سيتم تنفيذ الجانب الأيمن بغض النظر عن نتيجة الجانب الأيسر. مثل هذا الخطأ المطبعي ، في حالة الوالدين == فارغة ، سيؤدي على الفور إلى NPE في التعبير المنطقي التالي.

V6042 يتم التحقق من التعبير للتأكد من توافقه مع النوع "أ" ولكن يتم تحديده لكتابة "B". VectorColumnAssignFactory.java (347)

 public static VectorColumnAssign buildObjectAssign(VectorizedRowBatch outputBatch, int outColIndex, PrimitiveCategory category) throws HiveException { VectorColumnAssign outVCA = null; ColumnVector destCol = outputBatch.cols[outColIndex]; if (destCol == null) { .... } else if (destCol instanceof LongColumnVector) { switch(category) { .... case LONG: outVCA = new VectorLongColumnAssign() { .... } .init(.... , (LongColumnVector) destCol); break; case TIMESTAMP: outVCA = new VectorTimestampColumnAssign() { .... }.init(...., (TimestampColumnVector) destCol); // <= break; case DATE: outVCA = new VectorLongColumnAssign() { .... } .init(...., (LongColumnVector) destCol); break; case INTERVAL_YEAR_MONTH: outVCA = new VectorLongColumnAssign() { .... }.init(...., (LongColumnVector) destCol); break; case INTERVAL_DAY_TIME: outVCA = new VectorIntervalDayTimeColumnAssign() { .... }.init(...., (IntervalDayTimeColumnVector) destCol);// <= break; default: throw new HiveException(....); } } else if (destCol instanceof DoubleColumnVector) { .... } .... else { throw new HiveException(....); } return outVCA; } 

الفئات في السؤال هي LongColumnVector يمتد ColumnVector و TimestampColumnVector يمتد ColumnVector . التحقق من كائن destCol لملكية LongColumnVector يخبرنا بوضوح أن كائن هذه الفئة سيكون داخل البيان الشرطي. على الرغم من هذا ، نحن نلقي الضوء على TimestampColumnVector ! كما ترون ، هذه الفئات مختلفة ، ولا تحسب الأصل المشترك. نتيجة لذلك - ClassCastException .

يمكن قول كل نفس حول تحويل النوع إلى IntervalDayTimeColumnVector :

  • V6042 يتم التحقق من التعبير للتأكد من توافقه مع النوع "أ" ولكن يتم تحديده لكتابة "B". VectorColumnAssignFactory.java (390)

V6060 تم استخدام المرجع "var" قبل أن يتم التحقق منه ضد قيمة خالية. Var.java (402) ، Var.java (395)

 @Override public boolean equals(Object obj) { if (getClass() != obj.getClass()) { // <= return false; } Var var = (Var)obj; if (this == var) { return true; } else if (var == null || // <= var.value == null || this.value == null) { return false; } .... } 

مقارنة غريبة لكائن var مع فارغة بعد حدوث dereferencing. في هذا السياق ، var و obj هما نفس الكائن ( var = (Var) obj ). يعني التحقق من وجود قيمة خالية أن كائنًا فارغًا قد يأتي. وفي حالة تساوي (خالية) ، نحصل مباشرة على السطر الأول NPE بدلاً من false المتوقع. للأسف ، هناك شيك ، ولكن ليس هناك.

لحظات مشبوهة مماثلة تستخدم الكائن قبل حدوث الفحص:

  • V6060 تم استخدام مرجع "القيمة" قبل أن يتم التحقق منه ضد قيمة خالية. ParquetRecordReaderWrapper.java (168) ، ParquetRecordReaderWrapper.java (166)
  • V6060 تم استخدام مرجع "defaultConstraintCols" قبل أن يتم التحقق منه ضد القيمة الخالية. HiveMetaStore.java (2539) ، HiveMetaStore.java (2530)
  • V6060 تم استخدام مرجع "projIndxLst" قبل التحقق من أنه لاغٍ. RelOptHiveTable.java (683) ، RelOptHiveTable.java (682)
  • V6060 تم استخدام مرجع "oldp" قبل أن يتم التحقق منه ضد قيمة خالية. ObjectStore.java (4343) ، ObjectStore.java (4339)
  • وهلم جرا ...

استنتاج


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

المذكرة. توضح عمليات الفحص لمرة واحدة قدرات محلل الكود الثابت ، ولكنها طريقة خاطئة تمامًا لاستخدامه. يتم تقديم هذه الفكرة بمزيد من التفصيل هنا وهنا . استخدم التحليل بانتظام!

عند التحقق من الخلية ، تم اكتشاف عدد كاف من العيوب واللحظات المشبوهة. إذا لفتت هذه المقالة انتباه فريق تطوير Apache Hive ، فسيسعدنا المساهمة في هذه المهمة الصعبة.

من المستحيل أن تتخيل Apache Hive بدون Apache Hadoop ، لذلك من المحتمل أن يظهر يونيكورن من PVS-Studio هناك أيضًا. ولكن هذا كل شيء لهذا اليوم ، ولكن الآن قم بتنزيل المحلل وتحقق من المشاريع الخاصة بك.



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

Source: https://habr.com/ru/post/ar463759/


All Articles