最近喵了下 Introduction to Serverless Functions 视频感觉还不错。下面主要根据视频作者Jason Lengstorfppt 简单做下笔记,最底下会列出相关链接。

示例代码 示例网站

前言

serverless 一般分为bass(Backend as a Service)和fass(Function as a Service)两种,今天我们主要学习一下fass,国内云厂商一般称为云函数。简单来说,就是依赖云厂商的平台建设,使我们只关注业务代码,不需要考虑服务器相关内容,也更容易的进行资源的弹性扩容,基于事件驱动,避免闲时资源浪费。

当然有利也有弊,fass基于Data-shipping架构,即:将数据传输到代码所在处执行,而不是将代码传输到数据所在处执行,因为数据一般都比代码的传输量要大得多。而目前主流的 Faas 平台都是『将数据传输到代码所在处执行』这种架构,所以这是 Faas 的最大缺陷,同时也不好维护状态,事务啥的。

所以,一般就作为一些公共基础建设,比如图片预处理,API网关数据拉取,这种无状态独立功能函数。

准备工作

今天我们主要使用node.js构建我们的函数应用,所以你要先安装下node.js v12 or higher

需要云服务商NetlifyHasura提供服务,所以需要你有这两个网站的账号,都提供免费额度使用,所以无需担心费用问题。

还需要,omdbapi,提供模拟数据,所以创建一个API KEY即可,

另外有的网站可能需要科学上网,这个需要你自己解决了。

搭建本地环境

# install the Netlify CLI for local development
npm install -g netlify-cli@latest
# clone the starting point for development
git clone --branch start https://github.com/jlengstorf/frontendmasters-serverless.git
# recommended use vscode
code frontendmasters-serverless
# create functions dir
cd frontendmasters-serverless
mkdir functions
# cofnig functions location
vim netlify.toml
[build]
  command = "npm run build"
  publish = "_site"
  functions = "functions"

最终目录结构如下:

.eleventy.js
.env # 环境变量
.gitignore
_site
data # 静态数据
functions # 函数目录
netlify.toml # 配置文件
node_modules
package.json
package-lock.json
README.md
src # 网站代码

这个看你使用哪个云厂商提供服务了,目录跟配置项可能不一样,本示例使用的是Netlify Functions

我们现在就来使用serverless functions 搭建一个简单的我看过的电影展示列表,网站静态页面跟样式已经有了,你可以启动服务看下效果

ntl dev

Boop

cd functions
vim boop.js
exports.handler = (event, context, callback) => {
  // "event" has information about the path, body, headers, etc. of the request
  console.log('event', event)
  // "context" has information about the lambda environment and user details
  console.log('context', context)
  // The "callback" ends the execution of the function and returns a response back to the caller
  return callback(null, {
    statusCode: 200,
    body: JSON.stringify({
      data: 'Boop!'
    })
  })
}

可以看到,serverless functions 函数结构很简单,就是声明一个方法,预处理,然后执行回调函数。

当然你直接使用return

exports.handler = async () => {
    return {
        statusCode: 200,
        body: 'Boop!'
    }
}

然后,我们访问http://localhost:8888/.netlify/functions/boop,即可看到返回的数据了

获取本地数据

现在我们要从本地,data/movies.json 获取数据,然后渲染在页面上,functions 目录下创建movies.js

//  get movies from local json
const movies = require('../data/movies.json')
exports.handler = async () => {
    return {
        statusCode: 200,
        body: JSON.stringify(moviesWithRatings),
    }
}

然后src/index.html 请求数据,渲染页面即可

<script>
async function initialize() {
    const movies = await fetch('/.netlify/functions/movies').then((response) =>
      response.json(),
    );

    const container = document.querySelector('.movies');
    const template = document.querySelector('#movie-template');

    movies.forEach((movie) => {
      const element = template.content.cloneNode(true);

      const img = element.querySelector('img');
      img.src = movie.poster;
      img.alt = movie.title;

      element.querySelector('h2').innerText = movie.title;
      element.querySelector('.tagline').innerText = movie.tagline;

      container.appendChild(element);
    });
  }
</script> 

获取请求参数

用过event参数获取,functions 目录下创建movie-by-id.js

const movies = require('../data/movies.json')

exports.handler = async({ queryStringParameters }) => {
    const { id } = queryStringParameters;
    const moive = movies.find(m => m.id === id);

    if(!moive) {
        return {
            statusCode: 404,
            body: 'Not Found'
        }
    }

    return {
        statusCode: 200,
        body: JSON.stringify(moive)
    }
}

然后访问http://localhost:8888/.netlify/functions/movie-by-id?id=tt2975590

当然event还包含很多其他信息,有兴趣的话可以喵下官方文档,或者,把event返回出来看看。

拉取OMDBAPI数据

获取第三方接口,一般需要API-KEY提供凭证,一般配置在环境变量里面,

vim .env
OMDB_API_KEY=

这边使用node-fetch请求第三方接口

npm install node-fetch

我们这里,通过OMDBAPI获取影片的评分信息,调整functions/movies.js代码示例如下:


const { URL } = require('url')
const fetch = require('node-fetch')

//  get movies from local json
const movies = require('../data/movies.json')

exports.handler = async () => {

    const movieScoreApi = new URL("https://www.omdbapi.com/");
    // add the secret API key to the query string
    movieScoreApi.searchParams.set('apiKey', process.env.OMDB_API_KEY)

    const promises = movies.map(movie => {
        movieScoreApi.searchParams.set('i', movie.id);
        return fetch(movieScoreApi)
        .then(response => response.json())
        .then(data => {
            const scores = data.Ratings;
            return {
                ...movie,
                scores
            }
        })
    })

    // awaiting all Promises lets the requests happen in parallel
    // see: https://lwj.dev/blog/keep-async-await-from-blocking-execution/
    const moviesWithRatings = await Promise.all(promises);

    return {
        statusCode: 200,
        body: JSON.stringify(moviesWithRatings),
    }
}

src/index.html 代码页面也做相应调整,这里就不多赘述。

Hasura Graphql

movies.json的数据到达一定量的话,一般会存到数据库便于管理,这边使用Hasura Graphql,创建一张表movies表存储,并将movies.json数据填充几条到movies表中。

所以这边也要配置下Hasura Graphql 请求的环境变量:

vim .env
HASURA_API_URL=
HASURA_ADMIN_SECRET=

关于Hasura Graphql的使用,语法等可以参考官方文档hasura graphql

HASURA_ADMIN_SECRET 其实就是NEW ENV VARS即可,

首先我们写一个工具方法,用来调用Hasura Graphql接口,functions/util/hasura.js

const fetch = require('node-fetch')

async function query({ query, variables = {} }) {
    const result = await fetch(process.env.HASURA_API_URL, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-Hasura-Admin-Secret': process.env.HASURA_ADMIN_SECRET,
        },
        body: JSON.stringify({ query, variables }),
    })
    .then(response => response.json())
    .catch(function(e) {
        console.log(e);
    });
    // TODO send back helpful information if there are errors

    console.info(result)
    return result.data;
}

exports.query = query;

然后再调整一下,functions/movies.js


const { query } = require('./util/hasura');

exports.handler = async () => {

    // get movies from db
    const { movies } = await query({
        query: `
            query {
                movies {
                    id
                    title
                    tagline
                    poster
                }
            }
        `,
    });

    ...other code

    return {
        statusCode: 200,
        body: JSON.stringify(moviesWithRatings),
    }
}

然后再写个添加方法functions/add-movie.js

const { query } = require('./util/hasura')

exports.handler = async function(event) {
    const { id, title, tagline, poster } = JSON.parse(event.body);

    const result = await query({
        query: `
            mutation CreateMovie($id: String!, $poster: String!, $tagline: String!, $title: String!) {
                insert_movies_one(object: {id: $id, poster: $poster, tagline: $tagline, title: $title}) {
                    id
                    poster
                    tagline
                    title
                }
            }
        `,
        variables: { id, title, tagline, poster },
    });

    return {
        statusCode: 200,
        body: JSON.stringify(result),
    };
}

调整下添加页面,src/admin.html

<script>
  async function handleSubmit(event) {
    event.preventDefault();
    const data = new FormData(event.target);
    const result = await fetch('/.netlify/functions/add-movie', {
      method: 'POST',
      body: JSON.stringify({
        id: data.get('id'),
        title: data.get('title'),
        tagline: data.get('tagline'),
        poster: data.get('poster'),
      }),
    }).then((response) => {
      document.querySelector(
        '.message',
      ).innerText = `Response: ${response.status} — ${response.statusText}`;
    });
  }

  document.querySelector('#add-movie').addEventListener('submit', handleSubmit);
</script>

Netlify Identify

网站一般要进行身份验证,然后不同身份认证有不同的操作权限。比如我的影片列表,我可以进行添加编辑操作,其他人只能进行浏览。所以,一般要引入身份认证,如果从头自己搞登录逻辑,可能比较繁琐,一般也是独立出一个认证的微服务。

这边简单的使用Netlify Identify来进行网站的身份认证,如果你使用其他云厂商,这部分可以略过

此时需要我们先部署一个网站,你可以直接使用netlify-cli

ntl init

我这边不知道是因为网络原因还是啥的,netlify-cli认证不了,所以我直接登录app.netlify操作了,部署后,可以直接在线访问

app.netlify 对应站点管理,对我们刚部署的站点启用Identify

Netlify Identify 已经集成了UI界面,所以我们要引入 netlify-identity-widget

<!-- include the widget -->
<script type="text/javascript" src="https://identity.netlify.com/v1/netlify-identity-widget.js"></script >

这里,我只在src/admin.html src/login.html引入了,

这边我想要实现的效果是,用户登录才能访问src/admin页面进行添加电影的操作,

首先,我们在src/login.html 添加如下代码:

<div data-netlify-identity-button></div>
<script
  type="text/javascript"
  src="https://identity.netlify.com/v1/netlify-identity-widget.js"
></script>

<script>
  function handleLogin(user) {
    if (!user || !user.token) {
      return;
    }

    // if we get here, we have an active user; redirect to the admin page!
    window.location.pathname = '/admin/';
  }

  window.netlifyIdentity.on('init', handleLogin);
  window.netlifyIdentity.on('login', handleLogin);
</script>

调整src/admin.html 代码,

<script
  type="text/javascript"
  src="https://identity.netlify.com/v1/netlify-identity-widget.js"
></script>
<script>

  function handleIdentityEvent(user) {
    if (user && user.token) {
      return;
    }

    window.location.pathname = '/login/';
  }

  netlifyIdentity.on('init', handleIdentityEvent);
  netlifyIdentity.on('logout', handleIdentityEvent);

  document.querySelector('.logout').addEventListener('click', (event) => {
    event.preventDefault();
    netlifyIdentity.logout();
  });
    
  ....

腾讯云函数

这里简单举个小例子,比如做一个返回json数据的接口,触发管理为API网关触发器,可以看到语言规范基本一致。

'use strict';

exports.main_handler = (event, context, callback) => {
    console.log("Hello World")
    console.log(event)
    console.log(event["non-exist"])
    console.log(context)
    callback(null, require('data/fooddata.js'))
};

跟据自己的需要选择合适的云平台,当然也有很多完善云平台使用的serverless框架,

本篇仅作为一个简单的入门demo,回调方法也不仅仅是返回JSON,也有很多OUTPUT形式,各个云平台也会跟据自己现有的服务,提供各种集成机制,感兴趣的自己自行扩展阅读。

相关链接

github:serverless

github:awesome-serverless

Introduction to Serverless Functions

gihub:frontendmasters-serverless

netlify

hasura