手写JVM(二)-搜索class文件

代码、内容参考来自于张秀宏大佬的自己动手写Java虚拟机 (Java核心技术系列)。

启动Java应用程序:

首先启动Java虚拟机,然后加载主类,最后调用主类的main()方法。但即使是最简单的“Hello,World”程序,也是无法独自运行的,该程序的代码如下:

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, world!");
    }
}
加载HelloWorld类之前,首先要加载它的超类,也就是java.lang.Object。在调用main()方法之前,因为虚拟机需要准备好参数数组,所以需要加载java.lang.String和java.lang.String[]类。把字符串打印到控制台还需要加载java.lang.System类。深入了解-classpath选项,实现Java虚拟机从哪里寻找class文件,并实现class文件加载功能

 

1.类路径

Java虚拟机规范并没有规定虚拟机应该从哪里寻找类,不同的虚拟机实现可以采用不同的方法。Oracle的Java虚拟机实现根据类路径(class path)来搜索类。按照搜索的先后顺序,类路径可以分为以下3个部分:

  • 启动类路径(bootstrap classpath)
  • 扩展类路径(extension classpath)
  • 用户类路径(user classpath)

启动类路径默认对应jre\lib目录,Java标准库(大部分在rt.jar里)位于该路径。扩展类路径默认对应jre\lib\ext目录,使用Java扩展机制的类位于这个路径。我们自己实现的类,以及第三方类库则位于用户类路径。可以通过-Xbootclasspath选项修改启动类路径,不过通常并不需要这样做,所以这里就不详细介绍了。

用户类路径的默认值是当前目录,也就是“.”。可以设置CLASSPATH环境变量来修改用户类路径,但是这样做不够灵活,所以不推荐使用。更好的办法是给java命令传递-classpath(或简写为-cp)选项。-classpath/-cp选项的优先级更高,可以覆盖CLASSPATH环境变量设置。第1章简单介绍过这个选项,这里再详细解释一下。-classpath/-cp选项既可以指定目录,也可以指定JAR文件或者ZIP文件,如下:

java -cp path\to\classes ...
java -cp path\to\lib1.jar ...
java -cp path\to\lib2.zip ...

还可以同时指定多个目录或文件,用分隔符分开即可。分隔符因操作系统而异。在Windows系统下是分号,在类UNIX(包括Linux、Mac OS X等)系统下是冒号。

 

例如在Windows下:

java -cp path\to\classes;lib\a.jar;lib\b.jar;lib\c.zip ...

从Java 6开始,还可以使用通配符(*)指定某个目录下的所有JAR文件,格式如下:

java -cp classes;lib\* ...

 

 

2.准备工作

从第2章开始,每章的代码都是建立在前一章的基础之上。把ch01目录复制一份,然后改名为ch02。因为本章要创建的源文件都在classpath包中,所以在ch02目录中创建一个classpath子目录。现在目录结构看起来应该是这样:

我们的Java虚拟机将使用JDK的启动类路径来寻找和加载Java标准库中的类,因此需要某种方式指定jre目录的位置。命令行选项是个不错的选择,所以增加一个非标准选项-Xjre。打开ch02\cmd.go,修改Cmd结构体,添加XjreOption字段,代码如下:

type Cmd struct {
    helpFlag     bool
    versionFlag  bool
    cpOption     string
    XjreOption   string
    class        string
    args         []string
}

parseCmd()函数也要相应修改,代码如下:

func parseCmd() *Cmd {
    cmd := &Cmd{}
    flag.Usage = printUsage
    flag.BoolVar(&cmd.helpFlag, "help", false, "print help message")
    flag.BoolVar(&cmd.helpFlag, "?", false, "print help message")
    flag.BoolVar(&cmd.versionFlag, "version", false, "print version and exit")
    flag.StringVar(&cmd.cpOption, "classpath", "", "classpath")
    flag.StringVar(&cmd.cpOption, "cp", "", "classpath")
    flag.StringVar(&cmd.XjreOption, "Xjre", "", "path to jre")
    flag.Parse()
    args := flag.Args()
    if len(args) > 0 {
        cmd.class = args[0]
        cmd.args = args[1:]
    }
    return cmd

}

 

3.实现类路径

可以把类路径想象成一个大的整体,它由启动类路径、扩展类路径和用户类路径三个小路径构成。三个小路径又分别由更小的路径构成。是不是很像组合模式(composite pattern)?没错,本节就套用组合模式来设计和实现类路径。

Entry接口

先定义一个接口来表示类路径项。在ch02\classpath目录下创建entry.go文件,在其中定义Entry接口,代码如下:

package classpath

import (
    "os"
    "strings"
)

const pathListSeparator = string(os.PathListSeparator)

type Entry interface {
    readClass(className string) ([]byte, Entry, error)
    String() string
}

func newEntry(path string) Entry {
    if strings.Contains(path, pathListSeparator) {
        return newCompositeEntry(path)
    }

    if strings.HasSuffix(path, "*") {
        return newWildcardEntry(path)
    }

    if strings.HasSuffix(path, ".jar") || strings.HasSuffix(path, ".JAR") ||
    strings.HasSuffix(path, ".zip") || strings.HasSuffix(path, ".ZIP") {
        return newZipEntry(path)
    }

    return newDirEntry(path)
}

 

代码解释:

os.PathListSeparator

源码

  • 常量pathListSeparator是string类型,存放路径分隔符,后面会用到。Entry接口中有两个方法。readClass()方法负责寻找和加载class文件;String()方法的作用相当于Java中的toString(),用于返回变量的字符串表示。
  • readClass()方法的参数是class文件的相对路径,路径之间用斜线(/)分隔,文件名有.class后缀。比如要读取java.lang.Object类,传入的参数应该是java/lang/Object.class。返回值是读取到的字节数据、最终定位到class文件的Entry,以及错误信息。Go的函数或方法允许返回多个值,按照惯例,可以使用最后一个返回值作为错误信息。
  • newEntry()函数根据参数创建不同类型的Entry实例。

 

  • strings.Contains() :Contains() 方法用于判断字符串中是否包含指定的字符或字符串。

源码

 

  • strings.HasSuffix():HasSuffix测试字符串s是否以suffix结尾。

源码

 

  • Entry接口有4个实现,分别是DirEntry、ZipEntry、CompositeEntry和WildcardEntry。下面分别介绍每一种实现。

 

DirEntry

在4种实现中,DirEntry相对简单一些,表示目录形式的类路径。在ch02\classpath目录下创建entry_dir.go文件,在其中定义DirEntry结构体,代码如下:

package classpath

import (
    "io/ioutil"
    "path/filepath"
)

type DirEntry struct {
    absDir string
}

func newDirEntry(path string) *DirEntry {
    absDir, err := filepath.Abs(path)
    if err != nil {
        panic(err)
    }
    return &DirEntry{absDir}

}

func (self *DirEntry) readClass(className string) ([]byte, Entry, error) {
    fileName := filepath.Join(self.absDir, className)
    data, err := ioutil.ReadFile(fileName)
    return data, self, err

}

func (self *DirEntry) String() string {
    return self.absDir
}

 

注意:在go1.20.3版本,ioutil.ReadFile()已过时

从 Go 1.16 开始会废弃 io/ioutil 包,相关的功能会挪到 io 包或 os 包。为了便于版本升级,保持兼容性,ioutil 函数依旧会保留,但实际上调用的是 io、os 包里的函数。

在源码里调用os.ReadFile()。

 

这里建议使用os.ReadFile()

将代码改为(其实就是io/ioutil 改为os;ioutil.ReadFile改为os.ReadFile):

package classpath

import (
    "os"
    "path/filepath"
)

type DirEntry struct {
    absDir string
}

func newDirEntry(path string) *DirEntry {
    absDir, err := filepath.Abs(path)
    if err != nil {
        panic(err)
    }
    return &DirEntry{absDir}

}

func (self *DirEntry) readClass(className string) ([]byte, Entry, error) {
    fileName := filepath.Join(self.absDir, className)
    data, err := os.ReadFile(fileName)
    return data, self, err

}

func (self *DirEntry) String() string {
    return self.absDir
}

 

代码解释:

  • filepath.Abs()如果传如的参数路径是一个绝对路径则直接返回。如果传如的参数路径不是绝对路径,它将与当前工作目录联接,以将其转换为绝对路径。不能保证给定文件的绝对路径名是唯一的。

源码:

 

  • 当出现error不等于nil的时候,说明出现某些错误了,需要我们对这个错误进行一些处理,而如果等于nil说明运行正常没有错误。那什么是nil呢?nil的意思是无,或者是零值。在Go语言中,如果你声明了一个变量但是没有对它进行赋值操作,那么这个变量就会有一个类型的默认零值。
if err != nil {
    return err
}
if err != nil {
    panic(err)
}

不想写if err != nil 的代码,方式之一就是用panic来替代他。通过panic的方式取代了return err的函数返回,自然其所关联的下游业务代码也就不需要编写 if err != nil 的代码:

每种类型对应的零值为:

bool -> false 
numbers -> 0 
string -> ""
pointers -> nil
slices -> nil
maps -> nil
channels -> nil
functions -> nil
interfaces -> nil

 

  • DirEntry只有一个字段,用于存放目录的绝对路径。和Java语言不同,Go结构体不需要显示实现接口,只要方法匹配即可。Go没有专门的构造函数,本文统一使用new开头的函数来创建结构体实例,并把这类函数称为构造函数。newDirEntry()先把参数转换成绝对路径,如果转换过程出现错误,则调用panic()函数终止程序执行,否则创建DirEntry实例并返回。
  • readClass()先把目录和class文件名拼成一个完整的路径,然后调用os包提供的ReadFile()函数读取class文件内容,最后返回。
  • filepath.Join() :Go语言中的filepath.Join()函数用于将任意数量的指定路径元素连接到单个路径中,并在必要时添加分隔符。此函数对结果调用Clean,所有空字符串都将被忽略。此外,此函数在路径包下定义。在这里,您需要导入“path/filepath”包才能使用这些函数。

如:

fmt.Println(filepath.Join("G", "F", "G"))    
fmt.Println(filepath.Join("G/F", "G"))

输出:

G/F/G 
G/F/G

  • os.ReadFile:ReadFile 读取命名文件并返回内容。成功的调用返回 err == nil,而不是 err == EOF。因为ReadFile 读取整个文件,它不会将Read 中的EOF 视为要报告的错误。

  • String()方法很简单,直接返回目录.

 

ZipEntry

ZipEntry表示ZIP或JAR文件形式的类路径。在ch02\classpath目录下创建entry_zip.go文件,在其中定义ZipEntry结构体,

注意:

从 Go 1.16 开始会废弃 io/ioutil 包,相关的功能会挪到 io 包或 os 包。为了便于版本升级,保持兼容性,ioutil 函数依旧会保留,但实际上调用的是 io、os 包里的函数。

源码:

所以我们直接调用io包里的函数.

代码如下:

package classpath

import (
    "archive/zip"
    "errors"
    "io"
    "path/filepath"
)

type ZipEntry struct {
    absPath string
}

func newZipEntry(path string) *ZipEntry {
    absPath, err := filepath.Abs(path)
    if err != nil {
        panic(err)
    }
    return &ZipEntry{absPath}
}

func (self *ZipEntry) String() string {
    return self.absPath
}

func (self *ZipEntry) readClass(className string) ([]byte, Entry, error) {
    r, err := zip.OpenReader(self.absPath)
    if err != nil {
        return nil, nil, err
    }
    defer r.Close()
    for _, f := range r.File {
        if f.Name == className {
            rc, err := f.Open()
            if err != nil {
                return nil, nil, err
            }
            defer rc.Close()
            data, err := io.ReadAll(rc)
            if err != nil {
                return nil, nil, err
            }
            return data, self, nil
        }

    }
    return nil, nil, errors.New("Class not found:" + className)

}

absPath字段存放ZIP或JAR文件的绝对路径。构造函数和String()与DirEntry大同小异,就不多解释了。

readClass从ZIP文件中提取class文件。

首先打开ZIP文件,如果这一步出错的话,直接返回。然后遍历ZIP压缩包里的文件,看能否找到class文件。如果能找到,则打开class文件,把内容读取出来,并返回。如果找不到,或者出现其他错误,则返回错误信息。有两处使用了defer语句来确保打开的文件得以关闭。

Golang 的defer来讲就是延迟调用。defer 会在当前函数返回之前执行defer注册的函数。在 defer 语句所在的函数退出之前调用。

 

readClass()方法每次都要打开和关闭ZIP文件,因此效率不是很高。

为此我们进行了优化:

在ch02\classpath目录下创建entry_zip2.go文件

package classpath

import (
    "archive/zip"
    "errors"
    "io"
    "path/filepath"
)

type ZipEntry2 struct {
    absPath string
    zipRC *zip.ReadCloser
}

func newZipEntry2(path string) *ZipEntry2 {
    absPath, err := filepath.Abs(path)
    if err != nil {
        panic(err)
    }

    return &ZipEntry2{absPath, nil}
}

func (self *ZipEntry2) readClass(className string) ([]byte, Entry, error) {
    if self.zipRC == nil {
        err := self.openJar()
        if err != nil {
            return nil, nil, err
        }
    }

    classFile := self.findClass(className)
    if classFile == nil {
        return nil, nil, errors.New("class not found: " + className)
    }

    data, err := readClass(classFile)
    return data, self, err
}

// todo: close zip
func (self *ZipEntry2) openJar() error {
    r, err := zip.OpenReader(self.absPath)
    if err == nil {
        self.zipRC = r
    }
    return err
}

func (self *ZipEntry2) findClass(className string) *zip.File {
    for _, f := range self.zipRC.File {
        if f.Name == className {
            return f
        }
    }
    return nil
}

func readClass(classFile *zip.File) ([]byte, error) {
    rc, err := classFile.Open()
    if err != nil {
        return nil, err
    }
    // read class data
    data, err := io.ReadAll(rc)
    rc.Close()
    if err != nil {
        return nil, err
    }
    return data, nil
}

func (self *ZipEntry2) String() string {
    return self.absPath
}

代码解释:

  • absPath字段存放ZIP或JAR文件的绝对路径。构造函数和String()与DirEntry大同小异,就不多解释了。
  • 在结构体加入一个*zip.ReadCloser,通过对其操作,完成readClass()方法每次不再要打开和关闭ZIP文件
  • readClass方法,先判断zipRC是否为空,为空就调用openJar()
  • openJar()方法调用zip.OpenReader(),打开ZIP文件,成功了就对zipRC赋值
  • findClass方法接收一个文件名,遍历ZIP文件,找到与接收的文件名一致,就返回ZipEntry2结构体的指针
  • 再调用函数readClass,打开findClass方法找到的class文件,把内容读取出来,并返回。如果找不到,或者出现其他错误,则返回错误信息。
  • 通过先判断zipRC是否为空,来确保不要每次打开和关闭ZIP文件

 

  • zip.ReadCloser: 当不再需要时必须关闭的Reader。

 

  • zip.OpenReader:OpenReader将打开名称指定的Zip文件并返回readclose。

 

  • zip.File:文件是ZIP归档中的单个文件。文件信息在内嵌的FileHeader中。可以通过调用Open来访问文件内容。

 

  • classFile.Open():Open返回一个readclose,提供对文件内容的访问。多个文件可以并发读取。

 

  • rc.Close():Close关闭Zip文件,使其无法用于I/O。

 

  • ReadAll从r开始读取,直到出现错误或EOF,并返回所读取的数据。一个成功的调用返回err == nil。

 

CompositeEntry

在ch02\classpath目录下创建entry_composite.go文件,在其中定义CompositeEntry结构体,代码如下:

package classpath

import (
    "errors"
    "strings"
)

type CompositeEntry []Entry

func newCompositeEntry(pathList string) CompositeEntry {
    compositeEntry := []Entry{}
    for _, path := range strings.Split(pathList, pathListSeparator) {
        entry := newEntry(path)
        compositeEntry = append(compositeEntry, entry)
    }
    return compositeEntry

}

func (self CompositeEntry) readClass(className string) ([]byte, Entry, error) {
    for _, entry := range self {
        data, from, err := entry.readClass(className)
        if err == nil {
            return data, from, nil
        }
    }
    return nil, nil, errors.New("class not found:" + className)
}

func (self CompositeEntry) String() string {
    strs := make([]string, len(self))
    for i, entry := range self {
        strs[i] = entry.String()
    }
    return strings.Join(strs, pathListSeparator)
}

代码解释:

  • 如前所述,CompositeEntry由更小的Entry组成,正好可以表示成[]Entry。在Go语言中,数组属于比较低层的数据结构,很少直接使用。大部分情况下,使用更便利的slice类型。构造函数把参数(路径列表)按分隔符分成小路径,然后把每个小路径都转换成具体的Entry实例。
  • 相信你已经想到readClass()方法的代码了:依次调用每一个子路径的readClass()方法,如果成功读取到class数据,返回数据即可;如果收到错误信息,则继续;如果遍历完所有的子路径还没有找到class文件,则返回错误。
  • String()方法也不复杂。调用每一个子路径的String()方法,然后把得到的字符串用路径分隔符拼接起来即可。
  • Go语言的内建函数 append() 可以为切片动态添加元素,代码如下所示:
  • strings.Split函数用于将指定的分隔符切割字符串,并返回切割后的字符串切片。
  • Go语言中new和make是内建的两个函数,主要用来创建分配类型内存。
  • make(Type, len, cap)是Go语言内存分配的内置函数,默认有三个参数:Type:数据类型,必要参数,Type 的值只能是 slice、 map、 channel 这三种数据类型。
    len:数据类型实际占用的内存空间长度,map、 channel 是可选参数,slice 是必要参数。
    cap:为数据类型提前预留的内存空间长度,可选参数。所谓的提前预留是当前为数据类型申请内存空间的时候,提前申请好额外的内存空间,这样可以避免二次分配内存带来的开销,大大提高程序的性能。

 

WildcardEntry

WildcardEntry实际上也是CompositeEntry,所以就不再定义新的类型了。在ch02\classpath目录下创建entry_wildcard.go文件,在其中定义newWildcardEntry()函数,代码如下:

package classpath

import (
    "os"
    "path/filepath"
    "strings"
)

func newWildcardEntry(path string) CompositeEntry {
    baseDir := path[:len(path)-1] // remove*
    compositeEntry := []Entry{}
    walkFn := func(path string, info os.FileInfo, err error) error {
        if err != nil {
            return err
        }
        if info.IsDir() && path != baseDir {
            return filepath.SkipDir
        }
        if strings.HasSuffix(path, ".jar") || strings.HasSuffix(path, ".JAR") {
            jarEntry := newZipEntry(path)
            compositeEntry = append(compositeEntry, jarEntry)
        }
        return nil
    }
    filepath.Walk(baseDir, walkFn)
    return compositeEntry
}
  • 首先把路径末尾的星号去掉,得到baseDir,然后调用filepath包的Walk()函数遍历baseDir创建ZipEntry。Walk()函数的第二个参数也是一个函数,了解函数式编程的读者应该一眼就可以认出这种用法(即函数可作为参数)。
  • 在walkFn中,根据后缀名选出JAR文件,并且返回SkipDir跳过子目录(通配符类路径不能递归匹配子目录下的JAR文件)。
  • walk方法会遍历root下的所有文件(包含root)并对每一个目录和文件都调用walkFn方法。在访问文件和目录时发生的错误都会通过error参数传递给WalkFn方法。文件是按照词法顺序进行遍历的,这个通常让输出更漂亮,但是也会导致处理非常大的目录时效率会降低。另外,Walk函数不会遍历符号链接。

  •  filepath.SkipDir:SkipDir用作WalkFn的返回值,以指示调用中指定的目录将被跳过。它不会被任何函数作为错误返回。

源码:

  • info.IsDir()判断所给路径是否为文件夹

源码:

 

Classpath

Entry接口和4个实现介绍完了,接下来实现Classpath结构体。还是在ch02\classpath目录下创建classpath.go文件,把下面的代码输入进去。

package classpath

import (
    "os"
    "path/filepath"
)

type Classpath struct {
    bootClasspath Entry
    extClasspath  Entry
    userClasspath Entry
}

func Parse(jreOption, cpOption string) *Classpath {
    cp := &Classpath{}
    cp.parseBootAndExtClasspath(jreOption)
    cp.parseUserClasspath(cpOption)
    return cp
}

func (self *Classpath) parseBootAndExtClasspath(jreOption string) {
    jreDir := getJreDir(jreOption)
    // jre/lib/*
    jreLibPath := filepath.Join(jreDir, "lib", "*")
    self.bootClasspath = newWildcardEntry(jreLibPath)
    // jre/lib/ext/*
    jreExtPath := filepath.Join(jreDir, "lib", "ext", "*")
    self.extClasspath = newWildcardEntry(jreExtPath)
}

func getJreDir(jreoption string) string {
    if jreoption != "" && exists(jreoption) {
        return jreoption
    }
    if exists("./jre") {
        return "./jre"
    }
    if jh := os.Getenv("JAVA_HOME"); jh != "" {
        return filepath.Join(jh, "jre")
    }
    panic("Can not find jre folder!")
}

func exists(path string) bool {
    if _, err := os.Stat(path); err != nil {
        if os.IsNotExist(err) {
            return false
        }
    }
    return true
}

func (self *Classpath) parseUserClasspath(cpOption string) {
    if cpOption == "" {
        cpOption = "."
    }
    self.userClasspath = newEntry(cpOption)
}

func (self *Classpath) ReadClass(className string) ([]byte, Entry, error) {
    className = className + ".class"
    if data, entry, err := self.bootClasspath.readClass(className); err == nil {
        return data, entry, err
    }
    if data, entry, err := self.extClasspath.readClass(className); err == nil {
        return data, entry, err
    }
    return self.userClasspath.readClass(className)
}

func (self *Classpath) String() string {
    return self.userClasspath.String()
}

 

  • Classpath结构体有三个字段,分别存放三种类路径。
  • Parse()函数使用-Xjre选项解析启动类路径和扩展类路径,使用-classpath/-cp选项解析用户类路径。
  • getJreDir()函数:优先使用用户输入的-Xjre选项作为jre目录。如果没有输入该选项,则在当前目录下寻找jre目录。如果找不到,尝试使用JAVA_HOME环境变量。
  • os.Getenv功能:获取系统key的环境变量,如果没有环境变量就返回空

  • exists()函数用于判断目录是否存在

 

golang判断文件或文件夹是否存在的方法为使用os.Stat()函数返回的错误值进行判断:

    • 如果返回的错误为nil,说明文件或文件夹存在
    • 如果返回的错误类型使用os.IsNotExist()或者 os.IsExist() 判断为true,说明文件或文件夹不存在
    • 如果返回的错误为其它类型,则不确定是否在存在

 

  • 如果用户没有提供-classpath/-cp选项,则使用当前目录作为用户类路径。ReadClass()方法依次从启动类路径、扩展类路径和用户类路径中搜索class文件,
  • 注意,传递给ReadClass()方法的类名不包含“.class”后缀。最后,String()方法返回用户类路径的字符串表示

至此,整个类路径都实现了。

 

4.测试代码

打开ch02/main.go文件,添加两条import语句,代码如下:

package main

import (
    "fmt"
    "jvmgo/ch02/classpath"
    "strings"
)

func main() {
    cmd := parseCmd()
    if cmd.versionFlag {
        fmt.Println("java version 0.0.1")
    } else if cmd.helpFlag || cmd.class == "" {
        printUsage()
    } else {
        startJVM(cmd)
    }
}

func startJVM(cmd *Cmd) {

    cp := classpath.Parse(cmd.XjreOption, cmd.cpOption)
    fmt.Printf("classpath:%v class:%v args:%v\n", cp, cmd.class, cmd.args)
    className := strings.Replace(cmd.class, ".", "/", -1)
    classData, _, err := cp.ReadClass(className)
    if err != nil {
        fmt.Printf("Could not find or load main class %s\n", cmd.class)
        return
    }
    fmt.Printf("class data:%v\n", classData)

}

startJVM()先打印出命令行参数,然后读取主类数据,并打印到控制台。虽然还是无法真正启动Java虚拟机,不过相比第1章,已经有了很大的进步。打开命令行窗口,执行下面的命令编译本章代码。

go install jvmgo\ch02

编译成功后,在D:\go\workspace\bin目录下出现ch02.exe文件。执行ch02.exe,指定好-Xjre选项和类名,就可以把class文件的内容打印出来。

如图:还可以启动自带参数,这样直接在main()方法右键运行就行。输出一样。

 

5.参考

尚硅谷宋红康:JVM全套教程:https://www.bilibili.com/video/BV1PJ411n7xZ

周志明:深入理解java虚拟机

张秀宏:自己动手写Java虚拟机 (Java核心技术系列)

GO语言官网:Standard library – Go Packages

Java虚拟机规范:Chapter 4. The class File Format (oracle.com)

暂无评论

发送评论 编辑评论

|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇