返回顶部
首页 > 资讯 > 移动开发 >Android进阶手写IPC通信框架告别繁琐AIDL
  • 884
分享到

Android进阶手写IPC通信框架告别繁琐AIDL

AndroidIPC通信框架AndroidIPC 2023-01-29 12:01:30 884人浏览 安东尼
摘要

目录正文1 服务端 - reGISter1.1 定义服务唯一标识serviceId1.2 使用方式2 客户端与服务端的通信协议2.1 创建IPCService2.2 定义通讯协议2.

正文

对于进程间通信,很多项目中可能根本没有涉及到多进程,很多公司的app可能就一个主进程,但是对于进程间通信,我们也是必须要了解的。

如果在Android中想要实现进程间通信,有哪些方式呢?

(1)发广播(sendBroadcast):e.g. 两个app之间需要通信,那么可以通过发送广播的形式进行通信,如果只想单点通信,可以指定包名。但是这种方式存在的弊端在于发送方无法判断接收方是否接收到了广播,类似于UDP的通信形式,而且存在丢数据的形式;

(2)Socket通信:这种属于linux层面的进程间通信了,除此之外,还包括管道、信号量等,像传统的IPC进程间通信需要数据二次拷贝,这种效率是最低的;

(3)AIDL通信:这种算是Android当中主流的进程间通信方案,通过Service + Binder的形式进行通信,具备实时性而且能够通过回调得知接收方是否收到数据,弊端在于需要管理维护aidl接口,如果不同业务方需要使用不同的aidl接口,维护的成本会越来越高。

那么本篇文章并不是说完全丢弃掉AIDL,它依然不失为一个很好的进程间通信的手段,只是我会封装一个适用于任意业务场景的IPC进程间通讯框架,这个也是我在自己的项目中使用到的,不需要维护很多的AIDL接口文件。

有需要源码的伙伴,可以去我的GitHub首页获取 FastIPC源码地址,分支:feature/v0.0.1-snapshot有帮助的话麻烦给点个star⭐️⭐️⭐️

1 服务端 - register

首先这里先说明一下,就是对于传统的AIDL使用方式,这里就不再过多介绍了,这部分还是比较简单的,有兴趣的伙伴们可以去前面的文章中查看,本文将着重介绍框架层面的逻辑。

那么IPC进程间通信,需要两个端:客户端和服务端。服务端会提供一个注册方法,例如客户端定义的一些服务,通过向服务端注册来做一个备份,当客户端调用服务端某个方法的时候来返回值。

object IPC {
    //==========================================
    
    fun register(service: Class<*>) {
        Registry.instance.register(service)
    }
}

其实在注册的时候,我们的目的肯定是能够方便地拿到某个服务,并且能够调用这个服务提供的方法,拿到我想要的值;所以在定义服务的时候,需要注意以下两点:

(1)需要定义一个与当前服务一一对应的serviceId,通过serviceId来获取服务的实例;

(2)每个服务当中定义的方法同样需要对应起来,以便拿到服务对象之后,通过反射调用其中的方法。

所以在注册的时候,需要从这两点入手。

1.1 定义服务唯一标识serviceId

@Target(AnnotationTarget.TYPE)
@Retention(AnnotationRetention.RUNTIME)
annotation class ServiceId(
    val name: String
)

一般来说,如果涉及到反射,最常用的就是通过注解给Class做标记,因为通过反射能够拿到类上标记的注解,就能够拿到对应的serviceId。

class Registry {
    //=======================================
    
    private val serviceMaps: ConcurrentHashMap<String, Class<*>> by lazy {
        ConcurrentHashMap()
    }
    
    private val methodsMap: ConcurrentHashMap<Class<*>, ConcurrentHashMap<String, Method>> by lazy {
        ConcurrentHashMap()
    }
    //=======================================
    
    fun register(service: Class<*>) {
        // 获取serviceId与服务一一对应
        val serviceIdAnnotation = service.getAnnotation(ServiceId::class.java)
            ?: throw IllegalArgumentException("只有标记@ServiceId的服务才能够被注册")
        //获取serviceId
        val name = serviceIdAnnotation.name
        serviceMaps[name] = service
        //temp array
        val methods: ConcurrentHashMap<String, Method> = ConcurrentHashMap()
        // 获取服务当中的全部方法
        for (method in service.declaredMethods) {
            //这里需要注意,因为方法中存在重载方法,所以不能把方法名当做key,需要加上参数
            val buffer = StringBuffer()
            buffer.append(method.name).append("(")
            val params = method.parameterTypes
            if (params.size > 0) {
                buffer.append(params[0].name)
            }
            for (index in 1 until params.size) {
                buffer.append(",").append(params[index].name)
            }
            buffer.append(")")
            //保存
            methods[buffer.toString()] = method
        }
        //存入方法表
        methodsMap[service] = methods
    }
    compaNIOn object {
        val instance by lazy { Registry() }
    }
}

通过上面的register方法,当传入定义的服务class对象的时候,首先获取到服务上标记的@ServiceId注解,注意这里如果要注册必须标记,否则直接抛异常;拿到serviceId之后,存入到serviceMaps中。

然后需要获取服务中的全部方法,因为考虑到重载方法的存在,所以不能单单以方法名作为key,而是需要把参数也加上,因此这里做了一个逻辑就是将方法名与参数名组合一个key,存入到方法表中。

这样注册任务就完成了,其实还是比较简单的,关键在于完成2个表:服务表和方法表的初始化以及数据存储功能

1.2 使用方式

@ServiceId("UserManagerService")
interface IUserManager {
    fun getUserInfo(): User?
    fun setUserInfo(user: User)
    fun getUserId(): Int
    fun setUserId(id: Int)
}

假设项目中有一个用户信息管理的服务,这个服务用于给所有的App提供用户信息查询。

@ServiceId("UserManagerService")
class UserManager : IUserManager {
    private var user: User? = null
    private var userId: Int = 0
    override fun getUserInfo(): User? {
        return user
    }
    override fun setUserInfo(user: User) {
        this.user = user
    }
    override fun getUserId(): Int {
        return userId
    }
    override fun setUserId(id: Int) {
        this.userId = id
    }
}

用户中心可以注册这个服务,并且调用setUserInfo方法保存用户信息,那么其他App(客户端)连接这个服务之后,就可以调用getUserInfo这个方法,获取用户信息,从而完成进程间通信。

2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: entrySet key class com.lay.learn.asm.binder.UserManager
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue key setUserInfo(com.lay.learn.asm.binder.User)
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue value public void com.lay.learn.asm.binder.UserManager.setUserInfo(com.lay.learn.asm.binder.User)
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue key getUserInfo()
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue value public com.lay.learn.asm.binder.User com.lay.learn.asm.binder.UserManager.getUserInfo()
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue key getUserId()
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue value public int com.lay.learn.asm.binder.UserManager.getUserId()
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue key setUserId(int)
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue value public void com.lay.learn.asm.binder.UserManager.setUserId(int)

我们看调用register方法之后,每个方法的key值都是跟参数绑定在一起,这样服务端注册就完成了。

2 客户端与服务端的通信协议

对于客户端的连接,其实就是绑定服务,那么这里就会使用到AIDL通信,但是跟传统的相比,我们是将AIDL封装到框架层内部,对于用户来说是无感知的。

2.1 创建IPCService

这个服务就是用来完成进程间通信的,客户端需要与这个服务建立连接,通过服务端分发消息,或者接收客户端发送来的消息。

abstract class IPCService : Service() {
    override fun onBind(intent: Intent?): IBinder? {
        return null
    }
}

这里我定义了一个抽象的Service基类,为啥要这么做,前面我们提到过是因为整个项目中不可能只有一个服务,因为业务众多,为了保证单一职责,需要划分不同的类型,所以在框架中会衍生多个实现类,不同业务方可以注册这些服务,当然也可以自定义服务继承IPCService。

class IPCService01 : IPCService() {
}

在IPCService的onBind需要返回一个Binder对象,因此需要创建aidl文件。

2.2 定义通讯协议

像我们在请求接口的时候,通常也是向服务端发起一个请求(Request),然后得到服务端的一个响应(Response),因此在IPC通信的的时候,也可以根据这种方式建立通信协议。

data class Request(
    val type: Int,
    val serviceId: String?,
    val methodName: String?,
    val params: Array&lt;Parameters&gt;?
) : Serializable, Parcelable {
    //=====================================
    
    //获取实例的对象
    val GET_INSTANCE = "getInstance"
    //执行方法
    val INVOKE_METHOD = "invokeMethod"
    //=======================================
    constructor(parcel: Parcel) : this(
        parcel.readInt(),
        parcel.readString(),
        parcel.readString(),
        parcel.createTypedArray(Parameters.CREATOR)
    )
    override fun writeToParcel(parcel: Parcel, flags: Int) {
        parcel.writeInt(type)
        parcel.writeString(serviceId)
        parcel.writeString(methodName)
    }
    override fun describeContents(): Int {
        return 0
    }
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false
        other as Request
        if (type != other.type) return false
        if (serviceId != other.serviceId) return false
        if (methodName != other.methodName) return false
        if (params != null) {
            if (other.params == null) return false
            if (!params.contentEquals(other.params)) return false
        } else if (other.params != null) return false
        return true
    }
    override fun hashCode(): Int {
        var result = type
        result = 31 * result + (serviceId?.hashCode() ?: 0)
        result = 31 * result + (methodName?.hashCode() ?: 0)
        result = 31 * result + (params?.contentHashCode() ?: 0)
        return result
    }
    companion object CREATOR : Parcelable.Creator&lt;Request&gt; {
        override fun createFromParcel(parcel: Parcel): Request {
            return Request(parcel)
        }
        override fun newArray(size: Int): Array&lt;Request?&gt; {
            return arrayOfNulls(size)
        }
    }
}

对于客户端来说,致力于发起请求,请求实体类Request参数介绍如下:

type表示请求的类型,包括两种分别是:执行静态方法和执行普通方法(考虑到反射传参);

serviceId表示请求的服务id,要请求哪个服务,便可以获取到这个服务的实例对象,调用服务中提供的方法;

methodName表示要请求的方法名,也是在serviceId服务中定义的方法;

params表示请求的方法参数集合,我们在服务端注册的时候,方法名 + 参数名 作为key,因此需要知道请求的方法参数,以便获取到Method对象。

data class Response(
    val value:String?,
    val result:Boolean
):Parcelable {
    @SuppressLint("Newapi")
    constructor(parcel: Parcel) : this(
        parcel.readString(),
        parcel.readBoolean()
    )
    override fun writeToParcel(parcel: Parcel, flags: Int) {
        parcel.writeString(value)
        parcel.writeByte(if (result) 1 else 0)
    }
    override fun describeContents(): Int {
        return 0
    }
    companion object CREATOR : Parcelable.Creator<Response> {
        override fun createFromParcel(parcel: Parcel): Response {
            return Response(parcel)
        }
        override fun newArray(size: Int): Array<Response?> {
            return arrayOfNulls(size)
        }
    }
}

对于服务端来说,在接收到请求之后,需要针对具体的请求返回相应的结果,Response实体类参数介绍:

result表示请求成功或者失败;

value表示服务端返回的结果,是一个JSON字符串

因此定义aidl接口文件如下,输入一个请求之后,返回一个服务端的响应。

interface IIPCServiceInterface {
    Response send(in Request request);
}

这样IPCService就可以将aidl生成的Stub类作为Binder对象返回。

abstract class IPCService : Service() {
    override fun onBind(intent: Intent?): IBinder? {
        return BINDERS
    }
    companion object BINDERS : IIPCServiceInterface.Stub() {
        override fun send(request: Request?): Response? {
            when(request?.type){
                REQUEST.GET_INSTANCE.ordinal->{
                }
                REQUEST.INVOKE_METHOD.ordinal->{
                }
            }
            return null
        }
    }
}

2.3 内部通讯协议完善

当客户端发起请求,想要执行某个方法的时候,首先服务端会先向Registery中查询注册的服务,从而找到这个要执行的方法,这个流程是在内部完成。

override fun send(request: Request?): Response? {
    //获取服务对象id
    val serviceId = request?.serviceId
    val methodName = request?.methodName
    val params = request?.params
    // 反序列化拿到具体的参数类型
    val neededParams = parseParameters(params)
    val method = Registry.instance.findMethod(serviceId, methodName, neededParams)
    Log.e("TAG", "method $method")
    Log.e("TAG", "neededParams $neededParams")
    when (request?.type) {
        REQUEST_TYPE.GET_INSTANCE.ordinal -> {
            //==========执行静态方法
            try {
                var instance: Any? = null
                instance = if (neededParams == null || neededParams.isEmpty()) {
                    method?.invoke(null)
                } else {
                    method?.invoke(null, neededParams)
                }
                if (instance == null) {
                    return Response("instance == null", -101)
                }
                //存储实例对象
                Registry.instance.setServiceInstance(serviceId ?: "", instance)
                return Response(null, 200)
            } catch (e: Exception) {
                return Response("${e.message}", -102)
            }
        }
        REQUEST_TYPE.INVOKE_METHOD.ordinal -> {
            //==============执行普通方法
            val instance = Registry.instance.getServiceInstance(serviceId)
            if (instance == null) {
                return Response("instance == null ", -103)
            }
            //方法执行返回的结果
            return try {
                val result = if (neededParams == null || neededParams.isEmpty()) {
                    method?.invoke(instance)
                } else {
                    method?.invoke(instance, neededParams)
                }
                Response(gson.tojson(result), 200)
            } catch (e: Exception) {
                Response("${e.message}", -104)
            }
        }
    }
    return null
}

当客户端发起请求时,会将请求的参数封装到Request中,在服务端接收到请求后,就会解析这些参数,变成Method执行时需要传入的参数。

private fun parseParameters(params: Array<Parameters>?): Array<Any?>? {
    if (params == null || params.isEmpty()) {
        return null
    }
    val objects = arrayOfNulls<Any>(params.size)
    params.forEachIndexed { index, parameters ->
        objects[index] =
            gson.fromJson(parameters.value, Class.forName(parameters.className))
    }
    return objects
}

例如用户中心调用setUserInfo方法时,需要传入一个User实体类,如下所示:

UserManager().setUserInfo(User("ming",25))

那么在调用这个方法的时候,首先会把这个实体类转成一个JSON字符串,例如:

{
    "name":"ming",
    "age":25
}

为啥要”多此一举“呢?其实这种处理方式是最快速直接的,转成json字符串之后,能够最大限度地降低数据传输的大小,等到服务端处理这个方法的时候,再把Request中的params反json转成User对象即可。

fun findMethod(serviceId: String?, methodName: String?, neededParams: Array<Any?>?): Method? {
    //获取服务
    val serviceClazz = serviceMaps[serviceId] ?: return null
    //获取方法集合
    val methods = methodsMap[serviceClazz] ?: return null
    return methods[rebuildParamsFunc(methodName, neededParams)]
}
private fun rebuildParamsFunc(methodName: String?, params: Array<Any?>?): String {
    val stringBuffer = StringBuffer()
    stringBuffer.append(methodName).append("(")
    if (params == null || params.isEmpty()) {
        stringBuffer.append(")")
        return stringBuffer.toString()
    }
    stringBuffer.append(params[0]?.javaClass?.name)
    for (index in 1 until params.size) {
        stringBuffer.append(",").append(params[index]?.javaClass?.name)
    }
    stringBuffer.append(")")
    return stringBuffer.toString()
}

那么在查找注册方法的时候就简单多了,直接抽丝剥茧一层一层取到最终的Method。在拿到Method之后,这里是有2种处理方式,一种是通过静态单例的形式拿到实例对象,并保存在服务端;另一种就是执行普通方法,因为在反射的时候需要拿到类的实例对象才能调用,所以才在GET_INSTANCE的时候存一遍

3 客户端 - connect

在第二节中,我们已经完成了通讯协议的建设,最终一步就是客户端通过绑定服务,向服务端发起通信了。

3.1 bindService


fun connect(
    context: Context,
    pkgName: String,
    action: String = "",
    service: Class<out IPCService>
) {
    val intent = Intent()
    if (pkgName.isEmpty()) {
        //同app内的不同进程
        intent.setClass(context, service)
    } else {
        //不同APP之间进行通信
        intent.setPackage(pkgName)
        intent.setAction(action)
    }
    //绑定服务
    context.bindService(intent, IpcServiceConnection(service), Context.BIND_AUTO_CREATE)
}
inner class IpcServiceConnection(val simpleService: Class<out IPCService>) : ServiceConnection {
    override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
        val mService = IIPCServiceInterface.Stub.asInterface(service) as IIPCServiceInterface
        binders[simpleService] = mService
    }
    override fun onServiceDisconnected(name: ComponentName?) {
        //断连之后,直接移除即可
        binders.remove(simpleService)
    }
}

对于绑定服务这块,相信伙伴们也很熟悉了,这个需要说一点的就是,在Android 5.0以后,启动服务不能只依赖action启动,还需要指定应用包名,否则就会报错。

在服务连接成功之后,即回调onServiceConnected方法的时候,需要拿到服务端的一个代理对象,即IIPCServiceInterface的实例对象,然后存储在binders集合中,key为绑定的服务类class对象,value就是对应的服务端的代理对象。

fun send(
    type: Int,
    service: Class<out IPCService>,
    serviceId: String,
    methodName: String,
    params: Array<Parameters>
): Response? {
    //创建请求
    val request = Request(type, serviceId, methodName, params)
    //发起请求
    return try {
        binders[service]?.send(request)
    } catch (e: Exception) {
        null
    }
}

当拿到服务端的代理对象之后,就可以在客户端调用send方法向服务端发送消息。

class Channel {
    //====================================
    
    private val binders: ConcurrentHashMap<Class<out IPCService>, IIPCServiceInterface> by lazy {
        ConcurrentHashMap()
    }
    //====================================
    
    fun connect(
        context: Context,
        pkgName: String,
        action: String = "",
        service: Class<out IPCService>
    ) {
        val intent = Intent()
        if (pkgName.isEmpty()) {
            intent.setClass(context, service)
        } else {
            intent.setPackage(pkgName)
            intent.setAction(action)
            intent.setClass(context, service)
        }
        //绑定服务
        context.bindService(intent, IpcServiceConnection(service), Context.BIND_AUTO_CREATE)
    }
    inner class IpcServiceConnection(val simpleService: Class<out IPCService>) : ServiceConnection {
        override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
            val mService = IIPCServiceInterface.Stub.asInterface(service) as IIPCServiceInterface
            binders[simpleService] = mService
        }
        override fun onServiceDisconnected(name: ComponentName?) {
            //断连之后,直接移除即可
            binders.remove(simpleService)
        }
    }
    fun send(
        type: Int,
        service: Class<out IPCService>,
        serviceId: String,
        methodName: String,
        params: Array<Parameters>
    ): Response? {
        //创建请求
        val request = Request(type, serviceId, methodName, params)
        //发起请求
        return try {
            binders[service]?.send(request)
        } catch (e: Exception) {
            null
        }
    }
    companion object {
        private val instance by lazy {
            Channel()
        }
        
        fun getDefault(): Channel {
            return instance
        }
    }
}

3.2 动态代理获取接口实例

回到1.2小节中,我们定义了一个IUserManager接口,通过前面我们定义的通信协议,只要我们获取了IUserManager的实例对象,那么就能够调用其中的任意普通方法,所以在客户端需要设置一个获取接口实例对象的方法。

fun <T> getInstanceWithName(
    service: Class<out IPCService>,
    classType: Class<T>,
    clazz: Class<*>,
    methodName: String,
    params: Array<Parameters>
): T? {
    //获取serviceId
    val serviceId = clazz.getAnnotation(ServiceId::class.java)
    val response = Channel.getDefault()
        .send(REQUEST.GET_INSTANCE.ordinal, service, serviceId.name, methodName, params)
    Log.e("TAG", "response $response")
    if (response != null && response.result) {
        //请求成功,返回接口实例对象
        return Proxy.newProxyInstance(
            classType.classLoader,
            arrayOf(classType),
            IPCInvocationHandler()
        ) as T
    }
    return null
}

当我们通过客户端发送一个获取单例的请求后,如果成功了,那么就直接返回这个接口的单例对象,这里直接使用动态代理的方式返回一个接口实例对象,那么后续执行这个接口的方法时,会直接走到IPCInvocationHandler的invoke方法中。

class IPCInvocationHandler(
    val service: Class<out IPCService>,
    val serviceId: String?
) : InvocationHandler {
    private val gson = Gson()
    override fun invoke(proxy: Any?, method: Method?, args: Array<out Any>?): Any? {
        //执行客户端发送方法请求
        val response = Channel.getDefault()
            .send(
                REQUEST.INVOKE_METHOD.ordinal,
                service,
                serviceId,
                method?.name ?: "",
                args
            )
        //拿到服务端返回的结果
        if (response != null && response.result) {
            //反序列化得到结果
            return gson.fromJson(response.value, method?.returnType)
        }
        return null
    }
}

因为服务端在拿到Method的返回结果时,将javabean转换为了json字符串,因此在IPCInvocationHandler中,当调用接口中方法获取结果之后,用Gson将json转换为javabean对象,那么就直接获取到了结果。

3.3 框架使用

服务端:

UserManager2.getDefault().setUserInfo(User("ming", 25))
IPC.register(UserManager2::class.java)

同时在服务端需要注册一个IPCService的实例,这里用的是IPCService01

<service
    android:name=".UserService"
    android:enabled="true"
    android:exported="true" />
<service
    android:name="com.lay.ipc.service.IPCService01"
    android:enabled="true"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.GET_USER_INFO" />
    </intent-filter>
</service>

客户端:

调用connect方法,需要绑定服务端的服务,传入包名和action

IPC.connect(
    this,
    "com.lay.learn.asm",
    "android.intent.action.GET_USER_INFO",
    IPCService01::class.java
)

首先获取IUserManager的实例,注意这里要和服务端注册的UserManager2是同一个ServiceId,而且接口、javabean需要存放在与服务端一样的文件夹下

val userManager = IPC.getInstanceWithName(
    IPCService01::class.java,
    IUserManager::class.java,
    "getDefault",
    null
)
val info = userManager?.getUserInfo()

通过动态代理拿到接口的实例对象,只要调用接口中的方法,就会进入到InvocationHandler中的invoke方法,在这个方法中,通过查找服务端注册的方法名从而找到对应的Method,通过反射调用拿到UserManager中的方法返回值。

这样其实就通过5-6行代码,就完成了进程间通信,是不是比我们在使用AIDL的时候要方便地许多。

4 总结

如果我们面对下面这个类,如果这个类是个私有类,外部没法调用,想通过反射的方式调用其中某个方法。

@ServiceId(name = "UserManagerService")
public class UserManager2 implements IUserManager {
    private static UserManager2 userManager2 = new UserManager2();
    public static UserManager2 getDefault() {
        return userManager2;
    }
    private User user;
    @Nullable
    @Override
    public User getUserInfo() {
        return user;
    }
    @Override
    public void setUserInfo(@NonNull User user) {
        this.user = user;
    }
    @Override
    public int getUserId() {
        return 0;
    }
    @Override
    public void setUserId(int id) {
    }
}

那么我们可以这样做:

val method = UserManager2::class.java.getDeclaredMethod("getUserInfo")
method.isAccessible = true
method.invoke(this,params)

其实这个框架的原理就是上面这几行代码所能够完成的事;通过服务端注册的形式,将UserManager2中所有的方法Method收集起来;当另一个进程,也就是客户端想要调用其中某个方法的时候,通过方法名来获取到对应的Method,调用这个方法得到最终的返回值

以上就是Android进阶手写IPC通信框架告别繁琐AIDL的详细内容,更多关于Android IPC通信框架的资料请关注编程网其它相关文章!

--结束END--

本文标题: Android进阶手写IPC通信框架告别繁琐AIDL

本文链接: https://lsjlt.com/news/178577.html(转载时请注明来源链接)

有问题或投稿请发送至: 邮箱/279061341@qq.com    QQ/279061341

猜你喜欢
软考高级职称资格查询
编程网,编程工程师的家园,是目前国内优秀的开源技术社区之一,形成了由开源软件库、代码分享、资讯、协作翻译、讨论区和博客等几大频道内容,为IT开发者提供了一个发现、使用、并交流开源技术的平台。
  • 官方手机版

  • 微信公众号

  • 商务合作