Углубленный анализ принципа загрузчика классов Java

Java
Углубленный анализ принципа загрузчика классов Java

В этом документе анализируется реализация принципа модели родительского делегирования, а на примерах кода показано, когда вам нужно реализовать собственный загрузчик классов и как реализовать свой собственный загрузчик классов.

Эта статья основана на JDK8.

0 Роль ClassLoader

ClassLoader используется для загрузки файлов классов в JVM. Другая роль заключается в подтверждении того, каким загрузчиком классов должен быть загружен каждый класс.

Вторая функция также используется для оценки равенства двух классов во время выполнения JVM.К затронутым методам оценки относятся ключевые слова equals(), isAssignableFrom(), isInstance() и instanceof, которые будут проиллюстрированы в следующих разделах. .

0.1 Когда инициируется действие по загрузке класса?

Запускаемые триггеры можно разделить на неявную загрузку и отображаемую загрузку.

Неявная загрузка

Неявная загрузка включает следующие ситуации:

  • При встрече с 4 инструкциями байт-кода new, getstatic, putstatic и invokestatic
  • При рефлексивном вызове класса
  • При инициализации класса, если его родительский класс не был инициализирован, сначала загрузите его родительский класс и инициализируйте его.
  • Когда виртуальная машина запускается, она указывает основной класс, содержащий основную функцию загрузки и инициализации основного класса приоритета.

показать загрузку

Загрузка дисплея включает следующие ситуации:

  • Через метод loadClass ClassLoader
  • по классу.forName
  • Через метод findClass ClassLoader

0.2 Где хранятся загруженные классы

Метод будет загружен в область памяти до JDK8. От JDK8 до сих пор он будет загружен в область метаданных.

1 Каковы классы

Вся платформа JVM предоставляет три типа ClassLoader.

1.1 Bootstrap ClassLoader

Загрузите классы, необходимые для собственной работы JVM, которая реализована самой JVM. Он загрузит файлы в $JAVA_HOME/jre/lib

1.2 ExtClassLoader

Он является частью JVM и управляется sun.misc.Launcher.ExtClassLoader实现,他会加载Файл в каталоге JAVA_HOME/jre/lib/ext (или файл, указанный System.getProperty("java.ext.dirs") ).

1.3 AppClassLoader

Загрузчик классов приложения, который также является загрузчиком классов, с которым мы чаще всего сталкиваемся в нашей работе, реализован с помощью sun.misc.Launcher$AppClassLoader. Он загружает файлы в каталог, указанный System.getProperty("java.class.path"), который мы обычно называем путем к классам.

2 модель делегирования родителей

2.1 Принцип модели родительского делегирования

После JDK1.2 загрузчик классов представил модель родительского делегирования, и схема модели выглядит следующим образом:

ClassLoaderParentMod

Среди них родительским загрузчиком двух определяемых пользователем загрузчиков классов является AppClassLoader, родительским загрузчиком AppClassLoader является ExtClassLoader, а ExtClassLoader не имеет загрузчика родительского класса.В коде загрузчик родительского класса ExtClassLoader имеет значение null. BootstrapClassLoader также не имеет подклассов, поскольку полностью реализован JVM.

Принцип модели родительского делегирования заключается в следующем.: когда загрузчик класса получает запрос на загрузку класса, он сначала запрашивает загрузку своего родительского класса, и это относится к каждому слою.Когда загрузчик родительского класса не может найти класс (согласно полному имени класса ), дочерний загрузчик класса попытается загрузить его сам.

Чтобы проиллюстрировать эту связь наследования, я реализовал собственный загрузчик классов с именем TestClassLoader.В загрузчике классов родительское поле текущего загрузчика используется для представления загрузчика родительского класса текущего загрузчика, который определяется как следует:

public abstract class ClassLoader {
...
    // The parent class loader for delegation
    // Note: VM hardcoded the offset of this field, thus all new fields
    // must be added *after* it.
    private final ClassLoader parent;
...
}

Затем взгляните на эту структуру через отладку, как показано ниже.

ClassLoader委派模型关系图

Первое красное поле здесь — это загрузчик классов, который я определил самостоятельно, соответствующий нижней части рисунка выше; второе поле — это загрузчик родительского класса пользовательского загрузчика классов, вы можете видеть, что это AppClassLoader; третье поле — родительский загрузчик класса AppClassLoader, который является ExtClassLaoder; четвертое поле — это загрузчик родительского класса ExtClassLoader, который имеет значение null.

Хорошо, сначала интуитивное впечатление, а принцип реализации будет подробно представлен позже.

2.2 Проблема, которую решает эта модель

Зачем использовать модель родительского делегирования? Какую проблему он может решить?

Модель родительского делегирования была введена после JDK1.2. В соответствии с принципом родительской модели делегирования вы можете себе представить, что если нет родительской модели делегирования, если пользователь пишет полностью квалифицированный класс с именем java.lang.Object и загружает его с помощью своего собственного загрузчика классов, в то же время BootstrapClassLoader загружает пакет rt.jar java.lang.Object самого JDK в самом JDK, так что в памяти есть два класса Object, и в это время будет возникать много проблем, например, конкретный класс не может быть расположен по к полному имени.

С моделью родительского делегирования все операции загрузки класса будут предпочтительно делегированы загрузчику родительского класса.Таким образом, даже если пользователь настраивает java.lang.Object, поскольку BootstrapClassLoader обнаружил, что он загрузил этот класс, пользователь Custom загрузчики классов больше не загружаются повторно.

Поэтому модель родительского делегирования может гарантировать уникальность класса в памяти.

2.3 Принцип реализации родительской модели делегирования

Давайте посмотрим на реализацию модели родительского делегирования с точки зрения исходного кода.

Когда JVM загружает класс, он сначала вызывает метод loadClassInternal загрузчика классов, Исходный код этого метода выглядит следующим образом

// This method is invoked by the virtual machine to load a class.
private Class<?> loadClassInternal(String name)
    throws ClassNotFoundException
{
    // For backward compatibility, explicitly lock on 'this' when
    // the current class loader is not parallel capable.
    if (parallelLockMap == null) {
        synchronized (this) {
             return loadClass(name);
        }
    } else {
        return loadClass(name);
    }
}

Что делает этот метод, так это вызывает метод loadClass Реализация метода loadClass выглядит следующим образом.

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // First, check if the class has already been loaded
        // 先查看这个类是否已经被自己加载了
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                // 如果有父类加载器,先委派给父类加载器来加载
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    // 如果父类加载器为null,说明ExtClassLoader也没有找到目标类,则调用BootstrapClassLoader来查找
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }
            // 如果都没有找到,调用findClass方法,尝试自己加载这个类
            if (c == null) {
                // If still not found, then invoke findClass in order
                // to find the class.
                long t1 = System.nanoTime();
                c = findClass(name);

                // this is the defining class loader; record the stats
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

Несколько ключевых шагов описаны в исходном коде.

Вызов BootstrapClassLoad в коде фактически называется Native методом.

Видно, что ядром реализации модели родительского делегирования является метод loadClass.

3 Реализуйте свой собственный загрузчик классов

3.1 Зачем реализовывать собственный загрузчик классов

Ответ на этот вопрос начинается с размышлений о том, что делает загрузчик классов (выделено жирным шрифтом).

3.1.1 Роль загрузчика классов

Что делает загрузчик классов? Вернемся к исходному коду выше.

Из вышеперечисленного мы знаем JVM, чтобы найти класс методом LoadClass, поэтому,Его первая роль также является самой важной: найти файл класса по указанному пути (путь сканирования каждого типа загрузчика был указан выше).

Затем, когда загрузчик родительского класса скажет, что целевой класс не был загружен, он попытается загрузить целевой класс самостоятельно, что вызовет метод findClass.Вы можете взглянуть на определение метода findClass:

protected Class<?> findClass(String name) throws ClassNotFoundException {
    throw new ClassNotFoundException(name);
}

Можно обнаружить, что он требует вернуть экземпляр объекта класса, здесь я использую класс реализации sun.rmi.rmic.iiop.ClassPathLoader, чтобы проиллюстрировать, что делает findClass.

protected Class findClass(String var1) throws ClassNotFoundException {
    // 从指定路径加载指定名称的class的字节流
    byte[] var2 = this.loadClassData(var1);
    // 通过ClassLoader的defineClass来创建class对象实例
    return this.defineClass(var1, var2, 0, var2.length);
}

Что он делает, было дано в комментариях.Вы можете видеть, что объект класса, наконец, создается с помощью метода defineClass.

Кроме того, можно обнаружить, что мы можем контролировать сбор и обработку байтов файла класса.Итак, вторая роль: мы можем сделать некоторую пользовательскую обработку на этапе разбора потока байтов.Например, шифрование и дешифрование.

Дальше кажется, что есть defineClass, который позволяет нам что-то делать Реализация ClassLoader выглядит следующим образом:

protected final Class<?> defineClass(String name, byte[] b, int off, int len)
    throws ClassFormatError
{
    return defineClass(name, b, off, len, null);
}

Он был удален финалом и не может быть перезаписан, поэтому, похоже, здесь ничего нельзя сделать.

Обобщить:

  • Найти файлы по указанному пути через loadClass.
  • Поток байтов класса анализируется методом findClass, и создается экземпляр объекта класса.
3.1.2 Когда вам нужно реализовать собственный загрузчик классов

Когда реализация загрузчика классов JDK не может удовлетворить наши потребности, необходимо реализовать собственный загрузчик классов.

Существующие сценарии приложений: OSGI, Code Hot развертывание и другие поля.

Кроме того, в соответствии с ролью вышеуказанного загрузчика классов могут быть следующие сценарии, в которых вам необходимо реализовать загрузчик классов самостоятельно.

  • Когда вам нужно найти файлы классов в пользовательском каталоге (или в сетевом доступе)
  • Шифрование и дешифрование перед загрузкой класса загрузчиком классов (поле шифрования кода)

3.2 Как реализовать собственный загрузчик классов

Затем реализуйте пользовательский загрузчик классов, который находит и загружает классы в пути к пользовательскому классу.

package com.lordx.sprintbootdemo.classloader;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;

/**
 * 自定义ClassLoader
 * 功能:可自定义class文件的扫描路径
 * @author zhiminxu 
 */
// 继承ClassLoader,获取基础功能
public class TestClassLoader extends ClassLoader {

    // 自定义的class扫描路径
    private String classPath;

    public TestClassLoader(String classPath) {
        this.classPath = classPath;
    }

    // 覆写ClassLoader的findClass方法
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // getDate方法会根据自定义的路径扫描class,并返回class的字节
        byte[] classData = getDate(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        } else {
            // 生成class实例
            return defineClass(name, classData, 0, classData.length);
        }
    }


    private byte[] getDate(String name) {
        // 拼接目标class文件路径
        String path = classPath + File.separatorChar + name.replace('.', File.separatorChar) + ".class";
        try {
            InputStream is = new FileInputStream(path);
            ByteArrayOutputStream stream = new ByteArrayOutputStream();
            byte[] buffer = new byte[2048];
            int num = 0;
            while ((num = is.read(buffer)) != -1) {
                stream.write(buffer, 0 ,num);
            }
            return stream.toByteArray();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
}

Используйте пользовательские загрузчики классов

package com.lordx.sprintbootdemo.classloader;

public class MyClassLoader {
    public static void main(String[] args) throws ClassNotFoundException {
        // 自定义class类路径
        String classPath = "/Users/zhiminxu/developer/classloader";
        // 自定义的类加载器实现:TestClassLoader
        TestClassLoader testClassLoader = new TestClassLoader(classPath);
        // 通过自定义类加载器加载
        Class<?> object = testClassLoader.loadClass("ClassLoaderTest");
        // 这里的打印应该是我们自定义的类加载器:TestClassLoader
        System.out.println(object.getClassLoader());
    }
}

Класс ClassLoaderTest в эксперименте — это простой класс, определяющий два поля. Как показано ниже

classloadertest

Окончательный результат печати выглядит следующим образом

Result

PS: экспериментальный класс (ClassLoaderTest) лучше не помещать в каталог проекта IDE, потому что IDE сначала загрузит все классы проекта в память при запуске, поэтому этот класс не загружается настраиваемым загрузчиком классов. он загружается AppClassLoader.

3.3 Влияние загрузчика классов на оценку "равенства"

3.3.1 Влияние на Object.equals()

Или пользовательский загрузчик классов выше

Изменить код MyClassLoader

package com.lordx.sprintbootdemo.classloader;

public class MyClassLoader {
    public static void main(String[] args) throws ClassNotFoundException {
        // 自定义class类路径
        String classPath = "/Users/zhiminxu/developer/classloader";
        // 自定义的类加载器实现:TestClassLoader
        TestClassLoader testClassLoader = new TestClassLoader(classPath);
        // 通过自定义类加载器加载
        Class<?> object1 = testClassLoader.loadClass("ClassLoaderTest");
        Class<?> object2 = testClassLoader.loadClass("ClassLoaderTest");
        // object1和object2使用同一个类加载器加载时
        System.out.println(object1.equals(object2));

        // 新定义一个类加载器
        TestClassLoader testClassLoader2 = new TestClassLoader(classPath);
        Class<?> object3 = testClassLoader2.loadClass("ClassLoaderTest");
        // object1和object3使用不同类加载器加载时
        System.out.println(object1.equals(object3));
    }
}

распечатать результат:

true

false

Метод equals по умолчанию сравнивает адрес памяти.Видно, что разные экземпляры загрузчика классов имеют свое пространство памяти.Даже если полное имя класса одинаковое, но загрузчик классов другой.

Таким образом, условием равенства для двух классов в памяти является использование одного и того же экземпляра загрузчика классов для загрузки двух экземпляров классов с одним и тем же полным именем.

3.3.2 Влияние на instanceof

Измените TestClassLoader и добавьте основной метод для эксперимента.Измененный TestClassLoader выглядит следующим образом:

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;

/**
 * 自定义ClassLoader
 * 功能:可自定义class文件的扫描路径
 * @author zhiminxu
 */
// 继承ClassLoader,获取基础功能
public class TestClassLoader extends ClassLoader {

    public static void main(String[] args) throws ClassNotFoundException {
        TestClassLoader testClassLoader = new TestClassLoader("/Users/zhiminxu/developer/classloader");
        Object obj = testClassLoader.loadClass("ClassLoaderTest");
        // obj是testClassLoader加载的,ClassLoaderTest是AppClassLoader加载的,所以这里打印:false
        System.out.println(obj instanceof ClassLoaderTest);
    }

    // 自定义的class扫描路径
    private String classPath;

    public TestClassLoader(String classPath) {
        this.classPath = classPath;
    }

    // 覆写ClassLoader的findClass方法
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // getDate方法会根据自定义的路径扫描class,并返回class的字节
        byte[] classData = getDate(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        } else {
            // 生成class实例
            return defineClass(name, classData, 0, classData.length);
        }
    }


    private byte[] getDate(String name) {
        // 拼接目标class文件路径
        String path = classPath + File.separatorChar + name.replace('.', File.separatorChar) + ".class";
        try {
            InputStream is = new FileInputStream(path);
            ByteArrayOutputStream stream = new ByteArrayOutputStream();
            byte[] buffer = new byte[2048];
            int num = 0;
            while ((num = is.read(buffer)) != -1) {
                stream.write(buffer, 0 ,num);
            }
            return stream.toByteArray();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
}

распечатать результат:

false

Другие также влияют на метод isAssignableFrom() и метод isInstance() класса по тем же причинам, что и выше.

3.3.3 Дополнительные ноты

Не пытайтесь настраивать пакеты java.lang и загружать их с помощью загрузчика. как ниже

selfjavalang

Это вызовет исключение напрямую

error

Это исключение возникает во время процесса проверки вызова defineClass.Исходный код выглядит следующим образом.

// Note:  Checking logic in java.lang.invoke.MemberName.checkForTypeAlias
// relies on the fact that spoofing is impossible if a class has a name
// of the form "java.*"
if ((name != null) && name.startsWith("java.")) {
    throw new SecurityException
        ("Prohibited package name: " +
         name.substring(0, name.lastIndexOf('.')));
}

4 Несколько исключений, связанных с ClassLoader

Если вы хотите знать, при каких обстоятельствах выбрасываются следующие исключения, вам нужно только узнать, где они будут выброшены в ClassLoader, а затем посмотреть соответствующую логику.

4.1 ClassNotFoundException

Это исключение, я думаю, каждый часто встречается.

Итак, что вызывает это исключение?

Глядя на исходный код ClassLoader, это исключение возникает, когда JVM вызывает метод loadClassInternal.

Его декларация выглядит следующим образом:

// This method is invoked by the virtual machine to load a class.
private Class<?> loadClassInternal(String name)
    throws ClassNotFoundException
{
    // For backward compatibility, explicitly lock on 'this' when
    // the current class loader is not parallel capable.
    if (parallelLockMap == null) {
        synchronized (this) {
             return loadClass(name);
        }
    } else {
        return loadClass(name);
    }
}

Здесь метод loadClass вызовет это исключение, а затем посмотрите на метод loadClass.

public Class<?> loadClass(String name) throws ClassNotFoundException {
    return loadClass(name, false);
}

Переопределенный метод loadClass вызывается

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // First, check if the class has already been loaded
        // 查看findLoadedClass的声明,没有抛出这个异常
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    // 这里会抛,但同样是调loadClass方法,无需关注
                    c = parent.loadClass(name, false);
                } else {
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // catch住没有抛,因为要在下面尝试自己获取class
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }

            if (c == null) {
                // If still not found, then invoke findClass in order
                // to find the class.
                long t1 = System.nanoTime();
                // 关键:这里会抛
                c = findClass(name);

                // this is the defining class loader; record the stats
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

Давайте еще раз посмотрим на метод findClass.

/**
 * Finds the class with the specified <a href="#name">binary name</a>.
 * This method should be overridden by class loader implementations that
 * follow the delegation model for loading classes, and will be invoked by
 * the {@link #loadClass <tt>loadClass</tt>} method after checking the
 * parent class loader for the requested class.  The default implementation
 * throws a <tt>ClassNotFoundException</tt>.
 *
 * @param  name
 *         The <a href="#name">binary name</a> of the class
 *
 * @return  The resulting <tt>Class</tt> object
 *
 * @throws  ClassNotFoundException
 *          If the class could not be found
 *
 * @since  1.2
 */
protected Class<?> findClass(String name) throws ClassNotFoundException {
    throw new ClassNotFoundException(name);
}

Конечно же, оно выбрасывается здесь.Причина выбрасывания этого исключения в комментарии состоит в том, чтобы сказать, что оно выбрасывается, когда этот класс не может быть найден.

Вот почему в приведенном выше пользовательском загрузчике классов при переопределении метода findClass, если класс не найден, выдается это исключение.

На данный момент причина этого исключения ясна: во всех соответствующих загрузчиках классов родительской модели делегирования это исключение возникает, когда целевой класс не существует в пути сканирования каждого загрузчика классов.

4.2 NoClassDefFoundError

Это также часто встречающееся исключение, и незнакомые учащиеся часто могут задаться вопросом, когда выбрасывать ClassNotFoundException, а когда выбрасывать NoClassDefFoundError.

Мы все еще ищем это исключение в ClassLoader, и мы можем обнаружить, что это исключение может быть выброшено в методе defineClass.Исходный код метода defineClass выглядит следующим образом:

/**
 * ... 忽略注释和参数以及返回值的说明,直接看异常声明
 * 
 * @throws  ClassFormatError
 *          If the data did not contain a valid class
 *
 * 在这里。由于NoClassDefFoundError是Error下的,所以不用显示throws
 * @throws  NoClassDefFoundError
 *          If <tt>name</tt> is not equal to the <a href="#name">binary
 *          name</a> of the class specified by <tt>b</tt>
 *
 * @throws  IndexOutOfBoundsException
 *          If either <tt>off</tt> or <tt>len</tt> is negative, or if
 *          <tt>off+len</tt> is greater than <tt>b.length</tt>.
 *
 * @throws  SecurityException
 *          If an attempt is made to add this class to a package that
 *          contains classes that were signed by a different set of
 *          certificates than this class, or if <tt>name</tt> begins with
 *          "<tt>java.</tt>".
 */
protected final Class<?> defineClass(String name, byte[] b, int off, int len,
                                     ProtectionDomain protectionDomain)
    throws ClassFormatError
{
    protectionDomain = preDefineClass(name, protectionDomain);
    String source = defineClassSourceLocation(protectionDomain);
    Class<?> c = defineClass1(name, b, off, len, protectionDomain, source);
    postDefineClass(c, protectionDomain);
    return c;
}

Продолжайте отслеживать метод внутри, вы можете обнаружить, что он вызывается методом preDefineClass, исходный код этого метода выглядит следующим образом:

/* Determine protection domain, and check that:
    - not define java.* class,
    - signer of this class matches signers for the rest of the classes in
      package.
*/
private ProtectionDomain preDefineClass(String name,
                                        ProtectionDomain pd)
{
    // 这里显示抛出,可发现是在目标类名校验不通过时抛出的
    if (!checkName(name))
        throw new NoClassDefFoundError("IllegalName: " + name);

    // Note:  Checking logic in java.lang.invoke.MemberName.checkForTypeAlias
    // relies on the fact that spoofing is impossible if a class has a name
    // of the form "java.*"
    if ((name != null) && name.startsWith("java.")) {
        throw new SecurityException
            ("Prohibited package name: " +
             name.substring(0, name.lastIndexOf('.')));
    }
    if (pd == null) {
        pd = defaultDomain;
    }

    if (name != null) checkCerts(name, pd.getCodeSource());

    return pd;
}

Исходный код для этой проверки выглядит следующим образом

// true if the name is null or has the potential to be a valid binary name
private boolean checkName(String name) {
    if ((name == null) || (name.length() == 0))
        return true;
    if ((name.indexOf('/') != -1)
        || (!VM.allowArraySyntax() && (name.charAt(0) == '[')))
        return false;
    return true;
}

Следовательно, причина, по которой возникает это исключение, заключается в том, что проверка имени класса не удалась.

Но это исключение выбрасывается не только в ClassLoader, но и в других местах, таких как фреймворк, веб-контейнер, и конкретную проблему необходимо детально проанализировать.

5 Резюме

В этой статье впервые представлена ​​роль ClassLoader: она в основном используется для поиска классов по указанному пути и загрузки их в память, а также для определения равенства двух классов.

Модель родительского делегирования и принцип ее реализации будут представлены позже.JDK в основном реализует родительскую модель делегирования в методе loadClass классаLoader.

Затем примеры кода иллюстрируют, как реализовать собственный загрузчик классов, а также варианты использования загрузчика классов.

Наконец, объясняются два исключения, связанные с загрузчиком классов, которые часто встречаются в работе.


Добро пожаловать в мой публичный аккаунт WeChat

公众号