Android 使用Amazon S3 实现minIO的上传与下载

814 阅读5分钟

minIO是什么

一个开源的OSS存储方案....就这样。

Amazon S3是什么

Amazon S3(Simple Storage Service)是亚马逊云服务(AWS, Amazon Web Services)提供的一个对象存储服务,提供高可用性、可扩展性和安全的数据存储基础设施。minIO并且兼容 Amazon S3的API。

前景:

公司需要将阿里云存储缓存minIO存储,minIO有java的SDK,但是没有提供安卓的SDK。由于minIO兼容Amazon S3的API,所以我们客户端用S3的API 来上传文件。

依赖集成

下载Android S3的 SDK 地址,github.com/aws-amplify… 这里面有很多库,只copy我们需要的,

image.png

在S3 modules中的gradle 要添加上面的core 和kms依赖

api project(':libs:amazon-s3:aws-android-sdk-core')
api project(':libs:amazon-s3:aws-android-sdk-kms')

然后在项目合适的地方添加S3的依赖就可以了

implementation(project(":libs:amazon-s3:aws-android-sdk-s3"))

基本实现

在实现之前,我们一般需要确定几个东西:

  1. endPoint:服务器地址,后台开发者返回给你。
  2. 凭证数据token,SecretKey,AccessKey,用于服务器的鉴权,后台开发者返回给你。
  3. 签名方式QueryStringSignerType,AWS4SignerType, NoOpSignerType,AWSS3V4SignerType,跟服务器保持一致,
  4. bucketName:桶的位置,也就是服务器地址某个文件夹的路径。后台开发者返回给你。

确定了以上的东西,我们就可以初始化client了。

OSSUpDownloader 类



import android.util.Log
import com.amazonaws.AmazonClientException
import com.amazonaws.AmazonServiceException
import com.amazonaws.ClientConfiguration
import com.amazonaws.SDKGlobalConfiguration
import com.amazonaws.services.s3.AmazonS3
import com.amazonaws.services.s3.AmazonS3Client
import com.amazonaws.services.s3.S3ClientOptions
import com.amazonaws.services.s3.model.GetObjectRequest
import com.amazonaws.services.s3.model.ObjectMetadata
import com.amazonaws.services.s3.model.PutObjectRequest
import com.boyasec.ime.data.cross.file.StoreType
import com.boyasec.ime.data.oss.remote.OSSRemoteDataSource
import com.boyasec.ime.persistent.map.OssConfigPref
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.InputStream

private const val  TAG = "ctyun_OSSUpDownloader"
interface OSSUpDownloader {
    suspend fun preload()

    /**
     * 简单同步上传文件
     *
     * @param objectName 包含文件后缀在内的完整路径,例如abc/efg/123.jpg。
     * @param uploadData 二进制byte[]数组
     */
    suspend fun uploadBytes(
        objectName: String,
        uploadData: ByteArray,
        teamId: Long,
        @StoreType storeType: Int
    )

    /**
     * 简单同步上传文件
     *
     * @param objectName 包含文件后缀在内的完整路径,例如abc/efg/123.jpg。
     * @param uploadFilePath 本地文件地址
     */
    suspend fun uploadFile(
        objectName: String,
        uploadFilePath: String,
        teamId: Long,
        @StoreType storeType: Int
    )

    suspend fun uploadFileAsync(
        objectName: String,
        uploadFilePath: String,
        isPrivateBucket: Boolean =true,
        progressCallback: (currentSize: Long, totalSize: Long, result: Boolean?) -> Unit,
    )

    /**
     * 同步下载到本地文件
     *
     * @param objectKey 包含文件后缀在内的完整路径,例如abc/efg/123.jpg。
     */
    suspend fun downloadFile(
        objectKey: String,
        teamId: Long,
        @StoreType storeType: Int
    ): InputStream
}

internal class OSSUpDownloaderImpl(
    private val ossRemoteDataSource: OSSRemoteDataSource,
    private val ossConfigPref: OssConfigPref,
    private val defaultDispatcher: CoroutineDispatcher,
) : OSSUpDownloader {
    //鉴权相关
    private val refreshCredential = RefreshCredential(ossRemoteDataSource)

    private val ossClient: AmazonS3 by lazy {
        Log.i(TAG, "ossClient初始化: ")
        Log.i(TAG, "ossClient初始化: endpoint = " +ossConfigPref.ossEndPoint)
        // 创建client 配置对象
        val clientConfig = ClientConfiguration()
        clientConfig.connectionTimeout = 120 * 1000 // 设置连接的超时时间,单位毫秒
        clientConfig.socketTimeout = 120 * 1000 // 设置socket 超时时间,单位毫秒
        clientConfig.signerOverride="AWSS3V4SignerType"
//        clientConfig.signerOverride="NoOpSignerType"
        System.setProperty(SDKGlobalConfiguration.DISABLE_CERT_CHECKING_SYSTEM_PROPERTY,"true")

//        // 可以使用V4 签名,也可以使用V2 签名,false 为V2 签名,使用系统属性来设置
//        System.setProperty(
//            SDKGlobalConfiguration.ENABLE_S3_SIGV4_SYSTEM_PROPERTY,
//            "false"
//        )
        // V4 签名可以用负载参与签名、本例设置负载不参与签名
        val options = S3ClientOptions.builder().setPayloadSigningEnabled(false)
            .build()
         //创建client
        AmazonS3Client(
            refreshCredential,
            clientConfig
        ).apply {
            // 设置endpoint  这个地址是跟服务端协商的.
            endpoint = ossConfigPref.ossEndPoint
            // 设置选项
            setS3ClientOptions(options)
        }
    }
    //获取凭证
    override suspend fun preload() {
        refreshCredential.refreshCredentials()
    }

    /**
     * 简单同步上传文件
     *
     * @param objectName 包含文件后缀在内的完整路径,例如abc/efg/123.jpg。
     * @param uploadData 二进制byte[]数组
     */
    override suspend fun uploadBytes(
        objectName: String,
        uploadData: ByteArray,
        teamId: Long,
        storeType: Int,
    ) {
        when (storeType) {
            StoreType.PUBLIC -> {
            //bucketName 也是服务器告知的
                val bucketName = ossConfigPref.privateOssBucket
                val metadata = ObjectMetadata()
//                val dataLocation = CtyunBucketDataLocation()
//                metadata.dataLocation = dataLocation
                val request = PutObjectRequest(bucketName, objectName, uploadData.inputStream(), metadata)
                try {
                    ossClient.putObject(request)
                } catch (e: Exception) {
                    Timber.e(e)
                    when (e) {
                        is AmazonServiceException -> throw ServiceException(null)
                        is AmazonClientException -> throw ClientException(null)
                        else -> throw e
                    }
                }
            }

            StoreType.SELF_HOSTED -> ossRemoteDataSource.uploadBytes(teamId, objectName, uploadData)
        }
    }

    /**
     * 简单同步上传文件
     *
     * @param objectName 包含文件后缀在内的完整路径,例如abc/efg/123.jpg。
     * @param uploadFilePath 本地文件地址
     */
    override suspend fun uploadFile(
        objectName: String,
        uploadFilePath: String,
        teamId: Long,
        storeType: Int,
    ) {
        when (storeType) {
            StoreType.PUBLIC -> {
                val bucketName = ossConfigPref.privateOssBucket
                val metadata = ObjectMetadata()
//                val dataLocation = CtyunBucketDataLocation()
//                metadata.dataLocation = dataLocation
                val request = PutObjectRequest(bucketName, objectName, File(uploadFilePath))
                request.metadata = metadata
                try {
                    ossClient.putObject(request)
                } catch (e: Exception) {
                    Timber.e(e)
                    when (e) {
                        is AmazonServiceException -> throw ServiceException(null)
                        is AmazonClientException -> throw ClientException(null)
                        else -> throw e
                    }
                }
            }

            StoreType.SELF_HOSTED -> ossRemoteDataSource.uploadFile(
                teamId,
                objectName,
                uploadFilePath
            )
        }
    }

    override suspend fun uploadFileAsync(
        objectName: String,
        uploadFilePath: String,
        isPrivateBucket: Boolean ,
        progressCallback: (currentSize: Long, totalSize: Long, result: Boolean?) -> Unit,
    ) {
        val bucketName =
            if (isPrivateBucket) ossConfigPref.privateOssBucket else ossConfigPref.ossBucket
        val metadata = ObjectMetadata()
//        val dataLocation = CtyunBucketDataLocation()
//        metadata.dataLocation = dataLocation
        val file = File(uploadFilePath)
        val totalSize = file.length()
        val request = PutObjectRequest(bucketName, objectName, file)
        request.metadata = metadata
        request.withGeneralProgressListener {
            progressCallback(it.bytesTransferred, totalSize, null)
        }
        withContext(defaultDispatcher) {
            try {
                ossClient.putObject(request)
                progressCallback(totalSize, totalSize, true)
            } catch (_: Exception) {
                progressCallback(0, totalSize, false)
            }
        }
    }

    /**
     * 同步下载到本地文件
     *
     * @param objectKey 包含文件后缀在内的完整路径,例如abc/efg/123.jpg。
     */
    override suspend fun downloadFile(
        objectKey: String,
        teamId: Long,
        storeType: Int,
    ): InputStream {
        return when (storeType) {
            StoreType.PUBLIC -> {
                val bucketName = ossConfigPref.privateOssBucket
                val request = GetObjectRequest(bucketName, objectKey)
                try {
                    ossClient.getObject(request).objectContent
                } catch (e: Exception) {
                    Timber.e(e)
                    when (e) {
                        is AmazonServiceException -> throw ServiceException(e.message)
                        is AmazonClientException -> throw ClientException(e.message)
                        else -> throw e
                    }
                }
            }

            StoreType.SELF_HOSTED -> ossRemoteDataSource.downloadFile(teamId, objectKey)
            else -> error("Illegal store type = $storeType")
        }
    }
}

RefreshCredential 类

主要用于获取凭证数据:token,AccessKey,SecretKey,用来表明客户端身份的。


import com.amazonaws.auth.AWSRefreshableSessionCredentials
import com.amazonaws.services.securitytoken.model.Credentials
import com.boyasec.ime.data.oss.remote.OSSRemoteDataSource
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

class RefreshCredential(
    private val ossRemoteDataSource: OSSRemoteDataSource,
) : AWSRefreshableSessionCredentials {
    private val mutex = Mutex()
    private var sessionCredentials: Credentials? = null

    override fun getAWSAccessKeyId(): String {
        return getSessionCredentials().accessKeyId
    }

    override fun getAWSSecretKey(): String {
        return getSessionCredentials().secretAccessKey
    }

    override fun getSessionToken(): String {
        return getSessionCredentials().sessionToken
    }

    /**
     * 从后台服务器获取 需要的key 以及token
     */
    override fun refreshCredentials() {
        runBlocking {
            mutex.withLock {
                if (needsNewSession()) {
                    sessionCredentials = ossRemoteDataSource.getOssToken()
                }
            }
        }
    }

    private fun getSessionCredentials(): Credentials {
        if (needsNewSession()) {
            refreshCredentials()
        }
        return sessionCredentials!!
    }

    private fun needsNewSession(): Boolean {
        return if (sessionCredentials == null) {
            true
        } else {
            val expiration = sessionCredentials!!.expiration
            val timeRemaining = expiration.time - System.currentTimeMillis()
            timeRemaining < 60000L
        }
    }
}

到这基本就可以实现了基本的上传下载功能了,代码中的ossConfigPref 这个只是我项目中用来全局保存一些值的工具类,你们可以用自己的实现。

忽略HTTPS校验

如果上面的实现在验证的时候提示了Turst相关的错误,那么可能是服务器使用的http,我们需要忽略到HTTPS的校验,


查看源码得知 最终上传执行是在 UrlHttpClient 这个类,该类有个配置connection 的函数。里面有一段被注释的代码

//            // disable cert check
//            /*
//             * Commented as per https://support.google.com/faqs/answer/6346016. Uncomment for testing.
//
//            */
            if (System.getProperty(DISABLE_CERT_CHECKING_SYSTEM_PROPERTY) != null) {
                disableCertificateValidation(https);
            }

把其中的if判断直接去掉就可以了。 当然,也可以尝试设置 DISABLE_CERT_CHECKING_SYSTEM_PROPERTY 这个系统属性的值,我当时好像设置了没效果。