学习目标
-
掌握什么是serverless和FaaS
-
学习使用阿里云函数计算(FC)构建多语言的后台服务
-
使用Spring Boot + 阿里云函数计算 + OSS打造极低成本的表白小程序
一、520表白小程序介绍
1.1 需求说明
距离一年一度的520全民表白日,只!有!一!个!月!了!!!空气中处处弥漫着恋爱的甜(suan)蜜(chou)味!作为程序员,这一次我们来玩点不一样的!自己设计一款520表白小程序,来对他(她)表白吧!
二、什么是Serverless?
说起当前最火的技术,不得不提的一个概念就是 Serverless。近两年几乎所有人都在说 Serverless,Serverless 作为一种新型的互联网架构,直接或间接推动了云计算的发展,从 AWS Lambda 到阿里云函数计算,Serverless 一路高歌,同时基于 Serverless 的轻量计算开始登录云计算的舞台。
从Serverless名字上可以看出来,其实Serverless这个单词是两个单词的组合Serverless = server + less。Server指的是服务端,而less指的是较少关心,所以组合在一起就是较少关心服务端。
2.1 Serverfull的工作模式
要理解Serverless,我们先来看看Serverfull这种模式下,工作模式到底是怎么样的。
我们以服务端开发中两个角色为例:
做研发的小程,他是个精通JAVA WEB后端技术的程序员,主要负责与产品经理对接,将产品经理提出的产品需求转化成代码,当然他还得负责项目的版本管理以及后续线上bug的修复。
做运维的小魏,他只关心应用的服务端运维事务。他负责部署上线小程的 Web 应用以及日志监控。在用户访问量大的时候,他要给这个应用扩容(多加几台服务器);在用户访问量小的时候,他要给这个应用缩容(把服务器作他用节省成本);在服务器挂了的时候,他还要重启或者换一台服务器。
最开始小魏承诺将运维的事情全包了,小程不用关心任何部署运维相关的事情。小程每次发布新的应用,都会打电话给小魏,让小魏部署上线最新的代码。小魏要管理好迭代版本的发布,分支合并,将应用上线,遇到问题回滚。如果线上出了故障,还要抓取线上的日志发给小程解决。
这个时候小魏就觉得自己像一个工具人,每天都在重复的做一些琐碎的工作,特别是每次出现bug的时候,还要自己登陆到服务器上去查询日志,然后发给小程进行bug的修复。
这个时期研发和运维隔离,服务端运维都交给小服一个人,纯人力处理,也就是 Serverfull。
2.3 DevOps的工作模式
后来,小魏渐渐发现日常其实有很多事情都是重复性的工作,尤其是发布新版本的时候,与其每次都等小程电话,线上出故障了还要自己抓日志发过去,效率很低,不如干脆自己做一套运维平台,将部署上线和日志抓取的工作让小程自己处理。
运维平台上线后,小魏稍微轻松了一些,但是对于应用的扩容和缩容,还是需要小魏自己定期审查。而小程除了开发的任务,每次发布新版本或解决线上故障,都要自己到运维平台平台上去处理。这个时代就是研发兼运维 DevOps,小程兼任了小魏的部分工作。小魏将部分服务端运维的工作工具化了,自己可以去做更加专业的事情。相对ServerFull时代,看上去小程负责的内容更多了,但实际这些事情本身就应该是小程负责的。本来版本控制、线上故障就应该是小程自己处理的。而且小魏将这部分人力的工作工具化了,更加高效。其实已经有变少(less)的趋势了。那么还能不能再进一步优化呢?
2.4 Serverless的工作模式
这时,小魏发现资源优化和扩缩容方案也可以利用性能监控 + 流量估算解决。小魏又基于小程的开发流程,运维平台再进一步升级,帮小程做了一套代码自动化发布的流水线:从代码扫描 到测试再到上线。现在的小程连运维平台都不用登陆操作,只要将代码进行提交,剩下的就都由流水线自动化处理发布上线了。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zfX660vh-1651140767936)(pic\114.jpg)]
这个时候研发不需要运维了。小魏的服务端运维工作全部自动化了。小程也变回到最初,只需要关心自己的应用业务就可以了。我们不难看出,在服务端运维的发展历史中,对于小程来说,小魏的角色存在感越来越弱,需要小魏参与的事情越来越少,都由自动化工具替代了。这就是“Serverless”。
那么我们怎么来实现serverless呢?这时候我们就需要使用到Faas了,FaaS
是Functions as a Service
的缩写,可以广义的理解为功能服务化,也可以解释为函数服务化。使用FaaS只需要关注业务代码逻辑,无需关注服务器资源,所以FaaS也跟开发者无需关注服务器Serverless密切相关。可以说FaaS提供了一个更加细分和抽象的服务化能力。其实很多的云平台已经提供了Faas相关的功能,今天我们使用的就是阿里云提供的函数计算FC。函数计算FC,它可以让我们随时随地创建、使用、销毁一个函数。函数计算FC需要加载代码进行实例化,然后被触发器 Trigger 或者被其他的函数调用。而我们要做的就是购买函数计算FC的服务,然后上传代码,函数计算服务会自动编译我们的代码并将其发布到服务器上运行。然后我们就可以使用触发器访问服务了。
Serverless 对 Web 服务开发的革命之一,就是极度简化了服务端运维模型,使一个零经验的新手,也能快速搭建一套低成本、高性能的服务端应用。
三、阿里云函数计算FC入门
3.1 什么是函数计算
3.1.1 简介
函数计算是事件驱动的全托管计算服务。使用函数计算,您无需采购与管理服务器等基础设施,只需编写并上传代码。函数计算为您准备好计算资源,弹性地、可靠地运行任务,并提供日志查询、性能监控和报警等功能,借助函数计算,您可以快速构建任何类型的应用和服务,并且只需为任务实际消耗的资源付费。在众多Serverless产品中阿里云函数计算FC更是其中的佼佼者。
3月25日消息,日前权威咨询机构 Forrester 发布 2021 年第一季度 FaaS 平台(Function-As-A-Service Platforms)评估报告,阿里云凭借产品能力全球第一的优势脱颖而出,在八个评测维度中拿到最高分,成为比肩亚马逊的全球 FaaS 领导者。这也是首次有国内科技公司进入 FaaS 领导者象限。
-
无需采购和管理服务器等基础设施,运维成本低。
-
您只需专注业务逻辑的开发,使用函数计算支持的开发语言设计、优化、测试、审核以及上传自己的应用代码。
-
以事件驱动的方式触发应用响应用户请求。与阿里云对象存储OSS、API网关、日志服务和表格存储等服务无缝对接,帮助快速构建应用。
-
提供日志查询、性能监控和报警等功能快速排查故障。
-
毫秒级别弹性伸缩,快速实现底层扩容以应对峰值压力。
-
按需付费,支持毫秒级别收费。只需为实际使用的计算资源付费,适合有明显波峰波谷的用户访问场景。
-
函数计算支持多种语言开发,支持的语言列表如下:
-
Node.js Node.js运行环境 Python Python运行环境 PHP PHP运行环境 Java Java运行环境 C# .NET Core运行环境 Go Go Custom Runtime Ruby Ruby Custom Runtime PowerShell PowerShell Custom Runtime TypeScript TypeScript Custom Runtime F# F# Custom Runtime C++ C++ Custom Runtime Lua Lua Custom Runtime Dart Dart Custom Runtime 其他语言 Custom Runtime
3.1.2 工作流程
流程说明如下:
-
开发者使用编程语言编写应用和服务。函数计算支持的开发语言请参见开发语言列表。
-
开发者上传应用到函数计算。
上传途径包括:
- (推荐)通过
函数计算控制台
(https://fc.console.aliyun.com/?spm=a2c4g.11186623.2.9.6f33398eERpRzW)上传。 - (推荐)通过命令行工具Funcraft上传更多信息。
- 通过API或SDK上传。
- 通过命令行工具fcli上传。
- (推荐)通过
-
触发函数执行。触发方式包括OSS、API网关、日志服务、表格存储以及函数计算API、SDK等。
-
动态扩容以响应请求。函数计算可以根据用户请求量自动扩容,该过程对您和您的用户均透明无感知。
-
根据函数的实际执行时间按量计费。函数执行结束后,可以通过账单来查看执行费用,收费粒度精确到1毫秒。
流程图如下所示:
3.1.3 计费模式
函数计算每月为您提供一定的免费额度。您的阿里云账户与RAM用户共享每月免费的调用次数和执行时间额度。免费额度不会按月累积,在下一自然月的起始时刻,即1号零点,会清零然后重新计算。具体免费额度如下:
注意 免费额度只能在弹性实例的后付费模式下使用。
- 调用次数:每月前100万次函数调用免费。
- 函数实例资源使用量(内存大小):每月前400,000(GB-秒)函数实例资源使用量免费。
以下是以后付费单价为例计算月度费用。
函数执行内存 | 调用次数 | 执行时长 | 网络带宽 | 月度费用 |
---|---|---|---|---|
512 MB | 300万次 | 1秒/次 | 0 | 124.31元 |
128 MB | 3000万次 | 200毫秒/次 | 0 | 77.28元 |
128 MB | 2500万次 | 200毫秒/次 | 0 | 56.80元 |
448 MB | 500万次 | 500毫秒/次 | 0 | 82.04元 |
1024 MB | 250万次 | 1秒/次 | 0 | 234.24元 |
3.2 快速入门
3.2.1 环境搭建流程
函数计算使用流程图如下所示。
流程说明如下:
- 创建服务。
- 创建函数,编写代码,将应用部署到函数中。
- 以事件源触发函数。
- 查看执行日志。
- 查看服务的监控。
3.2.2 服务的基本操作
创建服务
-
登录函数计算控制台。
-
在顶部菜单栏,选择地域。
-
在左侧导航栏中,单击服务及函数。在服务列表区域右上角,单击新增服务。
- 在新建服务页面,设置服务参数,单击提交。
参数说明如下。
参数 | 说明 |
---|---|
服务名称 | 设置服务名称。 |
功能描述 | 设置服务描述信息,便于区分服务,非必选。 |
绑定日志 | 选择是否绑定日志。绑定日志后,您可以查看函数执行日志,方便您进行函数开发及调试。 |
开启链路追踪 | 选择是否开启链路追踪功能。更多信息,请参见链路追踪简介。 |
在服务及函数页面的服务列表中可以查看已创建的服务。
更新服务
-
登录函数计算控制台。
-
在顶部菜单栏,选择地域。
-
在左侧导航栏,单击服务及函数。
-
在服务及函数页面,单击目标服务。然后单击服务配置,在服务配置页签,单击修改配置。
- 在配置服务页面根据需要修改相应的参数,然后单击提交。
删除服务
注意 删除服务前,请确保您的服务中没有函数、预留的函数实例、版本及别名,否则会导致删除失败。
-
登录函数计算控制台。
-
在顶部菜单栏,选择地域。
-
在左侧导航栏,单击服务及函数。
-
在服务及函数页面,单击目标服务。然后单击服务配置,在服务配置页签,单击页面右上角的删除服务 。
- 在弹出的对话框中单击确认。
3.2.3 创建函数
-
登录函数计算控制台。
-
在顶部菜单栏,选择地域。
-
在左侧导航栏中,单击服务及函数。在服务及函数页面,单击目标服务,然后单击页面右上角的新增函数。
-
在新建函数页面选择创建的函数类型或函数模板,然后单击配置部署。
本文以创建HTTP函数为例。
- 在新建函数页面,设置相关参数,然后单击新建,以NodeJS为例(FC函数计算中,Java语言不支持在线编辑)。
参数说明如下所示。
参数 | 是否必填 | 操作 | 示例值 |
---|---|---|---|
函数类型 | 是 | 您选择的函数类型,选择后,无法更改。 | 事件函数 |
所在服务 | 是 | 若已创建服务:在列表中选择已存在的服务。若未创建服务:填写自定义的服务名称,系统将自动为您创建服务。 | Service |
函数名称 | 是 | 填写自定义的函数名称。 | Function |
运行环境 | 是 | 选择您熟悉的语言,例如Python、Java、PHP、Node.js等。函数计算支持的运行环境,请参见函数简介。选择运行环境后,您可以通过以下方式上传您的函数代码:代码包上传:选择后,单击上传代码,上传您的函数代码。文件夹上传:选择后,单击选择文件夹,选择您需要上传的文件夹。OSS上传:选择后,配置Bucket名称和Object名称,即可上传您OSS中的函数代码。使用示例代码:选择后,即可使用函数计算的示例代码。 | Node.JS 12.x |
函数入口 | 是 | 填写函数入口。格式为[文件名].[函数名]。 | index.handler |
高级设置 | |||
函数实例类型 | 是 | 选择适合您的实例类型。弹性实例****性能实例更多信息,请参见实例规格及使用模式。 | 弹性实例 |
函数执行内存 | 是 | 设置函数执行内存。选择输入:单击函数执行内存,在下拉列表中选择所需内存。手动输入:单击手动输入,可自定义函数执行内存。输入的内存必须为64 MB的倍数。 | 512 MB |
超时时间 | 是 | 设置超时时间。默认超时时间为60秒,最长为600秒。说明 超过设置的超时时间,函数将以执行失败结束。 | 60 |
单实例并发度 | 否 | 单个实例能够并发处理的请求数。更多信息,请参见单实例多并发简介。 | Python语言不支持设置实例并发度。 |
层 | 否 | 选择您需要加载的层。更多信息,请参见层概述。 | NodeJS |
修改代码
-
登录函数计算控制台。
-
在顶部菜单栏,选择地域。
-
在左侧导航栏,单击服务及函数。
-
点击函数名称,进入函数详情页面:
5.选择代码执行标签,编辑代码:
node代码修改如下:
var getRawBody = require('raw-body');
var getFormBody = require('body/form');
var body = require('body');
/*
To enable the initializer feature (https://help.aliyun.com/document_detail/156876.html)
please implement the initializer function as below:
exports.initializer = (context, callback) => {
console.log('initializing');
callback(null, '');
};
*/
exports.handler = (req, resp, context) => {
resp.send('hello world');
}
6.在代码执行标签页的最后,可以对函数进行测试:
3.2.4 Funcraft快速搭建Spring Boot环境
安装Funcraft
详见https://help.aliyun.com/document_detail/161136.htm?spm=a2c4g.11186623.2.6.6f756fcf9hheNP#section-jni-z2b-zrf
配置Funcraft
-
执行以下命令初始化Funcraft工具,配置账号信息。
fun config
-
根据提示依次配置Account ID(阿里云账号ID)、AccessKey ID、AccessKey Secret、Default Region Name。
如果您的账号是RAM用户,Account ID需要配置为阿里云账号的ID,AccessKey ID、AccessKey Secret为RAM用户的密钥。
完成配置后,Funcraft会将配置保存到用户目录下的.fcli/config.yaml文件中。
创建初始化模板
-
执行以下命令初始化项目模板。
fun init -n demo
-
根据提示选择一个项目模板。
项目模板类型如下:
- 以
event-
为前缀的模板是普通的事件函数。 - 以
http-trigger
为前缀的模板会默认为您创建HTTP触发器。HTTP触发器以Request、Response为入参,帮助您快速搭建Web应用。
本示例中,选择http-trigger-spring-boot
的模板,使用Idea加载项目,项目结构如下:
本地编译
通过 fun build 可以对项目进行编译构建:
fun build
执行效果如下:
部署到云端
3.2.5 迁移Spring Boot到函数计算
-
创建一个Spring Boot项目,详情请参见Spring Quickstart Guide。
在IDEA中选择File-New Project创建新项目,选择Spring Initializr,在右侧的服务URL中输入:https//start.aliyun.com
选择lombok和spring web两个组件:
删除多余的文件,项目结构如下图所示:
(重要)修改pom文件:
删除pom文件中如下这段
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
添加parent的继承关系:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.7.RELEASE</version>
</parent>
添加测试接口:
package com.itheima.love520demo.controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author itheima
* @version 1.0
* @date 2021/5/13 18:25
*/
@RestController
public class TestController {
@RequestMapping("/test")
public String test(){
return "itheima";
}
}
-
执行以下命令进入刚创建的示例项目或您已有的项目。
cd <project-name>
-
在项目的根目录下执行
mvn package -Dmaven.test.skip=true
命令打包。编译输出结果与以下示例类似。
mvn package -Dmaven.test.skip=true
[INFO] Scanning for projects... [INFO] [INFO] ----------------------< com.example:Spring-Boot >----------------------- [INFO] Building Spring-Boot 0.0.1-SNAPSHOT [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- maven-resources-plugin:3.1.0:resources (default-resources) @ Spring-Boot --- ... ... ... [INFO] [INFO] Results: [INFO] [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 [INFO] [INFO] [INFO] --- maven-jar-plugin:3.1.2:jar (default-jar) @ Spring-Boot --- [INFO] Building jar: /Users/txd123/Desktop/Spring-Boot/target/Spring-Boot-0.0.1-SNAPSHOT.jar [INFO] [INFO] --- spring-boot-maven-plugin:2.2.6.RELEASE:repackage (repackage) @ Spring-Boot --- [INFO] Replacing main artifact with repackaged archive [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 38.850 s [INFO] Finished at: 2020-03-31T15:09:34+08:00 [INFO] ------------------------------------------------------------------------
-
执行
fun deploy -y
命令将项目部署至函数计算。Funcraft会自动进入部署流程。
fun deploy -y
current folder is not a fun project. Generating template.yml... Generate Fun project successfully! ========= Fun will use 'fun deploy' to deploy your application to Function Compute! ========= using region: cn-qingdao using accountId: ***********3743 using accessKeyId: ***********Ptgk using timeout: 60 Collecting your services information, in order to caculate devlopment changes... Resources Changes(Beta version! Only FC resources changes will be displayed): trigger httpTrigger deploy success function Spring-Boot deploy success service Spring-Boot deploy success Detect 'DomainName:Auto' of custom domain 'Domain' Request a new temporary domain ... The assigned temporary domain is 15639196-XXX.test.functioncompute.com,expired at 2020-04-10 15:19:56, limited by 1000 per day. Waiting for custom domain Domain to be deployed... custom domain Domain deploy success
在控制台找到URL路径中的Path,将其复制下来:
修改application.properties文件,添加访问路径:
server.servlet.context-path=/2016-08-15/proxy/love520demo/love520demo
重新执行打包和部署:
mvn package -Dmaven.test.skip=true
fun deploy -y
如果在页面上调用测试不成功,可能是因为URL中版本被添加了.LATEST的关系,可以使用POSTMAN进行测试
postman测试结果如下:
四、阿里云对象存储OSS入门
阿里云对象存储OSS(Object Storage Service)是阿里云提供的海量、安全、低成本、高持久的云存储服务。其数据设计持久性不低于99.9999999999%(12个9),服务可用性(或业务连续性)不低于99.995%。OSS具有与平台无关的RESTful API接口,可以在任何应用、任何时间、任何地点存储和访问任意类型的数据。表白小程序将使用OSS上传二维码和用户自定义的图片。
4.1 创建存储空间
-
登录OSS管理控制台。
-
单击Bucket列表,然后单击创建Bucket。
您也可以单击概览,然后单击右上角的创建Bucket。
-
在创建Bucket面板,按如下说明配置必要参数。其他参数均可保持默认配置,也可以在Bucket创建完成后单独配置。
参数 描述 Bucket名称 Bucket的名称。Bucket一旦创建,则无法更改其名称。命名规则如下:Bucket名称必须全局唯一。只能包括小写字母、数字和短划线(-)。必须以小写字母或者数字开头和结尾。长度必须在3~63字节之间。 地域 Bucket的数据中心。Bucket一旦创建,则无法更改其所在地域。如需通过ECS内网访问OSS,请选择与ECS相同的地域。更多信息,请参见OSS访问域名使用规则。 同城冗余存储 OSS同城冗余存储采用多可用区(AZ)机制,将用户的数据以冗余的方式存放在同一地域(Region)的3个可用区。当某个可用区不可用时,仍然能够保障数据的正常访问。启用:开启同城冗余存储,则Bucket内的Object将以同城冗余的方式进行存储。例如,Bucket存储类型为标准存储,则该Bucket内的Object默认为标准存储(同城冗余)。详情请参见同城冗余存储。注意仅华南1(深圳)、华北2(北京)、华东1(杭州)、华东2(上海)、中国(香港)、新加坡地域支持开启同城冗余存储。仅允许创建Bucket时开启同城冗余存储。开启后不支持关闭,请谨慎操作。关闭:默认不开启同城冗余存储,则Bucket内的Object将以本地冗余的方式进行存储。例如,Bucket存储类型为标准存储,则该Bucket内的Object默认为标准存储(本地冗余)。
- 单击确定。
4.2 上传文件
-
登录OSS管理控制台。
-
单击左侧导航栏的Bucket列表,然后单击目标Bucket名称。
-
在文件管理页签,单击上传文件。
-
在上传文件面板,按如下说明配置各项参数。
参数 | 说明 |
---|---|
上传到 | 设置文件上传到OSS后的存储路径。当前目录:将文件上传到当前目录。指定目录:将文件上传到指定目录,您需要输入目录名称。若输入的目录不存在,OSS将自动创建对应的文件夹并将文件上传到该文件夹中。 |
文件ACL | 选择文件的读写权限。继承Bucket:以Bucket读写权限为准。私有(推荐):只有文件Owner拥有该文件的读写权限,其他用户没有权限操作该文件。公共读:文件Owner拥有该文件的读写权限,其他用户(包括匿名访问者)都可以对文件进行访问,这有可能造成您数据的外泄以及费用激增,请谨慎操作。公共读写:任何用户(包括匿名访问者)都可以对文件进行访问,并且向该文件写入数据。这有可能造成您数据的外泄以及费用激增,若被人恶意写入违法信息还可能会侵害您的合法权益。除特殊场景外,不建议您配置公共读写权限。有关文件ACL的更多信息,请参见Object ACL。 |
待上传文件 | 选择您需要上传的文件或文件夹。您可以单击扫描文件或扫描文件夹选择本地文件或文件夹,或者直接拖拽目标文件或文件夹到待上传文件区域。如果上传文件夹中包含了无需上传的文件,请单击目标文件右侧的移除将其移出文件列表。注意如果上传的文件与存储空间中已有的文件重名,则会覆盖已有文件。使用拖拽方式上传文件夹时,OSS会保留文件夹内的所有文件和子文件夹。文件上传过程中,请勿刷新或关闭页面,否则上传任务会被中断且列表会被清空。 |
-
单击上传文件。
此时,您可以在上传列表页签查看各个文件的上传进度。上传完成后,您可以在目标路径下查看上传文件的文件名、文件大小以及存储类型等信息。
4.3 下载文件
-
登录OSS管理控制台。
-
单击左侧导航栏的Bucket列表,然后单击目标Bucket名称。
-
单击左侧导航栏的文件管理,下载单个或多个文件。
-
下载单个文件
方式一:单击目标文件右侧的更多> 下载。
方式二:单击目标文件的文件名或其右侧的详情,在弹出的详情面板中单击下载。
-
下载多个文件
选中多个文件,选择批量操作 > 下载。通过OSS控制台可一次批量下载最多100个文件。
五、表白小程序服务端开发
5.1 系统设计
系统设计核心要点:
- 成本极低
- 部署难度低
- 支持动态扩容
为了保证服务部署难度和成本较低,使用了FC函数计算服务(Serverless)。存储层将所有的LOGO图片和表白信息都以文件的方式保存到阿里云OSS对象存储上,既能保证对大量文件保存的支持,也减少了部署数据库的成本。
注:由于小程序以个人身份不支持页面直接跳转,所以扫描二维码跳转小程序无法实现,所以需要单独部署表白信息的展示服务(tomcat),所以需要一台低成本的服务器。如以企业申请小程序,可直接在小程序中开发界面展示表白信息,真正做到访问量小的时候0成本。
5.2 小程序uni-app开发框架介绍
本小程序使用uni-app开发,uni-app
是一个使用 Vue.js 开发所有前端应用的框架,开发者编写一套代码,可发布到iOS、Android、Web(响应式)、以及各种小程序(微信/支付宝/百度/头条/QQ/钉钉/淘宝)、快应用等多个平台。
uni-app
在手,做啥都不愁。即使不跨端,uni-app
也是更好的小程序开发框架、更好的App跨平台框架、更方便的H5开发框架。不管领导安排什么样的项目,你都可以快速交付,不需要转换开发思维、不需要更改开发习惯。
5.3 环境搭建
5.3.1 创建服务端程序
请按照3.2.4的方式搭建初始环境
5.3.2 集成工具模块
1.添加依赖:
<!--阿里云OSS依赖-->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.10.2</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.1.1</version>
</dependency>
<!--二维码依赖-->
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.3.0</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.3.0</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
2.将资料中的工具类OssUtil和QRCodeKit添加到utils包下。
OSSUtil工具类:
package com.itheima.love520.utils;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.GetObjectRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.*;
/**
* @author brianxia
* @version 1.0
* @date 2021/4/18 23:45
*/
@Component
public class OssUtil {
@Value("${oss.bucketName}")
private String bucketName;
@Value("${oss.accessKeyId}")
private String accessKeyId;
@Value("${oss.accessKeySecret}")
private String accessKeySecret;
@Value("${qr.image.path}")
private String qrPath;
public OSS createClient() {
// Endpoint以杭州为例,其它Region请按实际情况填写。
String endpoint = "https://oss-cn-beijing.aliyuncs.com";
// 创建OSSClient实例。
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
return ossClient;
}
/**
* 以流的方式保存,用于保存文件
* @param objectName 文件名
* @param stream 流对象
*/
public void save(String objectName, InputStream stream) {
OSS client = createClient();
// 上传文件到指定的存储空间(bucketName)并将其保存为指定的文件名称(objectName)。
client.putObject(bucketName, objectName, stream);
// 关闭OSSClient。
client.shutdown();
}
/**
* 保存字符串
* @param objectName 文件名
* @param content 字符串
*/
public void save(String objectName, String content) {
OSS client = createClient();
// 上传文件到指定的存储空间(bucketName)并将其保存为指定的文件名称(objectName)。
client.putObject(bucketName, objectName, new ByteArrayInputStream(content.getBytes()));
// 关闭OSSClient。
client.shutdown();
}
/**
* 保存二维码
* @param objectName 文件名
* @param context 二维码内容
* @param logo 中间的logo图
*/
public void saveQr(String objectName, String context,String logo) throws IOException {
BufferedImage image = null;
if(StringUtils.isEmpty(logo)){
image = QRCodeKit.createQRCode(context);
}else{
String path = loadFile(logo);
File file = new File(path);
image = QRCodeKit.createQRCodeWithLogo(context,file);
if(file.exists()){
file.delete();
}
}
if(image == null){
return;
}
ByteArrayOutputStream os = new ByteArrayOutputStream();
ImageIO.write(image, "png", os);
InputStream qrCode = new ByteArrayInputStream(os.toByteArray());
try{
OSS client = createClient();
// 上传文件到指定的存储空间(bucketName)并将其保存为指定的文件名称(objectName)。
client.putObject(bucketName, objectName, qrCode);
// 关闭OSSClient。
client.shutdown();
}finally {
if(qrCode != null){
try {
qrCode.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
/**
* 加载文件
* @param objectName 文件名
* @return 文件内容字符串
*/
public String loadFile(String objectName) {
String path = qrPath + objectName;
OSS ossClient = createClient();
// 下载Object到本地文件,并保存到指定的本地路径中。如果指定的本地文件存在会覆盖,不存在则新建。
// 如果未指定本地路径,则下载后的文件默认保存到示例程序所属项目对应本地路径中。
ossClient.getObject(new GetObjectRequest(bucketName, objectName), new File(path));
// 关闭OSSClient。
ossClient.shutdown();
return path;
}
}
二维码工具类:
package com.itheima.love520.utils;
import com.google.zxing.*;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.codec.binary.Base64OutputStream;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.*;
import java.util.HashMap;
import java.util.Map;
public class QRCodeKit {
public static final String QRCODE_DEFAULT_CHARSET = "UTF-8";
public static final int QRCODE_DEFAULT_HEIGHT = 400;
public static final int QRCODE_DEFAULT_WIDTH = 400;
public static final int LOGO_DEFAULT_HEIGHT = 100;
public static final int LOGO_DEFAULT_WIDTH = 100;
private static final int BLACK = 0xFF000000;
private static final int WHITE = 0xFFFFFFFF;
public static void main(String[] args) throws IOException, NotFoundException{
String data = "http://www.baidu.com";
File logoFile = new File("logo.png");
BufferedImage image = QRCodeKit.createQRCodeWithLogo(data, logoFile);
ImageIO.write(image, "png", new File("result7.png"));
System.out.println("done");
}
/**
* Create qrcode with default settings
*
* @param data
* @return
*/
public static BufferedImage createQRCode(String data) {
return createQRCode(data, QRCODE_DEFAULT_WIDTH, QRCODE_DEFAULT_HEIGHT);
}
/**
* Create qrcode with default charset
*
* @param data
* @param width
* @param height
* @return
*/
public static BufferedImage createQRCode(String data, int width, int height) {
return createQRCode(data, QRCODE_DEFAULT_CHARSET, width, height);
}
/**
* Create qrcode with specified charset
*
* @param data
* @param charset
* @param width
* @param height
* @return
*/
@SuppressWarnings({ "unchecked", "rawtypes" })
public static BufferedImage createQRCode(String data, String charset, int width, int height) {
Map hint = new HashMap();
hint.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
hint.put(EncodeHintType.CHARACTER_SET, charset);
return createQRCode(data, charset, hint, width, height);
}
/**
* Create qrcode with specified hint
*
* @param data
* @param charset
* @param hint
* @param width
* @param height
* @return
*/
public static BufferedImage createQRCode(String data, String charset, Map<EncodeHintType, ?> hint, int width,
int height) {
BitMatrix matrix;
try {
matrix = new MultiFormatWriter().encode(new String(data.getBytes(charset), charset), BarcodeFormat.QR_CODE,
width, height, hint);
return toBufferedImage(matrix);
} catch (WriterException e) {
throw new RuntimeException(e.getMessage(), e);
} catch (Exception e) {
throw new RuntimeException(e.getMessage(), e);
}
}
public static BufferedImage toBufferedImage(BitMatrix matrix) {
int width = matrix.getWidth();
int height = matrix.getHeight();
BufferedImage image = new BufferedImage(width, height,
BufferedImage.TYPE_INT_RGB);
for (int x = 0; x < width; x++) {
for (int y = 0; y < height; y++) {
image.setRGB(x, y, matrix.get(x, y) ? BLACK : WHITE);
}
}
return image;
}
/**
* Create qrcode with default settings and logo
*
* @param data
* @param logoFile
* @return
*/
public static BufferedImage createQRCodeWithLogo(String data, File logoFile) {
return createQRCodeWithLogo(data, QRCODE_DEFAULT_WIDTH, QRCODE_DEFAULT_HEIGHT, logoFile);
}
/**
* Create qrcode with default charset and logo
*
* @param data
* @param width
* @param height
* @param logoFile
* @return
*/
public static BufferedImage createQRCodeWithLogo(String data, int width, int height, File logoFile) {
return createQRCodeWithLogo(data, QRCODE_DEFAULT_CHARSET, width, height, logoFile);
}
/**
* Create qrcode with specified charset and logo
*
* @param data
* @param charset
* @param width
* @param height
* @param logoFile
* @return
*/
@SuppressWarnings({ "unchecked", "rawtypes" })
public static BufferedImage createQRCodeWithLogo(String data, String charset, int width, int height, File logoFile) {
Map hint = new HashMap();
hint.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
hint.put(EncodeHintType.CHARACTER_SET, charset);
return createQRCodeWithLogo(data, charset, hint, width, height, logoFile);
}
/**
* Create qrcode with specified hint and logo
*
* @param data
* @param charset
* @param hint
* @param width
* @param height
* @param logoFile
* @return
*/
public static BufferedImage createQRCodeWithLogo(String data, String charset, Map<EncodeHintType, ?> hint,
int width, int height, File logoFile) {
try {
BufferedImage qrcode = createQRCode(data, charset, hint, width, height);
BufferedImage logo2 = ImageIO.read(logoFile);
BufferedImage logo =new BufferedImage(LOGO_DEFAULT_WIDTH,LOGO_DEFAULT_HEIGHT,BufferedImage.TYPE_INT_RGB);
Graphics graphics=logo.getGraphics();
//将原始位图缩小后绘制到bufferedImage对象中
graphics.drawImage(logo2,0,0,LOGO_DEFAULT_WIDTH,LOGO_DEFAULT_HEIGHT,null);
int deltaHeight = height - logo.getHeight();
int deltaWidth = width - logo.getWidth();
BufferedImage combined = new BufferedImage(height, width, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = (Graphics2D) combined.getGraphics();
g.drawImage(qrcode, 0, 0, null);
g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 1f));
g.drawImage(logo, (int) Math.round(deltaWidth / 2), (int) Math.round(deltaHeight / 2), null);
return combined;
} catch (IOException e) {
throw new RuntimeException(e.getMessage(), e);
} catch (Exception e) {
throw new RuntimeException(e.getMessage(), e);
}
}
/**
* Return base64 for image
*
* @param image
* @return
*/
public static String getImageBase64String(BufferedImage image) {
String result = null;
try {
ByteArrayOutputStream os = new ByteArrayOutputStream();
OutputStream b64 = new Base64OutputStream(os);
ImageIO.write(image, "png", b64);
result = os.toString("UTF-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e.getMessage(), e);
} catch (IOException e) {
throw new RuntimeException(e.getMessage(), e);
}
return result;
}
/**
* Decode the base64Image data to image
*
* @param base64ImageString
* @param file
*/
public static void convertBase64StringToImage(String base64ImageString, File file) {
FileOutputStream os;
try {
Base64 d = new Base64();
byte[] bs = d.decode(base64ImageString);
os = new FileOutputStream(file.getAbsolutePath());
os.write(bs);
os.close();
} catch (FileNotFoundException e) {
throw new RuntimeException(e.getMessage(), e);
} catch (IOException e) {
throw new RuntimeException(e.getMessage(), e);
} catch (Exception e) {
throw new RuntimeException(e.getMessage(), e);
}
}
}
3.创建resources文件夹,并添加配置文件application.properties:
oss.bucketName=OSS仓库名
oss.accessKeyId=OSS的ak
oss.accessKeySecret=OSS的SK
qr.url=http://www.cloudprogrammer.cn:8090/show/confess
qr.image.path=/tmp/
5.4 接口开发
5.4.1 接口文档
流程设计
整体的流程分为两个阶段:
1.表白人通过小程序进行操作,生成二维码。
2.表白对象扫描二维码,展示表白页面。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DsVW8QgW-1651140767946)(pic\520表白小程序业务逻辑流程 .jpg)]
1.小程序的用户点击上传LOGO按钮,将LOGO图片进行上传。后台系统会将此图片存储。
2.小程序的用户编写表白对象、表白内容、选择模板,完成之后点击生成二维码的按钮。
3.后台系统将上述所有的数据保存,并生成最终的访问链接,以二维码的方式返回。
1.表白人将二维码发给表白对象,表白对象扫描二维码。
2.向后端发起请求,后端获取之前保存的表白数据,生成页面。
3.表白对象看到表白的内容。
上传LOGO图片
接口地址:/2016-08-15/proxy/love520demo/love520demo/confess/upload
请求方式:POST
请求数据类型:multipart/form-data
响应数据类型:*/*
接口描述:
生成随机文件名,将文件保存到OSS中,返回文件名。
参数名称 | 参数说明 | in | 是否必须 | 数据类型 | schema |
---|---|---|---|---|---|
file | 图片文件 | formData | true | file |
响应参数:
文件名,是一个随机字符串
响应示例:
5609a150-22cf-4d8f-a8a4-7d673444b617
保存表白内容
接口地址:/2016-08-15/proxy/love520demo/love520demo/confess/save
请求方式:POST
请求数据类型:application/json
响应数据类型:*/*
接口描述:
小程序点击生成二维码时,向后端发送请求,保存表白内容
请求示例:
{
"content": "",
"logo": "",
"template": "",
"to": ""
}
请求参数:
参数名称 | 参数说明 | in | 是否必须 | 数据类型 | schema |
---|---|---|---|---|---|
confess | confess | body | true | 表白内容实体定义 | 表白内容实体定义 |
content | 表白内容 | false | string | ||
logo | 二维码中间的logo图片 | false | string | ||
template | 用户选择的模板 | false | string | ||
to | 表白对象 | false | string |
响应参数:
文件名,是一个随机字符串
响应示例:
5609a150-22cf-4d8f-a8a4-7d673444b617
表白展示接口
接口地址:/show/confess
请求方式:GET
请求数据类型:*
响应数据类型:*/*
接口描述:
请求参数:
参数名称 | 参数说明 | 是否必须 | 数据类型 | schema |
---|---|---|---|---|
id | 保存表白内容返回的ID | true | String |
响应参数:
展示页面
5.4.2 代码编写
创建表白实体类(也可以使用工具自动生成 https://www.bejson.com/json2javapojo/new/):
package com.itheima.love520.entity;
import lombok.Data;
/**
* 表白内容实体定义
* @author itheima
* @version 1.0
* @date 2021/4/18 23:35
*/
@Data
public class Confess {
//表白对象
private String to;
//表白内容
private String content;
//二维码中间的logo图片
private String logo;
//用户选择的模板
private String template;
}
创建接口Controller:
package com.itheima.love520demo.controller;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.itheima.love520demo.entity.Confess;
import com.itheima.love520demo.utils.OssUtil;
import lombok.extern.java.Log;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Map;
import java.util.UUID;
/**
* @author itheima
* @version 1.0
* @date 2021/4/18 23:34
*/
@RestController
@CrossOrigin("*")
@RequestMapping("/confess")
@Slf4j
public class ConfessController {
/**
* JSON转换器
*/
@Autowired
private ObjectMapper objectMapper;
/**
* OSS上传下载工具类
*/
@Autowired
private OssUtil ossUtil;
/**
* 表白的展示页面,已提供
*/
@Value("${qr.url}")
private String qrUrl;
/**
* 上传图片接口
* @param file 文件对象
* @return 文件名,是一个随机字符串
*/
@PostMapping("/upload")
public String uploadFile(MultipartFile file) throws Exception {
//生成随机字符串作为文件名
String uuid = UUID.randomUUID().toString();
try {
//存储图片
ossUtil.save(uuid,file.getInputStream());
} catch (Exception e) {
log.error("二维码图片保存失败",e);
}
return uuid;
}
/**
* 保存表白内容
* @param confess 表白内容
* @return 文件名,是一个随机字符串
*/
@PostMapping("/save")
public String saveConfess(@RequestBody Confess confess) throws Exception {
//生成随机文件名
String uuid = UUID.randomUUID().toString();
String qrRealUrl = qrUrl + "?id=" + uuid;
try {
//存储数据
ossUtil.save(uuid,objectMapper.writeValueAsString(confess));
//存储二维码
ossUtil.saveQr(uuid + "qr.jpg",qrRealUrl,confess.getLogo());
} catch (IOException e) {
log.error("表白保存失败",e);
}
return uuid;
}
}
查询接口请复制资料/初始工程
中的love520-show到本地工作目录中,并使用idea打开工程:
在controller下编写接口:
package com.itheima.love520show.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.itheima.love520show.entity.Confess;
import com.itheima.love520show.util.OssUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
* @author itheima
* @version 1.0
* @date 2021/4/20 23:58
*/
@Controller
@RequestMapping("/show")
public class ShowController {
@Autowired
OssUtil ossUtil;
@Autowired
ObjectMapper mapper;
@RequestMapping("/confess")
public String confess(HttpServletRequest request,Model model) throws IOException {
String id = request.getParameter("id");
String load = ossUtil.load(id);
Confess confess = mapper.readValue(load, Confess.class);
model.addAttribute("to",confess.getTo());
model.addAttribute("content",confess.getContent());
return "template" + confess.getTemplate();
}
}
5.5 接口测试
使用POSTMAN测试接口,上传图片接口:
表白内容保存接口:
运行love520-show项目,访问地址:
http://localhost:8090/show/confess?id=45eaa681-c252-4dce-98b6-c71aa6082db8
显示如下内容,代码编写成功:
5.6 前后端联调测试
5.6.1 部署函数服务
将项目中application.properties文件中的展示地址修改为最终服务器的ip地址或者域名:
qr.url=http://www.cloudprogrammer.cn:8090/show/confess
双击maven命令中的package,对当前项目进行打包:
成功之后,在当前目录下执行命令
fun deploy -y
如下图所示,已经显示成功
5.6.2 部署展示服务
同5.4.1中,将项目先进行打包,然后将项目上传到服务器中:
执行命令:
nohup java -jar love520-show.jar &
5.6.3 前端调试
使用HbuilderX打开前端代码,HbuilderX下载地址:https://www.dcloud.io/hbuilderx.html
修改接口地址和OSS地址(129行):
// 修改为程序真实地址
action: 'https://1847961572749689.cn-hangzhou.fc.aliyuncs.com/2016-08-15/proxy/love520-1/love520-1/confess/upload',
saveUrl : 'https://1847961572749689.cn-hangzhou.fc.aliyuncs.com/2016-08-15/proxy/love520-1/love520-1/confess/save',
ossUrl : 'https://itheima-test20210509.oss-cn-beijing.aliyuncs.com/',
点击真机调试,就可以看到画面并运行程序了:
六、总结
本次课程我们通过使用阿里云的FC函数计算和OSS对象存储打造了一款超低成本的520表白小程序,大家也感受到了Serverless带给我们的独特魅力。
Serverless的优势:
1.超低成本,每个月表白小程序的调用前100万次免费,OSS也有很大的免费使用量。
2.弹性扩容,当用户量增大时阿里云会自动增加服务器以提供支持。
3.降低运维成本,不需要运维人员参与,开发人员上传代码即可发布最新版本。
典型应用场景:
发布小程序注意事项
发布小程序注意事项
如果需要将小程序进行发布,还需要在上传及生成二维码时对文本和图片进行内容安全审核,可以调用小程序相应的内容安全代码(详见https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/sec-check/security.imgSecCheck.html)。
以下给出示例代码,图片审核:
Map map1 = restTemplate.getForObject("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=小程序ID&secret=小程序secret", Map.class);
String token = (String) map1.get("access_token");
Boolean aBoolean = PickCheckUtil.checkPic(file,token);
if(!aBoolean){
return "error";
}
文本审核:
String content = confess.getTo() + "," + confess.getContent();
Map map1 = restTemplate.getForObject("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=小程序ID&secret=小程序secret", Map.class);
String token = (String) map1.get("access_token");
HashMap<Object, Object> param = new HashMap<>();
param.put("content",content);
Map map = restTemplate.postForObject("https://api.weixin.qq.com/wxa/msg_sec_check?access_token=" + token, param, Map.class);
Integer errcode = (Integer) map.get("errcode");
if(errcode != 0){
return "error";
}
(https://help.aliyun.com/document_detail/148417.html)
发布小程序注意事项
发布小程序注意事项
如果需要将小程序进行发布,还需要在上传及生成二维码时对文本和图片进行内容安全审核,可以调用小程序相应的内容安全代码(详见https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/sec-check/security.imgSecCheck.html)。
以下给出示例代码,图片审核:
Map map1 = restTemplate.getForObject("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=小程序ID&secret=小程序secret", Map.class);
String token = (String) map1.get("access_token");
Boolean aBoolean = PickCheckUtil.checkPic(file,token);
if(!aBoolean){
return "error";
}
文本审核:
String content = confess.getTo() + "," + confess.getContent();
Map map1 = restTemplate.getForObject("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=小程序ID&secret=小程序secret", Map.class);
String token = (String) map1.get("access_token");
HashMap<Object, Object> param = new HashMap<>();
param.put("content",content);
Map map = restTemplate.postForObject("https://api.weixin.qq.com/wxa/msg_sec_check?access_token=" + token, param, Map.class);
Integer errcode = (Integer) map.get("errcode");
if(errcode != 0){
return "error";
}