[Professor麦]总结Mybatis的设计模式

今天特意来了一篇设计模式的实战,跟着源码真正了解设计模式,因为我第一次学习设计模式的时候,都是只知道每一个具体的设计模式的意思,并没有了解到一些框架优秀的设计模式!今天特意写一下这个

总结Mybatis框架用到的设计模式

SqlSessionFactoryBuilder:为什么要用建造者模式来创建SqlSessionFactory?

简单谈谈建造者模式

这里主要说说为什么需要建造者模式?

建造者模式和工厂模式都是用来创建对象的。平常我们一般创建对象都是直接new,通过构造器或者setter把对象属性注入。那如果这时候我们的属性变量很多,额,这里直接举个例子吧!image-20200717205055626

我们可以看看上面的需求,我们可以这样写

public class ResourceConfig {
    
    private static final int DEFAULT_MAX_TOTAL = 8;
    private static final int DEFAULT_MAX_IDLE = 8;
    private static final int DEFAULT_MIN_IDLE = 8;
    
    private String name;
    
    private int maxTotal = DEFAULT_MAX_TOTAL;
    private int maxIdle = DEFAULT_MAX_IDLE;
    private int minIdle = DEFAULT_MIN_IDLE;

    public ResourceConfig(String name) {
        this.name = name;
    }
    
    // 省略getter和setter
    // 可以利用setter来给不是必填的变量赋值
}

上面这样写再构造参数不多的时候是可以这样写到的,那如果必填的构造函数很多呢?那这样做就不行了,因为很容易导致传参错位,从而导致程序出错。或者是变量与变量之间有一些校验逻辑关系,那也会造成构造函数臃肿

还有一种情况是,如果这个类是一个不可变类,那它的setter方法就不能向外提供了,所以上面这样写也是不满足的!

那这时候就需要建造者模式了

public class ResourceConfigBuilder {
    private String name;
    private int maxTotal;
    private int maxIdle;
    private int minIdle;

    private ResourceConfigBuilder(Builder builder) {
        this.name = builder.name;
        this.maxTotal = builder.maxTotal;
        this.maxIdle = builder.maxIdle;
        this.minIdle = builder.minIdle;

    }

    @Override
    public String toString() {
        return "ResourceConfigBuilder{" +
                "name='" + name + '\'' +
                ", maxTotal=" + maxTotal +
                ", maxIdle=" + maxIdle +
                ", minIdle=" + minIdle +
                '}';
    }
    // 省略getter

    public static class Builder{

        private static final int DEFAULT_MAX_TOTAL = 8;
        private static final int DEFAULT_MAX_IDLE = 8;
        private static final int DEFAULT_MIN_IDLE = 8;

        private int maxTotal = DEFAULT_MAX_TOTAL;
        private int maxIdle = DEFAULT_MAX_IDLE;
        private int minIdle = DEFAULT_MIN_IDLE;

        private String name;

        public ResourceConfigBuilder build(){
            // 校验逻辑放到这里
            // if.....
            if (maxIdle > maxTotal){
                throw new IllegalArgumentException("test");
            }
            return new ResourceConfigBuilder(this);
        }


        public Builder setMaxTotal(int maxTotal) {
            this.maxTotal = maxTotal;
            return this;
        }

        public Builder setMaxIdle(int maxIdle) {
            this.maxIdle = maxIdle;
            return this;
        }

        public Builder setMinIdle(int minIdle) {
            this.minIdle = minIdle;
            return this;
        }

        public Builder setName(String name) {
            this.name = name;
            return this;
        }
    }

    public static void main(String[] args) {
        ResourceConfigBuilder resourceConfigBuilder = new Builder()
                .setName("professor")
                .setMaxIdle(200)
                .setMinIdle(100)
                .setMaxTotal(400)
                .build();

        System.out.println(resourceConfigBuilder.toString());
    }
}

结果也如我们所料设置成功,并且该方法的构造函数是私有的,并且没有向外提供setter方法,同时满足了上面我说过的条件!!image-20200717211619776

那我们试试校验逻辑是否可以校验成功,也是成功的image-20200717211753686

进入主题

// 可以先看看这段代码,是进行Mybatis编程的时候必写的
private SqlSessionFactory factory;

@Before
public void setUp() throws Exception {
    // 这里用到了SqlSessionFactoryBuilder,看名字,你可能以为这是建造者模式来创建SqlSessionFactory对象,我们先看看源码
    factory = new SqlSessionFactoryBuilder().build(Resources.getResourceAsReader("mybatis-config.xml"));
}


// 我们可以看到这里根本没有setter方法,而且 build() 方法也并非无参,需要传递参数,这明显不符合建造者模式的套路啊!!
public class SqlSessionFactoryBuilder {

  public SqlSessionFactory build(Reader reader) {
    return build(reader, null, null);
  }

  public SqlSessionFactory build(Reader reader, String environment) {
    return build(reader, environment, null);
  }

  public SqlSessionFactory build(Reader reader, Properties properties) {
    return build(reader, null, properties);
  }

  public SqlSessionFactory build(Reader reader, String environment, Properties properties) {
    try {
      XMLConfigBuilder parser = new XMLConfigBuilder(reader, environment, properties);
      return build(parser.parse());
    } catch (Exception e) {
      throw ExceptionFactory.wrapException("Error building SqlSession.", e);
    } finally {
      ErrorContext.instance().reset();
      try {
        reader.close();
      } catch (IOException e) {
        // Intentionally ignore. Prefer previous error.
      }
    }
  }

  public SqlSessionFactory build(InputStream inputStream) {
    return build(inputStream, null, null);
  }

  public SqlSessionFactory build(InputStream inputStream, String environment) {
    return build(inputStream, environment, null);
  }

  public SqlSessionFactory build(InputStream inputStream, Properties properties) {
    return build(inputStream, null, properties);
  }

  public SqlSessionFactory build(InputStream inputStream, String environment, Properties properties) {
    try {
      XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, environment, properties);
      return build(parser.parse());
    } catch (Exception e) {
      throw ExceptionFactory.wrapException("Error building SqlSession.", e);
    } finally {
      ErrorContext.instance().reset();
      try {
        inputStream.close();
      } catch (IOException e) {
        // Intentionally ignore. Prefer previous error.
      }
    }
  }
    // 最终所有的构造函数都会调用这个方法
  public SqlSessionFactory build(Configuration config) {
    return new DefaultSqlSessionFactory(config);
  }

}

// 再来看看SqlSessionFactory
// 这个方法的成员变量只有一个,那为什么不直接用构造函数来呢?为什么还要借助建造者模式创建 SqlSessionFactory呢?
public class DefaultSqlSessionFactory implements SqlSessionFactory {

  private final Configuration configuration;

  public DefaultSqlSessionFactory(Configuration configuration) {
    this.configuration = configuration;
  }
}
//

好了,这里揭开上面的问题,我们debug进去看看,我们先进入这里image-20200717214129048

不妨进入parse方法看看,这里调用的是parseConfiguration这个方法,我们就知道大概原因了,因为这个Mybatis的这个Configuration要配置很多的成员变量才能成功创建,那这个SqlSessionFactoryBuilder就是简化我们的开发的,将过程隐藏起来,为Configuration对象的构建省略一些无关的逻辑!image-20200717214156753

这里不妨再看看Configurationimage-20200717214527591

SqlSessionFactory:到底属于工厂模式还是建造器模式?

浅谈工厂模式

工厂模式也是创建对象的一种设计模式

工厂模式又分为简单工厂、工厂方法和抽象工厂,这里就细谈一下简单工厂和工厂方法

简单工厂

举个例子:比如现在的业务是根据不同的文件后缀名选择不同的解析器

public class RuleConfigSource {

    public RuleConfig load(String ruleConfigFilePath){
        String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath);
        IRuleConfigParser parser = null;
        // 可以看到这一块逻辑很累赘
        if ("json".equalsIgnoreCase(ruleConfigFileExtension)){
            parser = new JsonRuleConfigParser();
        }else if ("xml".equalsIgnoreCase(ruleConfigFileExtension)){
            parser = new XmlRuleConfigParser();
        }else if ("yaml".equalsIgnoreCase(ruleConfigFileExtension)){
            parser = new YamlRuleConfigParser();
        }else {
            throw new RuntimeException("test error");
        }
        String configText = "";
        RuleConfig ruleConfig = parser.parse(configText);
        return ruleConfig;
    }

    private String getFileExtension(String ruleConfigFilePath) {
        return null;
    }

}

// 可以用简单工厂模式实现上面那个创建对象的功能
public class RuleConfigParserFactory {
    public static IRuleConfigParser createParser(String configFormat){
        IRuleConfigParser parser = null;
        if ("json".equalsIgnoreCase(configFormat)){
            parser = new JsonRuleConfigParser();
        }else if ("xml".equalsIgnoreCase(configFormat)){
            parser = new XmlRuleConfigParser();
        }else if ("yaml".equalsIgnoreCase(configFormat)){
            parser = new YamlRuleConfigParser();
        }else {
            throw new RuntimeException("test error");
        }
        return parser;
    }
}

public class RuleConfigSource {

    public RuleConfig load(String ruleConfigFilePath){
        String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath);
        // 直接调用工厂方法
        IRuleConfigParser parser = RuleConfigParserFactory.createParser(ruleConfigFileExtension);
        String configText = "";
        RuleConfig ruleConfig = parser.parse(configText);
        return ruleConfig;
    }
    private String getFileExtension(String ruleConfigFilePath) {
        return null;
    }
}

// 上面只是第一种方式,这种方式每次调用RuleConfigParserFactory.createParser都要新建一个对象,我们要不要结合一下单例模式,可以将 parser 事先创建好缓存起来。当调用 createParser() 函数的时候,我们从缓存中取出 parser 对象直接使用。
// 简单工厂模式的第二种实现
public class RuleConfigParserFactory {
    private static final Map<String, IRuleConfigParser> cachedParsers = new HashMap<String, IRuleConfigParser>();

    static {
        cachedParsers.put("json", new JsonRuleConfigParser());
        cachedParsers.put("xml", new XmlRuleConfigParser());
        cachedParsers.put("yaml", new YamlRuleConfigParser());
    }
    public static IRuleConfigParser createParser(String configFormat){
        if (configFormat == null){
            return null;
        }

        IRuleConfigParser parser = cachedParsers.get(configFormat.toLowerCase());
        return parser;
    }
}
// 上面这种方法虽然违反了开闭原则,但是修改的代码不多还是可以接受的

工厂方法

public interface IRuleConfigParserFactory {
    public RuleConfig parse(String configText);
}
public class JsonRuleConfigParser implements IRuleConfigParserFactory {
    public RuleConfig parse(String configText) {
        // 自己的逻辑
        return null;
    }
}
public class XmlRuleConfigParser implements IRuleConfigParserFactory {
    public RuleConfig parse(String configText) {
        // 自己的逻辑
        return null;
    }
}
public class YamlRuleConfigParser implements IRuleConfigParserFactory {
    public RuleConfig parse(String configText) {
        // 自己的逻辑
        return null;
    }
}
// 上面这种工厂方法更符合开闭原则
public class RuleConfigSource {

    public RuleConfig load(String ruleConfigFilePath){
        String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath);
        IRuleConfigParserFactory parser = RuleConfigParserFactoryMap.getParserFactory(ruleConfigFileExtension);

        String configText = "";
        RuleConfig ruleConfig = parser.parse(configText);
        return ruleConfig;
    }



    private String getFileExtension(String ruleConfigFilePath) {
        return null;
    }

}
// 工厂的工厂
// 当要增加新的parser类的时候,只需要实现该类的逻辑,并在下面这个类改一下static的内容即可

public class RuleConfigParserFactoryMap {
    private static final Map<String, IRuleConfigParserFactory> cachedParsers = new HashMap<String, IRuleConfigParserFactory>();

    static {
        cachedParsers.put("json", new JsonRuleConfigParser());
        cachedParsers.put("xml", new XmlRuleConfigParser());
        cachedParsers.put("yaml", new YamlRuleConfigParser());
    }
    public static IRuleConfigParserFactory getParserFactory(String type){
        if (type == null){
            return null;
        }

        IRuleConfigParserFactory parser = cachedParsers.get(type.toLowerCase());
        return parser;
    }
}

那什么时候该用工厂方法模式,而非简单工厂模式呢?

  1. 当对象的创建逻辑比较复杂,不只是简单的 new 一下就可以,而是要组合其他类对象,做各种初始化操作的时候,我们推荐使用工厂方模式,将复杂的创建逻辑拆分到多个工厂类中,让每个工厂类都不至于过于复杂。而使用简单工厂模式,将所有的创建逻辑都放到一个工厂类中,会导致这个工厂类变得很复杂。
  2. 如果我们还想避免烦人的 if-else 分支逻辑,这个时候,我们就推荐使用工厂方法模式。

回到主题

可以先看看DefaultSqlSessionFactory的源码,image-20200718083113971

BaseExecutor:模板模式跟普通的继承有什么区别?

模板模式和继承,这里就不细说了。模板模式基于继承来实现代码复用。如果抽象类中包含模板方法,模板方法调用有待子类实
现的抽象方法,那这一般就是模板模式的代码实现。而且,在命名上,模板方法与抽象方法一般是一一对应的,抽象方法在模板方法前面多一个“do”,比如,在 BaseExecutor 类中,其中一个模板方法叫 update(),那对应的抽象方法就叫 doUpdate()。

可以看看BaseExecutor的源码image-20200718084616616

再来看看SimpleExecutorimage-20200718084713020

SqlNode:如何利用解释器模式来解析动态 SQL?

关于解析动态SQL,我上篇文章也有简单提了一下。

简单谈谈解析器模式

它用来描述如何构建一个简单的“语言”解释器,说白了,其实就是一个翻译器,好像百度翻译,将输入的中文翻译成英文。这里的翻译器就是解释器模式定义中的“解释器”。其实这种设计模式用得不多,所以就不重点介绍了!

回到正题

拿什么是动态SQL呢?就是在SQL 中可以包含在 trim、if、#{}等语法标签,在运行时根据条件来生成不同的 SQL。解释器模式在解释语法规则的时候,一般会把规则分割成小的单元,特别是可以嵌套的小单元,针对每个小单元来解析,最终再把解析结果合并在一起。这里也不例外。MyBatis 把每个语法小单元叫 SqlNode,这样就可以形成一颗SqlNode树

// 这个代码很简单
public interface SqlNode {
  boolean apply(DynamicContext context);
}

image-20200718090713288

每一个节点都会解析不同的语法,这里就不细讲了

ErrorContext:如何实现一个线程唯一的单例模式?

在 MyBatis 中,ErrorContext 这个类就是标准单例的变形:线程唯一的单例。image-20200718091157264

Cache:为什么要用装饰器模式而不设计成继承子类

谈谈装饰器模式

装饰器模式其实跟继承是很相似的。下面我们举一个JAVA IO的例子,因为java的IO设计就是用的装饰器模式

  1. 装饰器类和原始类继承同样的父类,这样我们可以对原始类“嵌套”多个装饰器类。

    // 可以看看这段代码理解一下
    // 让它既支持缓存读取,又支持按照基本数据类型来读取数据
    InputStream in = new FileInputStream("test.txt");
    InputStream bin = new BufferedInputStream(in);
    DataInputStream din = new DataInputStream(bin);
    int data = din.readInt();
    

    那如果不用装饰器模式,用继承来实现这个增强功能的话可以用一个类搞定

    // 
    InputStream din = new FileBufferedDataInputStream("test.txt");
    int data = din.readInt();
    

    像上面那样,如果用继承的话,看起来确实简洁了,但是内部实现确非常复杂,我们置到InputStream有很多子类继承,我们不可能让每个子类都实现都继承一些我们想要的功能吧,这样做的话真的是要做很多个组合类才行!!所以,用继承来实现显然是不妥的。

  2. 装饰器类是对功能的增强,这也是装饰器模式应用场景的一个重要特点。其实它只是把用继承实现的增强换成了用组合模式来实现

回到主题

如果对Mybatis缓存不太了解的话,可以先看看这篇文章。以LruCache为例分析一下

public class LruCache implements Cache {
	// 以组合的方式代替继承
  private final Cache delegate;
    // 缓存Map
  private Map<Object, Object> keyMap;
  private Object eldestKey;

  public LruCache(Cache delegate) {
    this.delegate = delegate;
    setSize(1024);
  }

  @Override
  public String getId() {
    return delegate.getId();
  }

  @Override
  public int getSize() {
    return delegate.getSize();
  }

  public void setSize(final int size) {
    keyMap = new LinkedHashMap<Object, Object>(size, .75F, true) {
      private static final long serialVersionUID = 4267176411845948333L;

      @Override
      protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) {
        boolean tooBig = size() > size;
        if (tooBig) {
          eldestKey = eldest.getKey();
        }
        return tooBig;
      }
    };
  }

  @Override
  public void putObject(Object key, Object value) {
    delegate.putObject(key, value);
    cycleKeyList(key);
  }

  @Override
  public Object getObject(Object key) {
      // 增强方法
    keyMap.get(key); //touch
    return delegate.getObject(key);
  }

  @Override
  public Object removeObject(Object key) {
    return delegate.removeObject(key);
  }

  @Override
  public void clear() {
    delegate.clear();
    keyMap.clear();
  }

  @Override
  public ReadWriteLock getReadWriteLock() {
    return null;
  }

  private void cycleKeyList(Object key) {
    keyMap.put(key, key);
    if (eldestKey != null) {
      delegate.removeObject(eldestKey);
      eldestKey = null;
    }
  }

}

可以看到上面就是典型的装饰器模式的实现。

Log:如何使用适配器模式来适配不同的日志框架?

谈谈适配器模式

这个模式就是用来做适配的,它将不兼容的接口转换为可兼容的接口,让原本由于接口不兼容而不能一起工作的类可以一起工作。我们可以举个例子

// 类适配器: 基于继承
public interface ITarget {
void f1();
void f2();
void fc();
}

// Adaptee 是一组不兼容 ITarget接口定义的接口
public class Adaptee {
public void fa() { //... }
public void fb() { //... }
public void fc() { //... }
}
 // 通过一个继承+接口的方法实现将 Adaptee 转化成一组符合 ITarget 接口定义的接口   
public class Adaptor extends Adaptee implements ITarget {
public void f1() {
super.fa();
}
public void f2() {
//...重新实现f2()...
}
// 这里fc()不需要实现,直接继承自Adaptee,这是跟对象适配器最大的不同点
}    
  
    
// 还有一种方法
// 对象适配器:基于组合
public interface ITarget {
void f1();
void f2();
void fc();
}
public class Adaptee {   
public void fa() { //... }
public void fb() { //... }
public void fc() { //... }
}
public class Adaptor implements ITarget {
private Adaptee adaptee;
public Adaptor(Adaptee adaptee) {
this.adaptee = adaptee;
}
public void f1() {
adaptee.fa(); //委托给Adaptee
}
public void f2() {
//...重新实现f2()...
}
public void fc() {
adaptee.fc();
}
}
  1. 如果 Adaptee 接口并不多,那两种实现方式都可以。
  2. 如果 Adaptee 接口很多,而且 Adaptee 和 ITarget 接口定义大部分都相同,那我们推荐使用类适配器,因为 Adaptor 复用父类 Adaptee 的接口,比起对象适配器的实现方式,Adaptor 的代码量要少一些。
  3. 如果 Adaptee 接口很多,而且 Adaptee 和 ITarget 接口定义大部分都不相同,那我们推荐使用对象适配器,因为组合结构相对于继承更加灵活。

回到正题

MyBatis 并没有直接使用 Slf4j提供的统一日志规范,而是自己又重复造轮子,定义了一套自己的日志访问接口。

public interface Log {

  boolean isDebugEnabled();

  boolean isTraceEnabled();

  void error(String s, Throwable e);

  void error(String s);

  void debug(String s);

  void trace(String s);

  void warn(String s);

}

image-20200718101556412

我们看看Log4jImpl

public class Log4jImpl implements Log {
  
  private static final String FQCN = Log4jImpl.class.getName();
	// 用的组合方式实现适配器
  private Logger log;

  public Log4jImpl(String clazz) {
    log = Logger.getLogger(clazz);
  }

  @Override
  public boolean isDebugEnabled() {
    return log.isDebugEnabled();
  }

  @Override
  public boolean isTraceEnabled() {
    return log.isTraceEnabled();
  }

  @Override
  public void error(String s, Throwable e) {
    log.log(FQCN, Level.ERROR, s, e);
  }

  @Override
  public void error(String s) {
    log.log(FQCN, Level.ERROR, s, null);
  }

  @Override
  public void debug(String s) {
    log.log(FQCN, Level.DEBUG, s, null);
  }

  @Override
  public void trace(String s) {
    log.log(FQCN, Level.TRACE, s, null);
  }

  @Override
  public void warn(String s) {
    log.log(FQCN, Level.WARN, s, null);
  }

}

利用职责链与代理模式实现Mybatis插件机制

谈谈职责链模式

在职责链模式中,多个处理器(也就是刚刚定义中说的“接收对象”)依次处理同一个请求。一个请求先经过 A 处理器处理,然后再把请求传递给 B 处理器,B 处理器处理完后再传递给 C 处理器,以此类推,形成一个链条。链条上的每个处理器各自承担各自的处理职责,所以叫作职责链模式。(这里有点像Netty的PipleLine种的handler,每一个handler都有自己的职责,昨晚自己的任务就通过handlerContext传递给下一个handler处理下一个任务)

我们来以Netty来举个例子吧,在这篇文章谈到过Netty的使用,不熟悉Netty的可以先看看。image-20200718103849997

谈谈代理模式

它在不改变原始类(或叫被代理类)代码的情况下,通过引入代理类来给原始类附加功能。最典型的实现就是Spring-AOP了,不了解的朋友可以看看我之前的文章

重点说说动态代理的原理,就是我们不事先为每个原始类编写代理类,而是在运行的时候,动态地创建原始类对应的代理类,然后
在系统中用代理类替换掉原始类。直接看看代码实现吧

// 被代理类
public interface IVehical {
    void run();
}
public class Car implements IVehical {
    public void run() {
        System.out.println("Car会跑");
    }
}

// 动态代理类
public class DynamicProxyHandler implements InvocationHandler {
    private Object proxiedObject;

    public DynamicProxyHandler(Object proxiedObject) {
        this.proxiedObject = proxiedObject;
    }

    /**
     * 执行代理类逻辑
     */
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        long startTimeStamp = System.currentTimeMillis();
        // 调用被代理类
        Object result = method.invoke(proxiedObject, args);
        long endTimeStamp = System.currentTimeMillis();
        long responseTime = endTimeStamp - startTimeStamp;
        System.out.println("该接口调用时间:"+ responseTime);
        String apiName = proxiedObject.getClass().getName() + ":" + method.getName();
        System.out.println("接口名字:" + apiName);

        return result;
    }
}

// 测试
  public static void main(String[] args) {
        IVehical car = new Car();
        // 创建代理类与被代理类的关系
      // newProxyInstance
        IVehical iVehical = (IVehical) Proxy.newProxyInstance(car.getClass().getClassLoader(), Car.class.getInterfaces(), new DynamicProxyHandler(car));
        // 这里调用被代理类的时候就可以先调用代理类的逻辑了
        iVehical.run();
    }

Mybatis插件机制的实现

个人唠叨

嘻嘻,先报告一下今天的算法学习情况,一切良好,两个多小时,题量应该有4或者5题这样子,已经开始有感觉了(虽然还未能free bug,但是已经可以写到完整的代码,思路也十分清晰了,只是细节方面不太注意!)有进步,哈哈哈,还是要多刷题啊,最近刚认识了一位师兄,他说都刷了两年了,每天如此image-20200721225251056

大佬不愧是大佬哎!!要多学习学习

谈谈最近的状态

最近的状态有点飘,主要是因为努力的方向出了一点点小问题,徘徊在应该怎么复习基础知识上,于是我就把问题抛上了知识星球和老师,看看大佬们都怎么回答的!!问题是这样子的image-20200721225536352

看看大佬是怎么回复我的image-20200721225720601

然后,师兄也是这么说的,首先上来也夸了我一下,还是有点飘了!!然后说我算法比骄傲薄弱(确实)!所以之后重点还是在算法,至于基础知识的复习,还是通过面试题的方式或者是看源码的方式复习到!!后期会出具体的文章,我是怎么复习基础知识的,敬请关注!!

好了,今天就说这么多吧!!晚安!!!

对于上面我提到的问题,有想法的大佬欢迎评论区给我留言,感谢感谢!!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值