自定义实现代码沙箱

本代码共提供两种实现方案

仓库地址:https://gitee.com/cui-lingyu/CodeSanbox

实现功能

  • api 签名认证
  • docker 实现
  • 本地实现 (本地实现未改造为 抽象模板方式,推荐使用 docker 容器方式)
  • 容器池管理 docker 容器
  • 运行 java 代码 (目前只支持 java 代码,后续可能会继续扩展,也可 fork 自定义扩展,只需继承 AbstractionCodeSandBox 抽象模板 即可自定义)

签名认证

在拦截器中去验证签名并防止请求重放攻击

主要参数

  • accessKey:每个用户独有,配合secretKey使用
  • nonce:用来防止请求重放,会放在 redis 中缓存三秒,若三秒内nonce 相同责视为重放攻击
  • timestamp:与nonce 配合使用都是用来防止重放的,判断条件:请求发送到这里时,若与当前时间戳相差大于 3 秒则视为重放
  • autograph:签名。生成规则为:通过 算法的方法名+盐值(可以在配置文件中配置)+secretKey 生成 32 位 MD5 摘要值并转为 16 进制字符串作为签名的值
autograph:
  salt: "CrazyRain"

拦截器

@Component
public class SignInterceptor implements HandlerInterceptor {


    @Value("${autograph.salt}")
    private String salt;

    @Resource
    private UsersService usersService;

    @Resource
    private RedisTemplate<String, String> redisTemplate;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        RepeatableHttpServletRequest repeatableHttpServletRequest = (RepeatableHttpServletRequest) request;
        //在读取参数之前设置响应编码 否则设置失效
        response.setContentType("application/json;charset=UTF-8");
        String accessKey = repeatableHttpServletRequest.getHeader("access-key");
        String nonce = repeatableHttpServletRequest.getHeader("nonce");
        String timestamp = repeatableHttpServletRequest.getHeader("timestamp");
        String autograph = repeatableHttpServletRequest.getHeader("autograph");

        //非空判断
        if (StringUtils.isAnyBlank(accessKey, nonce, timestamp, autograph)) {
            PrintWriter writer = response.getWriter();
            writer.write(error("验证失败", 4001));
            return false;
        }
        //判断是否为请求重放
        ValueOperations<String, String> stringStringValueOperations = redisTemplate.opsForValue();
        String nonceTmp = stringStringValueOperations.get(nonce);
        if (StringUtils.isNotBlank(nonceTmp)) {
            PrintWriter writer = response.getWriter();
            writer.write(error("重放攻击", 4002));
            return false;
        }
        //设置随机数过期时间为3秒
        stringStringValueOperations.set("nonce", nonce, 3, TimeUnit.SECONDS);
        //判断时间戳是否离当前时间过长
        long timestampLong = Long.parseLong(timestamp);

        long currentTimeMillis = System.currentTimeMillis() / 1000;

        // 如果请求发送到这里已经间隔三秒 则表示不通过
        if (currentTimeMillis - timestampLong > 3) {
            PrintWriter writer = response.getWriter();
            writer.write(error("请求超时", 4003));
            return false;
        }

        //获取请求参数
        BufferedReader requestReader = repeatableHttpServletRequest.getReader();
        StringBuilder body = new StringBuilder();
        String line = "";
        while ((line = requestReader.readLine()) != null) {
            body.append(line);
        }
        String bodyString = body.toString();
        ExecutionParameter executionParameter = JSONUtil.toBean(bodyString, ExecutionParameter.class);
        LambdaQueryWrapper<Users> usersLambdaQueryWrapper = new LambdaQueryWrapper<>();
        usersLambdaQueryWrapper.eq(Users::getAccessKey, accessKey);
        Users user = usersService.getOne(usersLambdaQueryWrapper);
        if (user == null) {
            PrintWriter writer = response.getWriter();
            writer.write(error("用户不存在", 4003));
            return false;
        }
        //根据相同的数据 和算法生成签名 对比 传递过来的签名是否相同
        String secretKey = user.getSecretKey();
        String methodName = executionParameter.getMethodName();
        String sign = DigestUtil.md5Hex(methodName
                + salt
                + secretKey);
        if (!autograph.equals(sign)) {
            PrintWriter writer = response.getWriter();
            writer.write(error("签名认证失败", 4004));
            return false;
        }
        return HandlerInterceptor.super.preHandle(repeatableHttpServletRequest, response, handler);
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
    }


    private String error(String errorMessage, Integer errorCode) {
        HashMap<String, Object> stringObjectHashMap = new HashMap<>() {
            {
                put("success", false);
                put("errorMessage", errorMessage);
                put("errorCode", errorCode);
            }
        };
        return JSONUtil.toJsonStr(stringObjectHashMap);
    }
}

因为 请求体只可以被获取一次所以需要自定义包装类缓存当请求体

自定义请求包装类

package com.crazy.rain.inkfragrancepavilion.wrapper;

import cn.hutool.core.io.IoUtil;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import org.apache.commons.io.IOUtils;

import java.io.*;

public class RepeatableHttpServletRequest extends HttpServletRequestWrapper {
    private final byte[] bytes;

    public RepeatableHttpServletRequest(HttpServletRequest request) throws IOException {
        super(request);
        bytes = IoUtil.readBytes(request.getInputStream());
    }

    @Override
    public ServletInputStream getInputStream() {
        return new ServletInputStream() {
            private int lastIndexRetrieved = -1;
            private ReadListener readListener = null;


            @Override
            public boolean isFinished() {
                return (lastIndexRetrieved == bytes.length - 1);
            }

            @Override
            public boolean isReady() {
                return isFinished();
            }

            @Override
            public void setReadListener(ReadListener readListener) {
                this.readListener = readListener;
                if (!isFinished()) {
                    try {
                        readListener.onDataAvailable();
                    } catch (IOException e) {
                        readListener.onError(e);
                    }
                } else {
                    try {
                        readListener.onAllDataRead();
                    } catch (IOException e) {
                        readListener.onError(e);
                    }
                }
            }

            @Override
            public int read() throws IOException {
                int i;
                if (!isFinished()) {
                    i = bytes[lastIndexRetrieved + 1];
                    lastIndexRetrieved++;
                    if (isFinished() && (readListener != null)) {
                        try {
                            readListener.onAllDataRead();
                        } catch (IOException ex) {
                            readListener.onError(ex);
                            throw ex;
                        }
                    }
                    return i;
                } else {
                    return -1;
                }
            }
        };
    }

    @Override
    public BufferedReader getReader() {
        ByteArrayInputStream is = new ByteArrayInputStream(bytes);
        return new BufferedReader(new InputStreamReader(is));
    }


}

过滤器

在过滤器中包装请求对象

package com.crazy.rain.inkfragrancepavilion.filter;

import com.crazy.rain.inkfragrancepavilion.wrapper.RepeatableHttpServletRequest;
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import java.io.IOException;

@WebFilter(filterName = "HttpServletRequestFilter",urlPatterns = {"/**"})
@Order(0)
@Component
public class HttpServletRequestFilter implements Filter {

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
            throws IOException, ServletException {
        HttpServletRequest httpServletRequest = new RepeatableHttpServletRequest((HttpServletRequest) servletRequest);
        HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse;
        filterChain.doFilter(httpServletRequest, httpServletResponse);
    }

}

docker 方式

docker 容器池

  1. ContainerPool 容器构造函数

  2. 创建 dockerClient 客户端

  3. 调用容器初始化方法 initializePool

  4. 容器初始化

  5. HostConfig 配置 docker 容器相关配置(比如:数据卷挂载、内存限制等)

  6. 设置 linux 安全模块 seccomp 配置

  7. getAvailableContainer 获取容器 id

  8. returnContainer 将容器设置为空闲状态

  9. ContainerInfo 容器相关属性信息

/**
 * 容器池
 */
@Slf4j
public class ContainerPool {

    private static final String USER_DIT = System.getProperty("user.dir");

    private static final String SEPARATOR = File.separator;
    private static final String RESOURCES_DIT = "src" + SEPARATOR + "main" + SEPARATOR + "resources";
    private static final String GLOBAL_USER_CATALOG = USER_DIT + SEPARATOR + RESOURCES_DIT + SEPARATOR + "userCode";
    private final String imageName;

    private final List<ContainerInfo> containerPool;
    private final int poolSize;
    @Getter
    private final DockerClient dockerClient;

    public ContainerPool(int poolSize, String imageName) {
        this.poolSize = poolSize;
        this.imageName = imageName;
        containerPool = new ArrayList<>();
        DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder()
                .withApiVersion("1.46")
                .build();
        //创建docker http信息
        DockerHttpClient httpClient = new ApacheDockerHttpClient
                .Builder()
                .dockerHost(config.getDockerHost())
                .maxConnections(100)
                .connectionTimeout(Duration.ofSeconds(30))
                .responseTimeout(Duration.ofSeconds(45))
                .build();
        dockerClient = DockerClientImpl.getInstance(config, httpClient);
        initializePool();
    }

    private void initializePool() {
        for (int i = 0; i < poolSize; i++) {
            //容器配置
            HostConfig hostConfig = new HostConfig();
            //限制容器最大内存
            hostConfig.withMemory(100 * 1000 * 1000L);
            //限制内存交换
            hostConfig.withMemorySwap(0L);
            //限制使用cpu数量
            hostConfig.withCpuCount(1L);
            //限制用户不能向Root写文件
            hostConfig.withReadonlyRootfs(true);
            String containerDirName = "container" + i;
            String containerDirPath = GLOBAL_USER_CATALOG + SEPARATOR + containerDirName;
            if (!FileUtil.exist(containerDirPath)) {
                FileUtil.mkdir(containerDirPath);
            }
            hostConfig.setBinds(new Bind(containerDirPath,
                    new Volume(SEPARATOR + "home" + SEPARATOR + "rain" + SEPARATOR + "app")));
            //读取安全配置文件
            List<String> seccompList = FileUtil.readUtf8Lines(USER_DIT + SEPARATOR + RESOURCES_DIT
                    + SEPARATOR + "seccomp.json");
            StringBuilder seccompStr = new StringBuilder("seccomp=");
            for (String str : seccompList) {
                seccompStr.append(str);
            }
            //开启安全配置文件
            hostConfig.withSecurityOpts(List.of(seccompStr.toString()));
            CreateContainerCmd containerCmd = dockerClient.createContainerCmd(imageName);
            CreateContainerResponse container = containerCmd.withAttachStdin(true)
                    .withName("container" + i)
                    //这三个属性表示让这个容器返回一个命令行而不是Jshell
                    .withTty(true)
                    .withStdinOpen(true)
                    .withCmd("bin/sh")
                    //关闭网络
                    .withNetworkDisabled(true)
                    .withHostConfig(hostConfig)
                    .withAttachStderr(true)
                    .withAttachStdout(true)
                    .exec();
            dockerClient.startContainerCmd(container.getId()).exec();
            containerPool.add(new ContainerInfo(container.getId(), true, containerDirName));
        }
    }

    /**
     * 返回容器id
     * @return 容器id
     */
    public ContainerInfo getAvailableContainer() {
        for (ContainerInfo containerInfo : containerPool) {
            if (containerInfo.isAvailable()) {
                containerInfo.setAvailable(false);
                return containerInfo;
            }
        }
        return null;
    }

    /**
     * 刷新容器状态
     * @param containerId 容器id
     */
    public void returnContainer(String containerId) {
        if (StringUtils.isBlank(containerId)) {
            log.info("容器id不存在:{}", containerId);
            return;
        }
        for (ContainerInfo containerInfo : containerPool) {
            if (containerInfo.getId().equals(containerId)) {
                containerInfo.setAvailable(true);
                break;
            }
        }
    }


    @Getter
    public static class ContainerInfo {
        private final String id;

        @Setter
        private boolean available;

        private final String containerDirName;

        public ContainerInfo(String id, boolean available, String containerDirName) {
            this.id = id;
            this.containerDirName = containerDirName;
            this.available = available;
        }

    }

}

docker 运行代码执行流程

  1. 对上传的代码进行安全校验

  2. 获取 docker 容器

  3. 若 docker 容器有空闲就可以直接获取,否则阻塞(通过 semaphore 实现)

  4. 将代码保存到本地

  5. 读取默认代码模板,将用户上传的代码追加到默认代码模板中,再保存到本地

  6. 因为配置了数据卷挂载所以 docker 容器会自动同步这个代码文件

  7. 运行代码

  8. 生成代码执行命令

  9. 通过 docker 客户端执行该命令

  10. 收集代码执行结果返回

  11. 收集结果

  12. 清理文件等资源

@Slf4j
public abstract class AbstractionCodeSandBox implements CodeSandBox {

    //TODO 修改获取资源的方式

    private static final String USER_DIT = System.getProperty("user.dir");

    private static final String SEPARATOR = File.separator;
    private static final String RESOURCES_DIT = "src" + SEPARATOR + "main" + SEPARATOR + "resources";
    private static final String GLOBAL_USER_CATALOG = USER_DIT + SEPARATOR + RESOURCES_DIT + SEPARATOR + "userCode";
    private static final String GLOBAL_USER_CODE_FILE_NAME = "Main.java";
    public static final List<String> BLACKLIST = new ArrayList<>();

    public static WordTree WORD_TREE;


    static {
        WORD_TREE = new WordTree();
        WORD_TREE.addWord(BLACKLIST.toString());
    }

    @Value("${code-sand-box.image-name}")
    private String imageName;

    @Resource
    private ContainerPool containerPool;

    /**
     * 处理异常情况
     * @param problemStatusEnum 系统执行状态
     * @param testResultEnum 用户代码执行状态
     * @param executiveMessage 系统执行信息
     * @param judgeInfoMessage 用户代码执行信息
     * @return 返回执行信息
     */
    public static ExecutiveOutcome exceptionProcessor(ProblemStatusEnum problemStatusEnum, TestResultEnum testResultEnum
            , String executiveMessage, String judgeInfoMessage) {
        ExecutiveOutcome executiveOutcome = new ExecutiveOutcome();
        if (problemStatusEnum != null) {
            executiveOutcome.setStatus(problemStatusEnum.getStatus());
            if (StringUtils.isNotBlank(executiveMessage)) {
                executiveOutcome.setMessage(executiveMessage);
            } else {
                executiveOutcome.setMessage(problemStatusEnum.getMessage());
            }
        }

        if (testResultEnum != null) {
            JudgeInfo judgeInfo = new JudgeInfo();
            judgeInfo.setStatus(testResultEnum.getStatus());
            judgeInfo.setMessage(testResultEnum.getMessage());
            if (StringUtils.isNotBlank(judgeInfoMessage)) {
                judgeInfo.setMessage(judgeInfoMessage);
            } else {
                judgeInfo.setMessage(testResultEnum.getMessage());
            }
            executiveOutcome.setJudgeInfo(judgeInfo);
        }
        return executiveOutcome;
    }

    /**
     * 校验用户代码
     * @param code 待校验的代码
     * @return 校验结果
     */
    public boolean verifyCode(String code) {
        //获取到用户代码 校验用户代码中是否存在违规关键字
        FoundWord foundWord = WORD_TREE.matchWord(code);
        if (foundWord != null) {
            log.error("代码不合法:{}", foundWord.getFoundWord());
            return false;
        }
        return true;
    }

    /**
     * 保存用户代码
     */
    public String saveUserCodeFile(String code, String containerDirName) {
        //判断用户代码目录是否存在,不存在则创建
        if (!FileUtil.exist(GLOBAL_USER_CATALOG)) {
            FileUtil.mkdir(GLOBAL_USER_CATALOG);
        }
        //读取默认调用用户代码的模板
        List<String> strings = FileUtil.readLines(GLOBAL_USER_CATALOG + SEPARATOR
                + "Main.java", StandardCharsets.UTF_8);
        //拼接成字符串
        StringBuilder stringBuilder = new StringBuilder();
        strings.forEach(item -> stringBuilder.append(item).append("\n"));
        //拼接用户代码(这里主要是将用户代码作为我们默认模板的内部类去使用,主要通过反射去实例化内部类对象,并调用方法)
        stringBuilder.append(code);
        //创建本次用户上传的目录(唯一)
        String userDirName = GLOBAL_USER_CATALOG + SEPARATOR + containerDirName + SEPARATOR;
        String userCodeFile = userDirName + SEPARATOR + GLOBAL_USER_CODE_FILE_NAME;
        //将拼接好的文件保存
        File file = FileUtil.writeUtf8String(stringBuilder.toString(), userCodeFile);
        return file.getAbsolutePath();
    }


    /**
     * 组装执行命令
     * @param inputList 用户输入,可为null
     * @param methodName 待执行算法名
     * @return 待执行命令
     */
    public List<String[]> createExecuteCommands(List<String> inputList, String methodName, String containerDirName) {
        ArrayList<String[]> strings = new ArrayList<>();
        if (inputList != null && !inputList.isEmpty()) {
            inputList.forEach(item -> {
                String[] inputArgsArray = item.split(",");
                strings.add(ArrayUtil.append(new String[]{"java", SEPARATOR + "home" + SEPARATOR + "rain" +
                                SEPARATOR + "app" + SEPARATOR + containerDirName
                                + SEPARATOR + "Main.java", methodName},
                        inputArgsArray));
            });
        } else {
            //构建命令
            strings.add(ArrayUtil.append(new String[]{"java", SEPARATOR + "home" + SEPARATOR + "rain" +
                    SEPARATOR + "app" + SEPARATOR + "Main.java", methodName}));
        }

        return strings;
    }

    public List<ExecutionInformation> runFile(String containerId, List<String> inputs, String containerDirName
            , String methodName) {

        //用户代码是否执行异常
        ArrayList<ExecutionInformation> executionInformationArrayList = new ArrayList<>();
        DockerClient dockerClient = containerPool.getDockerClient();
        try {
            //获取执行命令
            List<String[]> executeCommands = createExecuteCommands(inputs, methodName, containerDirName);
            // 拼接用户程序执行错误信息
            StringBuilder errorMessage = new StringBuilder();
            // 拼接用户程序执行成功信息
            StringBuilder message = new StringBuilder();
            ExecutionInformation executionInformation = new ExecutionInformation();
            executeCommands.forEach(item -> {
                ExecCreateCmdResponse execCreateCmdResponse = dockerClient.execCreateCmd(containerId)
                        .withCmd(item)
                        .withAttachStderr(true)
                        .withAttachStdin(true)
                        .withAttachStdout(true)
                        .exec();
                //标记代码是否运行超时
                final boolean[] hasItTimedOut = {true};
                try {
                    dockerClient.execStartCmd(execCreateCmdResponse.getId())
                            .exec(new ResultCallback.Adapter<>() {
                                //若程序在超时时间内,运行结束,则调用此代码
                                @Override
                                public void onComplete() {
                                    hasItTimedOut[0] = false;
                                    super.onComplete();
                                }

                                //每个阶段结束就会回调
                                @Override
                                public void onNext(Frame frame) {
                                    StreamType streamType = frame.getStreamType();
                                    if (StreamType.STDERR.equals(streamType)) {
                                        errorMessage.append(new String(frame.getPayload()));
                                    } else {
                                        message.append(new String(frame.getPayload()));
                                    }
                                    super.onNext(frame);
                                }
                            })
                            //阻塞调用
                            .awaitCompletion(5000, TimeUnit.MILLISECONDS);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                if (hasItTimedOut[0]) {
                    throw new RuntimeException("执行超时");
                }
                executionInformation.setMessage(message.toString());
                executionInformation.setErrorMessage(errorMessage.toString());
                executionInformationArrayList.add(executionInformation);
                //清空字符串
                message.setLength(0);
                errorMessage.setLength(0);
            });
        } catch (Exception e) {
            log.error("docker容器执行异常:{}", e.getLocalizedMessage());
            throw new RuntimeException(e);
        } finally {
            containerPool.returnContainer(containerId);
        }

        return executionInformationArrayList;
    }

    public ExecutiveOutcome collectResults(List<ExecutionInformation> executionInformations) {
        ExecutionInformation executionInformation = executionInformations.get(0);
        String errorMessage = executionInformation.getErrorMessage();
        StringBuilder stringBuilder = new StringBuilder();

        if (StringUtils.isNotBlank(errorMessage)) {
            executionInformations.forEach(item -> stringBuilder.append(item.getErrorMessage()));
            return exceptionProcessor(ProblemStatusEnum.FAIL_THE_TEST, TestResultEnum.RUNTIME_ERROR,
                    "运行异常", stringBuilder.toString());
        }

        ArrayList<String> output = new ArrayList<>();
        final Long[] maximumMemory = {0L};
        final Long[] maximumTimer = {0L};
        executionInformations.forEach(item -> {
            String message = item.getMessage();
            //TODO 如果用户代码包含了输出语句会导致这里出BUG 待优化
            String[] split = message.split("\n");
            maximumMemory[0] = Math.max(maximumMemory[0], Long.parseLong(split[0]));
            maximumTimer[0] = Math.max(maximumTimer[0], Long.parseLong(split[1]));
            output.add(split[2]);
        });
        JudgeInfo judgeInfo = new JudgeInfo();
        judgeInfo.setStatus(TestResultEnum.SUCCEED.getStatus());
        judgeInfo.setMessage(TestResultEnum.SUCCEED.getMessage());
        judgeInfo.setTime(maximumTimer[0]);
        judgeInfo.setMemory(maximumMemory[0]);
        ExecutiveOutcome executiveOutcome = new ExecutiveOutcome();
        executiveOutcome.setResultOfEnforcement(output);
        executiveOutcome.setStatus(ProblemStatusEnum.SUCCESSFUL_JUDGMENT.getStatus());
        executiveOutcome.setMessage(ProblemStatusEnum.SUCCESSFUL_JUDGMENT.getMessage());
        executiveOutcome.setJudgeInfo(judgeInfo);
        return executiveOutcome;
    }

    public boolean clearFiles(String path) {
        return FileUtil.del(path);
    }

    @Override
    public ExecutiveOutcome executeCode(ExecutionParameter executionParameter) {
        ExecutiveOutcome executiveOutcome;

        //1. 校验代码
        String code = executionParameter.getCode();
        boolean verifyCodeRes = verifyCode(code);
        if (!verifyCodeRes) {
            return exceptionProcessor(ProblemStatusEnum.FAIL_THE_TEST,
                    TestResultEnum.DANGEROUS_OPERATION, "代码不合法", "代码不合法");
        }
        ContainerPool.ContainerInfo availableContainer = null;
        try {
            availableContainer = containerPool.getAvailableContainer();
        } catch (Exception e) {
            exceptionProcessor(ProblemStatusEnum.FAIL_THE_TEST, TestResultEnum.EXECUTION_EXCEPTION,
                    "任务被异常中断",
                    "失败");
        }
        String containerId = availableContainer.getId();
        log.info("使用的容器id:{}", containerId);
        //2.保存用户代码
        String filePath = saveUserCodeFile(code, availableContainer.getContainerDirName());
        //3. 运行代码
        List<ExecutionInformation> executionInformations = null;
        try {
            executionInformations = runFile(containerId, executionParameter.getInputList(), availableContainer.getContainerDirName(),
                    executionParameter.getMethodName());
        } catch (Exception e) {
            log.error("代码运行异常");
            return exceptionProcessor(ProblemStatusEnum.FAIL_THE_TEST, TestResultEnum.RUNTIME_ERROR,
                    "运行异常", "执行异常");
        }
        //4. 收集结果
        executiveOutcome = collectResults(executionInformations);
        //5. 文件清理
        boolean result = clearFiles(filePath);
        if (!result) {
            log.error("清除文件异常");
            //TODO 通知管理员
        }


        return executiveOutcome;
    }
}

感觉有点乱,但是懒得改了

本地方式

。。。。。。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值