当前位置: 首页 > article >正文

Java开发笔记--通用消息组件设计(移动短信、华为短信、163邮件)

最近项目开发完了,整理梳理了一下框架,总结记录一下通用消息组件的设计。

项目中的消息的存在种类多样性:APP消息、短信、邮件、站内消息,短信的服务商又有多种。我们项目采用的是spirng cloud alibaba,于是把消息这块抽出来做成一个微服务,考虑到消息的共用性,并且可能在同一时间使用多种消息的推送。把消息接口封装成了同一个,接口的实现采用工厂方法模式,便于扩展消息的种类,扩展消息的多服务商。

0.各服务商开通及参数的获取(略)
1.接口设计
package sino.base.service;


import sino.base.events.models.SmsParam;

/**
 *
 * 短信发送业务
 */
public interface CommonSmsService {

    /**
     * 异步短信接口
     * 重发短信 只需要传smsid 和 userid
     * @return
     */
    void sendMsgAsync(SmsParam param);

    /**
     * 同步短信接口
     * 重发短信 只需要传smsid 和 userid
     * @return
     */
    boolean sendMsgSync(SmsParam param);

}
package sino.base.events.models;

import lombok.Data;

/**
 *@ClassName SmsParam
 *@Description TODO
 *@Author xc
 *@Date 2024/9/3 15:26
 *@Version 1.0
 */
@Data
public class SmsParam {
    //重发短信 只需要传smsid 和 userid
    private String smsid;

    /**
     * 标题: email 使用
     */
    private String title;

    /**
     * 电话号码或者邮箱 逗号分隔
     */
    private String tels;
    /**
     *   //若platform=hw,
     *   content为模板参数,规则为:   "参数1_参数2" 参数下划线分隔  。
     */
    private String content;

//    参数是否拆分  默认:是
//    private String contentSplit="1";
    private String addSerial;

    /**
     * 短信平台:移动  cm;华为云 hw  邮箱: email
     */
    private String platform;

    private String userid;
    /**
     * 模板编号
     *
     */
    private String templateno;

    private String customParam;

}
2.组件创建

创建通用组件父类

package sino.base.events.models;

import java.io.IOException;

/**
 *@ClassName SmsProperties
 *@Description TODO
 *@Author xc
 *@Date 2024/9/3 15:31
 *@Version 1.0
 */
public  class SmsComponent {
    public Boolean sendSms() throws IOException {
        return null;
    }
}

创建消息子类:华为短信子类、移动云短信子类、163邮箱消息子类、app短信(省略)、站内信息(省略)

package sino.base.events.models;

import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import com.cloud.apigateway.sdk.utils.Client;
import com.cloud.apigateway.sdk.utils.Request;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.util.EntityUtils;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;

/**
 * @ClassName 华为短信
 * @Description TODO
 * @Author xc
 * @Date 2024/9/3 15:28
 * @Version 1.0
 */
@Data
@Slf4j
public class HuaWeiSmsComponent extends SmsComponent {

    private String smsUrl = "https://smsapi.cn-north-4.myhuaweicloud.com:443/sms/batchSendSms/v1";
    private String appKey = "";
    private String appSecret = "";


    private String sender;
    private String receiver;
    private String templateId;
    private String templateParas;
    private String statusCallBack="";
    private String signature;
    private String extend;


    public HuaWeiSmsComponent(String sender, String receiver, String templateId, String templateParas, String statusCallBack, String signature, String extend) throws Exception {
        log.info("参数:sender="+sender+",receiver="+receiver+",templateId="+templateId+",templateParas="+templateParas+",statusCallBack="+statusCallBack+",signature="+signature);
        if (null == sender || null == receiver || null == templateId || sender.isEmpty() || receiver.isEmpty()
                || templateId.isEmpty()) {
            throw new Exception();
        }
        this.sender=sender;
        this.receiver=receiver;
        this.templateId=templateId;
        this.templateParas=templateParas;
        this.signature=signature;
        this.extend=extend;
    }


    String buildRequestBody() throws UnsupportedEncodingException {
        StringBuilder body = new StringBuilder();
        appendToBody(body, "from=", sender);
        appendToBody(body, "&to=", receiver);
        appendToBody(body, "&templateId=", templateId);
        appendToBody(body, "&templateParas=", templateParas);
        appendToBody(body, "&statusCallback=", statusCallBack);
        appendToBody(body, "&signature=", signature);
        appendToBody(body, "&extend=", extend);
        return body.toString();
    }

    private void appendToBody(StringBuilder body, String key, String val) throws UnsupportedEncodingException {
        if (null != val && !val.isEmpty()) {
            log.info("Print appendToBody: {}:{}", key, val);
            body.append(key).append(URLEncoder.encode(val, "UTF-8"));
        }
    }

    Request getRequest() throws UnsupportedEncodingException {
        Request request = new Request();
        request.setKey(appKey);
        request.setSecret(appSecret);
        request.setMethod("POST");
        request.addHeader("Content-Type", "application/x-www-form-urlencoded");
        request.setUrl(smsUrl);
        request.setBody(buildRequestBody());
        return request;
    }

    @Override
    public Boolean sendSms() throws IOException {
        CloseableHttpClient client = null;
        try {
            // Sign the request.
            HttpRequestBase signedRequest = Client.sign(getRequest(), Constant.SIGNATURE_ALGORITHM_SDK_HMAC_SHA256);
            // Do not verify ssl certificate
            client = (CloseableHttpClient) SSLCipherSuiteUtil.createHttpClient(Constant.INTERNATIONAL_PROTOCOL);
            HttpResponse response = client.execute(signedRequest);
            // Print the body of the response.
            HttpEntity resEntity = response.getEntity();
            if (resEntity != null) {
                String resultMsg = EntityUtils.toString(resEntity, "UTF-8");
                log.info("华为短信发送结果:"+resultMsg);
                boolean result = resultMsg.indexOf("\"code\":\"000000\",\"description\":\"Success\"") > 0;
                return result;
            }
        } catch (Exception e) {
            log.error(e.getMessage());
        } finally {
            if (client != null) {
                client.close();
            }
        }
        return false;
    }
}

package sino.base.events.models;

import cn.hutool.json.JSONUtil;
import com.alibaba.fastjson.JSONObject;
import sino.util.StringUtil;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.util.EntityUtils;
import sun.misc.BASE64Encoder;

import java.security.MessageDigest;
import java.util.HashMap;
import java.util.Map;

/**
 *@ClassName 中国移动短信
 *@Description TODO
 *@Author xc
 *@Date 2024/9/3 15:28
 *@Version 1.0
 */
@Data
@Slf4j
public class CmSmsComponent  extends SmsComponent {

    private  String smsUrl="";

    private  String ecName="";

    private  String sign="";
    private  String apId="";

    private  String secretKey="";

    private String tels;

    private String content;

    private String addSerial="";

    public CmSmsComponent(String tels, String content, String addSerial) {
        this.tels = tels;
        this.content = content;
        if(StringUtil.isNotEmpty(addSerial)){
            this.addSerial = addSerial;
        }
    }

    public String getTemporary(){
        String temporary = ecName+apId+secretKey+tels+content+sign+addSerial;
        return temporary;
    }

    public  String  post() throws Exception {
        String jsonParam=getEncodeStr3();
        HttpClient httpClient = new DefaultHttpClient();
        HttpPost post = new HttpPost(smsUrl);
        StringEntity postingString = new StringEntity(jsonParam);
        post.setEntity(postingString);
        HttpResponse response = httpClient.execute(post);
        String content = EntityUtils.toString(response.getEntity());
        System.out.println(content);
        return content;
    }

    public  String encrypt32(String encryptStr) {
        MessageDigest md5;
        try {
            md5 = MessageDigest.getInstance("MD5");
            byte[] md5Bytes = md5.digest(encryptStr.getBytes("UTF-8"));
            StringBuffer hexValue = new StringBuffer();
            for (int i = 0; i < md5Bytes.length; i++) {
                int val = ((int) md5Bytes[i]) & 0xff;
                if (val < 16) {
                    hexValue.append("0");
                }
                hexValue.append(Integer.toHexString(val));
            }
            encryptStr = hexValue.toString();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        return encryptStr;
    }

    public String  getEncodeStr3() throws Exception {
        String temporary = getTemporary();
        System.out.println("temporary:"+temporary);
        //MD5加密
        String mac = encrypt32(temporary) ;
        System.out.println("mac_MD5:"+mac);
        // 存放要进行加密的信息
        Map<String,String> params=new HashMap<String,String>();
        params.put("ecName",ecName);
        params.put("apId",apId);
        params.put("mobiles",tels);
        params.put("content",content);
        params.put("sign",sign);
        params.put("addSerial",addSerial);
        params.put("mac",mac);
        BASE64Encoder base64 = new BASE64Encoder();

        String encodeStr3 = base64.encode(JSONObject.toJSONString(params).getBytes("UTF-8"));
        return encodeStr3;
    }

    @Override
    public Boolean sendSms(){
        try {
            String ret = post();
            cn.hutool.json.JSONObject jsonObject= JSONUtil.parseObj(ret);
            log.info("短信发送结果:"+ret);
            if(jsonObject!=null&&"success".equalsIgnoreCase(jsonObject.get("rspcod").toString())){
                return true;
            }
        } catch (Exception e) {
           log.error(e.getMessage(),e);
        }
        return false;
    }
}

package sino.base.events.models;

import cn.hutool.extra.mail.MailAccount;
import cn.hutool.extra.mail.MailUtil;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;

/**
 * @ClassName 163邮箱
 * @Author xc
 * @Date 2024/9/3 15:28
 * @Version 1.0
 */
@Data
@Slf4j
public class Email163Component extends SmsComponent {

    private  String secret = "";
    private  String from = "";
    private String to;
    private String title;
    private String content;


    public Email163Component(String to, String title, String content) {
        this.to=to;
        this.title=title;
        this.content=content;
    }

    @Override
    public Boolean sendSms() {
        try {
            MailAccount mailAccount = new MailAccount();
            mailAccount.setFrom(from);//你的邮箱
            mailAccount.setPass(secret);// 授权码
            MailUtil.send(mailAccount, to, title, content, false);
        } catch (Exception e) {
            return false;
        }
        return true;
    }
}

3.接口实现

逻辑:1.发送短信    2.记录短信发送日志

package sino.base.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import sino.base.entity.SmsMessageEntity;
import sino.base.entity.SmsRulersHwEntity;
import sino.base.events.handlers.SmsEvent;
import sino.base.events.models.*;
import sino.base.service.CommonSmsService;
import sino.base.service.SmsMessageService;
import sino.base.service.SmsRulersHwService;
import sino.util.RandomUtil;
import sino.util.StringUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Service;

import java.util.Date;

/**
 *
 *短信发送
 */
@Service
@Slf4j
public class CommonSmsServiceImpl implements CommonSmsService {


    @Autowired
    private ApplicationContext context;

    @Autowired
    private SmsMessageService smsMessageService;


    @Autowired
    private SmsRulersHwService smsRulersHwService;



    @Override
    public void sendMsgAsync(SmsParam param) {
        log.info("进入异步发短信接口,参数 = " + param.toString());
        SmsEvent event=new SmsEvent(param);
        context.publishEvent(event);
    }

    @Override
    public boolean sendMsgSync(SmsParam param) {
        log.info("进入同步发短信接口,参数 = " + param.toString());
        return sendMsg(param);
    }

    public Boolean sendMsg(SmsParam param){
        try {
            SmsMessageEntity smsMessageEntity;
            String tels;
            String context;
            String serial;
            String templateNo;
            if (StringUtil.isNotEmpty(param.getSmsid())) {
                smsMessageEntity = smsMessageService.getInfo(param.getSmsid());
                tels = smsMessageEntity.getTels();
                context = smsMessageEntity.getContent();
                serial = smsMessageEntity.getSerial();
                templateNo=smsMessageEntity.getExt1();
                int count = Integer.parseInt(smsMessageEntity.getCount()) + 1;
                smsMessageEntity.setCount(count + "");
                smsMessageEntity.setUpdatetime(new Date());
                smsMessageEntity.setUpdateuser(param.getUserid());
            } else {
                tels = param.getTels();
                context = param.getContent();
                serial = param.getAddSerial();
                templateNo=param.getTemplateno();
                smsMessageEntity = new SmsMessageEntity();
                smsMessageEntity.setCount(1 + "");
                smsMessageEntity.setCreateuser(param.getUserid());
                smsMessageEntity.setTels(param.getTels());
                smsMessageEntity.setContent(param.getContent());
                smsMessageEntity.setPlatform(param.getPlatform());
                smsMessageEntity.setSendtime(new Date());
                smsMessageEntity.setSerial(param.getAddSerial());
            }
            smsMessageEntity.setExt1(templateNo);
            smsMessageEntity.setSmsid(RandomUtil.uuId());
            smsMessageEntity.setExt2(param.getCustomParam());
            String platform = param.getPlatform();
            if (StringUtil.isEmpty(platform)) {
                platform = "hw";
            }
            SmsComponent smsComponent;
            if (platform.equals("cm")) {
                smsComponent = new CmSmsComponent(tels, context, serial);
            } else if(platform.equals("hw")){
                SmsRulersHwEntity ruler= smsRulersHwService.getOne(new QueryWrapper<SmsRulersHwEntity>().eq("templateno",templateNo));
                if(null==ruler){
                    return false;
                }
                smsComponent = new HuaWeiSmsComponent(ruler.getSender(),param.getTels(),ruler.getTemplateid(),getParamsContext(param.getContent()),"",ruler.getSignature(),smsMessageEntity.getSmsid());
            }else if(platform.equals("email")){
                smsComponent= new Email163Component(tels,param.getTitle(),context);
            }else{
                //smsComponent = new HuaWeiSmsComponent("8822030413721",param.getTels(),"827f3b01dcf04fd5bc7657b6d575fdd3","[\"666666\"]","","");
                SmsRulersHwEntity ruler= smsRulersHwService.getOne(new QueryWrapper<SmsRulersHwEntity>().eq("templateno",templateNo));
                smsComponent = new HuaWeiSmsComponent(ruler.getSender(),param.getTels(),ruler.getTemplateid(),param.getContent(),"",ruler.getSignature(),smsMessageEntity.getSmsid());
            }
            smsMessageEntity.setPlatform(platform);
            Boolean result = smsComponent.sendSms();
            log.info("短信发送结果 = " + result);
            //记录短信日志
            if (result) {
                smsMessageEntity.setSendstatus("成功");
            } else {
                smsMessageEntity.setSendstatus("失败");
            }
            smsMessageService.saveOrUpdate(smsMessageEntity);
            return result;
        } catch (Exception e) {
            log.error("保存发送短信出错", e);
        }
        return false;
    }


    private String getParamsContext(String params){
        String msgParams="";
        String[] paramsArr= params.split("_");
        for (String p:paramsArr) {
            if(!msgParams.equals("")){
                msgParams=msgParams+",";
            }
            msgParams+="\""+p+"\"";
        }
        msgParams="["+msgParams+"]";
        return msgParams;
    }
}
4.其他需要的工具类
/*
 * Copyright (c) Huawei Technologies Co., Ltd. 2022-2022. All rights reserved.
 */

package sino.base.events.models;

import okhttp3.OkHttpClient;
import org.apache.http.client.HttpClient;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.bouncycastle.crypto.BlockCipher;
import org.bouncycastle.crypto.engines.AESEngine;
import org.bouncycastle.crypto.prng.SP800SecureRandomBuilder;
import org.openeuler.BGMProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.net.ssl.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.*;
import java.security.cert.X509Certificate;
import java.util.Locale;

public class SSLCipherSuiteUtil {
    private static final Logger LOGGER = LoggerFactory.getLogger(SSLCipherSuiteUtil.class);
    private static CloseableHttpClient httpClient;
    private static OkHttpClient okHttpClient;

    private static final int CIPHER_LEN = 256;

    private static final int ENTROPY_BITS_REQUIRED = 384;

    public static HttpClient createHttpClient(String protocol) throws Exception {
        SSLContext sslContext = getSslContext(protocol);
        // create factory
        SSLConnectionSocketFactory sslConnectionSocketFactory = new SSLConnectionSocketFactory(sslContext,
                new String[]{protocol}, Constant.SUPPORTED_CIPHER_SUITES, new TrustAllHostnameVerifier());

        httpClient = HttpClients.custom().setSSLSocketFactory(sslConnectionSocketFactory).build();
        return httpClient;
    }

//    public static OkHttpClient createOkHttpClient(String protocol) throws Exception {
//        SSLContext sslContext = getSslContext(protocol);
//        // Create an ssl socket factory with our all-trusting manager
//        SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();
//        OkHttpClient.Builder builder = new OkHttpClient.Builder()
//                .sslSocketFactory(sslSocketFactory, new TrustAllManager())
//                .hostnameVerifier(new TrustAllHostnameVerifier());
//        okHttpClient = builder.connectTimeout(10, TimeUnit.SECONDS).readTimeout(60, TimeUnit.SECONDS).build();
//        return okHttpClient;
//    }

    public static HttpURLConnection createHttpsOrHttpURLConnection(URL uUrl, String protocol) throws Exception {
        // initial connection
        if (uUrl.getProtocol().toUpperCase(Locale.getDefault()).equals(Constant.HTTPS)) {
            SSLContext sslContext = getSslContext(protocol);
            HttpsURLConnection.setDefaultHostnameVerifier(new TrustAllHostnameVerifier());
            HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.getSocketFactory());
            return (HttpsURLConnection) uUrl.openConnection();
        }
        return (HttpURLConnection) uUrl.openConnection();
    }

    private static SSLContext getSslContext(String protocol) throws Exception,
            NoSuchAlgorithmException, NoSuchProviderException, KeyManagementException {
        if (!Constant.GM_PROTOCOL.equals(protocol) && !Constant.INTERNATIONAL_PROTOCOL.equals(protocol)) {
            LOGGER.info("Unsupport protocol: {}, Only support GMTLS TLSv1.2", protocol);
            throw new Exception("Unsupport protocol, Only support GMTLS TLSv1.2");
        }
        // Create a trust manager that does not validate certificate chains
        TrustAllManager[] trust = {new TrustAllManager()};
        KeyManager[] kms = null;
        SSLContext sslContext;

        sslContext = SSLContext.getInstance(Constant.INTERNATIONAL_PROTOCOL, "SunJSSE");

        if (Constant.GM_PROTOCOL.equals(protocol)) {
            Security.insertProviderAt(new BGMProvider(), 1);
            sslContext = SSLContext.getInstance(Constant.GM_PROTOCOL, "BGMProvider");
        }
        SecureRandom secureRandom = getSecureRandom();
        sslContext.init(kms, trust, secureRandom);
        sslContext.getServerSessionContext().setSessionCacheSize(8192);
        sslContext.getServerSessionContext().setSessionTimeout(3600);
        return sslContext;
    }

    // 不校验域名
    private static class TrustAllHostnameVerifier implements HostnameVerifier {
        public boolean verify(String hostname, SSLSession session) {
            return true;
        }
    }
    // 不校验服务端证书
    private static class TrustAllManager implements X509TrustManager {
        private X509Certificate[] issuers;

        public TrustAllManager() {
            this.issuers = new X509Certificate[0];
        }

        public X509Certificate[] getAcceptedIssuers() {
            return issuers;
        }

        public void checkClientTrusted(X509Certificate[] chain, String authType) {
        }

        public void checkServerTrusted(X509Certificate[] chain, String authType) {
        }
    }

    private static SecureRandom getSecureRandom() {
        SecureRandom source;
        try {
            source = SecureRandom.getInstanceStrong();
        } catch (NoSuchAlgorithmException e) {
            LOGGER.error("get SecureRandom failed", e);
            throw new RuntimeException("get SecureRandom failed");
        }
        boolean predictionResistant = true;
        BlockCipher cipher = new AESEngine();
        boolean reSeed = false;
        return new SP800SecureRandomBuilder(source, predictionResistant).setEntropyBitsRequired(
                ENTROPY_BITS_REQUIRED).buildCTR(cipher, CIPHER_LEN, null, reSeed);
    }
}
/*
 * Copyright (c) Huawei Technologies Co., Ltd. 2022-2022. All rights reserved.
 */

package sino.base.events.models;

public final class Constant {
    public static final String HTTPS = "HTTPS";

    public static final String GM_PROTOCOL = "GMTLS";
    public static final String INTERNATIONAL_PROTOCOL = "TLSv1.2";
    public static final String SIGNATURE_ALGORITHM_SDK_HMAC_SHA256 = "SDK-HMAC-SHA256";
    public static final String SIGNATURE_ALGORITHM_SDK_HMAC_SM3 = "SDK-HMAC-SM3";
    public static final String[] SUPPORTED_CIPHER_SUITES = {"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
            "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
            "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"};
    private Constant() {
    }
}
5.相关依赖
      <!--  华为短信依赖 -->
       <dependency>
            <groupId>com.huawei.apigateway</groupId>
            <artifactId>java-sdk-core</artifactId>
            <version>3.2.4</version>
        </dependency>

        <dependency>
            <groupId>com.squareup.okhttp3</groupId>
            <artifactId>okhttp</artifactId>
            <version>4.9.1</version>
        </dependency>

        <dependency>
            <groupId>org.openeuler</groupId>
            <artifactId>bgmprovider</artifactId>
            <version>1.0.4</version>
        </dependency>
<!--  邮箱 -->       
 <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
        </dependency>
 6.后续待优化的地方

  ①参数名称设置统一

  ②组件的构造设置成统一参数

  ③接口实现的 方法还可以再拆封封装一下


http://www.kler.cn/a/290964.html

相关文章:

  • Ubuntu硬盘分区及挂载(命令行)
  • Apache Samza开源的分布式流处理框架
  • 微服务——技术选型与框架
  • PostgreSQL标识符长度限制不能超过63字节
  • 如何测量分辨率
  • go mod tidy 命令
  • chapter03 流程语句 知识点Note
  • JS基础-ClassList -移动端插件的引入-touch事件-sessionStorage 和 localStorage
  • STM32—I2C的基本时序,MU6050的ID读取
  • 云计算和传统IT相比,有哪些优势?
  • map和set的区别和底层实现是什么?map取值的 find,[],at方法的区别
  • GitLab 是什么?GitLab使用常见问题解答
  • 论文浅尝 | TaxoLLaMA: 用基于WordNet的模型来解决多个词汇语义任务(ACL2024)
  • 微信小程序npm扩展能力探究
  • Linux性能监控神器:深入nmon详解与使用
  • 经验笔记:Maven 与 Gradle —— Java 构建工具对比
  • 每日一练4:牛牛的快递(含链接)
  • @DateTimeFormat和@JsonFormat的区别和使用场景
  • 前端工程化之【模块化规范】
  • 黑马JavaWeb开发笔记15——用JAVA进行Web开发时候的请求、响应流程,B\S架构、C\S架构(概述)
  • log4j漏洞原理以及复现
  • 【JUC】12-CAS
  • Nordic Collegiate Programming ContestNCPC 2021
  • Linux基础 -- 获取CPU负载信息
  • 在react 中还有另外一种three.js 渲染方式
  • 生活因科技而美好:一键解锁PDF处理的无限可能