ToB企服应用市场:ToB评测及商务社交产业平台

标题: 果蔬识别系统性能优化之路(五) [打印本页]

作者: 卖不甜枣    时间: 2024-9-15 10:11
标题: 果蔬识别系统性能优化之路(五)
前情提要

果蔬识别系统性能优化之路(四)
剩下标题

办理方案

新建storeFeature表

  1. import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
  2. import { StoreFeature } from '../../feature/entities/store-feature.entity';
  3. @Entity()
  4. export class Store {
  5.   @PrimaryGeneratedColumn()
  6.   id: number;
  7.   @Column({ unique: true })
  8.   storeCode: string;
  9.   @Column({ nullable: true })
  10.   storeName: string;
  11.   @OneToMany(() => StoreFeature, (storeFeature) => storeFeature.store)
  12.   storeFeatures: StoreFeature[];
  13. }
复制代码
  1. import { Entity, ManyToOne, JoinColumn, PrimaryGeneratedColumn } from 'typeorm';
  2. import { Store } from '../../store/entities/store.entity';
  3. import { Feature } from './feature.entity';
  4. @Entity()
  5. export class StoreFeature {
  6.   @PrimaryGeneratedColumn()
  7.   id: number;
  8.   @ManyToOne(() => Store, { onDelete: 'CASCADE' })
  9.   @JoinColumn({ name: 'storeCode', referencedColumnName: 'storeCode' })
  10.   store: Store;
  11.   @ManyToOne(() => Feature, { onDelete: 'CASCADE' })
  12.   @JoinColumn({ name: 'featureId', referencedColumnName: 'id' })
  13.   feature: Feature;
  14. }
复制代码
storeFeature表关联store表和feature表
  1. import { Injectable } from '@nestjs/common';
  2. import { CreateFeatureDto } from './dto/create-feature.dto';
  3. import { Feature } from './entities/feature.entity';
  4. import { InjectRepository } from '@nestjs/typeorm';
  5. import { Repository, In } from 'typeorm';
  6. import { RedisService } from '../redis/redis.service';
  7. import { HttpService } from '@nestjs/axios';
  8. import { firstValueFrom } from 'rxjs';
  9. import * as FormData from 'form-data';
  10. import { Img } from '../img/entities/img.entity';
  11. import { Store } from '../store/entities/store.entity';
  12. import { StoreFeature } from './entities/store-feature.entity';
  13. @Injectable()
  14. export class FeatureService {
  15.   constructor(
  16.     @InjectRepository(Feature)
  17.     private readonly featureRepository: Repository<Feature>,
  18.     @InjectRepository(Img)
  19.     private readonly imgRepository: Repository<Img>,
  20.     @InjectRepository(Store)
  21.     private readonly storeRepository: Repository<Store>,
  22.     @InjectRepository(StoreFeature)
  23.     private readonly storeFeatureRepository: Repository<StoreFeature>,
  24.     private readonly httpService: HttpService,
  25.     private readonly redisService: RedisService,
  26.   ) {
  27.   }
  28.   /**
  29.    * 创建
  30.    * @param file
  31.    * @param createFeatureDto
  32.    * @param needSync //是否需要同步redis,默认为true
  33.    */
  34.   async create(file: Express.Multer.File, createFeatureDto: CreateFeatureDto, needSync: boolean = true): Promise<Feature> {
  35.     const img = this.imgRepository.create({
  36.       img: file.buffer,
  37.     });
  38.     await this.imgRepository.save(img);
  39.     const [feature, store] = await Promise.all([
  40.       new Promise(async (resolve) => {
  41.         const feature: Feature = this.featureRepository.create({
  42.           ...createFeatureDto,
  43.           imgId: img.id,
  44.         });
  45.         await this.featureRepository.save(feature);
  46.         resolve(feature);
  47.       }),
  48.       new Promise(async (resolve) => {
  49.         let store = await this.storeRepository.findOne({ where: { storeCode: createFeatureDto.storeCode } });
  50.         if (!store) {
  51.           store = this.storeRepository.create({
  52.             storeCode: createFeatureDto.storeCode,
  53.             storeName: createFeatureDto.storeName,
  54.           });
  55.           await this.storeRepository.save(store);
  56.         }
  57.         resolve(store);
  58.       }),
  59.     ]);
  60.     const storeFeature = this.storeFeatureRepository.create({
  61.       feature,
  62.       store,
  63.     });
  64.     await this.storeFeatureRepository.save(storeFeature);
  65.     needSync && await this.syncRedis(createFeatureDto.storeCode);
  66.     return feature as Feature;
  67.   }
  68.   /**
  69.    * 同步redis
  70.    * @param storeCode
  71.    */
  72.   async syncRedis(storeCode: string) {
  73.     const url = 'http://localhost:5000/sync'; // Python 服务的 URL
  74.     const s = Date.now();
  75.     const response = await firstValueFrom(this.httpService.post(url, { storeCode }));
  76.     const { ids } = response.data;
  77.     await this.redisService.set(`${storeCode}-featureDatabase`, JSON.stringify(ids));
  78.     const e = Date.now();
  79.     console.log(`门店:${storeCode},同步redis耗时:${e - s}ms`);
  80.   }
  81.   /**
  82.    * 查询所有
  83.    * @param storeCode
  84.    * @param selectP
  85.    */
  86.   async findAll(storeCode: string, selectP?: string[]) {
  87.     return await this.featureRepository
  88.       .createQueryBuilder('feature')
  89.       .select(selectP)
  90.       .innerJoin(StoreFeature, 'storeFeature', 'feature.id = storeFeature.featureId')
  91.       .innerJoin(Store, 'store', 'storeFeature.storeCode = store.storeCode')
  92.       .where('store.storeCode = :storeCode', { storeCode })
  93.       .getMany();
  94.   }
  95.   /**
  96.    * 查询特性及其关联的图像
  97.    * @param storeCode
  98.    */
  99.   async findAllWithImage(storeCode: string): Promise<Feature[]> {
  100.     return await this.featureRepository.createQueryBuilder('feature')
  101.       .leftJoinAndSelect('feature.img', 'img')
  102.       .innerJoin(StoreFeature, 'storeFeature', 'feature.id = storeFeature.featureId')
  103.       .innerJoin(Store, 'store', 'storeFeature.storeCode = store.storeCode')
  104.       .where('store.storeCode = :storeCode', { storeCode })
  105.       .getMany();
  106.   }
  107.   /**
  108.    * 删除门店所有数据
  109.    * @param storeCode
  110.    */
  111.   async removeAll(storeCode: string): Promise<void> {
  112.     const store = await this.storeRepository.findOne({ where: { storeCode }, relations: ['storeFeatures'] });
  113.     if (!store) {
  114.       return;
  115.     }
  116.     // 批量删除 storeFeatures 和 store
  117.     if (store.storeFeatures.length > 0) {
  118.       await this.storeFeatureRepository
  119.         .query('DELETE FROM store_feature WHERE id IN (?)', [store.storeFeatures.map(sf => sf.id)]);
  120.     }
  121.     await this.storeRepository.remove(store);  // 删除 store
  122.     const unreferencedFeatures = await this.featureRepository
  123.       .createQueryBuilder('feature')
  124.       .leftJoinAndSelect('feature.img', 'img')
  125.       .leftJoin('feature.storeFeatures', 'storeFeature')
  126.       .where('storeFeature.id IS NULL') // 这里的条件确保我们只选择那些没有其他引用的 feature
  127.       .getMany();
  128.     // 批量删除未引用的 features
  129.     if (unreferencedFeatures.length > 0) {
  130.       for (const feature of unreferencedFeatures) {
  131.         await this.remove(feature);
  132.       }
  133.     }
  134.     await this.redisService.del(`${storeCode}-featureDatabase`);
  135.     await this.syncRedis(storeCode);
  136.   }
  137.   /**
  138.    * 预测
  139.    * @param file
  140.    * @param num
  141.    * @param storeCode
  142.    * @param justPredict
  143.    * @param needList
  144.    */
  145.   async predict(
  146.     file: Express.Multer.File,
  147.     num: string = '5',
  148.     storeCode: string,
  149.     justPredict: string = 'false',
  150.     needList: boolean = false,
  151.   ) {
  152.     const PYTHON_SERVICE_URL = 'http://localhost:5000/predict'; // Python service URL
  153.     const REDIS_KEY_PREFIX = '-featureDatabase';
  154.     const startTime = Date.now();
  155.     const numInt = parseInt(num);
  156.     const isJustPredict = justPredict === 'true';
  157.     try {
  158.       // Prepare form data
  159.       const formData = new FormData();
  160.       formData.append('file', file.buffer, file.originalname);
  161.       formData.append('storeCode', storeCode);
  162.       formData.append('justPredict', justPredict);
  163.       // Send request to Python service
  164.       const response = await firstValueFrom(this.httpService.post(PYTHON_SERVICE_URL, formData));
  165.       const { features, index, predictTime } = response.data;
  166.       if (isJustPredict) {
  167.         return this.buildResponse([], features, predictTime, startTime, numInt);
  168.       }
  169.       // Retrieve feature database from Redis
  170.       const featureDatabaseStr = await this.redisService.get(`${storeCode}${REDIS_KEY_PREFIX}`);
  171.       if (!featureDatabaseStr) {
  172.         return this.buildResponse([], features, predictTime, startTime, numInt);
  173.       }
  174.       // Parse the Redis result and filter the IDs
  175.       const featureDatabase = JSON.parse(featureDatabaseStr);
  176.       const ids = index
  177.         .map((idx: number) => featureDatabase[idx]);
  178.       if (!ids.length) {
  179.         return this.buildResponse([], features, predictTime, startTime, numInt);
  180.       }
  181.       // Query for features in the database
  182.       const featureList = await this.featureRepository.createQueryBuilder('feature')
  183.         .where('feature.id IN (:...ids)', { ids })
  184.         .orderBy(`FIELD(feature.id, ${ids.map((id: any) => `'${id}'`).join(', ')})`, 'ASC')
  185.         .getMany();
  186.       // Filter to ensure unique labels
  187.       const uniqueList = this.filterUniqueFeatures(featureList, numInt);
  188.       const result = this.buildResponse(uniqueList, features, predictTime, startTime, numInt);
  189.       return needList ? { ...result, featureList: featureList.map(({ features, ...rest }) => rest) } : result;
  190.     } catch (error) {
  191.       throw new Error(`Prediction failed: ${error.message}`);
  192.     }
  193.   }
  194.   private filterUniqueFeatures(featureList: any[], limit: number) {
  195.     const uniqueList = [];
  196.     for (const feature of featureList) {
  197.       if (!uniqueList.some(f => f.label === feature.label)) {
  198.         uniqueList.push(feature);
  199.       }
  200.       if (uniqueList.length === limit) break;
  201.     }
  202.     return uniqueList;
  203.   }
  204.   private buildResponse(list: any[], features: any, predictTime: string, startTime: number, num: number) {
  205.     const totalTime = `${Date.now() - startTime}ms`;
  206.     return {
  207.       predictTime,
  208.       [`top${num}`]: list.map(({ features, ...rest }) => rest),
  209.       features,
  210.       totalTime,
  211.     };
  212.   }
  213.   /**
  214.    * 计算余弦相似度
  215.    * @param vecA
  216.    * @param vecB
  217.    */
  218.   cosineSimilarity(vecA: number[], vecB: number[]): number {
  219.     if (vecA.length !== vecB.length) {
  220.       throw new Error('Vectors must be of the same length');
  221.     }
  222.     const dotProduct = vecA.reduce((sum, value, index) => sum + value * vecB[index], 0);
  223.     const magnitudeA = Math.sqrt(vecA.reduce((sum, value) => sum + value * value, 0));
  224.     const magnitudeB = Math.sqrt(vecB.reduce((sum, value) => sum + value * value, 0));
  225.     return dotProduct / (magnitudeA * magnitudeB);
  226.   }
  227.   /**
  228.    * 查找相似
  229.    * @param inputFeatures
  230.    * @param num
  231.    * @param storeCode
  232.    */
  233.   async findTopNSimilar(inputFeatures: number[], num: number, storeCode: string): Promise<{
  234.     label: string;
  235.     similarity: number
  236.   }[]> {
  237.     const featureDatabaseStr = await this.redisService.get(`${storeCode}-featureDatabase`);
  238.     if (!featureDatabaseStr) {
  239.       return [];
  240.     }
  241.     const featureDatabase = JSON.parse(featureDatabaseStr);
  242.     const similarities = featureDatabase.map(({ features, label }) => {
  243.       let similarity = 0;
  244.       if (features) {
  245.         similarity = this.cosineSimilarity(inputFeatures, features);
  246.       }
  247.       return { label: label as string, similarity: similarity as number };
  248.     });
  249.     similarities.sort((a: { similarity: number; }, b: { similarity: number; }) => b.similarity - a.similarity);
  250.     const uniqueLabels = new Set<string>();
  251.     const topNUnique: { label: string; similarity: number; }[] = [];
  252.     for (const item of similarities) {
  253.       if (!uniqueLabels.has(item.label as string)) {
  254.         uniqueLabels.add(item.label);
  255.         item.similarity = Math.round(item.similarity * 100) / 100;
  256.         topNUnique.push(item);
  257.         if (topNUnique.length === num) break;
  258.       }
  259.     }
  260.     return topNUnique;
  261.   }
  262.   /**
  263.    * 根据名称查询
  264.    * @param label
  265.    * @param storeCode
  266.    */
  267.   async getByName(label: string, storeCode: string): Promise<Feature[]> {
  268.     return await this.featureRepository
  269.       .createQueryBuilder('feature')
  270.       .leftJoinAndSelect('feature.img', 'img')
  271.       .innerJoin(StoreFeature, 'storeFeature', 'feature.id = storeFeature.featureId')
  272.       .innerJoin(Store, 'store', 'storeFeature.storeCode = store.storeCode')
  273.       .where('store.storeCode = :storeCode', { storeCode })
  274.       .andWhere('feature.label = :label', { label })
  275.       .getMany();
  276.   }
  277.   /**
  278.    * 根据名称向量个数查询
  279.    * @param label
  280.    * @param storeCode
  281.    */
  282.   async getCountByLabel(label: string, storeCode: string): Promise<number> {
  283.     return await this.featureRepository
  284.       .createQueryBuilder('feature')
  285.       .leftJoinAndSelect('feature.img', 'img')
  286.       .innerJoin(StoreFeature, 'storeFeature', 'feature.id = storeFeature.featureId')
  287.       .innerJoin(Store, 'store', 'storeFeature.storeCode = store.storeCode')
  288.       .where('store.storeCode = :storeCode', { storeCode })
  289.       .andWhere('feature.label = :label', { label })
  290.       .getCount();
  291.   }
  292.   /**
  293.    * 批量学习
  294.    * @param files
  295.    * @param createFeatureDto
  296.    */
  297.   async batchStudy(files: Express.Multer.File[], createFeatureDto: CreateFeatureDto) {
  298.     const list = [];
  299.     for (const file of files) {
  300.       try {
  301.         const { features: f } = await this.predict(file, '5', createFeatureDto.storeCode, 'true');
  302.         const feature = await this.create(file, {
  303.           ...createFeatureDto,
  304.           features: f,
  305.         }, false);
  306.         // 创建一个副本,不包含 `features` 属性
  307.         const { features, ...featureWithoutFeatures } = feature;
  308.         // 将不包含 `features` 属性的对象推送到数组中
  309.         list.push(featureWithoutFeatures);
  310.       } catch (e) {
  311.         console.error(e);
  312.       }
  313.     }
  314.     await this.syncRedis(createFeatureDto.storeCode);
  315.     return list;
  316.   }
  317.   /**
  318.    * 删除门店的特征值数据
  319.    * @param feature
  320.    */
  321.   async remove(feature: Feature) {
  322.     await this.featureRepository.remove(feature);
  323.     await this.imgRepository.remove(feature.img);
  324.   }
  325.   /**
  326.    * 批量删除
  327.    * @param ids
  328.    * @param storeCode
  329.    */
  330.   async batchRemove(ids: string, storeCode: string) {
  331.     const list = ids.split(',').map(id => +id);
  332.     // 批量查询所有相关的 Feature
  333.     const features = await this.featureRepository.find({
  334.       where: { id: In(list) },
  335.       relations: ['img', 'storeFeatures'],
  336.     });
  337.     for (const feature of features) {
  338.       feature && await this.remove(feature);
  339.       await this.storeFeatureRepository.remove(feature.storeFeatures);
  340.     }
  341.     await this.syncRedis(storeCode);
  342.   }
  343.   /**
  344.    * 导入数据
  345.    * @param storeCode
  346.    * @param sourceStoreCode
  347.    * @param storeName
  348.    */
  349.   async importData(storeCode: string, sourceStoreCode?: string, storeName?: string) {
  350.     let storeFeatures = [];
  351.     // 第一步:查询指定 storeCode 关联的所有 featureId
  352.     const storeFeatureIds = await this.storeFeatureRepository
  353.       .createQueryBuilder('storeFeature')
  354.       .select('storeFeature.featureId')
  355.       .where('storeFeature.storeCode = :storeCode', { storeCode })
  356.       .getRawMany();
  357.     // 提取出 featureId 列表
  358.     const featureIdsToExclude = storeFeatureIds.map(row => row.featureId);
  359.     let distinctFeatureIds = [];
  360.     if (featureIdsToExclude.length === 0) {
  361.       distinctFeatureIds = await this.storeFeatureRepository
  362.         .createQueryBuilder('storeFeature')
  363.         .select('DISTINCT storeFeature.featureId')  // 确保 featureId 唯一
  364.         .getRawMany();
  365.     } else {
  366.       // 第二步:排除这些 featureId,并确保 featureId 唯一
  367.       distinctFeatureIds = await this.storeFeatureRepository
  368.         .createQueryBuilder('storeFeature')
  369.         .select('DISTINCT storeFeature.featureId')  // 确保 featureId 唯一
  370.         .where('storeFeature.featureId NOT IN (:...featureIdsToExclude)', { featureIdsToExclude })  // 排除 featureId
  371.         .getRawMany();
  372.     }
  373.     const featureIds = distinctFeatureIds.map(record => record.featureId);
  374.     if (!sourceStoreCode) {
  375.       storeFeatures = await this.featureRepository
  376.         .createQueryBuilder('feature')
  377.         .leftJoinAndSelect('feature.img', 'img')
  378.         .whereInIds(featureIds)
  379.         .getMany();
  380.     } else {
  381.       storeFeatures = await this.featureRepository
  382.         .createQueryBuilder('feature')
  383.         .leftJoinAndSelect('feature.img', 'img')
  384.         .innerJoin('feature.storeFeatures', 'storeFeatures')
  385.         .whereInIds(featureIds)
  386.         .andWhere('storeFeatures.storeCode = :storeCode', { storeCode: sourceStoreCode })  // 使用参数化查询
  387.         .getMany();
  388.     }
  389.     let targetStore = await this.storeRepository.findOne({ where: { storeCode: storeCode } });
  390.     if (!targetStore) {
  391.       targetStore = this.storeRepository.create({
  392.         storeCode: storeCode,
  393.         storeName: storeName,
  394.       });
  395.       await this.storeRepository.save(targetStore);
  396.     }
  397.     // Create new StoreFeature records for the target storeCode
  398.     const newStoreFeatures = storeFeatures.map((feature: Feature) => ({
  399.       store: targetStore,
  400.       feature, // Reuse the existing feature
  401.     }));
  402.     // Save new StoreFeature records
  403.     const storeFeatureInstances = this.storeFeatureRepository.create(newStoreFeatures);
  404.     await this.storeFeatureRepository.save(storeFeatureInstances);
  405.     await this.syncRedis(storeCode);
  406.     return `同步完成,共导入${storeFeatures.length}条数据`;
  407.   }
  408.   async init() {
  409.     const distinctStoreCodes = await this.storeRepository
  410.       .createQueryBuilder('store')
  411.       .select('store.storeCode')
  412.       .distinct(true)
  413.       .getRawMany();
  414.     const syncList = [];
  415.     for (const row of distinctStoreCodes) {
  416.       const storeCode = row.store_storeCode;
  417.       syncList.push(this.syncRedis(storeCode));
  418.     }
  419.     await Promise.all(syncList);
  420.     console.log('初始化完成');
  421.   }
  422. }
复制代码
实现ivf的动态增删改查

  1. import tensorflow as tf
  2. from tensorflow.keras.applications import MobileNetV2
  3. import numpy as np
  4. import time
  5. import gc
  6. from ivf import IVFPQ
  7. from feature import get_feature_by_store_code
  8. import orjson
  9. from concurrent.futures import ThreadPoolExecutor
  10. # 加载预训练的 MobileNetV2 模型,不包含顶部的分类层
  11. model = MobileNetV2(input_shape=(224, 224, 3), weights='imagenet', include_top=False, pooling='avg')
  12. class MainDetect:
  13.     # 初始化
  14.     def __init__(self):
  15.         super().__init__()
  16.         # 模型初始化
  17.         self.image_id = None
  18.         self.image_features = None
  19.         self.model = tf.keras.models.load_model("models/custom/my-model.h5")
  20.         self.ivfObj = {}
  21.     def classify_image(self, image_data, store_code, just_predict):
  22.         # Load and preprocess image
  23.         img = tf.image.decode_image(image_data, channels=3)
  24.         img = tf.image.resize(img, [224, 224])
  25.         img = tf.expand_dims(img, axis=0)  # Add batch dimension
  26.         # Run model prediction
  27.         start_time = time.time()
  28.         outputs = model.predict(img)
  29.         # outputs = self.model.predict(outputs)
  30.         # prediction = tf.divide(outputs, tf.norm(outputs))
  31.         i = []
  32.         if just_predict == "false":
  33.             if store_code + '-featureDatabase' in self.ivfObj:
  34.                 i = self.ivfObj[store_code + '-featureDatabase'].search(outputs)
  35.                 i = i.flatten().tolist()
  36.         end_time = time.time()
  37.         # Calculate elapsed time
  38.         elapsed_time = end_time - start_time
  39.         # Flatten the outputs and return them
  40.         # output_data = prediction.numpy().flatten().tolist()
  41.         output_data = outputs.flatten().tolist()
  42.         # Force garbage collection to free up memory
  43.         del img, outputs, end_time, start_time  # Ensure variables are deleted
  44.         gc.collect()
  45.         return {"outputs": output_data, "time": f"{elapsed_time * 1000:.2f}ms", "index": i}
  46.     def sync(self, store_code):
  47.         if store_code + '-featureDatabase' in self.ivfObj:
  48.             del self.ivfObj[store_code + '-featureDatabase']
  49.         data = get_feature_by_store_code(store_code)
  50.         if len(data) == 0:
  51.             return []
  52.         else:
  53.             def parse_features(item):
  54.                 return orjson.loads(item['features'])
  55.             with ThreadPoolExecutor() as executor:
  56.                 features_list = list(executor.map(parse_features, data))
  57.             # 提取所有特征并转换为 NumPy 数组
  58.             features = np.array(features_list, dtype=np.float32)
  59.             self.ivfObj[store_code + '-featureDatabase'] = IVFPQ(features)
  60.             ids = [item['id'] for item in data]
  61.             return ids
复制代码
ivf.py(ivf构造)
  1. import faiss
  2. import numpy as np
  3. num_threads = 8
  4. faiss.omp_set_num_threads(num_threads)
  5. class IVFPQ:
  6.     def __init__(self, features, nlist=100, m=16, n_bits=8):
  7.         d = features.shape[1]
  8.         # 创建量化器
  9.         quantizer = faiss.IndexFlatL2(d)  # 使用L2距离进行量化
  10.         self.index = faiss.IndexIVFFlat(quantizer, d, nlist)
  11.         # self.index = faiss.IndexIVFPQ(quantizer, d, nlist, m, n_bits)
  12.         # 训练索引
  13.         count = 3900
  14.         if features.size >= count * d:
  15.             self.index.train(features)
  16.             if features.size > 1000 * d:
  17.                 batch_size = 1000  # 每次处理1000个特征
  18.                 for i in range(0, len(features), batch_size):
  19.                     self.index.add(features[i:i + batch_size])
  20.             else:
  21.                 self.index.add(features)
  22.         else:
  23.             points = int(count - features.size / d)
  24.             np.random.seed(points)
  25.             xb = np.random.random((points, d)).astype('float32')  # 模拟数据库中的特征向量
  26.             combined_features = np.vstack((features, xb))  # Stack them vertically
  27.             # 训练索引
  28.             self.index.train(combined_features)
  29.             self.index.add(combined_features)  # 将特征向量添加到索引中
  30.     def search(self, xq, k=100):
  31.         d, i = self.index.search(xq, k)
  32.         return i
  33.     def add(self, xb):
  34.         self.index.add(xb)
  35.     def train(self, xb):
  36.         self.index.train(xb)
  37.     def sync(self, features):
  38.         for i in range(len(features)):
  39.             self.add(features[i])
复制代码
结语

这个项目优化到这差不多告一段落了,后续还有啥优化点会继续跟进,稍后会把整个架构图和功能点都梳理一遍

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。




欢迎光临 ToB企服应用市场:ToB评测及商务社交产业平台 (https://dis.qidao123.com/) Powered by Discuz! X3.4