2024年3月

本文从前端方面出发实现浏览器下载大文件的功能。不考虑网络异常、关闭网页等原因造成传输中断的情况。分片下载采用
串行
方式(并行下载需要对切片计算hash,比对hash,丢失重传,合并chunks的时候需要按顺序合并等,很麻烦。对传输速度有追求的,并且在带宽允许的情况下可以做
并行分片下载
)。

测试发现存一两个G左右数据到IndexedDB后,浏览器确实会内存占用过高导致退出 (我测试使用的是chrome103版本浏览器)

实现步骤

  1. 使用分片下载:
    将大文件分割成多个小块进行下载,可以降低内存占用和网络传输中断的风险。这样可以避免一次性下载整个大文件造成的性能问题。

  2. 断点续传:
    实现断点续传功能,即在下载中途中断后,可以从已下载的部分继续下载,而不需要重新下载整个文件。

  3. 进度条显示:
    在页面上展示下载进度,让用户清晰地看到文件下载的进度。如果一次全部下载可以从process中直接拿到参数计算得出(很精细),如果是分片下载,也是计算已下载的和总大小,只不过已下载的会成片成片的增加(不是很精细)。

  4. 取消下载和暂停下载功能:
    提供取消下载和暂停下载的按钮,让用户可以根据需要中止或暂停下载过程。

  5. 合并文件:
    下载完成后,将所有分片文件合并成一个完整的文件。

以下是一个基本的前端大文件下载的实现示例:

可以在类里面增加注入一个回调函数,用来更新外部的一些状态,示例中只展示下载完成后的回调

class FileDownloader {
  constructor({url, fileName, chunkSize = 2 * 1024 * 1024, cb}) {
    this.url = url;
    this.fileName = fileName;
    this.chunkSize = chunkSize;
    this.fileSize = 0;
    this.totalChunks = 0;
    this.currentChunk = 0;
    this.downloadedSize = 0;
    this.chunks = [];
    this.abortController = new AbortController();
    this.paused = false;
    this.cb = cb
  }

  async getFileSize() {
    const response = await fetch(this.url, { signal: this.abortController.signal });
    const contentLength = response.headers.get("content-length");
    this.fileSize = parseInt(contentLength);
    this.totalChunks = Math.ceil(this.fileSize / this.chunkSize);
  }

  async downloadChunk(chunkIndex) {
    const start = chunkIndex * this.chunkSize;
    const end = Math.min(this.fileSize, (chunkIndex + 1) * this.chunkSize - 1);

    const response = await fetch(this.url, {
      headers: { Range: `bytes=${start}-${end}` },
      signal: this.abortController.signal
    });

    const blob = await response.blob();
    this.chunks[chunkIndex] = blob;
    this.downloadedSize += blob.size;

    if (!this.paused && this.currentChunk < this.totalChunks - 1) {
      this.currentChunk++;
      this.downloadChunk(this.currentChunk);
    } else if (this.currentChunk === this.totalChunks - 1) {
      this.mergeChunks();
    }
  }

  async startDownload() {
    if (this.chunks.length === 0) {
      await this.getFileSize();
    }
    this.downloadChunk(this.currentChunk);
  }

  pauseDownload() {
    this.paused = true;
  }

  resumeDownload() {
    this.paused = false;
    this.downloadChunk(this.currentChunk);
  }

  cancelDownload() {
    this.abortController.abort();
    this.reset();
  }

  async mergeChunks() {
    const blob = new Blob(this.chunks, { type: "application/octet-stream" });
    const url = window.URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = this.fileName;
    document.body.appendChild(a);
    a.click();
    setTimeout(() => {
      this.cb && this.cb({
        downState: 1
      })
      this.reset();
      document.body.removeChild(a);
      window.URL.revokeObjectURL(url);
    }, 0);
  }
  
  reset() {
    this.chunks = [];
    this.fileName = '';
    this.fileSize = 0;
    this.totalChunks = 0;
    this.currentChunk = 0;
    this.downloadedSize = 0;
  }
}


// 使用示例
const url = "https://example.com/largefile.zip";
const fileName = "largefile.zip";

const downloader = new FileDownloader({url, fileName, cb: this.updateData});

// 更新状态
updateData(res) {
  const {downState} = res
  this.downState = downState
}

// 开始下载
downloader.startDownload();

// 暂停下载
// downloader.pauseDownload();

// 继续下载
// downloader.resumeDownload();

// 取消下载
// downloader.cancelDownload();

分片下载怎么实现断点续传?已下载的文件怎么存储?

浏览器的安全策略禁止网页(JS)直接访问和操作用户计算机上的文件系统。

在分片下载过程中,每个下载的文件块(chunk)都需要在客户端进行缓存或存储,方便实现断点续传功能,同时也方便后续将这些文件块合并成完整的文件。这些文件块可以暂时保存在内存中或者存储在客户端的本地存储(如 IndexedDB、LocalStorage 等)中。

一般情况下,为了避免占用过多的内存,推荐将文件块暂时保存在客户端的本地存储中。这样可以确保在下载大文件时不会因为内存占用过多而导致性能问题。

在上面提供的示例代码中,文件块是暂时保存在一个数组中的,最终在
mergeChunks()
方法中将这些文件块合并成完整的文件。如果你希望将文件块保存在本地存储中,可以根据需要修改代码,将文件块保存到 IndexedDB 或 LocalStorage 中。

IndexedDB本地存储

IndexedDB文档:
IndexedDB_API

IndexedDB 浏览器存储限制和清理标准

无痕模式是浏览器提供的一种隐私保护功能,它会在用户关闭浏览器窗口后自动清除所有的浏览数据,包括 LocalStorage、IndexedDB 和其他存储机制中的数据。

IndexedDB 数据实际上存储在浏览器的文件系统中,是浏览器的隐私目录之一,不同浏览器可能会有不同的存储位置,普通用户无法直接访问和手动删除这些文件,因为它们受到浏览器的安全限制。可以使用
deleteDatabase
方法来删除整个数据库,或者使用
deleteObjectStore
方法来删除特定的对象存储空间中的数据。

原生的indexedDB api 使用起来很麻烦,稍不留神就会出现各种问题,封装一下方便以后使用。

这个类封装了 IndexedDB 的常用操作,包括打开数据库、添加数据、通过 ID 获取数据、获取全部数据、更新数据、删除数据和删除数据表。

封装indexedDB类

class IndexedDBWrapper {
  constructor(dbName, storeName) {
    this.dbName = dbName;
    this.storeName = storeName;
    this.db = null;
  }

  openDatabase() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName);
      
      request.onerror = () => {
        console.error("Failed to open database");
        reject();
      };

      request.onsuccess = () => {
        this.db = request.result;
        resolve();
      };

      request.onupgradeneeded = () => {
        this.db = request.result;
        
        if (!this.db.objectStoreNames.contains(this.storeName)) {
          this.db.createObjectStore(this.storeName, { keyPath: "id" });
        }
      };
    });
  }

  addData(data) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction([this.storeName], "readwrite");
      const objectStore = transaction.objectStore(this.storeName);
      const request = objectStore.add(data);

      request.onsuccess = () => {
        resolve();
      };

      request.onerror = () => {
        console.error("Failed to add data");
        reject();
      };
    });
  }

  getDataById(id) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction([this.storeName], "readonly");
      const objectStore = transaction.objectStore(this.storeName);
      const request = objectStore.get(id);

      request.onsuccess = () => {
        resolve(request.result);
      };

      request.onerror = () => {
        console.error(`Failed to get data with id: ${id}`);
        reject();
      };
    });
  }

  getAllData() {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction([this.storeName], "readonly");
      const objectStore = transaction.objectStore(this.storeName);
      const request = objectStore.getAll();

      request.onsuccess = () => {
        resolve(request.result);
      };

      request.onerror = () => {
        console.error("Failed to get all data");
        reject();
      };
    });
  }

  updateData(data) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction([this.storeName], "readwrite");
      const objectStore = transaction.objectStore(this.storeName);
      const request = objectStore.put(data);

      request.onsuccess = () => {
        resolve();
      };

      request.onerror = () => {
        console.error("Failed to update data");
        reject();
      };
    });
  }

  deleteDataById(id) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction([this.storeName], "readwrite");
      const objectStore = transaction.objectStore(this.storeName);
      const request = objectStore.delete(id);

      request.onsuccess = () => {
        resolve();
      };

      request.onerror = () => {
        console.error(`Failed to delete data with id: ${id}`);
        reject();
      };
    });
  }

  deleteStore() {
    return new Promise((resolve, reject) => {
      const version = this.db.version + 1;
      this.db.close();

      const request = indexedDB.open(this.dbName, version);

      request.onupgradeneeded = () => {
        this.db = request.result;
        this.db.deleteObjectStore(this.storeName);
        resolve();
      };

      request.onsuccess = () => {
        resolve();
      };

      request.onerror = () => {
        console.error("Failed to delete object store");
        reject();
      };
    });
  }
}

使用indexedDB类示例:

const dbName = "myDatabase";
const storeName = "myStore";

const dbWrapper = new IndexedDBWrapper(dbName, storeName);

dbWrapper.openDatabase().then(() => {
  const data = { id: 1, name: "John Doe", age: 30 };

  dbWrapper.addData(data).then(() => {
    console.log("Data added successfully");

    dbWrapper.getDataById(1).then((result) => {
      console.log("Data retrieved:", result);

      const updatedData = { id: 1, name: "Jane Smith", age: 35 };
      dbWrapper.updateData(updatedData).then(() => {
        console.log("Data updated successfully");

        dbWrapper.getDataById(1).then((updatedResult) => {
          console.log("Updated data retrieved:", updatedResult);

          dbWrapper.deleteDataById(1).then(() => {
            console.log("Data deleted successfully");

            dbWrapper.getAllData().then((allData) => {
              console.log("All data:", allData);

              dbWrapper.deleteStore().then(() => {
                console.log("Object store deleted successfully");
              });
            });
          });
        });
      });
    });
  });
});

indexedDB的使用库 - localforage

这个库对浏览器本地存储的几种方式做了封装,自动降级处理。但是使用indexedDB上感觉不是很好,不可以添加索引,但是操作确实方便了很多。

文档地址:
localforage

下面展示 LocalForage 中使用 IndexedDB 存储引擎并结合
async/await
进行异步操作

const localforage = require('localforage');

// 配置 LocalForage
localforage.config({
  driver: localforage.INDEXEDDB, // 使用 IndexedDB 存储引擎
  name: 'myApp', // 数据库名称
  version: 1.0, // 数据库版本
  storeName: 'myData' // 存储表名称
});

// 使用 async/await 进行异步操作
(async () => {
  try {
    // 存储数据
    await localforage.setItem('key', 'value');
    console.log('数据保存成功');

    // 获取数据
    const value = await localforage.getItem('key');
    console.log('获取到的数据为:', value);

    // 移除数据
    await localforage.removeItem('key');
    console.log('数据移除成功');
    
    // 关闭 IndexedDB 连接
    await localforage.close();
    console.log('IndexedDB 已关闭');
  } catch (err) {
    console.error('操作失败', err);
  }
})();

现代的浏览器会自动管理 IndexedDB 连接的生命周期,包括在页面关闭时自动关闭连接,在大多数情况下,不需要显式地打开或关闭 IndexedDB 连接。

如果你有特殊的需求或者对性能有更高的要求,可以使用
localforage.close()
方法来关闭连接。

使用 LocalForage 来删除 IndexedDB 中的所有数据

import localforage from 'localforage';

// 使用 clear() 方法删除所有数据
localforage.clear()
  .then(() => {
    console.log('IndexedDB 中的所有数据已删除');
  })
  .catch((error) => {
    console.error('删除 IndexedDB 数据时出错:', error);
  });

IndexedDB内存暂用过高问题

使用 IndexedDB 可能会导致浏览器内存占用增加的原因有很多,以下是一些可能的原因:

  1. 数据量过大
    :如果你在 IndexedDB 中存储了大量数据,那么浏览器可能需要消耗更多内存来管理和处理这些数据。尤其是在读取或写入大量数据时,内存占用会显著增加。

  2. 未关闭的连接
    :如果在使用完 IndexedDB 后未正确关闭数据库连接,可能会导致内存泄漏。确保在不再需要使用 IndexedDB 时正确关闭数据库连接,以释放占用的内存。

  3. 索引和查询
    :如果你在 IndexedDB 中创建了大量索引或者执行复杂的查询操作,都会导致浏览器内存占用增加,特别是在处理大型数据集时。

  4. 缓存
    :浏览器可能会对 IndexedDB 中的数据进行缓存,以提高访问速度。这可能会导致内存占用增加,尤其是在大规模数据操作后。

  5. 浏览器实现
    :不同浏览器的 IndexedDB 实现可能存在差异,某些浏览器可能会在处理 IndexedDB 数据时占用更多内存。

为了减少内存占用,你可以考虑优化数据存储结构、合理使用索引、避免长时间保持大型数据集等措施。另外,使用浏览器的开发者工具进行内存分析,可以帮助你找到内存占用增加的具体原因,从而采取相应的优化措施。


我是
甜点cc
,个人网站:
blog.i-xiao.space/

唯有行动,才能战胜虚无!

公众号:【看见另一种可能】

本文分享自华为云社区《
3月阅读周·你不知道的JavaScript | 无人不识又无人不迷糊的this
》,作者: 叶一一。

关于this

this关键字是JavaScript中最复杂的机制之一。它是一个很特别的关键字,被自动定义在所有函数的作用域中。

为什么要用this

随着开发者的使用模式越来越复杂,显式传递上下文对象会让代码变得越来越混乱,使用this则不会这样。

比如下面的例子:

function identify() {return this.name.toUpperCase();
}

function speak() {
var greeting = "Hello, I'm" + identify.call(this);
console.log(greeting);
}
var me ={
name:
'Kyle',
};
var you ={
name:
'Reader',
};

console.log(identify.call(me));
console.log(identify.call(you));

speak.call(me);
speak.call(you);

打印一下结果:

2-1.png

上面的代码可以在不同的上下文对象(me和you)中重复使用函数identify()和speak(),不用针对每个对象编写不同版本的函数。如果不使用this,那就需要给identify()和speak()显式传入一个上下文对象。

误解

有两种常见的对于this的解释,但是它们都是错误的。

1、指向自身

人们很容易把this理解成指向函数自身。

那么为什么需要从函数内部引用函数自身呢?常见的原因是递归(从函数内部调用这个函数)或者可以写一个在第一次被调用后自己解除绑定的事件处理器。

看下面这段代码,思考foo会被调用了多少次?

function foo(num) {
console.log(
'foo:' +num);//记录foo被调用的次数 this.count++;
}

foo.count
= 0;vari;for (i = 0; i < 10; i++) {if (i > 5) {
foo(i);
}
}
//foo被调用了多少次? console.log(foo.count);

打印结果:

2-2.png

console.log语句产生了4条输出,证明foo(..)确实被调用了4次,但是foo.count仍然是0。显然从字面意思来理解this是错误的。

执行foo.count = 0时,的确向函数对象foo添加了一个属性count。但是函数内部代码this.count中的this并不是指向那个函数对象。

2、它的作用域

第二种常见的误解是,this指向函数的作用域。这个问题有点复杂,因为在某种情况下它是正确的,但是在其他情况下它却是错误的。

this在任何情况下都不指向函数的词法作用域。

function foo() {var a = 2;this.bar();
}

function bar() {
console.log(
this.a);
}

foo();

直接打印上面的代码会得到一个报错:

2-3.png

这段代码试图通过this.bar()来引用bar()函数。这是不可能实现的,使用this不可能在词法作用域中查到什么。

每当开发者想要把this和词法作用域的查找混合使用时,一定要提醒自己,这是无法实现的。

this到底是什么

this的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。

当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方式、传入的参数等信息。this就是这个记录的一个属性,会在函数执行的过程中用到。

this全面解析

调用位置

在理解this的绑定过程之前,首先要理解调用位置:调用位置就是函数在代码中被调用的位置(而不是声明的位置)。

寻找调用位置就是寻找“函数被调用的位置”。最重要的是要分析调用栈(就是为了到达当前执行位置所调用的所有函数)。调用位置就在当前正在执行的函数的前一个调用中。

通过下面的代码来看什么是调用栈和调用位置:

function baz() {//当前调用栈是:baz//因此,当前调用位置是全局作用域
console.log('baz');
bar();
//<-- bar的调用位置 }

function bar() {
//当前调用栈是baz -> bar//因此,当前调用位置在baz中 console.log('bar');
foo();
//<-- foo的调用位置 }

function foo() {
//当前调用栈是baz -> bar -> foo//因此,当前调用位置在bar中 console.log('foo');
}

baz();
//<-- baz的调用位置

打印的结果如下:

2-4.png

绑定规则

来看看在函数的执行过程中调用位置如何决定this的绑定对象。

首先必须找到调用位置,然后判断需要应用下面四条规则中的哪一条。

充分理解四条规则之后,再理解多条规则都可用时它们的优先级如何排列。

1、默认绑定

首先要介绍的是最常用的函数调用类型:独立函数调用。可以把这条规则看作是无法应用其他规则时的默认规则。

var a = 2;

function foo() {
console.log(
this.a);
}

foo();
//2

打印结果是2。也就是当调用foo()时,this.a被解析成了全局变量a。函数调用时应用了this的默认绑定,因此this指向全局对象。

2、隐式绑定

另一条需要考虑的规则是调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含,不过这种说法可能会造成一些误导。

思考下面的代码:

function foo() {
console.log(
this.a);
}
var obj ={
a:
2,
foo: foo,
};

obj.foo();
//2

当foo()被调用时,它的前面确实加上了对obj的引用。当函数引用有上下文对象时,隐式绑定规则会把函数调用中的this绑定到这个上下文对象。因为调用foo()时this被绑定到obj,因此this.a和obj.a是一样的。

3、显式绑定

JavaScript提供的绝大多数函数以及你自己创建的所有函数都可以使用call(..)和apply(..)方法。

它们的第一个参数是一个对象,是给this准备的,接着在调用函数时将其绑定到this。

因为可以直接指定this的绑定对象,因此我们称之为显式绑定。

思考下面的代码:

function foo() {
console.log(
this.a);
}
var obj ={
a:
2,
};

foo.call(obj);
//2

通过foo.call(..),我们可以在调用foo时强制把它的this绑定到obj上。

4、new绑定

在传统的面向类的语言中,“构造函数”是类中的一些特殊方法,使用new初始化类时会调用类中的构造函数。通常的形式是这样的:

something = new MyClass(..);

在JavaScript中,构造函数只是一些使用new操作符时被调用的函数。它们并不会属于某个类,也不会实例化一个类。实际上,它们甚至都不能说是一种特殊的函数类型,它们只是被new操作符调用的普通函数而已。

优先级

1、四条规则的优先级

new绑定 > 显式绑定 > 隐式绑定 > 默认绑定。

2、判断this

可以根据优先级来判断函数在某个调用位置应用的是哪条规则。可以按照下面的顺序来进行判断:

(1)函数是否在new中调用(new绑定)?如果是的话this绑定的是新创建的对象。

var bar = new foo();

(2)函数是否通过call、apply(显式绑定)或者硬绑定调用?如果是的话,this绑定的是指定的对象。

var bar = foo.call(obj2);

(3)函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this绑定的是那个上下文对象。

var bar = obj1.foo();

(4)如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到undefined,否则绑定到全局对象。

var bar = foo();

绑定例外

在某些场景下this的绑定行为会出乎意料,你认为应当应用其他绑定规则时,实际上应用的可能是默认绑定规则。

被忽略的this

如果你把null或者undefined作为this的绑定对象传入call、apply或者bind,这些值在调用时会被忽略,实际应用的是默认绑定规则:

function foo() {
console.log(
this.a);
}
var a = 2;

foo.call(
null); //2

那么什么情况下会传入null呢?

一种非常常见的做法是使用apply(..)来“展开”一个数组,并当作参数传入一个函数。类似地,bind(..)可以对参数进行柯里化(预先设置一些参数),这种方法有时非常有用。

间接引用

另一个需要注意的是,你有可能(有意或者无意地)创建一个函数的“间接引用”,在这种情况下,调用这个函数会应用默认绑定规则。

间接引用最容易在赋值时发生:

function foo() {
console.log(
this.a);
}
var a = 2;var o = { a: 3, foo: foo };var p = { a: 4};

o.foo();
//3 (p.foo = o.foo)(); //2

赋值表达式p.foo = o.foo的返回值是目标函数的引用,因此调用位置是foo()而不是p.foo()或者o.foo()。根据我们之前说过的,这里会应用默认绑定。

软绑定

如果可以给默认绑定指定一个全局对象和undefined以外的值,那就可以实现和硬绑定相同的效果,同时保留隐式绑定或者显式绑定修改this的能力。

function foo() {
console.log(
'name:' + this.name);
}
var obj = { name: 'obj'},
obj2
= { name: 'obj2'},
obj3
= { name: 'obj3'};var fooOBJ =foo.softBind(obj);

fooOBJ();
//name: obj obj2.foo=foo.softBind(obj);
obj2.foo();
//name: obj2 <---- 看!! ! fooOBJ.call(obj3);//name: obj3 <---- 看! setTimeout(obj2.foo,10);//name: obj <---- 应用了软绑定

可以看到,软绑定版本的foo()可以手动将this绑定到obj2或者obj3上,但如果应用默认绑定,则会将this绑定到obj。

this词法

ES6中介绍了一种无法使用这些规则的特殊函数类型:箭头函数。箭头函数并不是使用function关键字定义的,而是使用被称为“胖箭头”的操作符=>定义的。箭头函数不使用this的四种标准规则,而是根据外层(函数或者全局)作用域来决定this。

箭头函数的词法作用域:

function foo() {//返回一个箭头函数
  return a =>{//this继承自foo()
    console.log(this.a);
};
}
var obj1 ={
a:
2,
};
var obj2 ={
a:
3,
};
var bar =foo.call(obj1);
bar.call(obj2);
//2, 不是3!

foo()内部创建的箭头函数会捕获调用时foo()的this。由于foo()的this绑定到obj1,bar(引用箭头函数)的this也会绑定到obj1,箭头函数的绑定无法被修改。

总结

我们来总结一下本篇的主要内容:

  • this实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用。
  • 如果要判断一个运行中函数的this绑定,就需要找到这个函数的直接调用位置。找到之后就可以顺序应用下面这四条规则来判断this的绑定对象。
  • ES6中的箭头函数并不会使用四条标准的绑定规则,而是根据当前的词法作用域来决定this,具体来说,箭头函数会继承外层函数调用的this绑定(无论this绑定到什么)。这其实和ES6之前代码中的self = this机制一样。

点击关注,第一时间了解华为云新鲜技术~

介绍说明

将截图转化为 HTML 代码的工具。该项目可以将屏幕截图转化为 HTML/JS/Tailwind CSS 代码,它使用 GPT-4 Vision 生成代码、DALL-E 3 生成相似的图片。

安装运行

为了方便演示及保持原有系统的简洁,将使用 docker 的方式来安装运行 screenshot-to-code。由于项目本身已经提供了 Dockerfile,我们直接使用即可,需要注意的是,该项目有 frontend 和 backend 两个镜像。

  • 克隆代码

git clone https://github.com/abi/screenshot-to-code.git

  • 构建 frontend

cd frontend/docker build-t screenshot-to-code-frontend:latest -f ./Dockerfile .

docker images
| grep screenshot-to-code-frontend

  • 构建 backend

cd backend/docker build-t screenshot-to-code-backend:latest -f ./Dockerfile .

docker images
| grep screenshot-to-code-backend

  • 运行 frontend

docker rm -f screenshot-to-code-frontend

docker run
--name screenshot-to-code-frontend -d \--network host \-e VITE_WS_BACKEND_URL="ws://{your machine ip}:7001"\
screenshot
-to-code-frontend:latest

docker
ps | grep screenshot-to-code-frontend

  • 运行 backend

docker rm -f screenshot-to-code-backend

docker run
--name screenshot-to-code-backend -d \--network host \-e OPENAI_API_KEY="{your_openai_api_key}"\
screenshot
-to-code-backend:latest \
poetry run uvicorn main:app
--host 0.0.0.0 --port 7001dockerps | grep screenshot-to-code-backend

使用简介

经过上述步骤后,screenshot-to-code 已成功运行,此时可通过服务器 IP + 5173 端口访问(需要设置相关安全组或防火墙规则,这里需要设置 5173 和 7001 两个端口)。

  • 主页

  • 手机助手截图

  • 生成过程

  • 查看源码

  • 生成页面

  • 源码

<htmllang="zh-CN">
<head>
    <metacharset="UTF-8">
    <metaname="viewport"content="width=device-width, initial-scale=1.0">
    <title>网页示例</title>
    <scriptsrc="https://cdn.tailwindcss.com"></script>
    <style>body{font-family:'PingFang SC', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
        }
</style>
</head>
<bodyclass="bg-gray-100">
    <divclass="flex h-screen">
        <!--Sidebar-->
        <divclass="w-1/5 bg-gray-800 text-white p-5">
            <divclass="text-xl mb-8">导航栏目</div>
            <divclass="text-lg mb-4">首页</div>
            <divclass="text-lg mb-4">视频</div>
            <divclass="text-lg mb-4">直播</div>
            <divclass="text-lg mb-4">QQ空间</div>
            <divclass="text-lg mb-4">微信公众</div>
            <divclass="text-lg mb-4">手机天猫</div>
            <divclass="text-lg mb-4">全球文化</div>
            <divclass="text-lg mb-4">手机助手</div>
        </div>

        <!--Main content-->
        <divclass="w-4/5 p-5">
            <divclass="flex justify-between border-b-2 border-gray-300 pb-2 mb-5">
                <divclass="text-gray-700 text-lg">全部应用</div>
                <divclass="space-x-2">
                    <buttonclass="bg-blue-500 text-white px-4 py-2 rounded">全部应用</button>
                    <buttonclass="bg-transparent text-blue-500 border border-blue-500 px-4 py-2 rounded">全部团队</button>
                    <buttonclass="bg-transparent text-blue-500 border border-blue-500 px-4 py-2 rounded">下载新应用</button>
                </div>
            </div>
            <!--Placeholder for content-->
            <divclass="h-full"></div>
        </div>
    </div>
</body>
</html>

项目地址

更多项目详细信息请到项目主页获取

https://github.com/abi/screenshot-to-code

快捷镜像

ccr.ccs.tencentyun.com/erik_xu/screenshot-to-code-frontend:latest

ccr.ccs.tencentyun.com/erik_xu/screenshot-to-code-backend:latest

写在最后

[开源初探] 是一个介绍有趣开源项目的栏目,如果大家有什么问题或开源推荐,欢迎在文章或者公众号 - 跬步之巅留言交流。

章节

使用Go语言开发一个短链接服务:一、基本原理

使用Go语言开发一个短链接服务:二、架构设计

使用Go语言开发一个短链接服务:三、项目目录结构设计

使用Go语言开发一个短链接服务:四、生成code算法

使用Go语言开发一个短链接服务:五、添加和获取短链接

使用Go语言开发一个短链接服务:六、链接跳转

源码:
https://gitee.com/alxps/short_link

通过这个项目,你可以学到:

  1. 短链接原理
  2. Golang后端项目分层
  3. 接口逻辑涉及数据库、缓存、http请求,如何进行单元测试
  4. 何为缓存穿透、缓存击穿,如何应对他们

应用场景

假如我们正在运营一个在线课程网站,运营人员策划双11促销大降价!于是乎,产品经理鞭策研发对应的活动页面。我们的开发非常给力,经过数周007力度的劳作,解决无数bug后,活动页面不负众望开发完成。我们产品经理开心地把促销活动页面链接交给运营人员。运营人员早已准备好活动的短信、微博和微信文案模板,就等活动链接下锅了!但是当看到链接URL时,运营人员陷入了沉思……

https://www.mywebsite.com/courses/promotional-activities/date-6666661/ac60ffe3-8ef0-4efa-81d1-edc626569ff0

链接URL比预想的长几倍,加入URL后文案模板
给运营人员描绘活动信息的文字空间所剩无几。
接下来,产品运营、产品经理、技术研发三方,展开长达两年半年的激烈掰扯。不出意料,出了问题当然还是研发来解决。聪明的研发小伙小明提出了解决方案,在Nginx配置一个短的URL跳转到活动链接URL,问题解决、下班!

运营人员拿到“修改后的”活动链接,得到此次活动的短信大致长下面这样。

当然上面Nginx配置跳转只是硬编码方式的一种临时解决方案,将来有更多类似的活动,维护起来就像是“千层浆糊”,根本无从满足运营全链路深度营销、矩阵式打法。

鲁迅曾经说过:“当软件设计上遇到问题时,解决方案就是,加一层。” 所以研发组决定开发一个短链接服务,用来维护短URL映射跳转到长URL。

原理

短链接一般是通过映射关系,将长长的一串网址,映射到几个字符的短链接上,建立好这种映射关系之后保存到数据库里,用户每次访问短链接的时候,需要到数据库里查询这个短链接对应的源网址,然后给用户跳转到目标长链接。

短链接从生成到使用分为以下几步:

  1. 申请者,请求短链接服务,申请将长链接B生成对应的短链接
  2. 短链接服务器生成对应的短链接A,并保存短链接和长链接的映射关系到数据库,并返回短链接A给申请者
  3. 把短链接A拼接到短信等的内容上发送。
  4. 用户点击短链接A,浏览器用301/302进行重定向,访问到对应的长链接B。
  5. 展示对应的内容。

这里注意http重定向状态码301和302的区别:301 永久重定向,302 是临时重定向。浏览器接收到301重定向后会先请求短链接服务,由短链接服务再定向到目标长链接地址,后续浏览器再次访问短链接URL后,便不再经短链接服务跳转,而是直接访问目标长链接服务,302的话则每次要经过短链接服务重定向跳转。(
HTTP 中的 301、302、303、307、308 响应状态码
) 因此,如果要统计访问量,可以使用302;如果要减少短链接服务器压力,可以使用301。

代码实践

鲁迅又说: "
Talk is cheap, show me the code.
"

接下来我们用Gin框架实现一个简单的短链接示例

package main

import (
	"fmt"
	"log"
	"net/http"

	"github.com/gin-gonic/gin"
)

// shortLong 短链接ID和目标长链接映射关系,模拟数据库存储
var shortLong = map[string]string{
	"bd": "https://baike.baidu.com/item/%E7%9F%AD%E9%93%BE%E6%8E%A5/7224556?fr=ge_ala",
	"sg": "https://baike.sogou.com/v72514301.htm?fromTitle=%E7%9F%AD%E9%93%BE%E6%8E%A5",
}

// redirectHandler 查找链接映射,跳转到目标长链接
func redirectHandler(c *gin.Context) {
	shortCode := c.Param("code")
	longUrl, ok := shortLong[shortCode]

	if !ok {
		c.IndentedJSON(http.StatusNotFound, gin.H{
			"detail": fmt.Sprintf("短链接(%s)无对应的长链接地址", shortCode),
		})
		return
	}

	c.Redirect(http.StatusMovedPermanently, longUrl)
}

func main() {
	engine := gin.Default()
	engine.GET("/:code", redirectHandler)
	if err := engine.Run(":9999"); err != nil {
		log.Fatalf("启动gin server失败:%v", err)
	}
}


代码逻辑比较简单,就不一一解释了

整体复盘:

一个不算普通的周五中午,同事收到了大量了cpu异常的报警。根据报警表现和通过arthas查看,很明显的问题就是内存不足,疯狂无效gc。而且结合arthas和gc日志查看,老年代打满了,gc不了一点。既然问题是内存问题,那么老样子,通过jmap和heap dump 文件分析。
不感兴趣的可以直接看结论

  1. 通过jmap命令查看的类似下图,并没有项目中明显的自定义类,而占空间最大的又是char数组,当时线上占900M左右,整个老年代也就1.8个G;此时dump文件同事还在下载,网速较慢。
    image

  2. 通过业务日志查看,很多restTempalte请求报错,根据报错信息可知是某xx认证过期了,导致接收到回调,业务处理时调接口报错了;查询数据库,大概有20多万回调。根据过期时间和内存监控,大概能对的上号,表明内存异常和这个认证过期有关。怀疑度最高的只有回调以及回调补偿任务,但是一行一行代码看过去,并不觉得有什么异常。




    下载完dump文件后,先重启了服务器,避免影响业务,然后着手分析文件。



  3. 在dump文件下载完之后,使用jvisualvm分析,最多的char里大部分都是一些请求的路径,如“example/test/1",”“example/test/2"之类的,都是接口统一,但是参数不一样,因为是GET请求,所以实际路径都不一样。Jvisualvm点击gc_root又一直计算不出来,在等待计算的过程中,一度走了弯路
    image
    于是又现下载jprofiler,通过jprofiler的聚类,确定了一定是这个Meter导致的,而通过JProfile的分析,终于定位到是
    org.springframework.boot.actuate.metrics.web.client.MetricsClientHttpRequestInterceptor#intercept这个类。然后发现,MetricsClientHttpRequestInterceptor 持有一个meterRegistry,里面核心是个map,所以是map没有清除。根据依赖分析,发现是有次需求引入了redisson-spring-boot-starter,而redisson依赖了spring-boot-starter-actuator,这东西默认启动了,会拦截所有的RestTempalte请求,然后记录一些指标。
    image

    image

所以问题变成了,为什么map没有清掉已经执行完的请求?
我之前并没有研究过spring的actuator,只是看过skywalking的流程,所以我以为也和skywalking一样,记录然后上报,上报之后删除本地的。所以当时怀疑,难道是和我们请求都异常了有关,但是正如下面的代码,无论是否异常,都是执行finnally,所以又不太可能。

meterRegistry点击查看代码
ClientHttpResponse response = null;
try {
   response = execution.execute(request, body);
   return response;
}
finally {
   try {
      getTimeBuilder(request, response).register(this.meterRegistry).record(System.nanoTime() - startTime,
            TimeUnit.NANOSECONDS);
   }
   catch (Exception ex) {
      logger.info("Failed to record metrics.", ex);
   }
   if (urlTemplate.get().isEmpty()) {
      urlTemplate.remove();
   }
}

而在我自己尝试复现之后,meterRegistry的指标根本不会被自动清除,生命周期和应用的生命周期一样。因为并不存在上报,数据全部在内存(虽然可以导出到数据库,但并没有深入研究)。其实也合理,因为如果要通过Grafana等可视化平台查看的时候,我们也希望查看任意时刻的监控。而且其有一个属性是maxUriTags,默认值是100,其作用是限制meterMap里uri的个数,理论上并不会记录太多。

结论

所以到此为止,可以定结论,那就是因为引入了redisson-spring-boot-starter,导致不知情引入了spring-boot-starter-actuator。
因此默认开启了http.client.request指标的监控,关于http.client.request,有一个属性是maxUriTags,默认值是100,其作用是限制meterMap里uri的个数。但是maxUriTags起作用的地方MeterFilter没有生效。
由于maxUriTags没有生效,导致监控信息里的uri因为业务大量的GET请求中存在唯一id,本身就很占内存。压死内存的最后稻草是认证过期和补偿任务。补偿任务为保证及时性一直在频繁执行,而接口的uri里两个变量(token和uniId)导致meterMap里的key不重复,一直在插入,20万回调,token两小时更新一次,持续了两天,最终产生了124万条字符串,被map持有,无法回收。

解决方案

  1. 不需要监控
    直接排除掉spring-boot-starter-actuator
  2. 需要监控但不需要http.client.request指标
    management:
      metrics:
    	web:
    	  client:
    		request:
    		  autotime:
    			enabled: false
    
  3. 需要http.client.request指标
    jar包升到2.5.1或以上
    <dependency>
    	<groupId>org.springframework.boot</groupId>
    	<artifactId>spring-boot-actuator-autoconfigure</artifactId>
    	<version>2.5.1</version>
    </dependency>
    

复现:

新建测试项目
image

相关代码和配置如下

点击查看代码
@SpringBootApplication
@Slf4j
public class Application {
    public static void main(String[] args) {
        ConfigurableApplicationContext run = SpringApplication.run(Application.class);
        RestTemplate bean = run.getBean(RestTemplate.class);
        for (int i = 0; i < 300000; i++) {
            try {
                String forObject = bean.getForObject("http://localhost:9999/first/echo?i="+i, String.class);
            }catch (Exception e){
                log.error("执行"+i+"次");
            }
        }
    }
}

@Configuration
public class RestTemplateTestConfig {
    @Bean
    public RestTemplate restTemplate(RestTemplateBuilder builder){
        return builder.build();
    }
}

<dependencies>
    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson-spring-boot-starter</artifactId>
        <version>3.13.1</version>
    </dependency>
    <dependency>
        <groupId>org.apache.httpcomponents</groupId>
        <artifactId>httpclient</artifactId>
    </dependency>
</dependencies>

server:
  port: 8080
spring:
  redis:
    host: ************
    password: **********
#management:
#  endpoints:
#    web:
#      exposure:
#        include: "metrics"
#  metrics:
#    web:
#      client:
#        request:
#          autotime:
#            enabled: false

启动项目通过jconsole查看整个堆的监控和老年代监控分别如下,可以看出老年代一直在增长,并不会回收
image

image

甚至手动触发GC,老年代也回收不了

[Full GC (System.gc()) [Tenured: 195217K->195457K(204800K), 0.3975261 secs] 233021K->195457K(296960K), [Metaspace: 30823K->30823K(33152K)], 0.3976223 secs] [Times: user=0.39 sys=0.00, real=0.40 secs] 

通过jprofiler确定主要是meterMap占据内存了,最多的都是字符串。
image

image

分析

actuator导致rest启动了metrics记录
在使用RestTemplateBuilder构建RestTemplate的时候,会触发懒加载的RestTemplateAutoConfiguration里的RestTemplateBuilderConfigurer,在此期间,config中会注入RestTempalteCustomizer类型的bean。
image

而项目中引用了redisson-spring-boot-starter,从依赖分析可以看出间接引用了actuator相关的包。
image

这导致会在RestTemplateMetricsConfiguration配置类中实例化一个叫做MetricsRestTemplateCustomizer的bean,这个bean会通过上面的restTepalteBuilderConfigurer.configure方法给restTemplate添加拦截器MetricsClientHttpRequestInterceptor。
image

拦截器的intercept方法会在finnally中最终记录此次请求的一些指标
image

io.micrometer.core.instrument.Timer.Builder#register->
io.micrometer.core.instrument.MeterRegistry#time->
io.micrometer.core.instrument.MeterRegistry#registerMeterIfNecessary->
io.micrometer.core.instrument.MeterRegistry#getOrCreateMeter{
meterMap.put(mappedId, m);
}

image

最终存到了是SimpleMeterRegistry这个bean的meterMap中去,这个bean也是actuator-autoconfigure自动注入的
image

但是到目前为止,只是启动了metrics记录,假如maxUriTags有效的话,会在超过100条记录后getOrCreateMeter方法里的accept这里过滤掉,并不会走到下面的meterMap.put(mappedId, m)
image

为什么maxUriTags没有生效?

maxUriTags只在下图这个位置使用了,作用是构建了一个MeterFilter,根据debug我们可以确定bean是产生了的
image

但是在accept这里打上断点,再触发一些请求可以发现,代码并不会走到这里
image

往上跟,没有走到这里的情况只能是filters里没有这个MeterFilter,但我们刚才又确定metricsHttpCLientUriTagFilter这个bean是产生了的,那么就只能是没有添加到filters,也就是没有调用过meterFilter
image

image

从meterFilter往上只有可能是addFilters,一层一层往上最终到了MeterRegistryPostProcessor#postProcessAfterInitialization这个方法
image
image
image

我们上面说过负责记录的bean叫做simpleMeterRegistry,但是我们在这里打上条件断点发现并没有走到这里
image

找到SimpleMeterRegistry和MeterRegistryPostProcessor这两个bean注入的地方打断点观察,都产生了,且MeterRegistryPostProcessor比SimpleMeterRegistry产生的要早
image
image

理论上没问题,但现在确实没走到,所以只能在SimpleMeterRegistry产生的时候在org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory#applyBeanPostProcessorsAfterInitialization打断点,然后可以发现,在simpleMeterRegistry实例化快结束的时候,调用后处理器时this.beanPostProcessors确实没有MeterRegistryPostProcessor
image
image

一般来说,postPorcessor的bean注入是在refresh方法的registerBeanPostProcessors中,是早于普通bean的实例化
image

所以simpleMeterRegistry实例化的时候没有MeterRegistryPostProcessor是不合理的情况,定位simpleMeterRegistry是何时实例化的成了关键问题

simpleMeterRegistry的实例化时机

在new SimpleMeterRegistry这里打上断点观察堆栈发现,simpleMeterRegistry是MetricsRepositoryMethodInvocationListener的参数,MetricsRepositoryMethodInvocationListener则是metricsRepositoryMethodInvocationListenerBeanPostProcessor的参数
所以是在实例化metricsRepositoryMethodInvocationListenerBeanPostProcessor这个处理器的时候,因为依赖导致先实例化了simpleMeterRegistry这个bean依赖
image
image
image
image

导致实例化了SimpleMeterRegistry,而这个时候由于没有注册,所以SimpleMeterRegistry在执行applyBeanPostProcessorsAfterInitialization时就执行不到meterRegistryPostProcessor了
image

image

spring已经修复了这个问题,spring-boot-actuator-autoconfigure版本大于2.5.0的都已经没有问题了。解决方案
2.5.1 版本中,添加了一个这个ObjectProvider,在源头上不会立即把依赖的bean初始化完
image

image

2.5.0 版本
image

public Object resolveDependency(DependencyDescriptor descriptor, @Nullable String requestingBeanName,
      @Nullable Set<String> autowiredBeanNames, @Nullable TypeConverter typeConverter) throws BeansException {

   descriptor.initParameterNameDiscovery(getParameterNameDiscoverer());
   if (Optional.class == descriptor.getDependencyType()) {
      return createOptionalDependency(descriptor, requestingBeanName);
   }
   //由于使用了ObjectProvider,所以这里只是返回了一个DependencyObjectProvider
   else if (ObjectFactory.class == descriptor.getDependencyType() ||
         ObjectProvider.class == descriptor.getDependencyType()) {
      return new DependencyObjectProvider(descriptor, requestingBeanName);
   }
   else if (javaxInjectProviderClass == descriptor.getDependencyType()) {
      return new Jsr330Factory().createDependencyProvider(descriptor, requestingBeanName);
   }
   else {
   //2.5.0版本中会在这个方法加载入参依赖的bean
      Object result = getAutowireCandidateResolver().getLazyResolutionProxyIfNecessary(
            descriptor, requestingBeanName);
      if (result == null) {
         result = doResolveDependency(descriptor, requestingBeanName, autowiredBeanNames, typeConverter);
      }
      return result;
   }
}