31 Star 102 Fork 39

HankXV / Limitart

加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
克隆/下载
贡献代码
同步代码
取消
提示: 由于 Git 不支持空文件夾,创建文件夹后会生成空的 .keep 文件
Loading...
README
Apache-2.0

Maven Apache JDK 996.ICU

Image text

快速开始

Limitart鼓励一种可热更新依赖注入的项目搭建方式,具体可以参考project-starterproject-logic

Image text

一.通信

1.二进制通信快速开始

       //创建一个消息
       public class BinaryMessageDemo extends BinaryMessage {
           public String content = "hello limitart!";
   
           @Override
           public short id() {
               return BinaryMessages.createID(0X00, 0X01);
           }
   
       }

为这个消息创建处理器

    @Mapper
    public class BinaryManagerDemo {
    	public void doMessageDemo(@Request BinaryMessageDemo msg) {
    		System.out.println(msg.content);
    	}
    }
    //让消息工厂实例化注册消息处理器 注意:这里可以调用Router.create("[包名]","[自定义实例]")的接口来配合脚本加载器(ScriptLoader)或单例注入(Singletons)来初始化
    Router router = Router.empty().registerMapperClass(BinaryManagerDemo.class);

配置服务器实体

    BinaryEndPoint.server()
    				.router(router)
    				.build()
    				.start(AddressPair.withPort(8888));

开启客户端连接并发送消息

    BinaryEndPoint.client()
           .router(Router.empty()).onConnected((s, state) -> {
        if (state) {
            try {
                s.writeNow(new BinaryMessageDemo());
            } catch (Exception e) {
            }
        }
     }).build().start(AddressPair.withIP("127.0.0.1", 8888));

服务器日志+结果

[main] INFO BinaryMessageFactory - register msg BinaryMessageDemo at BinaryManagerDemo
[main] INFO AbstractNettyServer - Limitart-Binary-Server nio init
[nioEventLoopGroup-2-1] INFO AbstractNettyServer - Limitart-Binary-Server bind at port:8888
[nioEventLoopGroup-3-1] INFO AbstractNettyServer - /127.0.0.1:54062 connected!
hello limitart!

消息编码

消息长度(short,包含消息体长度+2)+消息ID(short)+消息体

2.Google Protobuf通信快速开始

创建跟1无异,只是入口变为ProtobufEndPoint

3.简单HTTP通信开始

        HTTPEndPoint.builder().onMessageIn((s, i) -> {
            if (i.getUrl().equals("/limitart")) {
                return "hello limitart!".getBytes(StandardCharsets.UTF_8);
            }
            return null;
        }).build().start(AddressPair.withPort(8080));

通过浏览器访问 http://127.0.0.1:8080/limitart

得到结果 hello limitart!

二.热加载

1.脚本源码模式

所有脚本都需要在基础代码里声明接口并继承

    public interface Script<KEY> {
        KEY key();
    }

其中的KEY为此脚本的唯一标识,是基础代码找到脚本实例的钥匙,通过在基础代码中埋点调用此脚本的方法。

    public interface HelloScript extends Script<Integer> {
      void sayHello();
    }
    public class HelloScriptImpl implements HelloScript {
        @Override
        public void sayHello() {
            System.out.println("hello script!!!");
        }
    
        @Override
        public Integer key() {
            return 1;
        }
    }

这里写一个简单的脚本,声明sayHello方法,并编写一个实现类,放在demo/script文件夹下。 然后我们启动一个定时重加载(也可以不定时,通过手动来调用重加载)的脚本加载器来加载demo/script目录下的所有脚本。

        FileScriptLoader<Integer> loader = new FileScriptLoader<>("./demo/script", 5);
        while (true) {
          Thread.sleep(1000);
          HelloScript script = loader.getScript(1);
          script.sayHello();
        }

输出的结果如下

hello script!!!

现在我们修改输出的内容如下,多了一段reload字符串

    public class HelloScriptImpl implements HelloScript {
        @Override
        public void sayHello() {
            System.out.println("hello script!!!reload!!!!");
        }
    
        @Override
        public Integer key() {
            return 1;
        }
    }

输出的结果如下,证明代码重新加载成功了

hello script!!!reload!!!!

通常这种热加载脚本的模式是把性能要求不高且修改频率高的逻辑剔出来,方便不停机修改逻辑,降低维护成本。应用的场景:游戏中活动逻辑的修改,副本逻辑的修改等。

2.agent模式

agent模式是利用了jvm提供的接口,用java的api直接附加的jvm上进行相应操作的一种技术(有些破解java程序也是使用这个技术,比如IDEA)。

这种模式直接加载jar包,需要引用sun.tools本地包,使用起来也是非常简单,他的模型就是一个基础的程序,我们叫他启动器(bootstraper)。他启动的时候会去加载一个jar包,在这个jar包里不存在需要持久化的数据,大部分都为逻辑(可以理解为脚本的集合)。

因为模型分为两部分:启动器+脚本jar包,所以这里我们先创建脚本jar包,脚本包里面不再由main函数为入口,而是一个自定义的类,他继承自RedefinableModule,如下

    public class ScriptEntrance extends RedefinableModule {
        private HelloRedefine helloRedefine = new HelloRedefine();
        @Override
        public void onStart(RedefinableApplicationContext context) {
            System.out.println("onStart");
            helloRedefine.hello();
        }
    
        @Override
        public void onRedefined(RedefinableApplicationContext context) {
            System.out.println("onRedefined");
            helloRedefine.hello();
        }
    
        @Override
        public void onDestroy(RedefinableApplicationContext context) {
            System.out.println("onDestroy");
        }
    }

其中HelloRedefine为本jar包里的一个逻辑,里面的方法很简单

    public class HelloRedefine {
        public void hello(){
            System.out.println("hello");
        }
    }

我们的目的是修改这个方法后,让他打印出修改后的内容,所以我们在onStart和onRedefine的时候打印一次,也就是在初次加载和重加载的时候分别打印,他们应该是不同的结果(除非未修改脚本)

我们把这个逻辑工程打包为script.jar

接下来再创建启动器工程,通过创建RedefinableApplication来加载jar包

        RedefinableApplication demo = new RedefinableApplication(new File("e://script.jar").toURI(),"top.limitart.redefinable.ScriptEntrance");
        demo.run(args);

执行run方法,打印结果如下

onStart
hello

然后我们马上修改HelloRedefine里的hello方法并重新打包

    public class HelloRedefine {
        public void hello(){
            System.out.println("hello,redefine!!");
        }
    }

调用RedefinableApplication的reload方法实现重加载

        demo.reload();

打印结果如下

onRedefined
hello,redefine!!

证明我们重加载成功了。

关于重加载的注意事项,比如是否可以修改数据结构,是否可以增加方法,删除方法,请关注jvm中关于类加载相关的内容。

三.简单单例(Singleton)IOC容器

首先IOC容器参考spring或者guice,本项目中的单例是最简单的一个实现,因为针对的应用场景也非常简单,比如:我有各种管理器,其中之一是玩家管理器,他只是做数据缓存和玩家的存取,整个项目中不会有其他实例,他就是一个单例而已。而且我并不想在其他地方调用的时候手动从某个地方去获取这个管理器,类似:Managers.getPlayerManager(),这样做代码非常冗余。这个时候我们就需要他自动注入这个管理器,随用随声明。

    @Singleton
    public class SingletonA {
      @Ref SingletonB singletonB;
    
      public void say() {
        System.out.println(singletonB);
      }
    }
    
    @Singleton
    public class SingletonB {
      @Ref SingletonA singletonA;
      @Ref SingletonC singletonC;
      @Ref SingletonD singletonD;
    
      public void say() {
        System.out.println(singletonA);
        System.out.println(singletonC);
        System.out.println(singletonD);
      }
    }
    
    @Singleton
    public class SingletonC {
      @Ref SingletonC singletonC;
    
      @Ref
      public SingletonC(SingletonA singletonA, SingletonB singletonB) {
        System.out.println("construct C");
      }
    }
    
    @Singleton
    public class SingletonD {
      @Ref
      public SingletonD(SingletonC singletonC) {
        System.out.println("construct D");
      }
    
      @Ref
      public void setSingletonA(SingletonA singletonA) {
        System.out.println(singletonA);
      }
    }

上面的单例A,B,C,D分别演示了字段注入,构造注入,方法注入和循环引用。声明单例的关键字为:@Singleton,声明注入的关键字为:@Ref。

创建单例容器

    new Singletons.Builder()
        .withPackage("top.limitart", SingletonDemo.class.getClassLoader())
        .bind(SingletonDemo.class)
        .build()
        .instance(SingletonDemo.class)
        .say();

你可以扫描你项目的package名称来加载全部单例,也可以在build前手动绑定(bind)一个单例。可以通过instace方法获取实例手动调用。调用结果如下

construct C
construct D
top.limitart.singleton.SingletonA@f107c50
top.limitart.singleton.SingletonA@f107c50
top.limitart.singleton.SingletonC@51133c06
top.limitart.singleton.SingletonD@4b213651
top.limitart.singleton.SingletonB@4241e0f4

了解更详细内容,可以点击这里

四.并发和并行

1.线程(消息队列或异步任务)

在游戏服务器或中间件服务器中都不可避免的需要根据任务类型将任务派发到不同线程中执行,比如大计算任务(统计计算等)、大IO任务(数据库、网络等交互)。还比如,需要把N个用户放在一个线程交互,避免数据发生多线程问题,或者在RPG游戏中,保证同地图在相同线程中(注:不是一个地图一个线程,可能是N个地图分一个线程)。在此项目中,我们使用TaskQueue创建一个线程来异步处理任务,TaskQueue实现了Executor接口并增加了cron表达式的调度,如下:

    TaskQueue queue = TaskQueue.create("test");
    queue.execute(()-> System.out.println("execute"));
    queue.submit(()-> System.out.println("submit")).get();
    queue.schedule(()-> System.out.println("schedule"), 1, TimeUnit.SECONDS);
    queue.schedule("cron1", "0 22 14 11 12 ? 2018", () -> System.out.println("tick1"));
    queue.schedule("cron1", "*/5 * * * * ? *", () -> System.out.println("tick2"));
    queue.schedule("cron1", "0 * * * * ? *", () -> System.out.println("tick3"));

了解思路可以点击这里

2.线程组

有些按逻辑划分的线程是需要一组线程来操作的,这里有一个简单的固定数量线程组TaskQueueGroup(我们并不推荐自动增长和自动销毁的线程容器,开销太大,不好把控)。下图创建有5个线程的线程组,并轮询执行任务

    TaskQueueGroup group = new TaskQueueGroup("limitart",5,s->TaskQueue.create(s));
    group.next().execute(()-> System.out.println("execute1"));
    group.next().execute(()-> System.out.println("execute2"));
    group.next().execute(()-> System.out.println("execute3"));
    group.next().execute(()-> System.out.println("execute4"));

3.线程切换模型

有时候我们一个网络会话Session会去持有一个Netty的Channel或其他类型的网络链接,我们想使用Session.getChannel()之类的方式直接拿到这个链接实例(这样更加面向对象),而不是通过一个ChannelManager或者ChannelSet用某个key来获取。但是我们担心持有了Channel实例会影响网络那边的链接释放导致内存泄漏。

还有一个例子,比如我们有个游戏手柄对象,有N多玩家对象,玩家需要来抢占手柄,并且我能通过玩家来拿到手柄对象,手柄同时只有一个玩家或没有

具体的例子,在RPG游戏中,玩家需要切换地图,玩家同时只能占有一张地图或者不占有地图,我们需要切换玩家的地图线程,编程过程中我们可以直接通过玩家拿到他在哪个地图,而不是通过玩家去地图管理器去拿。

这些例子的目的如下

1.让使用者明确知道需要资源的切换

2.方便调用者面向对象的思维,拿到什么就可以拿到他相关的东西,而不需要其他的集合容器或工具。

3.不关心资源释放,拿不到时说明他没有了,比如玩家下线了、地图人物登出、网络断线等

此项目中定义了这个模型,名为Actor(占用者),线程资源占用者为TaskQueueActor,是Actor的具体实现之一。Netty网络链接的占用者NettySessionActor。

拿NettySessionActor占用Netty网络链接举例

    BinaryEndPoint.builder(true)
        .router(
            Router.empty(BinaryMessage.class, BinaryRequestParam.class)
                .registerMapperClass(MessageMapper.class))
        .onConnected(//当链接连接或断开时
            (s, b) -> {
              if (b) {
                role.joinWhenFree(//role占用这个网络链接
                    s,
                    () -> {System.out.println("join session success!");
                        NettySession<BinaryMessage> where = role.where();//获取当前网络链接实例
                        where.writeNow();//写出消息
                    },
                    Throwable::printStackTrace);
              } else {
                role.leave(s);//当网络链接断开,释放
                System.out.println("leave session success");
              }
            })
        .build()
        .start(AddressPair.withPort(7878));

其中,在onConnected回调里(s为网络链接实例,b为布尔型,表示连接或断开),我们可以让这个role去占用这个网络链接(joinWhenFree),后续可以直接调用role.where()来使用,当网络链接断开时,可以调用role.leave()来取消占用。想知道role是否有网络链接很简单,判断role.where()是否为空就行了。

顺带一提,这种方式在处理玩家顶号也是极好的。

资源占用是用弱引用WeakRefHolder实现的。

4.线程切换模型扩展

这个模型其实可以用到任何一对一且不强关心资源释放的场景,比如玩家-地图,玩家-角色,角色-房间,玩家-帮会等等,项目中不再出现额外的资源管理器,只需要关心当前我拿到的对象他有什么,他可以做什么就行了。

五.基础包

1.唯一ID

很多场景我们需要生成唯一ID来标识对象的唯一性,方便做hash查找。比如玩家ID、房间ID。

区域的定义:我们不需要生成全球唯一ID,所以我们要根据不同的区域来实现该区域中的唯一ID。此项目中为AreaID,AreaID包含两个参数

majorID,主ID,范围1-255

minorID,次ID,范围1-65535

可以理解为省和市的关系。

唯一ID生成器:UniqueID,如果你不在意区域,可以用默认,也可以指定区域来区分不同区服,比如可以通过渠道编号+区服编号来组成AreaID。下面是使用方法,假设渠道ID为255,区服为1

    int areaID = AreaID.areaID(Byte.MAX_VALUE,1);
    byte channelID = AreaID.majorID(areaID);
    int serverID = AreaID.minorID(areaID);
            System.out.println("key:" + areaID + ",channel:" + channelID + ",server:" +serverID);
    for (int i = 0; i < 10000000; ++i) {
      long createUUID = UniqueID.nextID(areaID);
                  System.out.println(Thread.currentThread().getName() + ":" + createUUID);
    }

2.计数器

有时候我们讨厌写a+=1,a-=1,a+=12,a-=22等,写多了都不知道哪是哪了,这里提供了IntCounter和LongCounter,虽然很简单,但是能帮助理清逻辑,后面集合包里的计数Map更是对加快开发起了重要帮助,这里不赘述。

具体可以点击这里

3.比较器函数生成器

有时候写compare函数真的是让人头晕,是该返回1,0,-1?或者在long比较的时候越界了?这种情况只有在运行期才会体现出来,等出问题了就很头疼,这里提供了一个比较函数生成器CompareChain,参考的Apache的实现,使用方法很有过程化编程的风格。

  private static final Comparator<Bean> COMPARATOR =
      CompareChain.build(
          (o1, o2) -> CompareChain.start(o2.price * o2.item.getNum(), o1.price * o1.item.num));

4.持有器

单个:Alone,两个:Couple,三个:Triple

Alone,单个对象持有器,相当于包装一层,可用于某些回调中省去声明为final的麻烦(当然你要确定这个对象不会有多线程问题)。

Couple,相当于一对key-value。某些函数我需要返回一对数据,可以用这个。

Triple,同Couple

5.其他

Condition:简单版Guava的Precondition
Singleton:单例的包装实现
SmoothRandomWeight:平滑加权随机
SmoothRobinWeight:平滑加权轮询
URIScheme:通过资源定位符URI加载资源,文件,网络等
WeakRefHolder:弱引用包装
BinaryMeta:二进制元数据,二进制序列化的封装
Environment:通过Properties文件加载key-value配置
label包:各种标识性注解,非空@NotNull,可能空@Nullable,线程安全@ThreadSafe,非线程安全@ThreadUnsafe,调用者谨慎@CallerSensitive,枚举专用接口@EnumInterface,耗时操作@LongRunning
function包:帮助函数式开发的各种函数式接口

六.集合

1.排行榜

需要排序的结构我们要实现RankObj接口,比如我们对道具来排序(拍卖行经常用)

  public static class Bean implements RankMap.LongRankObj {
    private long id;
    private Item item;
    private int price;

    public Bean(long id, Item item, int price) {
      this.item = item;
      this.price = price;
      this.id = id;
    }

    public Bean copy() {
      return new Bean(this.id, this.item.copy(), this.price);
    }

    @Override
    public Long key() {
      return id;
    }

    @Override
    public String toString() {
      return "Bean{" + "id=" + id + ", item=" + item + ", price=" + price + '}';
    }
  }

  public static class Item {
    private int num;

    public Item copy() {
      return new Item(num);
    }

    public Item(int num) {
      this.num = num;
    }

    public int getNum() {
      return num;
    }

    public void setNum(int num) {
      this.num = num;
    }

    @Override
    public String toString() {
      return "Item{" + "num=" + num + '}';
    }
  }

实现他的比较器

  private static final Comparator<Bean> COMPARATOR =
      CompareChain.build(
          (o1, o2) -> CompareChain.start(o2.price * o2.item.getNum(), o1.price * o1.item.num));

然后做各种增删改查操作

    RankMap<Long, Bean> rankMap = RankMap.create(COMPARATOR, 100);
    Item i1 = new Item(2);
    Bean b1 = new Bean(1, i1, 100);
    Item i2 = new Item(3);
    Bean b2 = new Bean(2, i2, 100);
    Item i3 = new Item(3);
    Bean b3 = new Bean(3, i3, 500);
    Item i4 = new Item(3);
    rankMap.putIfAbsent(b1);
    rankMap.putIfAbsent(b2);
    System.out.println(rankMap.getAll());
    rankMap.update(b1.key(), v -> v.price = 200);
    System.out.println(rankMap.getAll());
    rankMap.putIfAbsent(b3);
    System.out.println(rankMap.getAll());
    rankMap.updateOrPut(4L, v -> v.price = 1, () -> new Bean(4, i4, 1));
    System.out.println(rankMap.getAll());

注意:在排行榜Map结构中,如果要修改涉及排序的数据,必须调用结构中的接口来完成,不要在外部更改,结构不会自动感知数据的变化。可用的接口可能为

  /**
   * 更新值
   *
   * @param key
   * @param process1
   */
  V update(@NotNull final K key, @NotNull final Process1<V> process1);
  
    /**
   * 新增或更新
   *
   * @param key
   * @param process1
   * @param instance
   */
  V updateOrPut(
          @NotNull final K key,
          @NotNull final Process1<V> process1,
          @NotNull final Function0<V> instance);

具体介绍可以点击这里

2.复合排行榜

同1,只不过可以有多个Comparator

3.Map计数器

如果我们直接使用Map的话,我们必须要处理

1.不管在放入计数或者是获取计数的时候是否存在一个键值对,如果不存在我们会初始化他

2.如果大部分的计数在常规状态下都为初始值(这里假设为0),那么我们会初始化一堆没有用的数据

3.每次计数改变的操作,都会先取出数据(取出的时候还要做第1步的检查),然后更改数值再放回,这些代码重复太多会让写代码的人不能直接关注需求本身而产生混乱从而导致很多BUG。

下面的代码简单的演示下上面的痛处

    //声明一个Map
    Map<String,Integer> tasks = new HashMap<>();
    //现在获取任务"kill monster"的进度
    String taskName = "kill monster";
    Integer taskProccess = tasks.get(taskName);
    //如果任务进度为空则初始化任务进度为0
    if(taskProccess == null){
        taskProccess = 0;
        tasks.put(taskName,taskProccess);
    }
    //任务进度+1
    taskProccess+=1;
    //这里由于惯性思维,在写很多复杂逻辑的时候很有可能会不做put操作而导致bug
    tasks.put(taskName,taskProccess);

那么看看经过优化过的IntMap是怎么写代码的

    IntMap<String> tasksNew = IntMap.empty();
    tasksNew.incrementAndGet(taskName);

两行代码解决,是不是轻松多了。

具体可以点击这里

4.枚举计数器

同Map计数器,不过key为枚举

5.枚举表

同List,不过key为枚举

6.缓存

ConcurrentLRUCache,最少使用集合,当达到设定阈值,就会先剔除最少使用的元素,保证内存够用,使用方法

  public static void main(String[] args) throws InterruptedException {
    ConcurrentLRUCache<Integer, CacheObj> cache = new ConcurrentLRUCache<>(5, true);
    ExecutorService executorService = Executors.newCachedThreadPool();
    AtomicInteger counter = new AtomicInteger(0);
    for (; ; ) {
      try {
        Thread.sleep(50);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
      executorService.execute(
          () -> {
            int andIncrement = counter.getAndIncrement();
            cache.put(andIncrement, new CacheObj(andIncrement));
          });
    }
  }

  public static class CacheObj extends ConcurrentLRUCache.LRUCacheable {
    private int value;

    public CacheObj(int value) {
      this.value = value;
    }

    @Override
    public String toString() {
      return "CacheObj{" + "value=" + value + '}';
    }

    @Override
    protected long aliveTime() {
      return 1000;
    }

    @Override
    protected boolean removable0() {
      return true;
    }

    @Override
    public void onRemoved(int currentSize) {
      System.out.println("removed " + this);
    }
  }

运行结果

removed CacheObj{value=0}
removed CacheObj{value=1}
removed CacheObj{value=2}
removed CacheObj{value=3}
removed CacheObj{value=4}
removed CacheObj{value=5}
[2019-12-22 23:00:05:662 WARN][pool-2-thread-1-ConcurrentLRUCache]collecting triggers too frequently,may be Memory Leak or Highly Concurrent

七.工具

对称加密工具EncryptionUtil
时间工具TimeUtil
编码工具CodecUtil
随机工具RandomUtil
敏感字SensitiveWords(暂行)
Copyright (c) 2016-present The Limitart Project 996 License Version 1.0 (Draft) Permission is hereby granted to any individual or legal entity obtaining a copy of this licensed work (including the source code, documentation and/or related items, hereinafter collectively referred to as the "licensed work"), free of charge, to deal with the licensed work for any purpose, including without limitation, the rights to use, reproduce, modify, prepare derivative works of, distribute, publish and sublicense the licensed work, subject to the following conditions: 1. The individual or the legal entity must conspicuously display, without modification, this License and the notice on each redistributed or derivative copy of the Licensed Work. 2. The individual or the legal entity must strictly comply with all applicable laws, regulations, rules and standards of the jurisdiction relating to labor and employment where the individual is physically located or where the individual was born or naturalized; or where the legal entity is registered or is operating (whichever is stricter). In case that the jurisdiction has no such laws, regulations, rules and standards or its laws, regulations, rules and standards are unenforceable, the individual or the legal entity are required to comply with Core International Labor Standards. 3. The individual or the legal entity shall not induce or force its employee(s), whether full-time or part-time, or its independent contractor(s), in any methods, to agree in oral or written form, to directly or indirectly restrict, weaken or relinquish his or her rights or remedies under such laws, regulations, rules and standards relating to labor and employment as mentioned above, no matter whether such written or oral agreement are enforceable under the laws of the said jurisdiction, nor shall such individual or the legal entity limit, in any methods, the rights of its employee(s) or independent contractor(s) from reporting or complaining to the copyright holder or relevant authorities monitoring the compliance of the license about its violation(s) of the said license. THE LICENSED WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN ANY WAY CONNECTION WITH THE LICENSED WORK OR THE USE OR OTHER DEALINGS IN THE LICENSED WORK.

简介

轻量级,高性能,少依赖,低级封装的服务器开发工具和项目搭建模板,可以开发游戏服务器和小型中间件等 展开 收起
Java 等 3 种语言
Apache-2.0
取消

贡献者

全部

近期动态

加载更多
不能加载更多了
Java
1
https://gitee.com/HankXV/Limitart.git
git@gitee.com:HankXV/Limitart.git
HankXV
Limitart
Limitart
3.x

搜索帮助

14c37bed 8189591 565d56ea 8189591